Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,3 @@
export const DATA_SET = "appEnv.DATA_SET";
export const MOBILE_SET = "appEnv.MOBILE_SET";
export const DIMENSIONS_SET = "appEnv.DIMENSIONS_SET";
@@ -0,0 +1,47 @@
import { AppEnvDimensionsState } from "../../stores/typings";
import * as actionTypes from "./actionTypes";
import {
DataSetAction,
DataSetPayload,
DimensionsSetAction,
MobileSetAction,
} from "./typings";
/**
*
* @param payload
*/
export function dataSet(payload: DataSetPayload): DataSetAction {
return {
type: actionTypes.DATA_SET,
payload,
};
}
/**
*
* @param isMobile
*/
export function mobileSet(isMobile: boolean): MobileSetAction {
return {
type: actionTypes.MOBILE_SET,
payload: {
isMobile,
},
};
}
/**
*
* @param dimensions
*/
export function dimensionsSet(
dimensions: Omit<AppEnvDimensionsState, "styleSizeType">
): DimensionsSetAction {
return {
type: actionTypes.DIMENSIONS_SET,
payload: {
dimensions,
},
};
}
@@ -0,0 +1,7 @@
import * as actionTypes from "./actionTypes";
import * as actions from "./actions";
import * as typings from "./typings";
export const appEnvActionTypes = actionTypes;
export const appEnvActions = actions;
export const appEnvTypings = typings;
@@ -0,0 +1 @@
export const ERROR_SET = "appInfo.ERROR_SET";
@@ -0,0 +1,21 @@
import AppErrorTypeEnum from "../../constants/AppErrorTypeEnum";
import * as types from "./actionTypes";
import { ErrorSetAction } from "./typings";
/**
*
* @param appErrorType
* @param errorId
*/
export function errorSet(
appErrorType: AppErrorTypeEnum,
errorId: string | null = null
): ErrorSetAction {
return {
type: types.ERROR_SET,
payload: {
appErrorType: appErrorType,
errorId,
},
};
}
@@ -0,0 +1,7 @@
import * as actionTypes from "./actionTypes";
import * as actions from "./actions";
import * as typings from "./typings";
export const appInfoActionTypes = actionTypes;
export const appInfoActions = actions;
export const appInfoTypings = typings;
@@ -0,0 +1,51 @@
export const ROLL_RESULT_GROUPS_LOAD = "rollResult.ROLL_RESULT_GROUPS_LOAD";
export const ROLL_RESULT_GROUP_CREATE = "rollResult.ROLL_RESULT_GROUP_CREATE";
export const ROLL_RESULT_GROUP_DESTROY = "rollResult.ROLL_RESULT_GROUP_DESTROY";
export const ROLL_RESULT_COMPONENT_GROUPS_SET =
"rollResult.ROLL_RESULT_COMPONENT_GROUPS_SET";
export const ROLL_RESULT_COMPONENT_GROUPS_SET_COMMIT =
"rollResult.ROLL_RESULT_COMPONENT_GROUPS_SET_COMMIT";
export const ROLL_RESULT_GROUP_ADD = "rollResult.ROLL_RESULT_GROUP_ADD";
export const ROLL_RESULT_GROUP_ADD_COMMIT =
"rollResult.ROLL_RESULT_GROUP_ADD_COMMIT";
export const ROLL_RESULT_GROUP_REMOVE = "rollResult.ROLL_RESULT_GROUP_REMOVE";
export const ROLL_RESULT_GROUP_REMOVE_COMMIT =
"rollResult.ROLL_RESULT_GROUP_REMOVE_COMMIT";
export const ROLL_RESULT_GROUP_DICE_ROLLS_SET =
"rollResult.ROLL_RESULT_GROUP_DICE_ROLLS_SET";
export const ROLL_RESULT_GROUP_DICE_ROLLS_SET_COMMIT =
"rollResult.ROLL_RESULT_GROUP_DICE_ROLLS_SET_COMMIT";
export const ROLL_RESULT_GROUP_ORDER_SET =
"rollResult.ROLL_RESULT_GROUP_ORDER_SET";
export const ROLL_RESULT_GROUP_ORDER_SET_COMMIT =
"rollResult.ROLL_RESULT_GROUP_ORDER_SET_COMMIT";
export const ROLL_RESULT_GROUP_RESET = "rollResult.ROLL_RESULT_GROUP_RESET";
export const ROLL_RESULT_GROUP_RESET_COMMIT =
"rollResult.ROLL_RESULT_GROUP_RESET_COMMIT";
export const ROLL_RESULT_DICE_ROLL_SET = "rollResult.ROLL_RESULT_DICE_ROLL_SET";
export const ROLL_RESULT_DICE_ROLL_SET_COMMIT =
"rollResult.ROLL_RESULT_DICE_ROLL_SET_COMMIT";
export const ROLL_RESULT_DICE_ROLL_STATUS_SET =
"rollResult.ROLL_RESULT_DICE_ROLL_STATUS_SET";
export const ROLL_RESULT_COMPONENT_GROUP_PERSIST =
"rollResult.ROLL_RESULT_COMPONENT_GROUP_PERSIST";
export const ROLL_RESULT_COMPONENT_PERSIST =
"rollResult.ROLL_RESULT_COMPONENT_PERSIST";
export const ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_SET =
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_SET";
export const ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_REMOVE =
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_REMOVE";
export const ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_PERSIST =
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_PERSIST";
export const ROLL_RESULT_COMPONENT_SIMULATED_DICE_ROLL_SET =
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_DICE_ROLL_SET";
@@ -0,0 +1,488 @@
import {
RollGroupContract,
RollResultContract,
} from "@dndbeyond/character-rules-engine/es";
import * as types from "./actionTypes";
import {
RollResultGroupAddAction,
RollResultGroupsLoadAction,
RollResultGroupRemoveAction,
RollResultGroupDiceRollsSetAction,
RollResultComponentGroupsSetAction,
RollResultGroupOrderSetAction,
RollResultGroupCreateAction,
RollResultGroupDestroyAction,
RollResultComponentGroupsSetCommitAction,
RollResultGroupAddCommitAction,
RollResultGroupRemoveCommitAction,
RollResultGroupDiceRollsSetCommitAction,
RollResultGroupOrderSetCommitAction,
RollResultGroupResetAction,
RollResultGroupResetCommitAction,
RollResultDiceRollSetAction,
RollResultDiceRollSetCommitAction,
RollResultDiceRollStatusSetAction,
RollResultComponentSimulatedGroupsSetAction,
RollResultComponentSimulatedGroupsPersistAction,
RollResultComponentSimulatedDiceRollSetAction,
RollResultComponentGroupPersistAction,
RollResultComponentPersistAction,
} from "./typings";
/**
*
* @param componentKey
* @param simulatedGroupCount
* @param simulatedRollResultCount
*/
export function rollResultGroupsLoad(
componentKey: string,
simulatedGroupCount: number | null,
simulatedRollResultCount?: number
): RollResultGroupsLoadAction {
return {
type: types.ROLL_RESULT_GROUPS_LOAD,
payload: {
componentKey,
simulatedGroupCount,
simulatedRollResultCount,
},
};
}
/**
*
* @param componentKey
* @param simulatedRollResultCount
*/
export function rollResultGroupCreate(
componentKey: string,
simulatedRollResultCount?: number
): RollResultGroupCreateAction {
return {
type: types.ROLL_RESULT_GROUP_CREATE,
payload: {
componentKey,
simulatedRollResultCount,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param nextGroupKey
*/
export function rollResultGroupDestroy(
componentKey: string,
groupKey: string,
nextGroupKey: string | null
): RollResultGroupDestroyAction {
return {
type: types.ROLL_RESULT_GROUP_DESTROY,
payload: {
componentKey,
groupKey,
nextGroupKey,
},
};
}
/**
*
* @param componentKey
* @param groups
*/
export function rollResultComponentGroupsSet(
componentKey: string,
groups: Array<RollGroupContract>
): RollResultComponentGroupsSetAction {
return {
type: types.ROLL_RESULT_COMPONENT_GROUPS_SET,
payload: {
componentKey,
groups,
},
};
}
/**
*
* @param componentKey
* @param groups
*/
export function rollResultComponentGroupsSetCommit(
componentKey: string,
groups: Array<RollGroupContract>
): RollResultComponentGroupsSetCommitAction {
return {
type: types.ROLL_RESULT_COMPONENT_GROUPS_SET_COMMIT,
payload: {
componentKey,
groups,
},
};
}
/**
*
* @param group
*/
export function rollResultGroupAdd(
group: RollGroupContract
): RollResultGroupAddAction {
return {
type: types.ROLL_RESULT_GROUP_ADD,
payload: {
group,
componentKey: group.componentKey,
},
};
}
/**
*
* @param group
*/
export function rollResultGroupAddCommit(
group: RollGroupContract
): RollResultGroupAddCommitAction {
return {
type: types.ROLL_RESULT_GROUP_ADD_COMMIT,
payload: {
group,
componentKey: group.componentKey,
},
};
}
/**
*
* @param componentKey
* @param groupKey
*/
export function rollResultGroupRemove(
componentKey: string,
groupKey: string
): RollResultGroupRemoveAction {
return {
type: types.ROLL_RESULT_GROUP_REMOVE,
payload: {
componentKey,
groupKey,
},
};
}
/**
*
* @param componentKey
* @param groupKey
*/
export function rollResultGroupRemoveCommit(
componentKey: string,
groupKey: string
): RollResultGroupRemoveCommitAction {
return {
type: types.ROLL_RESULT_GROUP_REMOVE_COMMIT,
payload: {
componentKey,
groupKey,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param rollResults
*/
export function rollResultGroupDiceRollsSet(
componentKey: string,
groupKey: string,
rollResults: Array<RollResultContract>
): RollResultGroupDiceRollsSetAction {
return {
type: types.ROLL_RESULT_GROUP_DICE_ROLLS_SET,
payload: {
componentKey,
groupKey,
rollResults,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param rollResults
*/
export function rollResultGroupDiceRollsSetCommit(
componentKey: string,
groupKey: string,
rollResults: Array<RollResultContract>
): RollResultGroupDiceRollsSetCommitAction {
return {
type: types.ROLL_RESULT_GROUP_DICE_ROLLS_SET_COMMIT,
payload: {
componentKey,
groupKey,
rollResults,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param nextGroupKey
*/
export function rollResultGroupOrderSet(
componentKey: string,
groupKey: string,
nextGroupKey: string | null
): RollResultGroupOrderSetAction {
return {
type: types.ROLL_RESULT_GROUP_ORDER_SET,
payload: {
componentKey,
groupKey,
nextGroupKey,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param nextGroupKey
*/
export function rollResultGroupOrderSetCommit(
componentKey: string,
groupKey: string,
nextGroupKey: string | null
): RollResultGroupOrderSetCommitAction {
return {
type: types.ROLL_RESULT_GROUP_ORDER_SET_COMMIT,
payload: {
componentKey,
groupKey,
nextGroupKey,
},
};
}
/**
*
* @param componentKey
* @param groupKey
*/
export function rollResultGroupReset(
componentKey: string,
groupKey: string,
rollResults: Array<RollResultContract>
): RollResultGroupResetAction {
return {
type: types.ROLL_RESULT_GROUP_RESET,
payload: {
componentKey,
groupKey,
rollResults,
},
};
}
/**
*
* @param componentKey
* @param groupKey
*/
export function rollResultGroupResetCommit(
componentKey: string,
groupKey: string,
rollResults: Array<RollResultContract>
): RollResultGroupResetCommitAction {
return {
type: types.ROLL_RESULT_GROUP_RESET_COMMIT,
payload: {
componentKey,
groupKey,
rollResults,
},
};
}
/**
*
* @param rollKey
* @param properties
* @param nextRollKey
*/
export function rollResultDiceRollSet(
rollKey: string,
properties: Omit<Partial<RollResultContract>, "rollKey">,
nextRollKey: string | null
): RollResultDiceRollSetAction {
return {
type: types.ROLL_RESULT_DICE_ROLL_SET,
payload: {
rollKey,
properties,
nextRollKey,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param rollKey
* @param properties
*/
export function rollResultDiceRollSetCommit(
componentKey: string,
groupKey: string,
rollKey: string,
properties: Omit<Partial<RollResultContract>, "rollKey">,
nextRollKey: string | null
): RollResultDiceRollSetCommitAction {
return {
type: types.ROLL_RESULT_DICE_ROLL_SET_COMMIT,
payload: {
componentKey,
groupKey,
rollKey,
properties,
nextRollKey,
},
};
}
/**
*
* @param rollKey
* @param loadingStatus
*/
export function rollResultDiceRollStatusSet(
rollKey,
loadingStatus
): RollResultDiceRollStatusSetAction {
return {
type: types.ROLL_RESULT_DICE_ROLL_STATUS_SET,
payload: {
rollKey,
loadingStatus,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param rollResults
*/
export function rollResultComponentGroupPersist(
componentKey: string,
groupKey: string,
rollResults: Array<RollResultContract>
): RollResultComponentGroupPersistAction {
return {
type: types.ROLL_RESULT_COMPONENT_GROUP_PERSIST,
payload: {
componentKey,
groupKey,
rollResults,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param rollKey
* @param properties
* @param nextRollKey
*/
export function rollResultComponentPersist(
componentKey: string,
groupKey: string,
rollKey: string,
properties: Partial<RollResultContract>,
nextRollKey: string | null
): RollResultComponentPersistAction {
return {
type: types.ROLL_RESULT_COMPONENT_PERSIST,
payload: {
componentKey,
groupKey,
rollKey,
properties,
nextRollKey,
},
};
}
/**
*
* @param componentKey
* @param groups
*/
export function rollResultComponentSimulatedGroupsSet(
componentKey: string,
groups: Array<RollGroupContract>
): RollResultComponentSimulatedGroupsSetAction {
return {
type: types.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_SET,
payload: {
componentKey,
groups,
},
};
}
/**
*
* @param componentKey
*/
export function rollResultComponentSimulatedGroupsPersist(
componentKey: string,
groupKey?: string,
rollKey?: string,
properties?: Partial<RollResultContract>
): RollResultComponentSimulatedGroupsPersistAction {
return {
type: types.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_PERSIST,
payload: {
componentKey,
groupKey,
rollKey,
properties,
},
};
}
/**
*
* @param componentKey
* @param groupKey
* @param rollKey
* @param properties
*/
export function rollResultComponentSimulatedDiceRollSet(
componentKey: string,
groupKey: string,
rollKey: string,
properties: Omit<Partial<RollResultContract>, "rollKey">
): RollResultComponentSimulatedDiceRollSetAction {
return {
type: types.ROLL_RESULT_COMPONENT_SIMULATED_DICE_ROLL_SET,
payload: {
componentKey,
groupKey,
rollKey,
properties,
},
};
}
@@ -0,0 +1,7 @@
import * as actionTypes from "./actionTypes";
import * as actions from "./actions";
import * as typings from "./typings";
export const rollResultActionTypes = actionTypes;
export const rollResultActions = actions;
export const rollResultTypings = typings;
@@ -0,0 +1,3 @@
export const ADD_MESSAGE = "toastMessages.ADD_MESSAGE";
export const REMOVE_MESSAGE = "toastMessages.REMOVE_MESSAGE";
export const CLEAR_MESSAGES = "toastMessages.CLEAR_MESSAGES";
@@ -0,0 +1,72 @@
import { ToastMessageMeta } from "../../stores/typings";
import * as actionTypes from "./actionTypes";
import {
ToastErrorAction,
ToastRemoveAction,
ToastSuccessAction,
} from "./typings";
/**
*
* @param title
* @param message
* @param meta
*/
export function toastSuccess(
title: string,
message: string,
meta?: ToastMessageMeta
): ToastSuccessAction {
return {
type: actionTypes.ADD_MESSAGE,
payload: {
toast: {
title,
message,
},
meta: {
level: "success",
...meta,
},
},
};
}
/**
*
* @param title
* @param message
* @param meta
*/
export function toastError(
title: string,
message: string,
meta?: ToastMessageMeta
): ToastErrorAction {
return {
type: actionTypes.ADD_MESSAGE,
payload: {
toast: {
title,
message,
},
meta: {
level: "error",
...meta,
},
},
};
}
/**
*
* @param id
*/
export function removeToast(id: number | string): ToastRemoveAction {
return {
type: actionTypes.REMOVE_MESSAGE,
payload: {
id,
},
};
}
@@ -0,0 +1,7 @@
import * as actionTypes from "./actionTypes";
import * as actions from "./actions";
import * as typings from "./typings";
export const toastMessageTypes = actionTypes;
export const toastMessageActions = actions;
export const toastMessageTypings = typings;
@@ -0,0 +1,648 @@
import React from "react";
import {
Collapsible,
DamageTypeIcon,
MarketplaceCta,
AoeTypeIcon,
ComponentConstants,
} from "@dndbeyond/character-components/es";
import {
AbilityLookup,
AccessUtils,
Action,
ActionUtils,
ActivationUtils,
BaseInventoryContract,
CharacterTheme,
Constants,
DiceUtils,
EntityUtils,
EntityValueLookup,
FormatUtils,
HelperUtils,
InfusionUtils,
InventoryLookup,
ItemUtils,
LimitedUseUtils,
ModelInfoContract,
RuleData,
RuleDataUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { InfoItem } from "~/components/InfoItem";
import { NumberDisplay } from "~/components/NumberDisplay";
import EditorBox from "../EditorBox";
import SlotManager from "../SlotManager";
import SlotManagerLarge from "../SlotManagerLarge";
import ValueEditor from "../ValueEditor";
import { RemoveButton } from "../common/Button";
import styles from "./styles.module.css";
interface Props {
action: Action;
ruleData: RuleData;
onCustomDataUpdate?: (
propertyKey: Constants.AdjustmentTypeEnum,
value: any,
source: any
) => void;
showCustomize?: boolean;
entityValueLookup: EntityValueLookup;
abilityLookup: AbilityLookup;
inventoryLookup: InventoryLookup;
isReadonly: boolean;
largePoolMinAmount?: number;
onLimitedUseSet?: (usedAmount: number) => void;
onCustomizationsRemove?: () => void;
proficiencyBonus: number;
theme?: CharacterTheme;
}
const infoItemProps = { role: "listItem", inline: true };
export const ActionDetail = ({
showCustomize = true,
largePoolMinAmount = 11,
onCustomizationsRemove,
ruleData,
abilityLookup,
action,
onLimitedUseSet,
proficiencyBonus,
isReadonly,
entityValueLookup,
onCustomDataUpdate,
theme,
inventoryLookup,
}: Props) => {
const handleRemoveCustomizations = () => {
if (onCustomizationsRemove) {
onCustomizationsRemove();
}
};
const renderSmallAmountSlotPool = (): React.ReactNode => {
let limitedUse = ActionUtils.getLimitedUse(action);
if (!limitedUse) {
return null;
}
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
const maxUses = LimitedUseUtils.deriveMaxUses(
limitedUse,
abilityLookup,
ruleData,
proficiencyBonus
);
return (
<SlotManager
used={numberUsed}
available={maxUses}
size={"small"}
onSet={onLimitedUseSet}
/>
);
};
const renderLargeAmountSlotPool = (): React.ReactNode => {
let limitedUse = ActionUtils.getLimitedUse(action);
if (!limitedUse) {
return null;
}
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
const maxUses = LimitedUseUtils.deriveMaxUses(
limitedUse,
abilityLookup,
ruleData,
proficiencyBonus
);
return (
<SlotManagerLarge
available={maxUses}
used={numberUsed}
onSet={onLimitedUseSet}
isReadonly={isReadonly}
/>
);
};
const renderLimitedUses = (): React.ReactNode => {
let limitedUse = ActionUtils.getLimitedUse(action);
if (!limitedUse) {
return null;
}
let maxUses = LimitedUseUtils.deriveMaxUses(
limitedUse,
abilityLookup,
ruleData,
proficiencyBonus
);
if (!maxUses) {
return null;
}
let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
let totalSlots: number = Math.max(maxUses, numberUsed);
return (
<div className={styles.limitedUses}>
<div className={styles.limitedUsesLabel}>Limited Use</div>
{totalSlots >= largePoolMinAmount
? renderLargeAmountSlotPool()
: renderSmallAmountSlotPool()}
</div>
);
};
const renderCustomize = (): React.ReactNode => {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (
!showCustomize ||
isReadonly ||
mappingId === null ||
mappingEntityTypeId === null
) {
return null;
}
let valueEditorComponents: Array<Constants.AdjustmentTypeEnum> = [
Constants.AdjustmentTypeEnum.TO_HIT_OVERRIDE,
Constants.AdjustmentTypeEnum.TO_HIT_BONUS,
Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS,
Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK,
Constants.AdjustmentTypeEnum.NAME_OVERRIDE,
Constants.AdjustmentTypeEnum.NOTES,
];
const isCustomized = ActionUtils.isCustomized(action);
return (
<Collapsible
layoutType={"minimal"}
header={`Customize${isCustomized ? "*" : ""}`}
className={styles.customize}
>
<EditorBox>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
mappingId,
mappingEntityTypeId
)}
onDataUpdate={onCustomDataUpdate}
valueEditors={valueEditorComponents}
ruleData={ruleData}
labelOverrides={{
[Constants.AdjustmentTypeEnum.NAME_OVERRIDE]: "Name",
[Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS]: "Damage Bonus",
}}
defaultValues={{
[Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK]:
ActionUtils.isDefaultDisplayAsAttack(action),
}}
layoutType={"compact"}
/>
<RemoveButton
enableConfirm={true}
size="medium"
style="filled"
disabled={!isCustomized}
isInteractive={isCustomized}
onClick={handleRemoveCustomizations}
>
{isCustomized ? "Remove" : "No"} Customizations
</RemoveButton>
</EditorBox>
</Collapsible>
);
};
const getRangedInfoData = (): Array<React.ReactNode> => {
let rangeInfo = ActionUtils.getRange(action);
let attackRangeId = ActionUtils.getAttackRangeId(action);
let rangeAreas: Array<React.ReactNode> = [];
if (
rangeInfo !== null &&
attackRangeId === Constants.AttackTypeRangeEnum.RANGED
) {
if (
rangeInfo.origin &&
rangeInfo.origin !== Constants.SpellRangeTypeEnum.RANGED
) {
let spellRangeType = RuleDataUtils.getSpellRangeType(
rangeInfo.origin,
ruleData
);
if (spellRangeType !== null) {
rangeAreas.push(spellRangeType.name);
}
}
if (rangeInfo.range) {
rangeAreas.push(
<React.Fragment>
<NumberDisplay type="distanceInFt" number={rangeInfo.range} />
{rangeInfo.longRange && <span>({rangeInfo.longRange})</span>}
</React.Fragment>
);
}
if (rangeInfo.aoeSize) {
let aoeType: ModelInfoContract | null = null;
if (rangeInfo.aoeType !== null) {
aoeType = RuleDataUtils.getAoeType(rangeInfo.aoeType, ruleData);
}
rangeAreas.push(
<React.Fragment>
<NumberDisplay type="distanceInFt" number={rangeInfo.aoeSize} />
{aoeType !== null && (
<span className={styles.rangeShape}>
<AoeTypeIcon
className={styles.rangeIcon}
type={
FormatUtils.slugify(
aoeType.name
) as ComponentConstants.AoeTypePropType
}
themeMode={theme?.isDarkMode ? "gray" : "dark"}
/>
</span>
)}
</React.Fragment>
);
}
}
return rangeAreas;
//`
};
const renderDamageProperties = (): React.ReactNode => {
let damage = ActionUtils.getDamage(action);
let damageDisplay: React.ReactNode = null;
if (damage.value !== null) {
if (typeof damage.value === "number") {
damageDisplay = damage.value;
} else {
damageDisplay = DiceUtils.renderDice(damage.value);
}
}
return (
<React.Fragment>
{damageDisplay !== null && (
<InfoItem label="Damage:" {...infoItemProps}>
{damageDisplay}
</InfoItem>
)}
{damage.type !== null && damage.type.name !== null && (
<InfoItem label="Damage Type:" {...infoItemProps}>
<DamageTypeIcon
theme={theme}
type={
FormatUtils.slugify(
damage.type.name
) as ComponentConstants.DamageTypePropType
}
/>
{damage.type.name}
</InfoItem>
)}
</React.Fragment>
);
};
const renderWeaponProperties = (): React.ReactNode => {
let isProficient = ActionUtils.isProficient(action);
let toHit = ActionUtils.getToHit(action);
let attackRangeId = ActionUtils.getAttackRangeId(action);
let statId = ActionUtils.getStatId(action);
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
let activation = ActionUtils.getActivation(action);
let notes = ActionUtils.getNotes(action);
let attackSubtypeId = ActionUtils.getAttackSubtypeId(action);
let saveStateId = ActionUtils.getSaveStatId(action);
let rangeAreas: Array<React.ReactNode> = [];
if (attackRangeId === Constants.AttackTypeRangeEnum.RANGED) {
rangeAreas = getRangedInfoData();
} else {
rangeAreas.push(
<React.Fragment>
<NumberDisplay
type="distanceInFt"
number={ActionUtils.getReach(action)}
/>{" "}
Reach
</React.Fragment>
);
}
return (
<div className={styles.properties} role="list">
{activation !== null && activation.activationType && (
<InfoItem label="Action Type:" {...infoItemProps}>
{ActivationUtils.renderActivation(activation, ruleData)}
</InfoItem>
)}
{attackSubtypeId && (
<InfoItem label="Attack Type:" {...infoItemProps}>
{ActionUtils.getAttackSubtypeName(action)}
</InfoItem>
)}
{requiresAttackRoll && (
<InfoItem label="To Hit:" {...infoItemProps}>
<NumberDisplay type="signed" number={toHit} />
</InfoItem>
)}
{requiresSavingThrow && (
<InfoItem label="Attack/Save:" {...infoItemProps}>
{saveStateId !== null
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
: ""}{" "}
{ActionUtils.getAttackSaveValue(action)}
</InfoItem>
)}
{renderDamageProperties()}
{requiresAttackRoll && (
<InfoItem label="Stat:" {...infoItemProps}>
{statId === null
? "--"
: RuleDataUtils.getStatNameById(statId, ruleData)}
</InfoItem>
)}
{rangeAreas.length > 0 && (
<InfoItem label="Range/Area:" {...infoItemProps}>
{rangeAreas.map((node, idx) => (
<React.Fragment key={idx}>
{node}
{idx + 1 < rangeAreas.length ? "/" : ""}
</React.Fragment>
))}
</InfoItem>
)}
{isProficient && (
<InfoItem label="Proficient:" {...infoItemProps}>
Yes
</InfoItem>
)}
{notes && (
<InfoItem label="Notes:" {...infoItemProps}>
{notes}
</InfoItem>
)}
</div>
);
};
const renderSpellProperties = (): React.ReactNode => {
let isProficient = ActionUtils.isProficient(action);
let toHit = ActionUtils.getToHit(action);
let statId = ActionUtils.getStatId(action);
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
let activation = ActionUtils.getActivation(action);
let notes = ActionUtils.getNotes(action);
let attackSubtypeId = ActionUtils.getAttackSubtypeId(action);
let saveStateId = ActionUtils.getSaveStatId(action);
let rangeAreas = getRangedInfoData();
return (
<div className={styles.properties} role="list">
{activation !== null && activation.activationType && (
<InfoItem label="Casting Time:" {...infoItemProps}>
{ActivationUtils.renderActivation(activation, ruleData)}
</InfoItem>
)}
{attackSubtypeId && (
<InfoItem label="Attack Type:">
{ActionUtils.getAttackSubtypeName(action)}
</InfoItem>
)}
{requiresAttackRoll && (
<InfoItem label="To Hit:">
<NumberDisplay type="signed" number={toHit} />
</InfoItem>
)}
{requiresSavingThrow && (
<InfoItem label="Attack/Save:">
{saveStateId !== null
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
: ""}{" "}
{ActionUtils.getAttackSaveValue(action)}
</InfoItem>
)}
{renderDamageProperties()}
{requiresAttackRoll && (
<InfoItem label="Stat:">
{statId === null
? "--"
: RuleDataUtils.getStatNameById(statId, ruleData)}
</InfoItem>
)}
{rangeAreas.length > 0 && (
<InfoItem label="Range/Area:">
{rangeAreas.map((node, idx) => (
<React.Fragment key={idx}>
{node}
{idx + 1 < rangeAreas.length ? "/" : ""}
</React.Fragment>
))}
</InfoItem>
)}
{isProficient && <InfoItem label="Proficient:">Yes</InfoItem>}
{notes && <InfoItem label="Notes:">{notes}</InfoItem>}
</div>
);
};
const renderGeneralProperties = (): React.ReactNode => {
let isProficient = ActionUtils.isProficient(action);
let toHit = ActionUtils.getToHit(action);
let statId = ActionUtils.getStatId(action);
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
let activation = ActionUtils.getActivation(action);
let notes = ActionUtils.getNotes(action);
let attackRangeId = ActionUtils.getAttackRangeId(action);
let saveStateId = ActionUtils.getSaveStatId(action);
let rangeAreas: Array<React.ReactNode> = [];
if (attackRangeId === Constants.AttackTypeRangeEnum.RANGED) {
rangeAreas = getRangedInfoData();
} else {
rangeAreas.push(
<React.Fragment>
<NumberDisplay
type="distanceInFt"
number={ActionUtils.getReach(action)}
/>{" "}
Reach
</React.Fragment>
);
}
return (
<div className={styles.properties} role="list">
{activation !== null && activation.activationType && (
<InfoItem label={"Action Type:"} {...infoItemProps}>
{ActivationUtils.renderActivation(activation, ruleData)}
</InfoItem>
)}
{requiresAttackRoll && (
<InfoItem label="To Hit:" {...infoItemProps}>
<NumberDisplay type="signed" number={toHit} />
</InfoItem>
)}
{requiresSavingThrow && (
<InfoItem label="Attack/Save:" {...infoItemProps}>
{saveStateId !== null
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
: ""}{" "}
{ActionUtils.getAttackSaveValue(action)}
</InfoItem>
)}
{renderDamageProperties()}
{requiresAttackRoll && (
<InfoItem label="Stat:" {...infoItemProps}>
{statId === null
? "--"
: RuleDataUtils.getStatNameById(statId, ruleData)}
</InfoItem>
)}
{rangeAreas.length > 0 && (
<InfoItem label="Range/Area:" {...infoItemProps}>
{rangeAreas.map((node, idx) => (
<React.Fragment key={idx}>
{node}
{idx + 1 < rangeAreas.length ? "/" : ""}
</React.Fragment>
))}
</InfoItem>
)}
{isProficient && (
<InfoItem label="Proficient:" {...infoItemProps}>
Yes
</InfoItem>
)}
{notes && (
<InfoItem label="Notes:" {...infoItemProps}>
{notes}
</InfoItem>
)}
</div>
);
};
const renderProperties = (): React.ReactNode => {
const actionTypeId = ActionUtils.getActionTypeId(action);
switch (actionTypeId) {
case Constants.ActionTypeEnum.SPELL:
return renderSpellProperties();
case Constants.ActionTypeEnum.WEAPON:
return renderWeaponProperties();
case Constants.ActionTypeEnum.GENERAL:
return renderGeneralProperties();
default:
// not implemented
}
return null;
};
const renderDescription = (): React.ReactNode => {
let description = ActionUtils.getDescription(action);
let dataOrigin = ActionUtils.getDataOrigin(action);
let dataOriginType = ActionUtils.getDataOriginType(action);
if (!description) {
description = EntityUtils.getPrimaryDescription(dataOrigin);
}
if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) {
const itemContract = dataOrigin.primary as BaseInventoryContract;
const itemMappingId = ItemUtils.getMappingId(itemContract);
const item = HelperUtils.lookupDataOrFallback(
inventoryLookup,
itemMappingId
);
if (item === null) {
return null;
}
let infusion = ItemUtils.getInfusion(item);
if (
infusion &&
!AccessUtils.isAccessible(InfusionUtils.getAccessType(infusion))
) {
const sources = InfusionUtils.getSources(infusion);
let sourceNames: Array<string> = [];
if (sources.length > 0) {
sources.forEach((sourceMapping) => {
let source = RuleDataUtils.getSourceDataInfo(
sourceMapping.sourceId,
ruleData
);
if (source !== null && source.description !== null) {
sourceNames.push(source.description);
}
});
}
const sourceName: string | null =
sourceNames.length > 0
? FormatUtils.renderNonOxfordCommaList(sourceNames)
: null;
return (
<div className={styles.marketplaceCta}>
<MarketplaceCta
showImage={false}
sourceName={sourceName}
description={
"To unlock this infusion, check out the Marketplace to view purchase options."
}
/>
</div>
);
}
}
if (!description) {
return null;
}
return (
<HtmlContent
className={styles.description}
html={description}
withoutTooltips
/>
);
};
return (
<div className={styles.details}>
{renderLimitedUses()}
{renderCustomize()}
{renderProperties()}
{renderDescription()}
</div>
);
};
@@ -0,0 +1,403 @@
import React, { useState } from "react";
import {
Collapsible,
CollapsibleHeaderContent,
CollapsibleHeading,
} from "@dndbeyond/character-components/es";
import {
CharacterTheme,
CharClass,
ClassUtils,
Constants,
DataOriginRefData,
EntityValueLookup,
Spell,
} from "@dndbeyond/character-rules-engine/es";
import SpellManager from "../SpellManager";
interface Props {
charClass: CharClass;
activeSpells: Array<Spell>;
isPrepareMaxed: boolean;
isCantripsKnownMaxed: boolean;
isSpellsKnownMaxed: boolean;
hasMultipleSpellClasses: boolean;
activeFeatureCantripCount: number;
knownFeatureSpellCount: number;
knownSpellCount: number;
activeCantripsCount: number;
preparedSpellCount: number;
knownCantripsMax: number | null;
knownSpellsMax: number | null;
prepareMax: number | null;
entityValueLookup: EntityValueLookup;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
theme: CharacterTheme;
}
interface State {
collapsedGroups: {
activeSpells: boolean;
spellbook: boolean;
addSpells: boolean;
};
}
export default function ClassSpellManager({
charClass,
activeSpells,
isPrepareMaxed,
isCantripsKnownMaxed,
isSpellsKnownMaxed,
activeFeatureCantripCount,
knownFeatureSpellCount,
knownSpellCount,
activeCantripsCount,
preparedSpellCount,
knownCantripsMax,
knownSpellsMax,
prepareMax,
entityValueLookup,
dataOriginRefData,
proficiencyBonus,
theme,
hasMultipleSpellClasses = false,
}: Props) {
const isSpellbookSpellcaster = ClassUtils.isSpellbookSpellcaster(charClass);
const isSpellbookEmpty = knownSpellCount === 0 && activeCantripsCount === 0;
const spellCastingLearningStyle = ClassUtils.getSpellCastingLearningStyle(charClass);
const buttonAddText = Constants.SpellCastingLearningStyleAddText[spellCastingLearningStyle];
const removeButtonText = Constants.SpellCastingLearningStyleRemoveText[spellCastingLearningStyle];
const [state, setState] = useState({
collapsedGroups: {
activeSpells:
hasMultipleSpellClasses || (isSpellbookSpellcaster && isSpellbookEmpty),
spellbook:
hasMultipleSpellClasses ||
!isSpellbookSpellcaster ||
(isSpellbookSpellcaster && !isSpellbookEmpty),
addSpells: true,
},
});
function handleShowSpellGroup(key: keyof State["collapsedGroups"]): void {
let resetState = {
activeSpells: true,
addSpells: true,
spellbook: true,
};
setState({
...state,
collapsedGroups: {
...resetState,
[key]: !state.collapsedGroups[key],
},
});
}
function doesAvailableSpellsHaveNotifications(): boolean {
if (knownCantripsMax !== null && activeCantripsCount > knownCantripsMax) {
return true;
}
if (prepareMax && preparedSpellCount > prepareMax) {
return true;
}
if (knownSpellsMax && knownSpellCount > knownSpellsMax) {
return true;
}
return false;
}
function renderSpellListSpellStatus(): React.ReactNode {
let featureSpellCount: number =
activeFeatureCantripCount + knownFeatureSpellCount;
let calloutNode: React.ReactNode;
if (featureSpellCount > 0) {
calloutNode = "*";
}
let cantripsNode: React.ReactNode = "";
let cantripsClsNames: Array<string> = [
"ct-class-spell-manager__info-entry",
"ct-class-spell-manager__info-entry--cantrips",
];
if (knownCantripsMax !== null) {
if (activeCantripsCount === knownCantripsMax) {
cantripsNode = `Cantrips: ${knownCantripsMax}`;
} else {
cantripsNode = `Cantrips: ${activeCantripsCount}/${knownCantripsMax}`;
if (activeCantripsCount > knownCantripsMax) {
cantripsClsNames.push("ct-class-spell-manager__info-entry--exceeded");
}
}
}
const spellDisplayListType = spellCastingLearningStyle === Constants.SpellCastingLearningStyle.Prepared
? "Prepared"
: "Known";
let spellsNode: React.ReactNode;
let spellsExtraNode: React.ReactNode;
let spellsClsNames: Array<string> = [
"ct-class-spell-manager__info-entry",
"ct-class-spell-manager__info-entry--spells",
];
if (prepareMax) {
if (preparedSpellCount === prepareMax) {
spellsNode = `Prepared Spells: ${preparedSpellCount}`;
} else {
spellsNode = `Prepared Spells: ${preparedSpellCount}/${prepareMax}`;
if (preparedSpellCount > prepareMax) {
spellsClsNames.push("ct-class-spell-manager__info-entry--exceeded");
}
}
spellsExtraNode = (
<span className="ct-class-spell-manager__info-entry-extra">
({knownSpellCount} Known)
</span>
);
} else if (knownSpellsMax) {
if (knownSpellCount === knownSpellsMax) {
spellsNode = `${spellDisplayListType} Spells: ${knownSpellCount}`;
} else {
spellsNode = `${spellDisplayListType} Spells: ${knownSpellCount}/${knownSpellsMax}`;
if (knownSpellCount > knownSpellsMax) {
spellsClsNames.push("ct-class-spell-manager__info-entry--exceeded");
}
}
}
return (
<div className="ct-class-spell-manager__info">
<div className={cantripsClsNames.join(" ")}>
{cantripsNode}
{calloutNode}
</div>
<div className={spellsClsNames.join(" ")}>
{spellsNode}
{spellsExtraNode}
{calloutNode}
</div>
{featureSpellCount > 0 && (
<div className="ct-class-spell-manager__info-features">
*{featureSpellCount} spell
{featureSpellCount !== 1 ? "s" : ""} included from class features.
</div>
)}
</div>
);
}
function renderEmptyActiveSpells(): React.ReactNode {
return (
<div className="ct-class-spell-manager__empty">
{ClassUtils.isSpellbookSpellcaster(charClass) && (
<React.Fragment>
You currently have no prepared spells.
</React.Fragment>
)}
{ClassUtils.isPreparedSpellcaster(charClass) && (
<React.Fragment>
You currently have no prepared spells. Learn and prepare spells from
your list of available spells below.
</React.Fragment>
)}
{ClassUtils.isKnownSpellcaster(charClass) && (
<React.Fragment>
You currently have no known spells. Learn spells from your list of
available spells below.
</React.Fragment>
)}
</div>
);
}
function renderActiveSpells(): React.ReactNode {
return (
<div className="ct-class-spell-manager__active">
<SpellManager
charClassId={ClassUtils.getActiveId(charClass)}
characterClassId={ClassUtils.getMappingId(charClass)}
theme={theme}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
hasActiveSpells
enableSpellRemove={!ClassUtils.isSpellbookSpellcaster(charClass)}
enablePrepare={false}
enableUnprepare={true}
showFilters={false}
addButtonText={buttonAddText}
buttonActiveStyle="outline"
buttonUnprepareText={removeButtonText}
entityValueLookup={entityValueLookup}
dataOriginRefData={dataOriginRefData}
showCustomize={false}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
}
function renderActiveSpellsGroup(): React.ReactNode {
const { collapsedGroups } = state;
let heading: React.ReactNode = "Prepared Spells";
if (ClassUtils.isKnownSpellcaster(charClass) && spellCastingLearningStyle !== Constants.SpellCastingLearningStyle.Prepared) {
heading = "Known Spells";
}
let headingNode: React.ReactNode = (
<CollapsibleHeading>
{heading}
<span className="ct-class-spell-manager__heading-extra">
({activeSpells.length})
</span>
</CollapsibleHeading>
);
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent heading={headingNode} />
);
return (
<Collapsible
collapsed={collapsedGroups.activeSpells}
header={headerNode}
onChangeHandler={handleShowSpellGroup.bind(this, "activeSpells")}
className="ct-class-spell-manager__group"
>
{activeSpells.length > 0
? renderActiveSpells()
: renderEmptyActiveSpells()}
</Collapsible>
);
}
function renderSpellbook(): React.ReactNode {
const { collapsedGroups } = state;
if (!ClassUtils.isSpellbookSpellcaster(charClass)) {
return null;
}
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent heading="Spellbook" />
);
return (
<Collapsible
collapsed={collapsedGroups.spellbook}
header={headerNode}
onChangeHandler={handleShowSpellGroup.bind(this, "spellbook")}
className="ct-class-spell-manager__group"
>
{isSpellbookEmpty && (
<div className="ct-class-spell-manager__empty">
You currently have no known spells. Learn spells from your list of
available spells below.
</div>
)}
{!isSpellbookEmpty && (
<React.Fragment>
{renderSpellListSpellStatus()}
<SpellManager
charClassId={ClassUtils.getActiveId(charClass)}
characterClassId={ClassUtils.getMappingId(charClass)}
theme={theme}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
enableAdd={false}
enableSpellRemove={false}
addButtonText={buttonAddText}
buttonUnprepareText={removeButtonText}
entityValueLookup={entityValueLookup}
dataOriginRefData={dataOriginRefData}
showCustomize={false}
proficiencyBonus={proficiencyBonus}
/>
</React.Fragment>
)}
</Collapsible>
);
}
function renderAddSpells(): React.ReactNode {
const { collapsedGroups } = state;
let heading: React.ReactNode = "Add Spells";
if (ClassUtils.isPreparedSpellcaster(charClass)) {
heading = "Known Spells";
}
let headingNode: React.ReactNode = (
<CollapsibleHeading>
{heading}
{doesAvailableSpellsHaveNotifications() && (
<div className="ct-class-spell-manager__notification">!</div>
)}
</CollapsibleHeading>
);
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent heading={headingNode} />
);
return (
<Collapsible
collapsed={collapsedGroups.addSpells}
header={headerNode}
onChangeHandler={handleShowSpellGroup.bind(this, "addSpells")}
className="ct-class-spell-manager__group"
>
{renderSpellListSpellStatus()}
<SpellManager
charClassId={ClassUtils.getActiveId(charClass)}
characterClassId={ClassUtils.getMappingId(charClass)}
theme={theme}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
shouldFetch
enablePrepare={!ClassUtils.isSpellbookSpellcaster(charClass)}
enableUnprepare={!ClassUtils.isSpellbookSpellcaster(charClass)}
buttonUnprepareText={removeButtonText}
addButtonText={buttonAddText}
entityValueLookup={entityValueLookup}
dataOriginRefData={dataOriginRefData}
showExpandedType={true}
showCustomize={false}
proficiencyBonus={proficiencyBonus}
/>
</Collapsible>
);
}
return (
<div className="ct-class-spell-manager">
<div className="ct-class-spell-manager__header">
<div className="ct-class-spell-manager__portrait">
<img
className="ct-class-spell-manager__portrait-img"
src={ClassUtils.getPortraitUrl(charClass)}
alt=""
/>
</div>
<div className="ct-class-spell-manager__heading">
{ClassUtils.getName(charClass)}
</div>
</div>
{renderAddSpells()}
{renderActiveSpellsGroup()}
{renderSpellbook()}
</div>
);
}
@@ -0,0 +1,4 @@
import ClassSpellManager from "./ClassSpellManager";
export default ClassSpellManager;
export { ClassSpellManager };
@@ -0,0 +1,126 @@
import * as React from "react";
import { ConditionLevelEffectLookup } from "@dndbeyond/character-rules-engine/es";
import SlotManager from "../SlotManager";
import { ThemeButton } from "../common/Button";
interface Props {
conditionName: string;
levels: Array<number>;
levelEffectLookup: ConditionLevelEffectLookup | null;
levelOverrides: Record<number, string>;
activeLevel: number | null;
isInteractive: boolean;
onLevelChange: (value: number | null) => void;
}
export default class ConditionLevelsTable extends React.PureComponent<Props> {
static defaultProps = {
activeLevel: null,
isInteractive: false,
onLevelChange: null,
levelOverrides: {},
levelEffectLookup: null,
};
handleLevelChange = (level: number | null, used: number): void => {
const { onLevelChange, activeLevel } = this.props;
let value: number | null = level;
if (level !== null && activeLevel === level) {
value = level > 1 ? level - 1 : null;
}
if (onLevelChange) {
onLevelChange(value);
}
};
renderEffect = (level: number): string => {
const { levelEffectLookup, levelOverrides } = this.props;
if (levelOverrides.hasOwnProperty(level)) {
return levelOverrides[level];
}
let effect: string = "";
if (levelEffectLookup !== null && levelEffectLookup.hasOwnProperty(level)) {
effect = levelEffectLookup[level];
}
return effect;
};
render() {
const { conditionName, levels, activeLevel, isInteractive } = this.props;
return (
<div className="ct-condition-levels-table">
<table className="ct-condition-levels-table__table">
<thead>
<tr>
<th>Applied</th>
<th>Level</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
{levels.map((level, idx) => {
let isActive: boolean = level === activeLevel;
let isImplied: boolean =
activeLevel !== null && level < activeLevel;
let classNames: Array<string> = [
"ct-condition-levels-table__table-slot",
];
if (isActive) {
classNames.push(
"ct-condition-levels-table__table-slot--active"
);
}
if (isImplied) {
classNames.push(
"ct-condition-levels-table__table-slot--implied"
);
}
if (isInteractive) {
classNames.push(
"ct-condition-levels-table__table-slot--interactive"
);
}
return (
<tr key={`${level}-${idx}`}>
<td className={classNames.join(" ")}>
<SlotManager
size="small"
available={1}
onSet={this.handleLevelChange.bind(this, level)}
isInteractive={isInteractive}
used={isActive || isImplied ? 1 : 0}
/>
</td>
<td>{level}</td>
<td className="left-align">{this.renderEffect(level)}</td>
</tr>
);
})}
</tbody>
</table>
{isInteractive && activeLevel !== null && (
<div className="ct-condition-levels-table__actions">
<div className="ct-condition-levels-table__actions-action">
<ThemeButton
size="small"
style="outline"
onClick={this.handleLevelChange.bind(this, null)}
>
Remove All {conditionName}
</ThemeButton>
</div>
</div>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import ConditionLevelsTable from "./ConditionLevelsTable";
export default ConditionLevelsTable;
export { ConditionLevelsTable };
@@ -0,0 +1,244 @@
import React, { useContext } from "react";
import {
Collapsible,
CollapsibleHeaderContent,
CollapsibleHeading,
} from "@dndbeyond/character-components/es";
import {
CampaignUtils,
CharacterTheme,
Container,
ContainerUtils,
InventoryManager,
CharacterCurrencyContract,
Item,
ItemUtils,
PartyInfo,
RuleData,
CoinManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { NumberDisplay } from "~/components/NumberDisplay";
import CurrencyCollapsible from "../../../CharacterSheet/components/CurrencyCollapsible";
import { CurrencyErrorTypeEnum } from "../../containers/panes/CurrencyPane/CurrencyPaneConstants";
import { CoinManagerContext } from "../../managers/CoinManagerContext";
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
import { CustomItemCreator } from "../CustomItemCreator";
import EquipmentShop from "../EquipmentShop";
import ItemDetail from "../ItemDetail";
import { ItemSlotManager } from "../ItemSlotManager";
import { ThemeButtonWithMenu } from "../common/Button";
interface Props {
currentContainer: Container;
theme: CharacterTheme;
onItemMove?: (item: Item, containerDefinitionKey: string) => void;
onItemEquip?: (item: Item, uses: number) => void;
ruleData: RuleData;
proficiencyBonus: number;
inventory: Array<Item>;
containers: Array<Container>;
shopOpenInitially?: boolean;
isReadonly: boolean;
partyInfo: PartyInfo | null;
inventoryManager: InventoryManager;
coinManager: CoinManager;
handleCurrencyChangeError: (
currencyName: string,
errorType: CurrencyErrorTypeEnum
) => void;
handleCurrencyAdjust: (
coin: Partial<CharacterCurrencyContract>,
multiplier: 1 | -1,
containerDefinitionKey: string
) => void;
handleAmountSet: (
containerDefinitionKey: string,
key: keyof CharacterCurrencyContract,
amount: number
) => void;
}
export const ContainerActionsComponent: React.FC<Props> = ({
currentContainer,
theme,
ruleData,
onItemMove,
onItemEquip,
proficiencyBonus,
inventory,
containers,
shopOpenInitially = false,
isReadonly,
partyInfo,
inventoryManager,
coinManager,
handleCurrencyAdjust,
handleAmountSet,
handleCurrencyChangeError,
}) => {
let showEquipmentShop = true;
if (
ContainerUtils.isShared(currentContainer) &&
partyInfo &&
CampaignUtils.isSharingStateInactive(
CampaignUtils.getSharingState(partyInfo)
)
) {
showEquipmentShop = false;
}
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading={
<CollapsibleHeading>{`Contents (${inventory.length})`}</CollapsibleHeading>
}
callout={
<div className="ct-equipment-manage-pane__callout">
<NumberDisplay
type="weightInLb"
number={ContainerUtils.getWeightInfo(currentContainer).total}
/>
</div>
}
/>
);
return (
<div className="ct-container-manager">
{showEquipmentShop && (
<Collapsible
header="Add Items"
initiallyCollapsed={!shopOpenInitially}
overrideCollapsed={!shopOpenInitially}
>
<EquipmentShop
limitAddToCurrentContainer={currentContainer}
partyInfo={partyInfo}
containers={containers}
theme={theme}
ruleData={ruleData}
proficiencyBonus={proficiencyBonus}
/>
<CustomItemCreator containers={[currentContainer]} />
</Collapsible>
)}
{coinManager.canUseCointainers() && (
<CurrencyCollapsible
heading={`${ContainerUtils.getName(currentContainer)} Coin`}
initiallyCollapsed={true}
isReadonly={isReadonly}
container={currentContainer}
handleCurrencyChangeError={handleCurrencyChangeError}
handleCurrencyAdjust={handleCurrencyAdjust}
handleAmountSet={handleAmountSet}
/>
)}
<Collapsible
header={headerNode}
initiallyCollapsed={shopOpenInitially}
overrideCollapsed={shopOpenInitially}
>
<div className="ct-container-manager__inventory">
{inventory.map((item, idx) => {
const canEquip = inventoryManager.canEquipUnequipItem(item);
const key = ItemUtils.getUniqueKey(item);
return (
<Collapsible
key={`${key}-${idx}`}
className="ct-container-manager__item"
layoutType={"minimal"}
header={
<CollapsibleHeaderContent
heading={
<div className="ct-container-manager__item-header">
<div className="ct-container-manager__item-header-action">
<ItemSlotManager
isUsed={!!ItemUtils.isEquipped(item)}
isReadonly={isReadonly}
canUse={canEquip}
onSet={(uses) => {
if (onItemEquip) {
onItemEquip(item, uses);
}
}}
theme={theme}
useTooltip={false}
/>
</div>
<div className="ct-container-manager__item-header-name">
<ItemName item={item} showLegacy={true} />
</div>
</div>
}
callout={
!isReadonly &&
partyInfo &&
inventoryManager.canMoveItem(item) ? (
<ThemeButtonWithMenu
showSingleOption={true}
containerEl={
document.querySelector(
".ct-sidebar__portal"
) as HTMLElement
}
groupedOptions={ContainerUtils.getGroupedOptions(
ItemUtils.getContainerDefinitionKey(item),
containers,
"Move To:",
CampaignUtils.getSharingState(partyInfo)
)}
buttonStyle="outline"
onSelect={(definitionKey) => {
if (onItemMove) {
onItemMove(item, definitionKey);
}
}}
>
Move
</ThemeButtonWithMenu>
) : null
}
/>
}
>
<ItemDetail
theme={theme}
item={item}
ruleData={ruleData}
showCustomize={false}
showImage={false}
proficiencyBonus={proficiencyBonus}
/>
</Collapsible>
);
})}
{ContainerUtils.getItemMappingIds(currentContainer).length === 0 && (
<div className="ct-container-manager__empty">
There are no items in your{" "}
{ContainerUtils.getName(currentContainer)}
</div>
)}
</div>
</Collapsible>
</div>
);
};
export const ContainerActions = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const { coinManager } = useContext(CoinManagerContext);
return (
<ContainerActionsComponent
inventoryManager={inventoryManager}
coinManager={coinManager}
{...props}
/>
);
};
export default ContainerActions;
@@ -0,0 +1,3 @@
import { ContainerActions } from "./ContainerActions";
export default ContainerActions;
@@ -0,0 +1,131 @@
import React from "react";
import { LightLinkOutSvg } from "@dndbeyond/character-components/es";
export enum CtaBannerOptions {
NEW = "New",
BETA = "Beta",
ALPHA = "Alpha",
}
interface Props {
preferenceEnabled: boolean;
className: string;
onPreferenceClick: () => void;
preferenceTitle: string;
linkHref?: string;
linkTitle?: string;
icon?: React.ReactNode;
borderColor?: string;
switchColor?: string;
backgroundImageUrl?: string;
bannerTitle?: CtaBannerOptions;
}
export default class CtaPreferenceManager extends React.PureComponent<
Props,
{}
> {
static defaultProps = {
className: "",
};
handlePreferenceToggle = (evt: React.MouseEvent): void => {
const { onPreferenceClick } = this.props;
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
onPreferenceClick();
};
handlePreferenceSwitch = (evt: React.MouseEvent): void => {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
//Needed to prevent the label from triggering the input onClick handler
//https://stackoverflow.com/questions/24501497/why-the-onclick-element-will-trigger-twice-for-label-element
};
render() {
const {
preferenceEnabled,
className,
linkHref,
linkTitle,
icon,
borderColor,
switchColor,
backgroundImageUrl,
bannerTitle,
preferenceTitle,
} = this.props;
const classNames: Array<string> = [className, "ct-cta-preference-manager"];
return (
<div
className={classNames.join(" ")}
style={borderColor ? { borderColor } : undefined}
>
<div
className="ct-cta-preference-manager__primary"
onClick={this.handlePreferenceToggle}
style={
backgroundImageUrl
? { backgroundImage: `url(${backgroundImageUrl})` }
: undefined
}
>
<div className="ct-cta-preference-manager__primary-info">
{bannerTitle && (
<div className="ct-cta-preference-manager__primary-info-banner">
{bannerTitle}
</div>
)}
<div className="ct-cta-preference-manager__primary-info-title">
{icon && (
<div className="ct-cta-preference-manager__primary-info-icon">
{icon}
</div>
)}
<div className="ct-cta-preference-manager__primary-info-label">
{preferenceTitle}
</div>
</div>
</div>
<label className="ct-cta-preference-manager__switch">
<input
className="ct-cta-preference-manager__switch-input"
type="checkbox"
checked={preferenceEnabled}
onClick={this.handlePreferenceSwitch}
onChange={() => {}}
/>
<span
style={
preferenceEnabled && switchColor
? { backgroundColor: switchColor }
: undefined
}
className="ct-cta-preference-manager__switch-slider"
>
{" "}
</span>
</label>
</div>
{linkHref && (
<a
className="ct-cta-preference-manager__link-out"
href={linkHref}
rel="noopener noreferrer"
target="_blank"
>
<span className="ct-cta-preference-manager__link-out-label">
{linkTitle ? linkTitle : "Feedback Forum"}
</span>
<LightLinkOutSvg />
</a>
)}
</div>
);
}
}
@@ -0,0 +1,5 @@
import CtaPreferenceManager, { CtaBannerOptions } from "./CtaPreferenceManager";
export default CtaPreferenceManager;
export { CtaPreferenceManager, CtaBannerOptions };
@@ -0,0 +1,105 @@
import React, { useCallback, useContext, useState } from "react";
import { Collapsible } from "@dndbeyond/character-components/es";
import {
Container,
ContainerUtils,
} from "@dndbeyond/character-rules-engine/es";
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
import { AppNotificationUtils } from "../../utils";
import CustomizeDataEditor from "../CustomizeDataEditor";
import EditorBox from "../EditorBox";
import SimpleQuantity from "../SimpleQuantity";
import { ThemeButtonWithMenu } from "../common/Button";
interface Props {
containers: Array<Container>;
}
export const CustomItemCreator: React.FC<Props> = ({ containers }) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const getInitialCustomItemState = () => ({
cost: null,
description: null,
name: inventoryManager.getDefaultCustomItemName(),
notes: null,
weight: null,
quantity: 1,
});
const [customItem, setCustomItemProps] = useState(
getInitialCustomItemState()
);
const handleOnSelectSuccess = useCallback(() => {
AppNotificationUtils.dispatchSuccess(
"Custom Item Added",
`Added ${customItem.name}`
);
setCustomItemProps(getInitialCustomItemState());
}, [getInitialCustomItemState, customItem]);
return (
<Collapsible
className="ct-equipment-manage-pane__custom"
layoutType="minimal"
header={"Add Custom Item"}
>
<EditorBox>
<CustomizeDataEditor
data={customItem}
enableName={true}
enableNotes={true}
enableDescription={true}
enableCost={true}
enableWeight={true}
maxNameLength={128}
onDataUpdate={(data) => {
setCustomItemProps({
...customItem,
...data,
});
}}
/>
<div className="ct-custom-item-creator__actions">
<div className="ct-custom-item-creator__action ct-custom-item-creator__action--amount">
<SimpleQuantity
quantity={customItem.quantity ?? 1}
onUpdate={(quantity) => {
setCustomItemProps({
...customItem,
quantity,
});
}}
/>
</div>
<div className="ct-custom-item-creator__action">
<ThemeButtonWithMenu
onSelect={(containerDefinitionKey) =>
inventoryManager.handleCustomAdd(
{ customItem, containerDefinitionKey },
handleOnSelectSuccess
)
}
groupedOptions={ContainerUtils.getGroupedOptions(
null,
containers,
"Add To:",
inventoryManager.getSharingState()
)}
>
Add{" "}
{customItem.quantity === 1
? " Item"
: ` ${customItem.quantity} Items`}
</ThemeButtonWithMenu>
</div>
</div>
</EditorBox>
</Collapsible>
);
};
export default CustomItemCreator;
@@ -0,0 +1,977 @@
/**
* NOTE: This file is meant to be removed over time. This is an older editor that is still used,
* but will be phased out eventually.
*/
import React from "react";
import { Checkbox, Select } from "@dndbeyond/character-components/es";
import {
HelperUtils,
HtmlSelectOption,
} from "@dndbeyond/character-rules-engine/es";
import CustomizeDataEditorProperty from "./CustomizeDataEditorProperty";
import CustomizeDataEditorPropertyLabel from "./CustomizeDataEditorPropertyLabel";
import CustomizeDataEditorPropertyValue from "./CustomizeDataEditorPropertyValue";
const CUSTOMIZE_KEY = {
ABILITY_MODIFIER_STAT: "abilityModifierStatId",
ATTACK_SUBTYPE: "attackSubtype",
ACTIVATION_TYPE: "activationType",
ACTIVATION_TIME: "activationTime",
AOE_SIZE: "aoeSize",
AOE_TYPE: "aoeType",
COST: "cost",
DAMAGE_BONUS: "damageBonus",
DAMAGE_TYPE: "damageTypeId",
DESCRIPTION: "description",
DICE_COUNT: "diceCount",
DICE_TYPE: "diceType",
DISPLAY_AS_ATTACK: "displayAsAttack",
FIXED_VALUE: "fixedValue",
IS_ADAMANTINE: "isAdamantine",
IS_OFFHAND: "isOffhand",
IS_PROFICIENT: "isProficient",
IS_SILVER: "isSilvered",
IS_MARTIAL_ARTS: "isMartialArts",
LONG_RANGE: "longRange",
MAGIC_BONUS: "magicBonus",
MISC_BONUS: "miscBonus",
NAME: "name",
NOTES: "notes",
OVERRIDE: "override",
PROFICIENCY_LEVEL: "proficiencyLevel",
RANGE: "range",
RANGE_TYPE: "rangeId",
SAVE_DC_BONUS: "saveDcBonus",
SAVE_DC: "fixedSaveDc",
SAVE_TYPE: "saveStatId",
SNIPPET: "snippet",
SPELL_RANGE_TYPE: "spellRangeType",
STAT: "statId",
TO_HIT_BONUS: "toHitBonus",
TO_HIT: "toHit",
WEIGHT: "weight",
};
interface SelectEditorProps {
label: string;
propertyKey: string;
defaultValue: number | null;
options: Array<any>;
onUpdate: (propertyKey: string, value: number | null) => void;
isEnabled: boolean;
}
interface SelectEditorState {
value: number | null;
}
class SelectEditor extends React.PureComponent<
SelectEditorProps,
SelectEditorState
> {
static defaultProps = {
options: [],
};
constructor(props: SelectEditorProps) {
super(props);
this.state = {
value: props.defaultValue,
};
}
handleChange = (value: string): void => {
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
let parsedValue = HelperUtils.parseInputInt(value);
onUpdate(propertyKey, parsedValue);
this.setState({
value: parsedValue,
});
}
};
render() {
const { value } = this.state;
const { propertyKey, label, options, isEnabled } = this.props;
if (!isEnabled) {
return null;
}
return (
<CustomizeDataEditorProperty propertyKey={propertyKey}>
<CustomizeDataEditorPropertyValue>
<Select
placeholder="--"
options={options}
value={value}
onChange={this.handleChange}
/>
</CustomizeDataEditorPropertyValue>
<CustomizeDataEditorPropertyLabel>
{label}
</CustomizeDataEditorPropertyLabel>
</CustomizeDataEditorProperty>
);
}
}
interface CheckboxEditorProps {
label: string;
propertyKey: string;
initiallyEnabled: boolean;
onUpdate: (propertyKey: string, value: boolean) => void;
isEnabled: boolean;
}
class CheckboxEditor extends React.PureComponent<CheckboxEditorProps> {
handleChange = (isEnabled: boolean): void => {
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
onUpdate(propertyKey, isEnabled);
}
};
render() {
const { propertyKey, label, initiallyEnabled, isEnabled } = this.props;
if (!isEnabled) {
return null;
}
return (
<CustomizeDataEditorProperty propertyKey={propertyKey}>
<CustomizeDataEditorPropertyValue>
<Checkbox
label={label}
initiallyEnabled={initiallyEnabled}
enabled={initiallyEnabled}
onChange={this.handleChange}
/>
</CustomizeDataEditorPropertyValue>
</CustomizeDataEditorProperty>
);
}
}
interface NumberEditorProps {
label: string;
propertyKey: string;
defaultValue: number | null;
minimumValue: number | null;
maximumValue: number | null;
onUpdate: (propertyKey: string, value: number | null) => void;
isEnabled: boolean;
}
interface NumberEditorState {
value: number | null;
}
class NumberEditor extends React.PureComponent<
NumberEditorProps,
NumberEditorState
> {
static defaultProps = {
minimumValue: null,
maximumValue: null,
};
constructor(props: NumberEditorProps) {
super(props);
this.state = {
value: props.defaultValue,
};
}
componentDidUpdate(
prevProps: Readonly<NumberEditorProps>,
prevState: Readonly<NumberEditorState>
): void {
const { defaultValue } = this.props;
if (defaultValue !== prevProps.defaultValue) {
this.setState({
value: defaultValue,
});
}
}
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { propertyKey, onUpdate, minimumValue, maximumValue } = this.props;
let parsedValue = HelperUtils.parseInputInt(evt.target.value);
let newValue: number | null =
parsedValue === null
? null
: HelperUtils.clampInt(parsedValue, minimumValue, maximumValue);
if (onUpdate) {
onUpdate(propertyKey, newValue);
}
this.setState({
value: newValue,
});
};
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
value: HelperUtils.parseInputInt(evt.target.value),
});
};
render() {
const { value } = this.state;
const { propertyKey, label, minimumValue, maximumValue, isEnabled } =
this.props;
if (!isEnabled) {
return null;
}
return (
<CustomizeDataEditorProperty propertyKey={propertyKey}>
<CustomizeDataEditorPropertyValue>
<input
type="number"
min={minimumValue ?? ""}
max={maximumValue ?? ""}
value={value ?? ""}
onBlur={this.handleBlur}
onChange={this.handleChange}
/>
</CustomizeDataEditorPropertyValue>
<CustomizeDataEditorPropertyLabel>
{label}
</CustomizeDataEditorPropertyLabel>
</CustomizeDataEditorProperty>
);
}
}
interface TextAreaEditorProps {
label: string;
propertyKey: string;
defaultValue: string | null;
onUpdate: (propertyKey: string, value: string | null) => void;
isEnabled: boolean;
}
interface TextAreaEditorState {
value: string;
}
class TextAreaEditor extends React.PureComponent<
TextAreaEditorProps,
TextAreaEditorState
> {
constructor(props: TextEditorProps) {
super(props);
this.state = {
value: props.defaultValue ?? "",
};
}
componentDidUpdate(
prevProps: Readonly<TextAreaEditorProps>,
prevState: Readonly<TextAreaEditorState>
): void {
const { defaultValue } = this.props;
if (defaultValue !== prevProps.defaultValue) {
this.setState({
value: defaultValue ?? "",
});
}
}
handleBlur = (evt: React.FocusEvent<HTMLTextAreaElement>): void => {
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
onUpdate(propertyKey, evt.target.value);
}
};
handleChange = (evt: React.FocusEvent<HTMLTextAreaElement>): void => {
const { value } = this.state;
if (value !== evt.target.value) {
this.setState({
value: evt.target.value,
});
}
};
render() {
const { propertyKey, label, isEnabled } = this.props;
const { value } = this.state;
if (!isEnabled) {
return null;
}
return (
<CustomizeDataEditorProperty propertyKey={propertyKey} isBlock={true}>
<CustomizeDataEditorPropertyValue>
<textarea
value={value}
onBlur={this.handleBlur}
onChange={this.handleChange}
/>
</CustomizeDataEditorPropertyValue>
<CustomizeDataEditorPropertyLabel>
{label}
</CustomizeDataEditorPropertyLabel>
</CustomizeDataEditorProperty>
);
}
}
interface TextEditorProps {
label: string;
propertyKey: string;
defaultValue: string | null;
onUpdate: (propertyKey: string, value: string | null) => void;
isEnabled: boolean;
maxLength: number | null;
initialFocus?: boolean;
}
interface TextAreaState {
value: string | null;
}
class TextEditor extends React.PureComponent<TextEditorProps, TextAreaState> {
static defaultProps = {
maxLength: null,
};
constructor(props: TextEditorProps) {
super(props);
this.state = {
value: props.defaultValue,
};
}
inputRef = React.createRef<HTMLInputElement>();
componentDidMount() {
if (this.inputRef.current && this.props.initialFocus) {
this.inputRef.current.focus();
}
}
componentDidUpdate(
prevProps: Readonly<TextEditorProps>,
prevState: Readonly<TextAreaState>
): void {
const { defaultValue } = this.props;
if (defaultValue !== prevProps.defaultValue) {
this.setState({
value: defaultValue,
});
}
}
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
onUpdate(propertyKey, evt.target.value);
}
};
handleChange = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { value } = this.state;
if (value !== evt.target.value) {
this.setState({
value: evt.target.value,
});
}
};
render() {
const { propertyKey, label, isEnabled, maxLength } = this.props;
const { value } = this.state;
if (!isEnabled) {
return null;
}
return (
<CustomizeDataEditorProperty propertyKey={propertyKey} isBlock={true}>
<CustomizeDataEditorPropertyValue>
<input
type="text"
maxLength={maxLength ?? undefined}
value={value ?? ""}
onBlur={this.handleBlur}
onChange={this.handleChange}
ref={this.inputRef}
/>
</CustomizeDataEditorPropertyValue>
<CustomizeDataEditorPropertyLabel>
{label}
</CustomizeDataEditorPropertyLabel>
</CustomizeDataEditorProperty>
);
}
}
type DataLookup = Record<string, any>;
interface Props {
data: DataLookup;
onDataUpdate: (data: DataLookup) => void;
enableActivationType: boolean;
enableActivationTime: boolean;
enableAoeType: boolean;
enableAoeSize: boolean;
enableAttackSubtype: boolean;
enableCost: boolean;
enableDamageBonus: boolean;
enableDamageType: boolean;
enableDescription: boolean;
enableDiceCount: boolean;
enableDiceType: boolean;
enableFixedValue: boolean;
enableIsAdamantine: boolean;
enableIsMartialArts: boolean;
enableIsOffhand: boolean;
enableIsProficient: boolean;
enableIsSilver: boolean;
enableLongRange: boolean;
enableMagicBonus: boolean;
enableMiscBonus: boolean;
enableName: boolean;
enableNotes: boolean;
enableOverride: boolean;
enableProficiencyLevel: boolean;
enableRange: boolean;
enableRangeType: boolean;
enableSaveDcBonus: boolean;
enableSaveDc: boolean;
enableSaveType: boolean;
enableSnippet: boolean;
enableSpellRangeType: boolean;
enableStat: boolean;
enableToHitBonus: boolean;
enableToHit: boolean;
enableWeight: boolean;
enableDisplayAsAttack: boolean;
maxNameLength: number;
fallbackValues: Record<string, any>;
labelOverrides: Record<string, string>;
damageTypeOptions?: Array<HtmlSelectOption>;
diceTypeOptions?: Array<HtmlSelectOption>;
rangeOptions?: Array<HtmlSelectOption>;
statOptions?: Array<HtmlSelectOption>;
attackSubtypeOptions?: Array<HtmlSelectOption>;
activationTypeOptions?: Array<HtmlSelectOption>;
spellRangeTypeOptions?: Array<HtmlSelectOption>;
aoeTypeOptions?: Array<HtmlSelectOption>;
proficiencyLevelOptions?: Array<HtmlSelectOption>;
className: Array<string>;
}
interface State {
data: DataLookup;
}
export default class CustomizeDataEditor extends React.PureComponent<
Props,
State
> {
static defaultProps = {
enableActivationTime: false,
enableActivationType: false,
enableAoeType: false,
enableAoeSize: false,
enableAttackSubtype: false,
enableDamageBonus: false,
enableName: false,
enableNotes: false,
enableSnippet: false,
enableToHitBonus: false,
enableToHit: false,
enableDisplayAsAttack: false,
enableCost: false,
enableIsAdamantine: false,
enableDescription: false,
enableIsMartialArts: false,
enableIsOffhand: false,
enableIsProficient: false,
enableIsSilver: false,
enableSaveDcBonus: false,
enableSaveDc: false,
enableSaveType: false,
enableSpellRangeType: false,
enableWeight: false,
enableProficiencyLevel: false,
enableMagicBonus: false,
enableMiscBonus: false,
enableOverride: false,
enableRange: false,
enableLongRange: false,
enableRangeType: false,
enableStat: false,
enableDiceCount: false,
enableDiceType: false,
enableFixedValue: false,
enableDamageType: false,
maxNameLength: 256,
fallbackValues: {},
labelOverrides: {},
className: "",
};
constructor(props: Props) {
super(props);
this.state = {
data: props.data,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { data } = this.props;
if (data !== prevProps.data) {
this.setState({
data,
});
}
}
handleDataUpdate = (key: string, value: any): void => {
const { onDataUpdate } = this.props;
this.setState((prevState: State) => {
let newData: DataLookup = {
...prevState.data,
[key]: value,
};
if (onDataUpdate) {
onDataUpdate(newData);
}
return {
data: newData,
};
});
};
getPropertyDefaultValue = (key: string, fallback: any = null): any => {
if (this.state.data.hasOwnProperty(key) && this.state.data[key] !== null) {
return this.state.data[key];
}
if (this.props.fallbackValues.hasOwnProperty(key)) {
return this.props.fallbackValues[key];
}
return fallback;
};
getPropertyLabelOverride = (key: string, fallback: any): any => {
if (this.props.labelOverrides.hasOwnProperty(key)) {
return this.props.labelOverrides[key];
}
return fallback;
};
render() {
const {
enableActivationTime,
enableActivationType,
enableAoeSize,
enableAoeType,
enableAttackSubtype,
enableCost,
enableDisplayAsAttack,
enableDamageBonus,
enableDamageType,
enableDescription,
enableDiceCount,
enableDiceType,
enableFixedValue,
enableIsAdamantine,
enableIsMartialArts,
enableIsOffhand,
enableIsProficient,
enableIsSilver,
enableLongRange,
enableMagicBonus,
enableMiscBonus,
enableName,
enableNotes,
enableOverride,
enableProficiencyLevel,
enableRange,
enableRangeType,
enableStat,
enableSaveDcBonus,
enableSaveDc,
enableSaveType,
enableSnippet,
enableSpellRangeType,
enableToHitBonus,
enableToHit,
enableWeight,
damageTypeOptions,
diceTypeOptions,
rangeOptions,
statOptions,
attackSubtypeOptions,
activationTypeOptions,
spellRangeTypeOptions,
aoeTypeOptions,
proficiencyLevelOptions,
className,
maxNameLength,
} = this.props;
return (
<div className={`ct-customize-data-editor ${className}`}>
<div className="ct-customize-data-editor__properties">
<SelectEditor
propertyKey={CUSTOMIZE_KEY.ATTACK_SUBTYPE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.ATTACK_SUBTYPE
)}
label="Attack Type"
options={attackSubtypeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableAttackSubtype}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.TO_HIT}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.TO_HIT)}
label="To Hit Override"
onUpdate={this.handleDataUpdate}
isEnabled={enableToHitBonus}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.TO_HIT_BONUS}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.TO_HIT_BONUS
)}
label="To Hit Bonus"
minimumValue={1}
onUpdate={this.handleDataUpdate}
isEnabled={enableToHit}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.OVERRIDE}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.OVERRIDE)}
label="Override"
onUpdate={this.handleDataUpdate}
isEnabled={enableOverride}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.MAGIC_BONUS}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.MAGIC_BONUS
)}
label="Magic Bonus"
onUpdate={this.handleDataUpdate}
isEnabled={enableMagicBonus}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.MISC_BONUS}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.MISC_BONUS
)}
label="Misc Bonus"
onUpdate={this.handleDataUpdate}
isEnabled={enableMiscBonus}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.RANGE_TYPE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.RANGE_TYPE
)}
label="Range"
options={rangeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableRangeType}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.STAT}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.STAT)}
label="Stat"
options={statOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableStat}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.PROFICIENCY_LEVEL}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.PROFICIENCY_LEVEL
)}
label="Proficiency Level"
options={proficiencyLevelOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableProficiencyLevel}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.DICE_COUNT}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.DICE_COUNT
)}
minimumValue={1}
label="Dice Count"
onUpdate={this.handleDataUpdate}
isEnabled={enableDiceCount}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.DICE_TYPE}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.DICE_TYPE)}
label="Die Type"
options={diceTypeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableDiceType}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.FIXED_VALUE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.FIXED_VALUE
)}
minimumValue={1}
label={this.getPropertyLabelOverride(
CUSTOMIZE_KEY.FIXED_VALUE,
"Additional Bonus"
)}
onUpdate={this.handleDataUpdate}
isEnabled={enableFixedValue}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.DAMAGE_TYPE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.DAMAGE_TYPE
)}
label="Damage Type"
options={damageTypeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableDamageType}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.DAMAGE_BONUS}
minimumValue={1}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.DAMAGE_BONUS
)}
label="Damage Bonus"
onUpdate={this.handleDataUpdate}
isEnabled={enableDamageBonus}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.SAVE_TYPE}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.SAVE_TYPE)}
label="Save Type"
options={statOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableSaveType}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.SAVE_DC}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.SAVE_DC)}
minimumValue={0}
label="Fixed Save DC"
onUpdate={this.handleDataUpdate}
isEnabled={enableSaveDc}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.SAVE_DC_BONUS}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.SAVE_DC_BONUS
)}
label="DC Bonus"
onUpdate={this.handleDataUpdate}
isEnabled={enableSaveDcBonus}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.COST}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.COST)}
minimumValue={0}
label="Cost Override"
onUpdate={this.handleDataUpdate}
isEnabled={enableCost}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.WEIGHT}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.WEIGHT)}
minimumValue={0}
label="Weight Override"
onUpdate={this.handleDataUpdate}
isEnabled={enableWeight}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.SPELL_RANGE_TYPE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.SPELL_RANGE_TYPE
)}
label="Spell Range Type"
options={spellRangeTypeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableSpellRangeType}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.RANGE}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.RANGE)}
minimumValue={0}
label="Range"
onUpdate={this.handleDataUpdate}
isEnabled={enableRange}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.LONG_RANGE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.LONG_RANGE
)}
minimumValue={0}
label="Long Range"
onUpdate={this.handleDataUpdate}
isEnabled={enableLongRange}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.AOE_TYPE}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.AOE_TYPE)}
label="AoE Type"
options={aoeTypeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableAoeType}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.AOE_SIZE}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.AOE_SIZE)}
minimumValue={0}
label="AoE Size"
onUpdate={this.handleDataUpdate}
isEnabled={enableAoeSize}
/>
<SelectEditor
propertyKey={CUSTOMIZE_KEY.ACTIVATION_TYPE}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.ACTIVATION_TYPE
)}
label="Activation Type"
options={activationTypeOptions}
onUpdate={this.handleDataUpdate}
isEnabled={enableActivationType}
/>
<NumberEditor
propertyKey={CUSTOMIZE_KEY.ACTIVATION_TIME}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.ACTIVATION_TIME
)}
minimumValue={0}
label="Activation Time"
onUpdate={this.handleDataUpdate}
isEnabled={enableActivationTime}
/>
<CheckboxEditor
propertyKey={CUSTOMIZE_KEY.IS_MARTIAL_ARTS}
initiallyEnabled={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.IS_MARTIAL_ARTS
)}
label="Affected by Martial Arts"
onUpdate={this.handleDataUpdate}
isEnabled={enableIsMartialArts}
/>
<CheckboxEditor
propertyKey={CUSTOMIZE_KEY.IS_PROFICIENT}
initiallyEnabled={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.IS_PROFICIENT
)}
label="Proficient"
onUpdate={this.handleDataUpdate}
isEnabled={enableIsProficient}
/>
<CheckboxEditor
propertyKey={CUSTOMIZE_KEY.IS_OFFHAND}
initiallyEnabled={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.IS_OFFHAND
)}
label="Dual Wield"
onUpdate={this.handleDataUpdate}
isEnabled={enableIsOffhand}
/>
<CheckboxEditor
propertyKey={CUSTOMIZE_KEY.IS_SILVER}
initiallyEnabled={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.IS_SILVER
)}
label="Silvered"
onUpdate={this.handleDataUpdate}
isEnabled={enableIsSilver}
/>
<CheckboxEditor
propertyKey={CUSTOMIZE_KEY.IS_ADAMANTINE}
initiallyEnabled={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.IS_ADAMANTINE
)}
label="Adamantine"
onUpdate={this.handleDataUpdate}
isEnabled={enableIsAdamantine}
/>
<CheckboxEditor
propertyKey={CUSTOMIZE_KEY.DISPLAY_AS_ATTACK}
initiallyEnabled={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.DISPLAY_AS_ATTACK
)}
label="Display as Attack"
onUpdate={this.handleDataUpdate}
isEnabled={enableDisplayAsAttack}
/>
<TextEditor
propertyKey={CUSTOMIZE_KEY.NAME}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.NAME)}
label="Name"
maxLength={maxNameLength}
onUpdate={this.handleDataUpdate}
isEnabled={enableName}
initialFocus={true}
/>
<TextEditor
propertyKey={CUSTOMIZE_KEY.NOTES}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.NOTES)}
label="Notes"
onUpdate={this.handleDataUpdate}
isEnabled={enableNotes}
/>
<TextAreaEditor
propertyKey={CUSTOMIZE_KEY.SNIPPET}
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.SNIPPET)}
label="Snippet"
onUpdate={this.handleDataUpdate}
isEnabled={enableSnippet}
/>
<TextAreaEditor
propertyKey={CUSTOMIZE_KEY.DESCRIPTION}
defaultValue={this.getPropertyDefaultValue(
CUSTOMIZE_KEY.DESCRIPTION
)}
label="Description"
onUpdate={this.handleDataUpdate}
isEnabled={enableDescription}
/>
</div>
</div>
);
}
}
@@ -0,0 +1,27 @@
import React from "react";
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
interface Props {
isBlock: boolean;
propertyKey: string;
}
export default class CustomizeDataEditorProperty extends React.PureComponent<Props> {
static defaultProps = {
isBlock: false,
};
render() {
const { isBlock, propertyKey } = this.props;
let classNames: Array<string> = [
"ct-customize-data-editor__property",
`ct-customize-data-editor__property--${FormatUtils.slugify(propertyKey)}`,
];
if (isBlock) {
classNames.push("ct-customize-data-editor__property--block");
}
return <div className={classNames.join(" ")}>{this.props.children}</div>;
}
}
@@ -0,0 +1,4 @@
import CustomizeDataEditorProperty from "./CustomizeDataEditorProperty";
export default CustomizeDataEditorProperty;
export { CustomizeDataEditorProperty };
@@ -0,0 +1,11 @@
import React from "react";
export default class CustomizeDataEditorPropertyLabel extends React.PureComponent {
render() {
return (
<div className="ct-customize-data-editor__property-label">
{this.props.children}
</div>
);
}
}
@@ -0,0 +1,4 @@
import CustomizeDataEditorPropertyLabel from "./CustomizeDataEditorPropertyLabel";
export default CustomizeDataEditorPropertyLabel;
export { CustomizeDataEditorPropertyLabel };
@@ -0,0 +1,11 @@
import React from "react";
export default class CustomizeDataEditorPropertyValue extends React.PureComponent {
render() {
return (
<div className="ct-customize-data-editor__property-value">
{this.props.children}
</div>
);
}
}
@@ -0,0 +1,4 @@
import CustomizeDataEditorPropertyValue from "./CustomizeDataEditorPropertyValue";
export default CustomizeDataEditorPropertyValue;
export { CustomizeDataEditorPropertyValue };
@@ -0,0 +1,12 @@
import CustomizeDataEditor from "./CustomizeDataEditor";
import CustomizeDataEditorProperty from "./CustomizeDataEditorProperty";
import CustomizeDataEditorPropertyLabel from "./CustomizeDataEditorPropertyLabel";
import CustomizeDataEditorPropertyValue from "./CustomizeDataEditorPropertyValue";
export default CustomizeDataEditor;
export {
CustomizeDataEditor,
CustomizeDataEditorProperty,
CustomizeDataEditorPropertyLabel,
CustomizeDataEditorPropertyValue,
};
@@ -0,0 +1,144 @@
import React from "react";
import {
AdvantageIcon,
DarkModeNegativeBonusNegativeSvg,
DarkModePositiveBonusPositiveSvg,
DataOriginName,
DisadvantageIcon,
NegativeBonusNegativeSvg,
PositiveBonusPositiveSvg,
} from "@dndbeyond/character-components/es";
import {
DiceAdjustment,
DataOrigin,
Constants,
FormatUtils,
RuleData,
RuleDataUtils,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
diceAdjustment: DiceAdjustment;
ruleData: RuleData;
showDataOrigin: boolean;
onDataOriginClick?: (dataOrigin: DataOrigin) => void;
theme: CharacterTheme;
}
export default class DiceAdjustmentSummary extends React.PureComponent<
Props,
{}
> {
static defaultProps = {
showDataOrigin: true,
};
render() {
const {
diceAdjustment,
ruleData,
showDataOrigin,
onDataOriginClick,
theme,
} = this.props;
const { statId, restriction, type, rollType, amount, dataOrigin } =
diceAdjustment;
let abilityNode: React.ReactNode;
if (statId) {
abilityNode = (
<React.Fragment>
on{" "}
<span
className={`ct-dice-adjustment-summary__description--ability ${
theme?.isDarkMode
? "ct-dice-adjustment-summary__description--ability--dark-mode"
: ""
}`}
>
{RuleDataUtils.getStatNameById(statId, ruleData)}
</span>
</React.Fragment>
);
} else if (rollType === Constants.DiceAdjustmentRollTypeEnum.DEATH_SAVE) {
abilityNode = <React.Fragment>on Death Saves</React.Fragment>;
} else {
if (
!restriction &&
rollType === Constants.DiceAdjustmentRollTypeEnum.SAVE
) {
abilityNode = <React.Fragment>on saves</React.Fragment>;
}
}
let saveAmount: number | null = null;
let IconComponent: React.ComponentType<any> | null = null;
switch (type) {
case Constants.DiceAdjustmentTypeEnum.BONUS:
if (amount) {
saveAmount = amount;
if (amount >= 0) {
IconComponent = theme?.isDarkMode
? DarkModePositiveBonusPositiveSvg
: PositiveBonusPositiveSvg;
} else {
IconComponent = theme?.isDarkMode
? DarkModeNegativeBonusNegativeSvg
: NegativeBonusNegativeSvg;
}
}
break;
case Constants.DiceAdjustmentTypeEnum.ADVANTAGE:
IconComponent = AdvantageIcon;
break;
case Constants.DiceAdjustmentTypeEnum.DISADVANTAGE:
IconComponent = DisadvantageIcon;
break;
default:
// not implemented
}
let dataOriginNode: React.ReactNode;
if (showDataOrigin) {
dataOriginNode = (
<DataOriginName
dataOrigin={dataOrigin}
onClick={onDataOriginClick}
theme={theme}
/>
);
}
return (
<div className="ct-dice-adjustment-summary">
{IconComponent && (
<IconComponent
theme={theme}
className="ct-dice-adjustment-summary__icon"
/>
)}
{typeof saveAmount === "number" && (
<span className="ct-dice-adjustment-summary__value">
{saveAmount}
</span>
)}
{abilityNode && (
<span className="ct-dice-adjustment-summary__description">
{abilityNode}
</span>
)}
{restriction !== null && restriction.length > 0 && (
<span className="ct-dice-adjustment-summary__restriction">
{FormatUtils.lowerCaseLetters(restriction.trim(), 0)}
</span>
)}
{showDataOrigin && dataOriginNode && (
<span className="ct-dice-adjustment-summary__data-origin">
({dataOriginNode})
</span>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import DiceAdjustmentSummary from "./DiceAdjustmentSummary";
export default DiceAdjustmentSummary;
export { DiceAdjustmentSummary };
@@ -0,0 +1,13 @@
import clsx from "clsx";
import React from "react";
interface Props {
className?: string,
}
export default class EditorBox extends React.PureComponent<Props> {
render() {
const { children, className } = this.props;
return <div className={clsx(["ct-editor-box", className])}>{children}</div>;
}
}
@@ -0,0 +1,4 @@
import EditorBox from "./EditorBox";
export default EditorBox;
export { EditorBox };
@@ -0,0 +1,386 @@
import axios, { Canceler } from "axios";
import React from "react";
import { useContext } from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
Container,
ContainerUtils,
CharacterTheme,
Item,
RuleData,
PartyInfo,
InventoryManager,
ItemManager,
ContainerManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemFilter } from "~/components/ItemFilter";
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
import { AppLoggerUtils } from "../../utils";
import { ThemeButton } from "../common/Button";
import { EquipmentShopItem } from "./EquipmentShopItem";
interface Props {
items?: Array<Item>;
pageSize: number;
// CAN THIS GO AS WELL!?
ruleData: RuleData;
proficiencyBonus: number;
containers: Array<Container>;
theme: CharacterTheme;
limitAddToCurrentContainer: Container | null;
partyInfo: PartyInfo | null;
inventoryManager: InventoryManager;
}
interface State {
query: "";
shoppeContainer: ContainerManager | null;
filteredItems: Array<Item>;
currentPage: number;
loading: boolean;
loaded: boolean;
filterTypes: Array<string>;
filterQuery: string;
filterProficient: boolean;
filterBasic: boolean;
filterMagic: boolean;
filterContainer: boolean;
filterSourceCategories: Array<number>;
}
export class EquipmentShop extends React.PureComponent<Props, State> {
static defaultProps = {
pageSize: 12,
limitAddToCurrentContainer: null,
};
// TODO: I am going to hold off on this
loadItemsCanceler: null | Canceler = null;
constructor(props: Props) {
super(props);
this.state = {
query: "",
shoppeContainer: null,
filteredItems: props.items ? props.items : [],
currentPage: 0,
loading: false,
loaded: false,
filterTypes: [],
filterQuery: "",
filterProficient: false,
filterBasic: false,
filterMagic: false,
filterContainer: false,
filterSourceCategories: [],
};
}
componentDidMount() {
const { inventoryManager } = this.props;
if (inventoryManager) {
this.setState({
loading: true,
});
// promise or callback vs async/await? geeze
inventoryManager
.getInventoryShoppe({
onSuccess: (shoppeContainer: ContainerManager) => {
this.setState({
shoppeContainer,
loading: false,
loaded: true,
});
},
additionalApiConfig: {
cancelToken: new axios.CancelToken((c) => {
this.loadItemsCanceler = c;
}),
},
})
.then((res) => {
this.loadItemsCanceler = null;
return res;
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
this.setState({
loaded: true,
});
}
}
componentWillUnmount(): void {
if (this.loadItemsCanceler !== null) {
this.loadItemsCanceler();
}
}
isFiltered = (): boolean => {
const {
filterQuery,
filterTypes,
filterProficient,
filterMagic,
filterBasic,
filterContainer,
filterSourceCategories,
} = this.state;
if (filterProficient || filterMagic || filterBasic || filterContainer) {
return true;
}
if (filterQuery) {
return true;
}
if (filterTypes.length || filterSourceCategories.length) {
return true;
}
return false;
};
handlePageMore = (): void => {
this.setState((prevState) => ({
currentPage: prevState.currentPage + 1,
}));
};
handleQueryChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
filterQuery: evt.target.value,
currentPage: 0,
});
};
handleFilterItemType = (type: string): void => {
this.setState((prevState: State) => ({
filterTypes: prevState.filterTypes.includes(type)
? prevState.filterTypes.filter((t) => t !== type)
: [...prevState.filterTypes, type],
currentPage: 0,
}));
};
handleSourceCategoryClick = (categoryId: number): void => {
this.setState((prevState: State) => ({
filterSourceCategories: prevState.filterSourceCategories.includes(
categoryId
)
? prevState.filterSourceCategories.filter((cat) => cat !== categoryId)
: [...prevState.filterSourceCategories, categoryId],
currentPage: 0,
}));
};
handleToggleFilter = (type: string): void => {
switch (type) {
case "Proficient":
this.handleProficientToggle();
break;
case "Common":
this.handleBasicToggle();
break;
case "Magical":
this.handleMagicToggle();
break;
case "Container":
this.handleContainerToggle();
break;
default:
}
};
handleProficientToggle = (): void => {
this.setState((prevState: State) => ({
filterProficient: !prevState.filterProficient,
currentPage: 0,
}));
};
handleBasicToggle = (): void => {
this.setState((prevState: State) => ({
filterBasic: !prevState.filterBasic,
currentPage: 0,
}));
};
handleMagicToggle = (): void => {
this.setState((prevState: State) => ({
filterMagic: !prevState.filterMagic,
currentPage: 0,
}));
};
handleContainerToggle = (): void => {
this.setState((prevState: State) => ({
filterContainer: !prevState.filterContainer,
currentPage: 0,
}));
};
renderPager = (
filteredItems: Array<ItemManager>,
totalItems: number
): React.ReactNode => {
if (!this.isFiltered()) {
return null;
}
if (filteredItems.length >= totalItems) {
return null;
}
return (
<div className="ct-equipment-shop__pager">
<ThemeButton block={true} onClick={this.handlePageMore}>
Load More
</ThemeButton>
</div>
);
};
renderPagedListing = (filteredItems: Array<ItemManager>): React.ReactNode => {
const { currentPage } = this.state;
const {
pageSize,
ruleData,
proficiencyBonus,
containers,
theme,
limitAddToCurrentContainer,
partyInfo,
} = this.props;
if (!this.isFiltered()) {
return null;
}
const pagedFilteredItems: Array<ItemManager> = filteredItems.slice(
0,
(currentPage + 1) * pageSize
);
return (
<div className="ct-equipment-shop__items">
{pagedFilteredItems.length ? (
pagedFilteredItems.map((item, idx) => {
let filteredContainers = containers;
const isContainer = item.isContainer();
const isPack = item.isPack();
if (isContainer || isPack) {
filteredContainers = containers.filter(
(container) =>
ContainerUtils.isCharacterContainer(container) ||
ContainerUtils.isPartyContainer(container)
);
} else if (limitAddToCurrentContainer) {
filteredContainers = [limitAddToCurrentContainer];
}
return (
<EquipmentShopItem
key={`${item.getDefinitionId()}-${idx}`}
containers={filteredContainers}
theme={theme}
item={item}
ruleData={ruleData}
proficiencyBonus={proficiencyBonus}
partyInfo={partyInfo}
/>
);
})
) : (
<div className="ct-equipment-shop__empty">No Results Found</div>
)}
</div>
);
};
renderUi = (): React.ReactNode => {
const {
filterTypes,
filterQuery,
filterProficient,
filterBasic,
filterMagic,
filterContainer,
filterSourceCategories,
currentPage,
} = this.state;
const { pageSize } = this.props;
const itemData = this.state.shoppeContainer?.getInventoryItems({
filterOptions: {
filterTypes,
filterQuery,
filterProficient,
filterBasic,
filterMagic,
filterContainer,
filterSourceCategories,
},
paginationOptions: {
currentPage,
pageSize,
},
isShoppe: true,
});
const items = itemData?.items ?? [];
const totalItems = itemData?.totalItems ?? 0;
const sourceCategories = itemData?.sourceCategories || [];
return (
<React.Fragment>
<ItemFilter
filterQuery={filterQuery}
onQueryChange={(value) =>
this.setState({
filterQuery: value,
})
}
filterTypes={filterTypes}
sourceCategories={sourceCategories}
onFilterButtonClick={this.handleFilterItemType}
onCheckboxChange={this.handleToggleFilter}
onSourceCategoryClick={this.handleSourceCategoryClick}
filterSourceCategories={filterSourceCategories}
filterProficient={filterProficient}
filterBasic={filterBasic}
filterMagic={filterMagic}
filterContainer={filterContainer}
themed
/>
{this.renderPagedListing(items)}
{this.renderPager(items, totalItems)}
</React.Fragment>
);
};
renderLoading = (): React.ReactNode => {
return <LoadingPlaceholder />;
};
render() {
const { loaded, loading } = this.state;
return (
<div className="ct-equipment-shop">
{loading && this.renderLoading()}
{!loading && loaded && this.renderUi()}
</div>
);
}
}
export default function EquipmentShopContainer(props) {
const { inventoryManager } = useContext(InventoryManagerContext);
return <EquipmentShop inventoryManager={inventoryManager} {...props} />;
}
@@ -0,0 +1,247 @@
import React from "react";
import {
Collapsible,
CollapsibleHeaderContent,
} from "@dndbeyond/character-components/es";
import {
CampaignUtils,
Container,
ContainerUtils,
CharacterTheme,
HelperUtils,
Item,
ItemUtils,
RuleData,
MenuOption,
PartyInfo,
Constants,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { AppNotificationUtils } from "../../../utils";
import ItemDetail from "../../ItemDetail";
import { ThemeButton, ThemeButtonWithMenu } from "../../common/Button";
interface Props {
item: ItemManager;
enableAdd: boolean;
minAddAmount: number;
maxAddAmount: number;
ruleData: RuleData;
proficiencyBonus: number;
containers: Array<Container>;
theme: CharacterTheme;
partyInfo: PartyInfo | null;
}
interface State {
amount: number | null;
}
export default class EquipmentShopItem extends React.PureComponent<
Props,
State
> {
static defaultProps = {
enableAdd: true,
minAddAmount: 1,
maxAddAmount: 10,
};
constructor(props: Props) {
super(props);
this.state = {
amount: 1,
};
}
handleAmountChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
amount: HelperUtils.parseInputInt(evt.target.value),
});
};
handleAmountBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { minAddAmount, maxAddAmount } = this.props;
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
const clampedValue = HelperUtils.clampInt(
parsedValue === null ? 0 : parsedValue,
minAddAmount,
maxAddAmount
);
this.setState({
amount: clampedValue,
});
};
handleDecrementCountClick = (): void => {
const { minAddAmount } = this.props;
this.setState((prevState, props) => ({
amount: Math.max(
(prevState.amount === null ? 0 : prevState.amount) - 1,
minAddAmount
),
}));
};
handleIncrementCountClick = (): void => {
this.setState((prevState, props) => ({
amount: (prevState.amount === null ? 0 : prevState.amount) + 1,
}));
};
handleAmountKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
const { minAddAmount, maxAddAmount } = this.props;
const parsedValue = HelperUtils.parseInputInt(
(evt.target as HTMLInputElement).value
);
const clampedValue = HelperUtils.clampInt(
parsedValue === null ? 0 : parsedValue,
minAddAmount,
maxAddAmount
);
this.setState({
amount: clampedValue,
});
};
handleAdd = (containerDefinitionKey?: string): void => {
const { amount: stateAmount } = this.state;
const { item, minAddAmount } = this.props;
const amount = stateAmount === null ? minAddAmount : stateAmount;
item.handleAdd(
{ amount, containerDefinitionKey },
AppNotificationUtils.handleItemAddAccepted.bind(this, item.item, amount),
AppNotificationUtils.handleItemAddRejected.bind(this, item.item, amount)
);
};
renderHeader = (
item: ItemManager,
metaItems: Array<string>
): React.ReactNode => {
const { containers, partyInfo } = this.props;
return (
<CollapsibleHeaderContent
heading={<ItemName item={item.item} />}
metaItems={metaItems}
callout={
<ThemeButtonWithMenu
groupedOptions={ContainerUtils.getGroupedOptions(
null,
containers,
"Add To:",
partyInfo
? CampaignUtils.getSharingState(partyInfo)
: Constants.PartyInventorySharingStateEnum.OFF
)}
onSelect={this.handleAdd}
>
Add
</ThemeButtonWithMenu>
}
/>
);
};
render() {
const { amount } = this.state;
const {
item,
minAddAmount,
maxAddAmount,
ruleData,
proficiencyBonus,
theme,
containers,
partyInfo,
} = this.props;
const totalAmount: number =
(amount === null ? 1 : amount) * item.getBundleSize();
return (
<Collapsible
layoutType={"minimal"}
header={this.renderHeader(item, item.getMetaText())}
className="ct-equipment-shop__item"
>
<ItemDetail
theme={theme}
item={item.item}
ruleData={ruleData}
showCustomize={false}
showActions={false}
showImage={false}
proficiencyBonus={proficiencyBonus}
/>
<div className="ct-equipment-shop__item-actions">
<div className="ct-equipment-shop__item-action">
<div className="ct-equipment-shop__item-amount">
<div className="ct-equipment-shop__item-amount-label">
Amount to add
</div>
<div className="ct-equipment-shop__item-amount-controls">
<div className="ct-equipment-shop__item-amount-decrease">
<ThemeButton
className="button-action-decrease button-action-decrease--small"
onClick={this.handleDecrementCountClick}
disabled={amount !== null && amount <= minAddAmount}
size="small"
/>
</div>
<div className="ct-equipment-shop__item-amount-value">
<input
type="number"
value={amount === null ? "" : amount}
className="character-input ct-equipment-shop__item-amount-input"
onChange={this.handleAmountChange}
onBlur={this.handleAmountBlur}
onKeyUp={this.handleAmountKeyUp}
min={minAddAmount}
max={maxAddAmount}
/>
</div>
<div className="ct-equipment-shop__item-amount-increase">
<ThemeButton
className="button-action-increase button-action-increase--small"
onClick={this.handleIncrementCountClick}
disabled={amount !== null && amount >= maxAddAmount}
size="small"
/>
</div>
</div>
</div>
</div>
<div className="ct-equipment-shop__item-action">
<ThemeButtonWithMenu
onSelect={(definitionKey) => this.handleAdd(definitionKey)}
containerEl={
document.querySelector(".ct-sidebar__portal") as HTMLElement
}
groupedOptions={ContainerUtils.getGroupedOptions(
null,
containers,
"Add To:",
partyInfo
? CampaignUtils.getSharingState(partyInfo)
: Constants.PartyInventorySharingStateEnum.OFF
)}
>
Add {totalAmount === 1 ? " Item" : ` ${totalAmount} Items`}
</ThemeButtonWithMenu>
</div>
</div>
</Collapsible>
);
}
}
@@ -0,0 +1,5 @@
import EquipmentShop from "./EquipmentShop";
import EquipmentShopItem from "./EquipmentShopItem";
export default EquipmentShop;
export { EquipmentShop, EquipmentShopItem };
@@ -0,0 +1,81 @@
import React from "react";
import { Link } from "~/components/Link";
import config from "../../../config";
import { ErrorUtils } from "../../utils";
interface Props {}
interface State {
hasError: boolean;
errorId: string | null;
}
export default class ErrorBoundary extends React.PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
hasError: false,
errorId: null,
};
}
static getDerivedStateFromError(error) {
return {
hasError: true,
};
}
componentDidCatch(error: Error, componentInfo: React.ErrorInfo) {
console.error("ERROR >>>", error);
ErrorUtils.dispatchException(error, {
componentInfo,
});
}
handleBack = (): void => {
window.history.back();
};
renderErrorUi = (): React.ReactNode => {
const { errorId } = this.state;
return (
<div className="ct-error-boundary">
<div className="ct-error-boundary__header">
<div className="ct-error-boundary__heading">
A beholder has appeared and ruined everything
</div>
</div>
<div className="ct-error-boundary__content">
<p>
The error that summoned it has been logged and the D&amp;D Beyond
team will hit the forge to make repairs.{" "}
<Link href="#" onClick={this.handleBack}>
Back to Previous Page
</Link>
</p>
</div>
<div className="ct-error-boundary__reporting">
{errorId !== null && (
<p>
Error Code:{" "}
<span className="ct-error-boundary__code">{errorId}</span>
</p>
)}
<p>
Version:{" "}
<span className="ct-error-boundary__code">{config.version}</span>
</p>
</div>
</div>
);
};
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return this.renderErrorUi();
}
return this.props.children;
}
}
@@ -0,0 +1,4 @@
import ErrorBoundary from "./ErrorBoundary";
export default ErrorBoundary;
export { ErrorBoundary };
@@ -0,0 +1,69 @@
import { orderBy } from "lodash";
import React from "react";
import {
CharacterTheme,
ExtraManager,
} from "@dndbeyond/character-rules-engine/es";
import { ExtraRow } from "../ExtraRow";
interface Props {
extras: Array<ExtraManager>;
showNotes: boolean;
onShow?: (extra: ExtraManager) => void;
onStatusChange?: (extra: ExtraManager, newStatus: boolean) => void;
isReadonly: boolean;
theme: CharacterTheme;
}
export default class ExtraList extends React.PureComponent<Props, {}> {
static defaultProps = {
showNotes: true,
isReadonly: false,
};
render() {
const { extras, showNotes, onShow, onStatusChange, isReadonly, theme } =
this.props;
let orderedExtras: Array<ExtraManager> = orderBy(extras, (extra) =>
extra.getName()
);
return (
<div className="ct-extra-list">
<div className="ct-extra-list__row-header">
<div className="ct-extra-list__col ct-extra-list__col--preview" />
<div className="ct-extra-list__col ct-extra-list__col--primary">
Name
</div>
<div className="ct-extra-list__col ct-extra-list__col--ac">AC</div>
<div className="ct-extra-list__col ct-extra-list__col--hp">
Hit Points
</div>
<div className="ct-extra-list__col ct-extra-list__col--speed">
Speed
</div>
{showNotes && (
<div className="ct-extra-list__col ct-extra-list__col--notes">
Notes
</div>
)}
</div>
<div className="ct-extra-list__items">
{orderedExtras.map((extra, idx) => (
<ExtraRow
theme={theme}
key={`${idx}-${extra.getUniqueKey()}`}
extra={extra}
onShow={onShow}
onStatusChange={onStatusChange}
showNotes={showNotes}
isReadonly={isReadonly}
/>
))}
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import ExtraList from "./ExtraList";
export default ExtraList;
export { ExtraList };
@@ -0,0 +1,66 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
Constants,
ExtraManager,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
extra: ExtraManager;
onClick?: (extra: ExtraManager) => void;
className: string;
theme?: CharacterTheme;
}
export default class ExtraName extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick, extra } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(extra);
}
};
render() {
const { extra, className, theme } = this.props;
let title: string = "Customized";
switch (extra.getExtraType()) {
case Constants.ExtraTypeEnum.CREATURE:
title = `Creature is ${title}`;
break;
case Constants.ExtraTypeEnum.VEHICLE:
title = `Vehicle is ${title}`;
break;
default:
//not implemented
}
let isCustomized = extra.isCustomized();
let classNames: Array<string> = [className, "ddbc-extra-name"];
return (
<span className={classNames.join(" ")} onClick={this.handleClick}>
{extra.getName()}
{isCustomized && (
<Tooltip
isDarkMode={theme?.isDarkMode}
title={title}
className="ddbc-extra-name__customized"
>
*
</Tooltip>
)}
</span>
);
}
}
@@ -0,0 +1,4 @@
import ExtraName from "./ExtraName";
export default ExtraName;
export { ExtraName };
@@ -0,0 +1,277 @@
import React, { FC, HTMLAttributes } from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { NoteComponents } from "@dndbeyond/character-components/es";
import {
CharacterTheme,
Constants,
Creature,
ExtraManager,
} from "@dndbeyond/character-rules-engine/es";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import ExtraName from "../ExtraName";
interface Props extends HTMLAttributes<HTMLDivElement> {
extra: ExtraManager;
onShow?: (extra: ExtraManager) => void;
onStatusChange?: (extra: ExtraManager, newStatus: boolean) => void;
showNotes: boolean;
isReadonly: boolean;
theme: CharacterTheme;
}
export const ExtraRow: FC<Props> = ({
extra,
onShow,
onStatusChange,
showNotes,
isReadonly = false,
theme,
...props
}) => {
const { hpInfo } = useCharacterEngine();
let avatarUrl = extra.getAvatarUrl();
if (!avatarUrl) {
avatarUrl = "";
}
const name = extra.getName();
const handleClick = (evt: React.MouseEvent): void => {
if (onShow) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onShow(extra);
}
};
const handleActivate = (): void => {
if (onStatusChange) {
onStatusChange(extra, true);
}
};
const handleDeactivate = (): void => {
if (onStatusChange) {
onStatusChange(extra, false);
}
};
const renderMetaItems = (): React.ReactNode => {
let metaItems: Array<string> = [];
const sizeInfo = extra.getSizeInfo();
let size: string = "";
if (sizeInfo !== null && sizeInfo.name !== null) {
size = sizeInfo.name;
}
let type = extra.getType();
let typeName: string = "";
if (type) {
typeName = type;
}
let description: string = `${size} ${typeName.toLowerCase()}`.trim();
if (description) {
metaItems.push(description);
}
return (
<div
className={`ct-extra-row__meta ${
theme.isDarkMode ? "ct-extra-row__meta--dark-mode" : ""
}`}
>
{metaItems.map((metaItem, idx) => (
<span key={idx} className="ct-extra-row__meta-item">
{metaItem}
</span>
))}
</div>
);
};
const renderHitPoints = (): React.ReactNode => {
let useOwnerHp = false;
if (extra.isCreature()) {
const extraData = extra.getExtraData() as Creature;
if (extraData) {
useOwnerHp = extraData.useOwnerHp;
}
}
const hitPointInfo = useOwnerHp ? hpInfo : extra.getHitPointInfo().data;
const showTooltip = useOwnerHp
? false
: extra.getHitPointInfo().showTooltip;
let contentNode: React.ReactNode = null;
if (hitPointInfo !== null) {
let totalHp: number = hitPointInfo.totalHp ? hitPointInfo.totalHp : 0;
let remainingHp: number = hitPointInfo.remainingHp
? hitPointInfo.remainingHp
: 0;
let tempHp: number = hitPointInfo.tempHp ? hitPointInfo.tempHp : 0;
const current: number = remainingHp + tempHp;
const total: number = totalHp + tempHp;
contentNode = (
<React.Fragment>
<span className="ct-extra-row__hp-value ct-extra-row__hp-value--current">
{current}
</span>
<span className="ct-extra-row__hp-sep">/</span>
<span className="ct-extra-row__hp-value ct-extra-row__hp-value--total">
{total}
</span>
</React.Fragment>
);
} else {
contentNode = (
<span className="ct-extra-row__hp-value ct-extra-row__hp-value--current">
--
</span>
);
}
let classNames: Array<string> = ["ct-extra-row__hp"];
if (
hitPointInfo !== null &&
hitPointInfo.tempHp !== null &&
hitPointInfo.tempHp > 0
) {
classNames.push("ct-extra-row__hp--has-temp");
}
if (theme.isDarkMode) {
classNames.push("ct-extra-row__hp--dark-mode");
}
return (
<div className={classNames.join(" ")}>
{contentNode}
{showTooltip && renderAdditionalInfoTooltip()}
</div>
);
};
const renderSpeed = (): React.ReactNode => {
const movementInfo = extra.getMovementInfo();
const { data, label, showTooltip } = movementInfo;
let contentNode: React.ReactNode = null;
if (data !== null) {
contentNode = (
<React.Fragment>
<div className="ct-extra-row__speed-value">
<NumberDisplay type="distanceInFt" number={data.speed} />
{showTooltip && renderAdditionalInfoTooltip()}
</div>
{data.movementId !== Constants.MovementTypeEnum.WALK && (
<div className="ct-extra-row__speed-callout">{label}</div>
)}
</React.Fragment>
);
} else {
contentNode = (
<div className="ct-extra-row__speed-value">
--
{showTooltip && renderAdditionalInfoTooltip()}
</div>
);
}
return (
<div
className={`ct-extra-row__speed ${
theme.isDarkMode ? "ct-extra-row__speed--dark-mode" : ""
}`}
>
{contentNode}
</div>
);
};
const renderArmorClass = (): React.ReactNode => {
let armorClassInfo = extra.getArmorClassInfo();
let armorClassText: string | number = "--";
if (armorClassInfo.value !== null) {
armorClassText = armorClassInfo.value;
}
return (
<React.Fragment>
{armorClassText}
{armorClassInfo.showTooltip && renderAdditionalInfoTooltip()}
</React.Fragment>
);
};
const renderAdditionalInfoTooltip = (): React.ReactNode => {
return (
<Tooltip
isDarkMode={theme.isDarkMode}
title={"View the full description for additional info"}
className={`ct-extra-row__tooltip ${
theme.isDarkMode ? "ct-extra-row__tooltip--dark-mode" : ""
}`}
>
*
</Tooltip>
);
};
return (
<div className="ct-extra-row" onClick={handleClick}>
<div className="ct-extra-row__preview">
{avatarUrl && (
<img
className="ct-extra-row__img"
src={avatarUrl}
alt={`${name} preview`}
/>
)}
</div>
<div className="ct-extra-row__primary">
<div
className={`ct-extra-row__name ${
theme.isDarkMode ? "ct-extra-row__name--dark-mode" : ""
}`}
>
<ExtraName theme={theme} extra={extra} />
</div>
{renderMetaItems()}
</div>
<div
className={`ct-extra-row__ac ${
theme.isDarkMode ? "ct-extra-row__ac--dark-mode" : ""
}`}
>
{renderArmorClass()}
</div>
{renderHitPoints()}
{renderSpeed()}
{showNotes && (
<div className="ct-extra-row__notes">
<NoteComponents theme={theme} notes={extra.getNoteComponents()} />
</div>
)}
{/*<div className="ct-extra-row__action">
<SlotManager
used={isActive ? 1 : 0}
available={1}
size="small"
onUse={this.handleActivate}
onClear={this.handleDeactivate}
isInteractive={!isReadonly}
/>
</div>*/}
</div>
);
};
@@ -0,0 +1,286 @@
import React from "react";
import {
CharacterHitPointChange,
CharacterUtils,
HelperUtils,
HitPointInfo,
} from "@dndbeyond/character-rules-engine/es";
import ThemeButton from "../common/Button/ThemeButton";
interface Props {
hitPointInfo: HitPointInfo;
enableVibration: boolean;
onSave?: (amount: number) => void;
onCancel?: () => void;
vibrationAmount: number;
}
interface State {
tickCount: number;
healingAmount: number | null;
damageAmount: number | null;
isDirtyHitPoints: boolean;
}
export default class HealthAdjuster extends React.PureComponent<Props, State> {
static defaultProps = {
enableVibration: true,
vibrationAmount: 15,
};
defaultState = {
tickCount: 0,
healingAmount: 0,
damageAmount: 0,
isDirtyHitPoints: false,
};
constructor(props) {
super(props);
this.state = {
...this.defaultState,
};
}
reset = (): void => {
this.setState(this.defaultState);
};
getDirtyHitPointsChange = (): number => {
const { tickCount, healingAmount, damageAmount } = this.state;
let healingAmountValue: number = healingAmount === null ? 0 : healingAmount;
let damageAmountValue: number = damageAmount === null ? 0 : damageAmount;
return tickCount + healingAmountValue + damageAmountValue;
};
calculateHitPoints = (): CharacterHitPointChange => {
const { hitPointInfo } = this.props;
return CharacterUtils.calculateHitPoints(
hitPointInfo,
this.getDirtyHitPointsChange()
);
};
handleIncrease = (): void => {
const { enableVibration, vibrationAmount } = this.props;
this.setState((prevState: State) => {
let newHealing: number | null = prevState.healingAmount;
let newDamage: number | null = prevState.damageAmount;
if (prevState.damageAmount === null || prevState.damageAmount === 0) {
newHealing =
prevState.healingAmount === null ? 1 : prevState.healingAmount + 1;
newDamage = 0;
} else if (prevState.damageAmount < 0) {
newDamage = prevState.damageAmount + 1;
}
return {
healingAmount: newHealing,
damageAmount: newDamage,
isDirtyHitPoints: true,
};
});
if (enableVibration && navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
handleDecrease = (): void => {
const { enableVibration, vibrationAmount } = this.props;
this.setState((prevState: State) => {
let newHealing: number | null = prevState.healingAmount;
let newDamage: number | null = prevState.damageAmount;
if (prevState.healingAmount === null || prevState.healingAmount === 0) {
newDamage =
prevState.damageAmount === null ? -1 : prevState.damageAmount - 1;
newHealing = 0;
} else if (prevState.healingAmount > 0) {
newHealing = prevState.healingAmount - 1;
}
return {
healingAmount: newHealing,
damageAmount: newDamage,
isDirtyHitPoints: true,
};
});
if (enableVibration && navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
handleTickChange = (tickCount: number): void => {
const { enableVibration, vibrationAmount } = this.props;
this.setState({
tickCount,
isDirtyHitPoints: true,
});
if (enableVibration && navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
handleHealingSet = (evt: React.ChangeEvent<HTMLInputElement>): void => {
let value = HelperUtils.parseInputInt(evt.target.value);
this.setState({
tickCount: 0,
healingAmount: value === null ? value : HelperUtils.clampInt(value, 0),
damageAmount: 0,
isDirtyHitPoints: true,
});
};
handleDamageSet = (evt: React.ChangeEvent<HTMLInputElement>): void => {
let value = HelperUtils.parseInputInt(evt.target.value);
this.setState({
tickCount: 0,
healingAmount: 0,
damageAmount:
value === null ? value : HelperUtils.clampInt(value, 0) * -1,
isDirtyHitPoints: true,
});
};
handleSave = (): void => {
const { onSave } = this.props;
if (onSave) {
onSave(this.getDirtyHitPointsChange());
}
this.reset();
};
handleCancel = (): void => {
const { onCancel } = this.props;
if (onCancel) {
onCancel();
}
this.reset();
};
renderActions = (): React.ReactNode => {
const { isDirtyHitPoints } = this.state;
if (!isDirtyHitPoints) {
return null;
}
return (
<div className="ct-health-adjuster__actions">
<div className="ct-health-adjuster__action">
<ThemeButton onClick={this.handleSave}>Apply Changes</ThemeButton>
</div>
<div className="ct-health-adjuster__action">
<ThemeButton onClick={this.handleCancel} style="outline">
Cancel
</ThemeButton>
</div>
</div>
);
};
render() {
const { healingAmount, damageAmount } = this.state;
const { hitPointInfo } = this.props;
let healingDisplay: React.ReactText =
healingAmount === null ? "" : Math.max(this.getDirtyHitPointsChange(), 0);
let damageDisplay: React.ReactText =
damageAmount === null
? ""
: Math.abs(Math.min(this.getDirtyHitPointsChange(), 0));
const { newTemp, newHp } = this.calculateHitPoints();
let hpDiffClassNames: Array<string> = ["ct-health-adjuster__new"];
if (newHp > hitPointInfo.remainingHp) {
hpDiffClassNames.push("ct-health-adjuster__status--positive");
} else if (newHp < hitPointInfo.remainingHp) {
hpDiffClassNames.push("ct-health-adjuster__status--negative");
}
let tempDiffClassNames: Array<string> = ["ct-health-adjuster__new"];
if (hitPointInfo.tempHp !== null) {
if (newTemp > hitPointInfo.tempHp) {
tempDiffClassNames.push("ct-health-adjuster__status--positive");
} else if (newTemp < hitPointInfo.tempHp) {
tempDiffClassNames.push("ct-health-adjuster__status--negative");
}
}
return (
<div className="ct-health-adjuster">
<div className="ct-health-adjuster__controls">
<div className="ct-health-adjuster__details">
<div className="ct-health-adjuster__healing">
<div className="ct-health-adjuster__healing-label">Healing</div>
<div className="ct-health-adjuster__healing-value">
<input
className="ct-health-adjuster__healing-input"
type="number"
value={healingDisplay}
onChange={this.handleHealingSet}
/>
</div>
</div>
<div className="ct-health-adjuster__updates">
<div className={hpDiffClassNames.join(" ")}>
<div className="ct-health-adjuster__new-label">New HP</div>
<div className="ct-health-adjuster__new-value">{newHp}</div>
</div>
{hitPointInfo.tempHp !== null && hitPointInfo.tempHp > 0 && (
<div className={tempDiffClassNames.join(" ")}>
<div className="ct-health-adjuster__new-label">New Temp</div>
<div className="ct-health-adjuster__new-value">{newTemp}</div>
</div>
)}
</div>
<div className="ct-health-adjuster__damage">
<div className="ct-health-adjuster__damage-label">Damage</div>
<div className="ct-health-adjuster__damage-value">
<input
className="ct-health-adjuster__damage-input"
type="number"
value={damageDisplay}
onChange={this.handleDamageSet}
/>
</div>
</div>
</div>
<div className="ct-health-adjuster__buttons">
<div className="ct-health-adjuster__button ct-health-adjuster__button--increase">
<ThemeButton
className="action-increase"
onClick={this.handleIncrease}
/>
</div>
<div className="ct-health-adjuster__button ct-health-adjuster__button--decrease">
<ThemeButton
className="action-decrease"
onClick={this.handleDecrease}
/>
</div>
</div>
</div>
{this.renderActions()}
</div>
);
}
}
@@ -0,0 +1,4 @@
import HealthAdjuster from "./HealthAdjuster";
export default HealthAdjuster;
export { HealthAdjuster };
@@ -0,0 +1,625 @@
import axios, { Canceler } from "axios";
import { orderBy } from "lodash";
import * as React from "react";
import { LoadingPlaceholder, Select } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
Constants,
ContainerManager,
DefinitionPool,
DefinitionPoolUtils,
DefinitionUtils,
EntitledEntity,
HelperUtils,
HtmlSelectOption,
HtmlSelectOptionGroup,
InfusionChoice,
InfusionChoiceUtils,
InfusionDefinitionContract,
InfusionUtils,
InventoryManager,
Item,
ItemUtils,
KnownInfusionUtils,
Modifier,
RuleData,
RuleDataUtils,
TypeValueLookup,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import DataLoadingStatusEnum from "../../constants/DataLoadingStatusEnum";
import { AppLoggerUtils, TypeScriptUtils } from "../../utils";
type OnInfusionChangeFunc = (
choiceKey: string,
infusionId: string | null,
oldInfusionId: string | null,
accept: () => void,
reject: () => void
) => void;
interface Props {
infusionChoices: Array<InfusionChoice>;
contextLevel: number | null;
globalModifiers: Array<Modifier>;
typeValueLookup: TypeValueLookup;
ruleData: RuleData;
definitionPool: DefinitionPool;
knownInfusionLookup: Record<string, InfusionChoice>;
knownReplicatedItems: Array<string>;
onInfusionChoiceItemChangePromise?: (
infusionChoiceKey: string,
infusionId: string,
itemDefinitionKey: string,
itemName: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceItemDestroyPromise?: (
infusionChoiceKey: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceChangePromise?: (
infusionChoiceKey: string,
infusionId: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceDestroyPromise?: (
infusionChoiceKey: string,
accept: () => void,
reject: () => void
) => void;
onInfusionChoiceCreatePromise?: (
infusionChoiceKey: string,
infusionId: string,
accept: () => void,
reject: () => void
) => void;
loadAvailableInfusions: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<InfusionDefinitionContract>>
>;
onDefinitionsLoaded?: (
definitions: Array<InfusionDefinitionContract>,
accessTypes: Record<string, number>
) => void;
inventoryManager: InventoryManager;
}
interface State {
equipment: Array<Item>;
loadingStatus: DataLoadingStatusEnum;
}
class InfusionChoiceManager extends React.PureComponent<Props, State> {
static defaultProps = {
loadAvailableInfusions: null,
};
loadItemsCanceler: null | Canceler = null;
loadInfusionsCanceler: null | Canceler = null;
constructor(props: Props) {
super(props);
this.state = {
equipment: [],
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
};
}
componentDidMount() {
const { loadingStatus } = this.state;
const {
infusionChoices,
inventoryManager,
loadAvailableInfusions,
globalModifiers,
typeValueLookup,
ruleData,
onDefinitionsLoaded,
} = this.props;
if (
infusionChoices.length &&
loadingStatus === DataLoadingStatusEnum.NOT_LOADED
) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
// TODO fix typings "any" once axios fixes how all can have multiple return types
axios
.all<any>([
inventoryManager.getInventoryShoppe({
onSuccess: (shoppeContainer: ContainerManager) => {
this.setState({
equipment: shoppeContainer
.getInventoryItems()
.items.map((item) => item.getItem()),
loadingStatus: DataLoadingStatusEnum.LOADED,
});
},
additionalApiConfig: {
cancelToken: new axios.CancelToken((c) => {
this.loadItemsCanceler = c;
}),
},
}),
loadAvailableInfusions({
cancelToken: new axios.CancelToken((c) => {
this.loadInfusionsCanceler = c;
}),
}),
])
.then(([equipmentResponse, infusionResponse]) => {
//do nothing with equipmentResponse since we moved converted to the inventoryManager which sets state on onsuccess
let infusionData =
ApiAdapterUtils.getResponseData<
EntitledEntity<InfusionDefinitionContract>
>(infusionResponse);
if (
infusionData &&
infusionData.definitionData.length > 0 &&
onDefinitionsLoaded
) {
onDefinitionsLoaded(
infusionData.definitionData,
infusionData.accessTypes
);
}
this.loadItemsCanceler = null;
this.loadInfusionsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
}
componentWillUnmount(): void {
if (this.loadItemsCanceler !== null) {
this.loadItemsCanceler();
}
if (this.loadInfusionsCanceler !== null) {
this.loadInfusionsCanceler();
}
}
getItemKeyParts = (itemKey: string): Array<string> => {
return itemKey.split("|");
};
getItemKey = (id: number, name: string): string => {
return [
DefinitionUtils.hack__generateDefinitionKey(
Constants.FUTURE_ITEM_DEFINITION_TYPE,
id
),
name,
].join("|");
};
handleChoiceItemChangePromise = (
infusionChoice: InfusionChoice,
itemKey: string | null,
oldItemKey: string | null,
accept: () => void,
reject: () => void
): void => {
const {
onInfusionChoiceItemChangePromise,
onInfusionChoiceItemDestroyPromise,
} = this.props;
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
if (choiceKey === null) {
reject();
return;
}
if (itemKey) {
if (onInfusionChoiceItemChangePromise) {
const knownInfusion =
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
if (knownInfusion === null) {
reject();
return;
}
const simulatedInfusion =
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
if (simulatedInfusion === null) {
reject();
return;
}
const infusionId = InfusionUtils.getId(simulatedInfusion);
if (infusionId === null) {
reject();
return;
}
const itemKeyParts = this.getItemKeyParts(itemKey);
onInfusionChoiceItemChangePromise(
choiceKey,
infusionId,
itemKeyParts[0],
itemKeyParts[1],
accept,
reject
);
}
} else {
if (onInfusionChoiceItemDestroyPromise) {
onInfusionChoiceItemDestroyPromise(choiceKey, accept, reject);
}
}
};
handleChoiceChangePromise = (
choiceKey: string,
infusionId: string | null,
oldInfusionId: string | null,
accept: () => void,
reject: () => void
): void => {
const { onInfusionChoiceChangePromise, onInfusionChoiceDestroyPromise } =
this.props;
if (infusionId) {
if (onInfusionChoiceChangePromise) {
onInfusionChoiceChangePromise(choiceKey, infusionId, accept, reject);
}
} else {
if (onInfusionChoiceDestroyPromise) {
onInfusionChoiceDestroyPromise(choiceKey, accept, reject);
}
}
};
handleChoiceCreatePromise = (
choiceKey: string,
infusionId: string,
oldInfusionId: string | null,
accept: () => void,
reject: () => void
) => {
const { onInfusionChoiceCreatePromise } = this.props;
if (onInfusionChoiceCreatePromise) {
onInfusionChoiceCreatePromise(choiceKey, infusionId, accept, reject);
}
};
renderInfusionReplicateItemChoice = (
infusionChoice: InfusionChoice
): React.ReactNode => {
const { equipment, loadingStatus } = this.state;
const { contextLevel, knownReplicatedItems } = this.props;
let knownInfusion = InfusionChoiceUtils.getKnownInfusion(infusionChoice);
if (knownInfusion === null) {
return null;
}
let simulatedInfusion =
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
if (
simulatedInfusion === null ||
InfusionUtils.getType(simulatedInfusion) !==
Constants.InfusionTypeEnum.REPLICATE
) {
return null;
}
let contentNode: React.ReactNode;
const classNames: Array<string> = [
"ct-infusion-choice-manager__choice",
"ct-infusion-choice-manager__choice--child",
];
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
contentNode = <LoadingPlaceholder />;
} else {
let groups: Array<HtmlSelectOptionGroup> = [];
let infusableItems: Array<Item> = equipment.filter((item) => {
const levelInfusionGranted = ItemUtils.getLevelInfusionGranted(item);
const itemDefinitionKey = ItemUtils.getDefinitionKey(item);
let chosenItemDefinitionKey: string | null = null;
if (knownInfusion !== null) {
chosenItemDefinitionKey =
KnownInfusionUtils.getItemDefinitionKey(knownInfusion);
}
const hasKnownReplicatedItem: boolean =
knownReplicatedItems.includes(itemDefinitionKey);
if (
hasKnownReplicatedItem &&
chosenItemDefinitionKey === itemDefinitionKey
) {
return true;
}
return (
!hasKnownReplicatedItem &&
levelInfusionGranted !== null &&
contextLevel !== null &&
contextLevel >= levelInfusionGranted
);
});
let orderedInfusableItems = orderBy(infusableItems, (item) =>
ItemUtils.getName(item)
);
orderedInfusableItems.forEach((item) => {
const levelInfusionGranted = ItemUtils.getLevelInfusionGranted(item);
if (levelInfusionGranted === null) {
return;
}
if (!groups.hasOwnProperty(levelInfusionGranted)) {
groups[levelInfusionGranted] = {
optGroupLabel: `Level ${levelInfusionGranted}`,
options: [],
};
}
const definitionName = ItemUtils.getDefinitionName(item);
groups[levelInfusionGranted].options.push({
label: definitionName,
value: this.getItemKey(
ItemUtils.getId(item),
definitionName === null ? "" : definitionName
),
});
});
groups.forEach((group) => {
group.options = orderBy(
group.options,
(option: HtmlSelectOption) => option.label
);
});
let selectedValue: string | null = null;
let knownItemId = KnownInfusionUtils.getItemId(knownInfusion);
let knownItemName = KnownInfusionUtils.getItemName(knownInfusion);
if (knownItemId && knownItemName) {
selectedValue = this.getItemKey(knownItemId, knownItemName);
}
if (selectedValue === null) {
classNames.push("ct-infusion-choice-manager__choice--todo");
}
contentNode = (
<Select
options={groups}
onChangePromise={this.handleChoiceItemChangePromise.bind(
this,
infusionChoice
)}
value={selectedValue}
/>
);
}
return <div className={classNames.join(" ")}>{contentNode}</div>;
};
renderInfusionChoice = (infusionChoice: InfusionChoice): React.ReactNode => {
const { ruleData, knownInfusionLookup, definitionPool, contextLevel } =
this.props;
// TODO check typing once definition pool is updated
let infusionDefinitions = DefinitionPoolUtils.getTypedDefinitionList(
Constants.DefinitionTypeEnum.INFUSION,
definitionPool,
true
);
let infusionOptions: Array<HtmlSelectOption> = infusionDefinitions
.map((infusionDefinition) =>
InfusionUtils.simulateInfusion(
DefinitionUtils.getDefinitionKey(infusionDefinition),
definitionPool
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.filter((infusion) => {
// if the infusion allows duplicates just let it through
if (InfusionUtils.getAllowDuplicates(infusion)) {
return true;
}
const infusionDefinitionKey = InfusionUtils.getDefinitionKey(infusion);
let knownInfusion =
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
// if choice has the infusion, show it in the list
if (
knownInfusion !== null &&
KnownInfusionUtils.getDefinitionKey(knownInfusion) ===
infusionDefinitionKey
) {
return true;
}
// if its already selected somewhere else and is here, it shouldnt be shown
if (
infusionDefinitionKey !== null &&
HelperUtils.lookupDataOrFallback(
knownInfusionLookup,
infusionDefinitionKey
)
) {
return false;
}
if (
!InfusionUtils.validateIsAvailableByContextLevel(
infusion,
contextLevel
)
) {
return false;
}
return true;
})
.map((infusion) => {
let label: string | null = InfusionUtils.getName(infusion);
const sources = InfusionUtils.getSources(infusion);
const hasAllToggleableSources: boolean = sources.every(
(sourceMapping) => {
const sourceDataInfo = RuleDataUtils.getSourceDataInfo(
sourceMapping.sourceId,
ruleData
);
return sourceDataInfo?.sourceCategory?.isToggleable;
}
);
if (sources.length && hasAllToggleableSources) {
const calloutSources: Array<string> = sources
.map((sourceMapping) => {
const sourceDataInfo = RuleDataUtils.getSourceDataInfo(
sourceMapping.sourceId,
ruleData
);
return sourceDataInfo?.name ?? null;
})
.filter(TypeScriptUtils.isNotNullOrUndefined);
label = `${calloutSources.join(", ")}${label}`;
}
const infusionId = InfusionUtils.getId(infusion);
return {
label,
value: infusionId ?? "",
};
});
let knownInfusion = InfusionChoiceUtils.getKnownInfusion(infusionChoice);
//TODO this typing is weird because of the binding that will happen further down... may need to split this into more components to remove the bind and make more sense
let onChangePromise: OnInfusionChangeFunc = this.handleChoiceCreatePromise;
if (knownInfusion !== null) {
onChangePromise = this.handleChoiceChangePromise;
}
let warningNode: React.ReactNode;
let descriptionNode: React.ReactNode;
const classNames: Array<string> = ["ct-infusion-choice-manager__choice"];
if (knownInfusion === null) {
classNames.push("ct-infusion-choice-manager__choice--todo");
} else {
const simulatedInfusion =
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
if (simulatedInfusion !== null) {
const description = InfusionUtils.getDescription(simulatedInfusion);
if (description !== null) {
descriptionNode = (
<HtmlContent
className="ct-infusion-choice-manager__choice-description"
html={description}
withoutTooltips
/>
);
}
if (
!InfusionUtils.validateIsAvailableByContextLevel(
simulatedInfusion,
contextLevel
)
) {
classNames.push("ct-infusion-choice-manager__choice--warning");
warningNode = (
<div className="ct-infusion-choice-manager__choice-warning">
<strong>{InfusionUtils.getName(simulatedInfusion)}</strong> is not
a valid choice because it requires level{" "}
<strong>{InfusionUtils.getLevel(simulatedInfusion)}</strong>.
</div>
);
}
}
}
let knownInfusionDefinitionKey: string | null = null;
if (knownInfusion !== null) {
knownInfusionDefinitionKey =
KnownInfusionUtils.getDefinitionKey(knownInfusion);
}
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
return (
<div
className={classNames.join(" ")}
key={choiceKey === null ? "" : choiceKey}
>
<Select
options={infusionOptions}
placeholder={`- Choose a Level ${InfusionChoiceUtils.getLevel(
infusionChoice
)} Option -`}
onChangePromise={onChangePromise.bind(this, choiceKey)}
value={
knownInfusionDefinitionKey === null
? null
: DefinitionUtils.getDefinitionKeyId(knownInfusionDefinitionKey)
}
/>
{this.renderInfusionReplicateItemChoice(infusionChoice)}
{warningNode}
{descriptionNode}
</div>
);
//`
};
render() {
const { loadingStatus } = this.state;
const { infusionChoices } = this.props;
if (!infusionChoices.length) {
return null;
}
let contentNode: React.ReactNode;
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
contentNode = <LoadingPlaceholder />;
} else {
contentNode = (
<React.Fragment>
{infusionChoices.map((infusionChoice) =>
this.renderInfusionChoice(infusionChoice)
)}
</React.Fragment>
);
}
return (
<div className="ct-infusion-choice-manager">
<div className="ct-infusion-choice-manager__header">
Infusion Choices
</div>
{contentNode}
</div>
);
}
}
export default InfusionChoiceManager;
@@ -0,0 +1,4 @@
import InfusionChoiceManager from "./InfusionChoiceManager";
export default InfusionChoiceManager;
export { InfusionChoiceManager };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
import ItemDetail from "./ItemDetail";
export default ItemDetail;
export { ItemDetail };
@@ -0,0 +1,33 @@
import {
Constants,
DefinitionUtils,
Item,
ItemUtils,
RuleData,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
interface Props {
ruleData: RuleData;
item: Item;
}
export const ItemListInformationCollapsible = ({ ruleData, item }: Props) => {
const definitionKey = DefinitionUtils.hack__generateDefinitionKey(
ItemUtils.getEntityTypeId(item),
ItemUtils.getId(item)
);
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
[definitionKey],
ruleData,
Constants.DisplayConfigurationTypeEnum.ITEM
);
return (
<div className="ct-item-list-information-collapsible">
<HelperTextAccordion builderHelperText={builderText} />
</div>
);
};
@@ -0,0 +1,4 @@
import { ItemListInformationCollapsible } from "./ItemListInformationCollapsible";
export default ItemListInformationCollapsible;
export { ItemListInformationCollapsible };
@@ -0,0 +1,51 @@
import React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import SlotManager from "../SlotManager";
interface Props {
isUsed: boolean;
isReadonly?: boolean;
canUse: boolean;
onSet?: (uses: number) => void;
theme: CharacterTheme;
useTooltip?: boolean;
showEmptySlot?: boolean;
tooltipTitle?: string;
}
export const ItemSlotManager: React.FC<Props> = ({
isUsed,
isReadonly = false,
canUse,
onSet,
theme,
useTooltip = true,
showEmptySlot = true,
tooltipTitle,
}) => {
return (
<div className="ct-item-slot-manager">
{canUse ? (
<SlotManager
size="small"
used={isUsed ? 1 : 0}
available={1}
onSet={onSet}
isInteractive={!isReadonly}
/>
) : showEmptySlot ? (
<Tooltip
enabled={useTooltip}
title={tooltipTitle ?? ""}
isDarkMode={theme.isDarkMode}
>
<div className="ct-item-slot-manager--empty">--</div>
</Tooltip>
) : null}
</div>
);
};
export default ItemSlotManager;
@@ -0,0 +1,59 @@
import React from "react";
import { AnimatedLoadingRingSvg } from "@dndbeyond/character-components/es";
interface Props {
isFinished: boolean;
hideDelay: number;
}
interface State {
isVisible: boolean;
}
export default class LoadingBlocker extends React.PureComponent<Props, State> {
static defaultProps = {
hideDelay: 2000,
};
constructor(props: Props) {
super(props);
this.state = {
isVisible: !props.isFinished,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { isFinished, hideDelay } = this.props;
if (!prevProps.isFinished && isFinished) {
setTimeout(() => {
this.setState({
isVisible: false,
});
}, hideDelay);
}
}
render() {
const { isVisible } = this.state;
const { isFinished } = this.props;
if (!isVisible) {
return null;
}
return (
<div
className={`ct-loading-blocker ${
isFinished ? " ct-loading-blocker--finished" : ""
}`}
>
<div className="ct-loading-blocker__logo" />
<AnimatedLoadingRingSvg className="ct-loading-blocker__anim" />
</div>
);
}
}
@@ -0,0 +1,4 @@
import LoadingBlocker from "./LoadingBlocker";
export default LoadingBlocker;
export { LoadingBlocker };
@@ -0,0 +1,83 @@
import { sortBy } from "lodash";
import React from "react";
import {
ClassSpellListSpellsLookup,
BaseSpell,
HelperUtils,
ClassSpellListSpellInfo,
ClassUtils,
SpellUtils,
FormatUtils,
} from "@dndbeyond/character-rules-engine/es";
import { SpellName } from "~/components/SpellName";
//This uses the common SpellName component due to builder modals not playing nice with the dataOriginRefData
//would revert back to the character-components SpellName if we ever changed the builder modals
interface Props {
spellListIds: Array<number>;
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
}
export default class SimpleClassSpellList extends React.PureComponent<
Props,
{}
> {
getClassSpellsLookupByClassName = (): Record<string, Array<BaseSpell>> => {
const { spellListIds, classSpellListSpellsLookup } = this.props;
return spellListIds.reduce((acc, id) => {
const classSpellListSpellInfos = HelperUtils.lookupDataOrFallback(
classSpellListSpellsLookup,
id
);
classSpellListSpellInfos?.forEach((info: ClassSpellListSpellInfo) => {
const charClassName = ClassUtils.getName(info.charClass);
if (charClassName) {
if (!acc[charClassName]) {
acc[charClassName] = [];
}
acc[charClassName].push(info.spell);
}
});
return acc;
}, {});
};
render() {
const classSpellsLookup = this.getClassSpellsLookupByClassName();
return (
<div className="ct-simple-class-spell-list">
{Object.keys(classSpellsLookup).map((charClassName) => {
const sortedSpells = sortBy(classSpellsLookup[charClassName], [
(spell) => SpellUtils.getLevel(spell),
(spell) => SpellUtils.getName(spell),
]);
return (
<div
className="ct-simple-class-spell-list__class"
key={charClassName}
>
<strong>{charClassName}</strong>
{sortedSpells.map((spell) => {
return (
<div
className="ct-simple-class-spell-list__class-spell"
key={`${charClassName}-${SpellUtils.getUniqueKey(spell)}`}
>
<SpellName spell={spell} showLegacy={true} />
</div>
);
})}
</div>
);
})}
</div>
);
}
}
@@ -0,0 +1,183 @@
import React from "react";
import { HelperUtils } from "@dndbeyond/character-rules-engine/es";
import { ThemeButton } from "../common/Button";
interface Props {
quantity: number;
minimum: number;
maximum: number | null;
label: string;
onUpdate?: (quantity: number) => void;
isReadonly: boolean;
}
interface State {
quantity: number;
newQuantity: number | null;
}
export default class SimpleQuantity extends React.PureComponent<Props, State> {
static defaultProps = {
label: "Quantity",
minimum: 0,
maximum: null,
isReadonly: false,
};
constructor(props) {
super(props);
this.state = {
quantity: props.quantity,
newQuantity: props.quantity,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { quantity } = this.props;
if (quantity !== prevState.quantity) {
this.setState({
quantity,
newQuantity: quantity,
});
}
}
handleUpdate = (): void => {
const { newQuantity } = this.state;
const { onUpdate, quantity } = this.props;
if (newQuantity !== quantity && onUpdate) {
onUpdate(newQuantity === null ? 0 : newQuantity);
}
};
handleAmountChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newQuantity: HelperUtils.parseInputInt(evt.target.value, null),
});
};
handleAmountKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
const { minimum, maximum } = this.props;
if (evt.key === "Enter") {
const parsedValue = HelperUtils.parseInputInt(
(evt.target as HTMLInputElement).value
);
const clampedValue = HelperUtils.clampInt(
parsedValue ?? minimum,
minimum,
maximum
);
this.setState(
{
quantity: clampedValue,
newQuantity: clampedValue,
},
this.handleUpdate
);
}
};
handleAmountBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { minimum, maximum } = this.props;
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
const clampedValue = HelperUtils.clampInt(
parsedValue ?? minimum,
minimum,
maximum
);
this.setState(
{
quantity: clampedValue,
newQuantity: clampedValue,
},
this.handleUpdate
);
};
handleIncrementClick = () => {
const { quantity, isReadonly } = this.props;
if (!isReadonly) {
let newValue: number = quantity + 1;
this.setState(
{
quantity: newValue,
newQuantity: newValue,
},
this.handleUpdate
);
}
};
handleDecrementClick = () => {
const { quantity, isReadonly } = this.props;
if (!isReadonly) {
let newValue: number = quantity - 1;
this.setState(
{
quantity: newValue,
newQuantity: newValue,
},
this.handleUpdate
);
}
};
render() {
const { newQuantity } = this.state;
const { label, quantity, minimum, maximum, isReadonly } = this.props;
return (
<div className="ct-simple-quantity">
<div className="ct-simple-quantity__label">{label}</div>
<div className="ct-simple-quantity__controls">
<div className="ct-simple-quantity__decrease">
<ThemeButton
className="button-action-decrease button-action-decrease--small"
onClick={this.handleDecrementClick}
disabled={minimum !== null && quantity <= minimum}
size="small"
isInteractive={!isReadonly}
aria-label={"decrease quantity"}
data-testid={"decrease-quantity"}
/>
</div>
<div className="ct-simple-quantity__value">
<input
type="number"
value={newQuantity === null ? "" : newQuantity}
className="character-input ct-simple-quantity__input"
onChange={this.handleAmountChange}
onBlur={this.handleAmountBlur}
onKeyUp={this.handleAmountKeyUp}
min={0}
readOnly={isReadonly}
data-testid={"change-quantity"}
/>
</div>
<div className="ct-simple-quantity__increase">
<ThemeButton
className="button-action-increase button-action-increase--small"
onClick={this.handleIncrementClick}
size="small"
disabled={maximum !== null && quantity >= maximum}
isInteractive={!isReadonly}
aria-label={"increase quantity"}
data-testid={"increase-quantity"}
/>
</div>
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import SimpleQuantity from "./SimpleQuantity";
export default SimpleQuantity;
export { SimpleQuantity };
@@ -0,0 +1,71 @@
import React from "react";
interface Props {
used: number;
available: number;
onSet?: (used: number) => void;
size: "normal" | "small";
isInteractive: boolean;
}
export default class SlotManager extends React.PureComponent<Props> {
static defaultProps = {
size: "normal",
isInteractive: true,
};
handleClick = (isUsed: boolean, evt: React.MouseEvent): void => {
let { onSet, used, available, isInteractive } = this.props;
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
if (!onSet || !isInteractive) {
return;
}
if (isUsed) {
onSet(Math.max(used - 1, 0));
} else {
onSet(Math.min(used + 1, available));
}
};
render() {
let { available, used, size, isInteractive } = this.props;
let slotElements: Array<React.ReactNode> = [];
let totalSlots: number = Math.max(available, used);
for (let i = 0; i < totalSlots; i++) {
let isUsed = i < used;
let slotClassNames: Array<string> = ["ct-slot-manager__slot"];
if (isUsed) {
slotClassNames.push("ct-slot-manager__slot--used");
}
if (isInteractive) {
slotClassNames.push("ct-slot-manager__slot--interactive");
}
if (i >= available) {
slotClassNames.push("ct-slot-manager__slot--over-used");
}
slotElements.push(
<div
role="checkbox"
aria-checked={isUsed}
aria-label={"use"}
className={slotClassNames.join(" ")}
key={i}
onClick={this.handleClick.bind(this, isUsed)}
/>
);
}
return (
<div className={`ct-slot-manager ct-slot-manager--size-${size}`}>
{slotElements}
</div>
);
}
}
@@ -0,0 +1,4 @@
import SlotManager from "./SlotManager";
export default SlotManager;
export { SlotManager };
@@ -0,0 +1,160 @@
import React from "react";
import { NumberDisplay } from "~/components/NumberDisplay";
import { ThemeButton } from "../common/Button";
interface Props {
used: number;
available: number;
label: string;
onSet?: (usedAmount: number) => void;
isReadonly: boolean;
isInteractive: boolean;
}
interface State {
startUsed: number;
newUsed: number;
}
export default class SlotManagerLarge extends React.PureComponent<
Props,
State
> {
static defaultProps = {
label: "",
isInteractive: true,
isReadonly: false,
};
constructor(props: Props) {
super(props);
this.state = {
startUsed: props.used,
newUsed: props.used,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { used } = this.props;
if (used !== prevState.startUsed) {
this.setState({
startUsed: used,
newUsed: used,
});
}
}
handleSetClick = (): void => {
const { newUsed } = this.state;
const { onSet } = this.props;
if (onSet) {
onSet(newUsed);
}
this.setState({
startUsed: newUsed,
});
};
handleResetClick = (): void => {
const { startUsed } = this.state;
this.setState({
newUsed: startUsed,
});
};
handleUseClick = (): void => {
const { isReadonly } = this.props;
if (!isReadonly) {
this.setState((prevState, props) => ({
newUsed: Math.min(prevState.newUsed + 1, props.available),
}));
}
};
handleGainClick = (): void => {
const { isReadonly } = this.props;
if (!isReadonly) {
this.setState((prevState, props) => ({
newUsed: Math.max(prevState.newUsed - 1, 0),
}));
}
};
render() {
const { newUsed, startUsed } = this.state;
const { label, available, isInteractive } = this.props;
const usedDiff: number = startUsed - newUsed;
let classNames: Array<string> = ["ct-slot-manager-large__values"];
if (usedDiff > 0) {
classNames.push("ct-slot-manager-large__values--gain");
} else if (usedDiff < 0) {
classNames.push("ct-slot-manager-large__values--use");
}
return (
<div className="ct-slot-manager-large">
<div className={classNames.join(" ")}>
{label && <div className="ct-slot-manager-large__label">{label}</div>}
<div className="ct-slot-manager-large__value-control ct-slot-manager-large__value-control--use">
<ThemeButton
size="small"
className={"button-action-decrease button-action-decrease--small"}
onClick={this.handleUseClick}
disabled={newUsed === available}
isInteractive={isInteractive}
/>
</div>
<div className="ct-slot-manager-large__value ct-slot-manager-large__value--cur">
{available - newUsed}
</div>
<div className="ct-slot-manager-large__value-control ct-slot-manager-large__value-control--gain">
<ThemeButton
size="small"
className={"button-action-increase button-action-increase--small"}
onClick={this.handleGainClick}
disabled={newUsed === 0}
isInteractive={isInteractive}
/>
</div>
</div>
{usedDiff !== 0 && (
<div className="ct-slot-manager-large__diff">
<div className="ct-slot-manager-large__diff-explain">
<NumberDisplay type="signed" number={usedDiff} />
</div>
<div className="ct-slot-manager-large__diff-actions">
<ThemeButton
className="ct-slot-manager-large__diff-action"
size={"small"}
onClick={this.handleSetClick}
>
Confirm
</ThemeButton>
<ThemeButton
className="ct-slot-manager-large__diff-action"
size={"small"}
style={"outline"}
onClick={this.handleResetClick}
>
Clear
</ThemeButton>
</div>
</div>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import SlotManagerLarge from "./SlotManagerLarge";
export default SlotManagerLarge;
export { SlotManagerLarge };
@@ -0,0 +1,965 @@
import React, { useContext } from "react";
import { DispatchProp } from "react-redux";
import {
ComponentConstants,
DamageTypeIcon,
} from "@dndbeyond/character-components/es";
import {
AbilityLookup,
BaseInventoryContract,
Constants,
DiceUtils,
EntityUtils,
FormatUtils,
ItemUtils,
LimitedUseUtils,
ModifierUtils,
Spell,
SpellCasterInfo,
SpellSlotContract,
SpellUtils,
characterActions,
ApiRequestHelpers,
RuleData,
RuleDataUtils,
CharacterTheme,
InventoryManager,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { toastMessageActions } from "../../actions/toastMessage";
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
import { ThemeButton } from "../common/Button";
function canCastAtLevel(level, available) {
return available.indexOf(level) > -1;
}
function isCastAvailableAtLevel(level, available) {
return available.indexOf(level) > -1;
}
// TODO dispatch needs to be factored out
interface Props extends DispatchProp {
characterLevel: number;
spell: Spell;
spellSlots: Array<SpellSlotContract>;
pactMagicSlots: Array<SpellSlotContract>;
castableSpellLevels: Array<number>;
castablePactMagicLevels: Array<number>;
availableSpellLevels: Array<number>;
availablePactMagicLevels: Array<number>;
initialCastLevel: number | null;
spellCasterInfo: SpellCasterInfo;
ruleData: RuleData;
abilityLookup: AbilityLookup;
isReadonly: boolean;
proficiencyBonus: number;
theme?: CharacterTheme;
inventoryManager: InventoryManager;
}
interface State {
castLevel: number;
castableLevelsIdx: number;
castableLevels: Array<number>;
minLevel: number;
maxLevel: number;
}
export class SpellCaster extends React.PureComponent<Props, State> {
static defaultProps = {
initialCastLevel: null,
};
constructor(props: Props) {
super(props);
this.state = this.getStartingCastState(props);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { spell } = this.props;
if (
SpellUtils.getUniqueKey(spell) !==
SpellUtils.getUniqueKey(prevProps.spell)
) {
this.setState({
...this.getStartingCastState(this.props),
});
}
}
getStartingCastState = (props: Props): State => {
const { spell, initialCastLevel, spellCasterInfo, ruleData } = props;
let { startLevel, endLevel } = SpellUtils.getCastLevelRange(
spell,
spellCasterInfo,
ruleData
);
const castableLevels = SpellUtils.getCastableLevels(
spell,
spellCasterInfo,
ruleData,
initialCastLevel
);
const castLevel = SpellUtils.getMinCastLevel(
spell,
spellCasterInfo,
ruleData,
initialCastLevel
);
const castableLevelsIdx = castableLevels.findIndex(
(level) => level === castLevel
);
return {
castLevel,
castableLevelsIdx,
castableLevels,
minLevel: startLevel,
maxLevel: endLevel,
};
};
getSpellLevelUses = (
spellSlots: Array<SpellSlotContract>,
level: number
): number => {
const spellSlot = spellSlots.find((slot) => slot.level === level);
if (spellSlot) {
return spellSlot.used;
}
return 0;
};
handleIncreaseCastLevel = (): void => {
this.setState((prevState, props) => ({
castableLevelsIdx: prevState.castableLevelsIdx + 1,
castLevel: prevState.castableLevels[prevState.castableLevelsIdx + 1],
}));
};
handleDecreaseCastLevel = (): void => {
this.setState((prevState, props) => ({
castableLevelsIdx: prevState.castableLevelsIdx - 1,
castLevel: prevState.castableLevels[prevState.castableLevelsIdx - 1],
}));
};
handleCastSpellSlot = (): void => {
this.handleSpellUse(true, false);
};
handleCastPactMagicSlot = (): void => {
this.handleSpellUse(false, true);
};
// TODO dispatch needs to be factored out
handleSpellUse = (useSpellSlot: boolean, usePactMagicSlot: boolean): void => {
const { castLevel, minLevel } = this.state;
const {
dispatch,
spell,
spellSlots,
pactMagicSlots,
isReadonly,
inventoryManager,
} = this.props;
if (isReadonly) {
return;
}
const dataOrigin = SpellUtils.getDataOrigin(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
let limitedUse = SpellUtils.getLimitedUse(spell);
let mappingId: number | null = null;
let mappingTypeId: number | null = null;
if (limitedUse) {
let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
let uses = SpellUtils.getConsumedLimitedUse(spell, castLevel - minLevel);
switch (dataOriginType) {
case Constants.DataOriginTypeEnum.ITEM:
//TODO itemManager: make a way to get the item with just he mapping id and lookups?
mappingId = ItemUtils.getMappingId(
dataOrigin.primary as BaseInventoryContract
);
mappingTypeId = ItemUtils.getMappingEntityTypeId(
dataOrigin.primary as BaseInventoryContract
);
const itemData = dataOrigin.primary as BaseInventoryContract;
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
item.handleItemLimitedUseSet(numberUsed + uses);
dispatch(
toastMessageActions.toastSuccess(
"Item Spell Cast",
`Cast ${SpellUtils.getName(
spell
)} from ${EntityUtils.getDataOriginName(dataOrigin)}`
)
);
break;
default:
mappingId = SpellUtils.getMappingId(spell);
mappingTypeId = SpellUtils.getMappingEntityTypeId(spell);
if (mappingId !== null && mappingTypeId !== null) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingTypeId,
numberUsed + uses,
dataOriginType
)
);
dispatch(
toastMessageActions.toastSuccess(
"Limited Use Spell Cast",
`Cast ${SpellUtils.getName(spell)} with limited use`
)
);
}
}
}
if (useSpellSlot) {
let castLevelKey =
ApiRequestHelpers.getSpellLevelSpellSlotRequestsDataKey(castLevel);
if (castLevelKey !== null) {
dispatch(
characterActions.spellLevelSpellSlotsSet({
[castLevelKey]: this.getSpellLevelUses(spellSlots, castLevel) + 1,
})
);
dispatch(
toastMessageActions.toastSuccess(
"Spell Cast",
`Cast ${SpellUtils.getName(spell)} in spell slot level ${castLevel}`
)
);
}
}
if (usePactMagicSlot) {
let castLevelKey =
ApiRequestHelpers.getSpellLevelPactMagicRequestsDataKey(castLevel);
if (castLevelKey !== null) {
dispatch(
characterActions.spellLevelPactMagicSlotsSet({
[castLevelKey]:
this.getSpellLevelUses(pactMagicSlots, castLevel) + 1,
})
);
dispatch(
toastMessageActions.toastSuccess(
"Spell Cast",
`Cast ${SpellUtils.getName(spell)} in a pact magic slot`
)
);
}
}
};
handleCast = (): void => {
const { castLevel } = this.state;
const { spell, spellCasterInfo } = this.props;
let usesSpellSlot = SpellUtils.getUsesSpellSlot(spell);
let limitedUse = SpellUtils.getLimitedUse(spell);
const isSpellSlotAvailable = canCastAtLevel(
castLevel,
spellCasterInfo.castableSpellLevels
);
const isPactSlotAvailable = canCastAtLevel(
castLevel,
spellCasterInfo.castablePactMagicLevels
);
if (usesSpellSlot) {
if (limitedUse) {
if (SpellUtils.isValidToUsePactSlots(spell)) {
if (isPactSlotAvailable) {
this.handleSpellUse(false, true);
}
} else {
if (isSpellSlotAvailable) {
this.handleSpellUse(true, false);
}
}
} else {
if (isSpellSlotAvailable) {
this.handleSpellUse(true, false);
} else if (isPactSlotAvailable) {
this.handleSpellUse(false, true);
}
}
} else {
this.handleSpellUse(false, false);
}
};
renderLimitedUseDetails = (): React.ReactNode => {
const { spell, ruleData, abilityLookup, proficiencyBonus } = this.props;
const limitedUse = SpellUtils.getLimitedUse(spell);
if (!limitedUse) {
return null;
}
const maxUses = LimitedUseUtils.deriveMaxUses(
limitedUse,
abilityLookup,
ruleData,
proficiencyBonus
);
const resetType = LimitedUseUtils.getResetType(limitedUse);
if (resetType === null) {
return null;
}
let limitedUseType: string = "Use";
if (
SpellUtils.getDataOriginType(spell) === Constants.DataOriginTypeEnum.ITEM
) {
limitedUseType = "Charge";
}
let resetDisplay: string = "";
switch (resetType) {
case Constants.LimitedUseResetTypeEnum.SHORT_REST:
case Constants.LimitedUseResetTypeEnum.LONG_REST:
resetDisplay = `${
maxUses === 1 ? "Once" : maxUses
} per ${RuleDataUtils.getLimitedUseResetTypeName(resetType, ruleData)}`;
break;
default:
resetDisplay = `${maxUses} ${limitedUseType}${
maxUses !== 1 ? "s" : ""
}, Reset: ${RuleDataUtils.getLimitedUseResetTypeName(
resetType,
ruleData
)}`;
break;
}
return <div className="ct-spell-caster__limited">{resetDisplay}</div>;
};
renderCasting = (): React.ReactNode => {
const { castLevel, minLevel, maxLevel, castableLevelsIdx, castableLevels } =
this.state;
const {
spell,
castableSpellLevels,
castablePactMagicLevels,
spellSlots,
pactMagicSlots,
availableSpellLevels,
availablePactMagicLevels,
ruleData,
abilityLookup,
spellCasterInfo,
isReadonly,
proficiencyBonus,
} = this.props;
const limitedUse = SpellUtils.getLimitedUse(spell);
const isAtWill = SpellUtils.validateIsAtWill(spell, castLevel);
const castAsRitual = SpellUtils.isCastAsRitual(spell);
let castLevelNode: React.ReactNode;
let castActionsNode: React.ReactNode;
if (castAsRitual) {
castActionsNode = "As Ritual";
} else if (isAtWill) {
castActionsNode = "At Will";
} else {
if (limitedUse) {
const maxUses = LimitedUseUtils.deriveMaxUses(
limitedUse,
abilityLookup,
ruleData,
proficiencyBonus
);
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
const consumedAmount = SpellUtils.getConsumedLimitedUse(
spell,
castLevel - minLevel
);
const numberRemaining: number = maxUses - numberUsed;
let limitedUseButtonText: React.ReactNode;
let showRemainingCount: boolean = true;
const dataOriginType = SpellUtils.getDataOriginType(spell);
if (maxUses === 1) {
if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) {
limitedUseButtonText = "1 Charge";
} else {
limitedUseButtonText = "Use";
showRemainingCount = false;
}
} else {
limitedUseButtonText = `${consumedAmount} ${
dataOriginType === Constants.DataOriginTypeEnum.ITEM
? "Charge"
: "Use"
}${consumedAmount !== 1 ? "s" : ""}`;
}
const isLimitedUseAvailable =
SpellUtils.validateIsLimitedUseAvailableAtScaledAmount(
spell,
castLevel,
castLevel - minLevel,
abilityLookup,
ruleData,
spellCasterInfo,
proficiencyBonus
);
const usesSpellSlots = SpellUtils.getUsesSpellSlot(spell);
let spellSlotsAvailable: number | null = null;
let spellSlotName: string = "";
if (usesSpellSlots) {
if (SpellUtils.isValidToUsePactSlots(spell)) {
spellSlotsAvailable = SpellUtils.getCastLevelAvailableCount(
castLevel,
pactMagicSlots
);
spellSlotName = "Pact Slot";
} else {
spellSlotsAvailable = SpellUtils.getCastLevelAvailableCount(
castLevel,
spellSlots
);
spellSlotName = "Spell Slot";
}
}
castActionsNode = (
<React.Fragment>
<div className="ct-spell-caster__casting-action">
<ThemeButton
disabled={!isLimitedUseAvailable}
isInteractive={!isReadonly}
onClick={this.handleCast}
className="ct-spell-caster__casting-action-button--limited"
>
{usesSpellSlots ? `${spellSlotName}, ` : ""}
{limitedUseButtonText}
{usesSpellSlots && (
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--spellcasting">
{spellSlotsAvailable}
</span>
)}
{showRemainingCount && (
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--limited-use">
{numberRemaining}
</span>
)}
</ThemeButton>
</div>
</React.Fragment>
);
} else {
const canSpellCastAtLevel = canCastAtLevel(
castLevel,
castableSpellLevels
);
const canPactMagicCastAtLevel = canCastAtLevel(
castLevel,
castablePactMagicLevels
);
const isSpellCastAvailableAtLevel = isCastAvailableAtLevel(
castLevel,
availableSpellLevels
);
const isPactMagicCastAvailableAtLevel = isCastAvailableAtLevel(
castLevel,
availablePactMagicLevels
);
const spellCastLevelAvailableCount =
SpellUtils.getCastLevelAvailableCount(castLevel, spellSlots);
const pactMagicCastLevelAvailableCount =
SpellUtils.getCastLevelAvailableCount(castLevel, pactMagicSlots);
castActionsNode = (
<React.Fragment>
{isSpellCastAvailableAtLevel && (
<div className="ct-spell-caster__casting-action">
<ThemeButton
disabled={!canSpellCastAtLevel}
isInteractive={!isReadonly}
onClick={this.handleCastSpellSlot}
>
Spell Slot
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--spellcasting">
{spellCastLevelAvailableCount}
</span>
</ThemeButton>
</div>
)}
{isPactMagicCastAvailableAtLevel && (
<div className="ct-spell-caster__casting-action">
<ThemeButton
disabled={!canPactMagicCastAtLevel}
onClick={this.handleCastPactMagicSlot}
>
Pact Slot
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--spellcasting">
{pactMagicCastLevelAvailableCount}
</span>
</ThemeButton>
</div>
)}
</React.Fragment>
);
}
}
if (!SpellUtils.isCantrip(spell)) {
castLevelNode = (
<div className="ct-spell-caster__casting-level">
<span className="ct-spell-caster__casting-level-label">Level</span>
{minLevel !== maxLevel && (
<span className="ct-spell-caster__casting-level-action">
<ThemeButton
disabled={castableLevelsIdx === 0}
onClick={this.handleDecreaseCastLevel}
className="ct-button--decrease"
/>
</span>
)}
<span
className={`ct-spell-caster__casting-level-current ct-spell-caster__casting-level-current--${
minLevel === maxLevel ? "nocontrols" : "controls"
}`}
>
{FormatUtils.renderSpellLevelAbbreviation(castLevel)}
</span>
{minLevel !== maxLevel && (
<span className="ct-spell-caster__casting-level-action">
<ThemeButton
disabled={castableLevelsIdx + 1 === castableLevels.length}
onClick={this.handleIncreaseCastLevel}
className="ct-button--increase"
/>
</span>
)}
</div>
);
}
//`
return (
<div className="ct-spell-caster__casting">
<div className="ct-spell-caster__casting-label">Cast</div>
<div className="ct-spell-caster__casting-actions">
{castActionsNode}
</div>
{castLevelNode}
</div>
);
};
renderAtHigherLevels = (): React.ReactNode => {
const { castLevel } = this.state;
const { spell, characterLevel } = this.props;
const atHigherLevels = SpellUtils.getAtHigherLevels(spell);
if (!atHigherLevels) {
return null;
}
const scaleType = SpellUtils.getScaleType(spell);
const {
additionalAttacks,
additionalTargets,
areaOfEffect,
duration,
creatures,
special,
range,
} = atHigherLevels;
const level = SpellUtils.getLevel(spell);
let additionalAttacksDisplay: React.ReactNode;
if (additionalAttacks !== null) {
const additionalAttackInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
additionalAttacks,
characterLevel,
castLevel
);
if (additionalAttackInfo) {
const additionalAttackCount =
level === 0
? additionalAttackInfo.totalCount + 1
: additionalAttackInfo.totalCount;
const additionalAttackLabel = level === 0 ? "Total" : "Additional";
additionalAttacksDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--attacks">
{additionalAttackLabel} {additionalAttackInfo.description}:{" "}
{additionalAttackCount}
</div>
);
}
}
let additionalTargetsDisplay: React.ReactNode;
if (additionalTargets !== null) {
const additionalTargetInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
additionalTargets,
characterLevel,
castLevel
);
if (additionalTargetInfo) {
additionalTargetsDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--targets">
<div className="ct-spell-caster__higher-targets-info">
{additionalTargetInfo.targets} Additional Target
{additionalTargetInfo.targets === 1 ? "" : "s"}
</div>
<div className="ct-spell-caster__higher-targets-description">
{additionalTargetInfo.description}
</div>
</div>
);
}
}
let aoeDisplay: React.ReactNode;
if (areaOfEffect !== null) {
const aoeInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
areaOfEffect,
characterLevel,
castLevel
);
if (aoeInfo && aoeInfo.extendedAoe !== null) {
aoeDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--aoe">
Extended Area: {FormatUtils.renderDistance(aoeInfo.extendedAoe)}{" "}
{aoeInfo.description}
</div>
);
}
}
let rangeDisplay: React.ReactNode;
if (range !== null) {
const rangeInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
range,
characterLevel,
castLevel
);
if (rangeInfo && rangeInfo.range) {
rangeDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--range">
Extended Range: {FormatUtils.renderDistance(rangeInfo.range)}{" "}
{rangeInfo.description}
</div>
);
}
}
let durationDisplay: React.ReactNode;
if (duration !== null) {
const durationInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
duration,
characterLevel,
castLevel
);
if (durationInfo) {
durationDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--duration">
Extended Duration: {durationInfo.description}
</div>
);
}
}
let creaturesDisplay: React.ReactNode;
if (creatures !== null) {
const creatureInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
creatures,
characterLevel,
castLevel
);
if (creatureInfo) {
creaturesDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--creatures">
Creatures: {creatureInfo.description}
</div>
);
}
}
let specialDisplay: React.ReactNode;
if (special !== null) {
const specialInfo = SpellUtils.getSpellScaledAtHigher(
spell,
scaleType,
special,
characterLevel,
castLevel
);
if (specialInfo) {
specialDisplay = (
<div className="ct-spell-caster__higher ct-spell-caster__higher--special">
Special: {specialInfo.description}
</div>
);
}
}
if (
!additionalAttacksDisplay &&
!additionalTargetsDisplay &&
!aoeDisplay &&
!durationDisplay &&
!creaturesDisplay &&
!specialDisplay &&
!rangeDisplay
) {
return null;
}
return (
<div className="ct-spell-caster__higher-items">
{additionalAttacksDisplay}
{additionalTargetsDisplay}
{rangeDisplay}
{aoeDisplay}
{durationDisplay}
{creaturesDisplay}
{specialDisplay}
</div>
);
};
render() {
const { castLevel } = this.state;
const { characterLevel, spell, theme } = this.props;
const modifiers = SpellUtils.getModifiers(spell);
const damageModifiers = modifiers.filter((modifier) =>
ModifierUtils.isSpellDamageModifier(modifier)
);
let damageNode: React.ReactNode;
if (damageModifiers.length) {
damageNode = (
<div className="ct-spell-caster__modifiers ct-spell-caster__modifiers--damages">
{damageModifiers.map((modifier) => {
const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier);
const id = ModifierUtils.getId(modifier);
const restriction = ModifierUtils.getRestriction(modifier);
const friendlySubtypeName =
ModifierUtils.getFriendlySubtypeName(modifier);
const atHigherDamage = SpellUtils.getSpellScaledAtHigher(
spell,
atHigherLevels ? SpellUtils.getScaleType(spell) : null,
atHigherLevels && atHigherLevels.points
? atHigherLevels.points
: [],
characterLevel,
castLevel
);
const scaledDamageDie = SpellUtils.getSpellFinalScaledDie(
spell,
modifier,
atHigherDamage
);
return (
<div
className="ct-spell-caster__modifier ct-spell-caster__modifier--damage"
key={id ? id : ""}
>
{scaledDamageDie && (
<span className="ct-spell-caster__modifier-amount">
{DiceUtils.renderDice(scaledDamageDie)}
</span>
)}
{friendlySubtypeName !== null && (
<span className="ct-spell-caster__modifier-effect">
<DamageTypeIcon
theme={theme}
type={
FormatUtils.slugify(
friendlySubtypeName
) as ComponentConstants.DamageTypePropType
}
/>{" "}
Damage
</span>
)}
{!!restriction && (
<span className="ct-spell-caster__modifier-restriction">
({restriction})
</span>
)}
</div>
);
})}
</div>
);
}
const hitPointHealingModifiers = modifiers.filter((modifier) =>
ModifierUtils.isSpellHealingHitPointsModifier(modifier)
);
const tempHitPointHealingModifiers = modifiers.filter((modifier) =>
ModifierUtils.isSpellHealingTempHitPointsModifier(modifier)
);
let healingNode: React.ReactNode;
if (
hitPointHealingModifiers.length ||
tempHitPointHealingModifiers.length
) {
healingNode = (
<div className="ct-spell-caster__modifiers ct-spell-caster__modifiers--healing">
{hitPointHealingModifiers.map((modifier) => {
const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier);
const id = ModifierUtils.getId(modifier);
const restriction = ModifierUtils.getRestriction(modifier);
const atHigherHealing = SpellUtils.getSpellScaledAtHigher(
spell,
atHigherLevels ? SpellUtils.getScaleType(spell) : null,
atHigherLevels && atHigherLevels.points
? atHigherLevels.points
: [],
characterLevel,
castLevel
);
const scaledHealingDie = SpellUtils.getSpellFinalScaledDie(
spell,
modifier,
atHigherHealing,
castLevel
);
const isHealingDieAdditional =
SpellUtils.hack__isHealingDieAdditionalBonusFixedValue(
damageModifiers,
spell,
scaledHealingDie
);
return (
<div
className="ct-spell-caster__modifier ct-spell-caster__modifier--hp"
key={id ? id : ""}
>
<span className="ct-spell-caster__modifier-amount">
Regain {isHealingDieAdditional && "Additional"}{" "}
{scaledHealingDie && DiceUtils.renderDice(scaledHealingDie)}{" "}
Hit Points
</span>
{!!restriction && (
<span className="ct-spell-caster__modifier-restriction">
({restriction})
</span>
)}
</div>
);
})}
{tempHitPointHealingModifiers.map((modifier) => {
const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier);
const id = ModifierUtils.getId(modifier);
const restriction = ModifierUtils.getRestriction(modifier);
const atHigherHealing = SpellUtils.getSpellScaledAtHigher(
spell,
atHigherLevels ? SpellUtils.getScaleType(spell) : null,
atHigherLevels && atHigherLevels.points
? atHigherLevels.points
: [],
characterLevel,
castLevel
);
const scaledHealingDie = SpellUtils.getSpellFinalScaledDie(
spell,
modifier,
atHigherHealing,
castLevel
);
return (
<div
className="ct-spell-caster__modifier ct-spell-caster__modifier--temp"
key={id ? id : ""}
>
<span className="ct-spell-caster__modifier-amount">
Regain{" "}
{scaledHealingDie && DiceUtils.renderDice(scaledHealingDie)}{" "}
Temp Hit Points
</span>
{!!restriction && (
<span className="ct-spell-caster__modifier-restriction">
({restriction})
</span>
)}
</div>
);
})}
</div>
);
}
const castingNode = this.renderCasting();
const limitedUseNode = this.renderLimitedUseDetails();
const atHigherLevelsNode = this.renderAtHigherLevels();
if (
!castingNode &&
!limitedUseNode &&
!damageNode &&
!healingNode &&
!atHigherLevelsNode
) {
return null;
}
return (
<div className="ct-spell-caster">
{castingNode}
{limitedUseNode}
{damageNode}
{healingNode}
{atHigherLevelsNode}
</div>
);
}
}
const SpellCasterContainer = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
return <SpellCaster inventoryManager={inventoryManager} {...props} />;
};
export default SpellCasterContainer;
@@ -0,0 +1,4 @@
import SpellCaster from "./SpellCaster";
export default SpellCaster;
export { SpellCaster };
@@ -0,0 +1,475 @@
import React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
Collapsible,
AoeTypeIcon,
ComponentConstants,
} from "@dndbeyond/character-components/es";
import {
ActivationUtils,
AnySimpleDataType,
CharacterTheme,
CharacterUtils,
CharacterValuesContract,
Constants,
DurationUtils,
EntityValueLookup,
FormatUtils,
RuleData,
RuleDataUtils,
SourceMappingContract,
Spell,
SpellCasterInfo,
SpellUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { InfoItem } from "~/components/InfoItem";
import { NumberDisplay } from "~/components/NumberDisplay";
import { Reference } from "~/components/Reference";
import { TagGroup } from "~/components/TagGroup";
import SpellDetailCaster from "../../containers/SpellDetailCaster";
import EditorBox from "../EditorBox";
import ValueEditor from "../ValueEditor";
import { RemoveButton, ThemeButton } from "../common/Button";
interface Props {
spell: Spell;
enableCaster: boolean;
castAsRitual: boolean;
initialCastLevel: number | null;
isPreparedMaxed: boolean;
onRemove?: () => void;
onPrepareToggle?: () => void;
onCustomDataUpdate?: (
key: number,
value: AnySimpleDataType,
source: string | null
) => void;
onCustomizationsRemove?: () => void;
showCustomize: boolean;
showActions: boolean;
entityValueLookup?: EntityValueLookup;
spellCasterInfo: SpellCasterInfo;
ruleData: RuleData;
isReadonly: boolean;
proficiencyBonus: number;
theme: CharacterTheme;
isCustomizeClosed?: boolean;
onCustomizeClick?: (isCollapsed: boolean) => void;
}
export default class SpellDetail extends React.PureComponent<Props> {
static defaultProps = {
initialCastLevel: null,
isPreparedMaxed: true,
enableCaster: true,
showActions: true,
showCustomize: true,
castAsRitual: false,
spellCasterInfo: {},
isReadonly: false,
};
handleRemoveCustomizations = () => {
const { onCustomizationsRemove } = this.props;
if (onCustomizationsRemove) {
onCustomizationsRemove();
}
};
renderCustomize = (): React.ReactNode => {
const {
spell,
showCustomize,
onCustomDataUpdate,
entityValueLookup,
ruleData,
isReadonly,
isCustomizeClosed,
onCustomizeClick,
} = this.props;
const dataOrigin = SpellUtils.getDataOrigin(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
if (
!entityValueLookup ||
!showCustomize ||
!dataOrigin ||
(dataOrigin && dataOriginType === Constants.DataOriginTypeEnum.ITEM) ||
isReadonly
) {
return null;
}
const displayAsAttack = SpellUtils.isDisplayAsAttack(spell);
const initialDisplayAsAttack: boolean =
displayAsAttack ||
(SpellUtils.isAttack(spell) &&
(displayAsAttack || displayAsAttack === null));
let customizationValues: Array<Constants.AdjustmentTypeEnum> = [
Constants.AdjustmentTypeEnum.TO_HIT_OVERRIDE,
Constants.AdjustmentTypeEnum.TO_HIT_BONUS,
Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS,
Constants.AdjustmentTypeEnum.SAVE_DC_OVERRIDE,
Constants.AdjustmentTypeEnum.SAVE_DC_BONUS,
];
if (!SpellUtils.asPartOfWeaponAttack(spell)) {
customizationValues.push(Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK);
}
customizationValues.push(Constants.AdjustmentTypeEnum.NAME_OVERRIDE);
customizationValues.push(Constants.AdjustmentTypeEnum.NOTES);
let customizationLabelOverrides: Partial<
Record<Constants.AdjustmentTypeEnum, string>
> = {
[Constants.AdjustmentTypeEnum.NAME_OVERRIDE]: "Name",
[Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS]: "Damage Bonus",
[Constants.AdjustmentTypeEnum.SAVE_DC_OVERRIDE]: "DC Override",
[Constants.AdjustmentTypeEnum.SAVE_DC_BONUS]: "DC Bonus",
};
let customizationDefaults: Partial<
Record<Constants.AdjustmentTypeEnum, any>
> = {
[Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK]: initialDisplayAsAttack,
};
let [contextId, contextTypeId] = SpellUtils.deriveExpandedContextIds(spell);
const mappingId = SpellUtils.getMappingId(spell);
const mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
let dataLookup: Record<number, CharacterValuesContract> = {};
if (mappingId !== null && mappingEntityTypeId !== null) {
dataLookup = ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(mappingId),
ValueUtils.hack__toString(mappingEntityTypeId),
ValueUtils.hack__toString(contextId),
ValueUtils.hack__toString(contextTypeId)
);
}
const isCustomized = SpellUtils.isCustomized(spell);
return (
<div className="ct-spell-detail__customize">
<Collapsible
layoutType={"minimal"}
header={`Customize${isCustomized ? "*" : ""}`}
collapsed={isCustomizeClosed}
onChangeHandler={onCustomizeClick}
>
<EditorBox>
<ValueEditor
dataLookup={dataLookup}
onDataUpdate={onCustomDataUpdate}
valueEditors={customizationValues}
ruleData={ruleData}
labelOverrides={customizationLabelOverrides}
defaultValues={customizationDefaults}
layoutType={"compact"}
/>
<RemoveButton
enableConfirm={true}
size="medium"
style="filled"
disabled={!isCustomized}
isInteractive={isCustomized}
onClick={this.handleRemoveCustomizations}
>
{isCustomized ? "Remove" : "No"} Customizations
</RemoveButton>
</EditorBox>
</Collapsible>
</div>
);
//`
};
renderActions = (): React.ReactNode => {
const { spell, isPreparedMaxed, onRemove, onPrepareToggle, showActions } =
this.props;
const dataOrigin = SpellUtils.getDataOrigin(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
if (
!showActions ||
!dataOrigin ||
(dataOrigin && dataOriginType !== Constants.DataOriginTypeEnum.CLASS)
) {
return null;
}
const isPrepared = SpellUtils.isPrepared(spell);
const showRemove = SpellUtils.canRemove(spell);
const showPrepare =
SpellUtils.canPrepare(spell) && !SpellUtils.isAlwaysPrepared(spell);
if (!showRemove && !showPrepare) {
return null;
}
return (
<div className="ct-spell-detail__actions">
{showPrepare && (
<div className="ct-spell-detail__action">
<ThemeButton
size="small"
style={isPrepared ? "" : "outline"}
onClick={onPrepareToggle}
stopPropagation={true}
disabled={isPreparedMaxed && !isPrepared}
>
{isPrepared ? "Prepared" : "Prepare"}
</ThemeButton>
</div>
)}
{showRemove && (
<div className="ct-spell-detail__action">
<RemoveButton onClick={onRemove} />
</div>
)}
</div>
);
};
renderCaster = (): React.ReactNode => {
const {
spell,
initialCastLevel,
enableCaster,
castAsRitual,
spellCasterInfo,
proficiencyBonus,
} = this.props;
if (!enableCaster || castAsRitual || !SpellUtils.isActive(spell)) {
return null;
}
return (
<SpellDetailCaster
spell={spell}
initialCastLevel={initialCastLevel}
proficiencyBonus={proficiencyBonus}
{...spellCasterInfo}
/>
);
};
render() {
const { spell, ruleData, theme } = this.props;
let { castAsRitual } = this.props;
castAsRitual = castAsRitual || SpellUtils.isCastAsRitual(spell);
const attackSaveValue = SpellUtils.getAttackSaveValue(spell);
const notes = SpellUtils.getNotes(spell);
const range = SpellUtils.getRange(spell);
const additionalDescription = SpellUtils.getAdditionalDescription(spell);
const level = SpellUtils.getLevel(spell);
const components = SpellUtils.getComponents(spell);
const componentsDescription = SpellUtils.getComponentsDescription(spell);
const duration = SpellUtils.getDuration(spell);
const school = SpellUtils.getSchool(spell);
const description = SpellUtils.getDescription(spell);
const version = SpellUtils.getVersion(spell);
const requiresSavingThrow = SpellUtils.getRequiresSavingThrow(spell);
const tags = SpellUtils.getTags(spell);
const activation = SpellUtils.getActivation(spell);
const castingTimeDescription = SpellUtils.getCastingTimeDescription(spell);
let sourceId: number | null = null;
let sourcePage: number | null = null;
let filteredSources = SpellUtils.getSources(spell).filter(
CharacterUtils.isPrimarySource
);
if (filteredSources.length) {
let primarySource: SourceMappingContract = filteredSources[0];
sourceId = primarySource.sourceId;
sourcePage = primarySource.pageNumber;
}
let rangeAreas: Array<React.ReactNode> = [];
if (range) {
if (
range.origin &&
range.origin !== Constants.SpellRangeTypeNameEnum.RANGED
) {
rangeAreas.push(range.origin);
}
if (range.rangeValue) {
rangeAreas.push(
<NumberDisplay type="distanceInFt" number={range.rangeValue} />
);
}
if (range.aoeValue) {
rangeAreas.push(
<React.Fragment>
<NumberDisplay type="distanceInFt" number={range.aoeValue} />
<span className="ct-spell-detail__range-shape">
<AoeTypeIcon
className="ct-spell-detail__range-icon"
type={
FormatUtils.slugify(
range.aoeType
) as ComponentConstants.AoeTypePropType
}
themeMode={theme.isDarkMode ? "gray" : "dark"}
/>
</span>
</React.Fragment>
);
}
}
let componentsNode: React.ReactNode;
if (components) {
componentsNode = (
<span className="ct-spell-detail__components">
{components.map((componentId, idx) => {
let componentInfo = RuleDataUtils.getSpellComponentInfo(
componentId,
ruleData
);
if (componentInfo === null) {
return null;
}
return (
<React.Fragment key={componentId}>
<Tooltip
isDarkMode={theme.isDarkMode}
title={componentInfo.name ? componentInfo.name : ""}
>
{componentInfo.shortName}
</Tooltip>
{idx + 1 < components.length ? ", " : ""}
</React.Fragment>
);
})}
</span>
);
}
let castingTimeDisplay: React.ReactNode;
if (activation) {
castingTimeDisplay = ActivationUtils.renderCastingTime(
activation,
castAsRitual ? 10 : 0,
ruleData
);
}
const infoItemProps = { role: "listitem", inline: true };
const getSourceInfo = (sourceId) =>
sourceId ? RuleDataUtils.getSourceDataInfo(sourceId, ruleData) : null;
return (
<div className="ct-spell-detail">
<div className="ct-spell-detail__level-school">
{SpellUtils.isLegacy(spell) && (
<span className="ct-spell-detail__level-school-item">
Legacy {" "}
</span>
)}
<span className="ct-spell-detail__level-school-item">
{SpellUtils.isCantrip(spell)
? school
: FormatUtils.renderSpellLevelName(level)}
</span>
<span className="ct-spell-detail__level-school-item">
{SpellUtils.isCantrip(spell)
? FormatUtils.renderSpellLevelName(level)
: school}
</span>
</div>
{this.renderCaster()}
{this.renderCustomize()}
<div className="ct-spell-detail__properties" role="list">
<InfoItem label="Casting Time:" {...infoItemProps}>
{castingTimeDisplay}
{castingTimeDescription ? `, ${castingTimeDescription}` : ""}
</InfoItem>
<InfoItem label="Range/Area:" {...infoItemProps}>
{rangeAreas.map((node, idx) => (
<React.Fragment key={idx}>
{node}
{idx + 1 < rangeAreas.length ? "/" : ""}
</React.Fragment>
))}
</InfoItem>
<InfoItem label="Components:" {...infoItemProps}>
{componentsNode}
{componentsDescription && (
<span className="ct-spell-detail__components-description">
({componentsDescription})
</span>
)}
</InfoItem>
{duration !== null && (
<InfoItem label="Duration:" {...infoItemProps}>
{DurationUtils.renderDuration(
duration,
SpellUtils.getConcentration(spell)
)}
</InfoItem>
)}
{requiresSavingThrow && (
<InfoItem label="Attack/Save:" {...infoItemProps}>
{SpellUtils.getSaveDcAbilityShortName(spell, ruleData)}{" "}
{attackSaveValue}
</InfoItem>
)}
{version !== null && (
<InfoItem label="Version:" {...infoItemProps}>
{version}
</InfoItem>
)}
{notes && (
<InfoItem label="Notes:" {...infoItemProps}>
{notes}
</InfoItem>
)}
{sourceId !== null && (
<InfoItem label="Source:" {...infoItemProps}>
<Reference
name={getSourceInfo(sourceId)?.description || ""}
page={sourcePage}
isDarkMode={theme?.isDarkMode}
/>
</InfoItem>
)}
</div>
{description !== null && (
<HtmlContent
className="ct-spell-detail__description"
html={description}
withoutTooltips
/>
)}
{additionalDescription && (
<HtmlContent
className="ct-spell-detail__additional-description"
html={additionalDescription}
withoutTooltips
/>
)}
{this.renderActions()}
{tags.length > 0 && (
<TagGroup
className="ct-spell-detail__tags"
label="Tags"
tags={tags}
/>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpellDetail from "./SpellDetail";
export default SpellDetail;
export { SpellDetail };
@@ -0,0 +1,264 @@
import { sortBy } from "lodash";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
CharacterTheme,
DataOriginRefData,
EntityValueLookup,
rulesEngineSelectors,
SimpleSourceCategoryContract,
SpellManager as SpellManagerType,
} from "@dndbeyond/character-rules-engine";
import { Link } from "~/components/Link";
import { SpellFilter } from "~/components/SpellFilter";
import DataLoadingStatusEnum from "../../constants/DataLoadingStatusEnum";
import { SpellsManagerContext } from "../../managers/SpellsManagerContext";
import { FilterUtils } from "../../utils";
import SpellManagerItem from "./SpellManagerItem";
interface Props {
charClassId: number;
characterClassId: number;
shouldFetch?: boolean;
hasActiveSpells?: boolean;
isPrepareMaxed: boolean;
isCantripsKnownMaxed: boolean;
isSpellsKnownMaxed: boolean;
addButtonText: string;
enableAdd?: boolean;
enablePrepare?: boolean;
enableUnprepare?: boolean;
enableSpellRemove?: boolean;
showFilters?: boolean;
showExpandedType?: boolean;
showCustomize: boolean;
buttonActiveStyle?: string;
buttonUnprepareText?: string;
entityValueLookup: EntityValueLookup;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
theme: CharacterTheme;
}
// TODO: migrate this all into here
export default function SpellManagerContainer({
showFilters = true,
enableAdd = true,
enableSpellRemove = true,
showExpandedType = true,
enablePrepare = true,
enableUnprepare = true,
shouldFetch = false,
hasActiveSpells = false,
charClassId,
// TODO: get from manager?
characterClassId,
isPrepareMaxed,
isCantripsKnownMaxed,
isSpellsKnownMaxed,
addButtonText,
buttonActiveStyle,
buttonUnprepareText,
entityValueLookup,
dataOriginRefData,
showCustomize,
proficiencyBonus,
theme,
}: Props) {
const ruleData = useSelector(rulesEngineSelectors.getRuleData);
const spellCasterInfo = useSelector(rulesEngineSelectors.getSpellCasterInfo);
const { spellsManager } = useContext(SpellsManagerContext);
const [availableSpells, setAvailableSpells] = useState<
Array<SpellManagerType>
>([]);
const [spells, setSpells] = useState<Array<SpellManagerType>>([]);
const [sourceCategories, setSourceCategories] = useState<
Array<SimpleSourceCategoryContract>
>([]);
const [loadingStatus, setLoadingStatus] = useState(
DataLoadingStatusEnum.LOADING
);
const [filterLevels, setFilterLevels] = useState<Array<number>>([]);
const [filterQuery, setFilterQuery] = useState("");
const [filterSourceCategories, setFilterSourceCategories] = useState<
Array<number>
>([]);
const getData = useCallback(
async function getData() {
let classSpellMap = await spellsManager.getSpellShoppe();
if (!classSpellMap[charClassId] || shouldFetch) {
classSpellMap = await spellsManager.getSpellShoppe(true);
}
setSpells(
hasActiveSpells
? classSpellMap[charClassId].activeSpells
: classSpellMap[charClassId].knownSpells
);
setAvailableSpells(
shouldFetch ? classSpellMap[charClassId].availableSpells : []
);
setSourceCategories(classSpellMap[charClassId].sourceCategories);
setLoadingStatus(DataLoadingStatusEnum.LOADED);
},
[spellsManager, hasActiveSpells, shouldFetch, charClassId]
);
useEffect(() => {
getData();
}, [getData]);
function getCombinedSpells(): Array<SpellManagerType> {
// const remainingLazySpells = availableSpells.filter((spell) => !knownSpellIds.includes(spell.deriveKnownKey()));
return sortBy(
[...availableSpells, ...spells],
[
(spell) => spell.getLevel(),
(spell) => spell.getName(),
(spell) => spell.getExpandedDataOriginRef() !== null,
(spell) => spell.getUniqueKey(),
]
);
}
function getFilteredSpells(
combinedSpells: Array<SpellManagerType>
): Array<SpellManagerType> {
return combinedSpells.filter((spell) => {
if (filterSourceCategories.length !== 0) {
const sourceCategoryId = spell.getPrimarySourceCategoryId();
if (!filterSourceCategories.includes(sourceCategoryId)) {
return false;
}
}
if (
filterLevels.length !== 0 &&
!filterLevels.includes(spell.getLevel())
) {
return false;
}
if (
filterQuery !== "" &&
!FilterUtils.doesQueryMatchData(filterQuery, spell.getName())
) {
return false;
}
return true;
});
}
function handleFilterSpellLevel(level: number): void {
setFilterLevels(
filterLevels.includes(level)
? filterLevels.filter((l) => l !== level)
: [...filterLevels, level]
);
}
function handleSourceCategoryClick(categoryId: number): void {
setFilterSourceCategories(
filterSourceCategories.includes(categoryId)
? filterSourceCategories.filter((cat) => cat !== categoryId)
: [...filterSourceCategories, categoryId]
);
}
function onSuccess() {
getData();
}
function onFailure() {
getData();
}
function renderUi(): React.ReactNode {
const combinedSpells = getCombinedSpells();
const filteredSpells = getFilteredSpells(combinedSpells);
return (
<React.Fragment>
{showFilters && (
<React.Fragment>
<SpellFilter
spells={combinedSpells.map((manager) => manager.getSpell())}
sourceCategories={sourceCategories}
filterQuery={filterQuery}
filterLevels={filterLevels}
onLevelFilterClick={handleFilterSpellLevel}
onQueryChange={setFilterQuery}
onSourceCategoryClick={handleSourceCategoryClick}
filterSourceCategories={filterSourceCategories}
themed
/>
<div className="ct-character-tools__marketplace-callout">
Looking for something not in the list below? Unlock all official
options in the <Link href="/marketplace">Marketplace</Link>.
</div>
</React.Fragment>
)}
{filteredSpells.length === 0 && <div>No Results Found</div>}
{filteredSpells.map((spell, idx) => (
<SpellManagerItem
theme={theme}
spell={spell}
key={`${spell.getId()}-${idx}`}
onPrepare={() => {
spell.handlePrepare({ characterClassId }, onSuccess, onFailure);
}}
onUnprepare={() => {
spell.handleUnprepare({ characterClassId }, onSuccess, onFailure);
}}
onRemove={() => {
spell.handleRemove({ characterClassId }, onSuccess, onFailure);
}}
onAdd={() => {
spell.handleAdd({ characterClassId }, onSuccess, onFailure);
}}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
addButtonText={addButtonText}
enableAdd={enableAdd}
enablePrepare={enablePrepare}
enableUnprepare={enableUnprepare}
enableSpellRemove={enableSpellRemove}
buttonUnprepareText={buttonUnprepareText}
buttonActiveStyle={buttonActiveStyle}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
entityValueLookup={entityValueLookup}
showExpandedType={showExpandedType ?? true}
showCustomize={showCustomize}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
/>
))}
</React.Fragment>
);
}
function renderLoading(): React.ReactNode {
return <LoadingPlaceholder />;
}
let content: React.ReactNode;
switch (loadingStatus) {
case DataLoadingStatusEnum.LOADED:
content = renderUi();
break;
case DataLoadingStatusEnum.LOADING:
default:
content = renderLoading();
break;
}
return <div className="ct-spell-manager">{content}</div>;
}
@@ -0,0 +1,190 @@
import React from "react";
import {
Collapsible,
CollapsibleHeaderContent,
} from "@dndbeyond/character-components/es";
import {
CharacterTheme,
Constants,
DataOriginRefData,
EntityValueLookup,
RuleData,
SpellCasterInfo,
SpellManager,
} from "@dndbeyond/character-rules-engine";
import { SpellName } from "~/components/SpellName";
import SpellDetail from "../../SpellDetail";
import { RemoveButton, ThemeButton } from "../../common/Button";
interface Props {
spell: SpellManager;
buttonUnprepareText?: string;
buttonActiveStyle?: string;
enableSpellRemove: boolean;
isPrepareMaxed: boolean;
isCantripsKnownMaxed: boolean;
isSpellsKnownMaxed: boolean;
addButtonText: string;
enableAdd: boolean;
enablePrepare: boolean;
enableUnprepare: boolean;
showExpandedType: boolean;
showCustomize: boolean;
onPrepare: () => void;
onUnprepare: () => void;
onRemove: () => void;
onAdd: () => void;
spellCasterInfo: SpellCasterInfo;
ruleData: RuleData;
entityValueLookup: EntityValueLookup;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
theme: CharacterTheme;
}
export default function SpellManagerItem({
spell,
isPrepareMaxed,
isCantripsKnownMaxed,
isSpellsKnownMaxed,
showExpandedType,
showCustomize,
onPrepare,
onUnprepare,
onRemove,
onAdd,
spellCasterInfo,
ruleData,
entityValueLookup,
dataOriginRefData,
proficiencyBonus,
theme,
enableSpellRemove = true,
enableAdd = true,
enablePrepare = true,
enableUnprepare = true,
buttonUnprepareText = "Prepared",
addButtonText = "Prepare",
buttonActiveStyle = "",
}: Props) {
function handlePrepareToggle(): void {
if (spell.isPrepared()) {
onUnprepare();
} else {
onPrepare();
}
}
function handleRemove(): void {
onRemove();
}
function handleAdd(): void {
onAdd();
}
function renderButtons(classNames: Array<string>): React.ReactNode {
const alwaysPrepared = spell.isAlwaysPrepared();
const isPrepared = spell.isPrepared();
const canRemove = spell.canRemove();
const canPrepare = spell.canPrepare();
const canAdd = spell.canAdd();
const isCantrip = spell.isCantrip();
let isAddDisabled: boolean = false;
if (isCantrip && isCantripsKnownMaxed) {
isAddDisabled = true;
}
if (!isCantrip && isSpellsKnownMaxed) {
isAddDisabled = true;
}
let showPrepare: boolean =
!alwaysPrepared && canPrepare && (enablePrepare || enableUnprepare);
let showRemove: boolean =
canRemove &&
(enableSpellRemove || (spell.isSpellbookCaster() && isCantrip));
if (isCantrip) {
addButtonText = Constants.SpellCastingLearningStyleAddText[Constants.SpellCastingLearningStyle.Learned];
buttonUnprepareText = Constants.SpellCastingLearningStyleRemoveText[Constants.SpellCastingLearningStyle.Learned];
}
return (
<div className={classNames.join(" ")}>
{alwaysPrepared && (
<div className="ct-spell-manager__spell-always">Always Prepared</div>
)}
{showPrepare && (
<ThemeButton
size="small"
disabled={isPrepareMaxed && !isPrepared}
style={isPrepared ? buttonActiveStyle : "outline"}
onClick={handlePrepareToggle}
stopPropagation={true}
>
{isPrepared ? buttonUnprepareText : addButtonText}
</ThemeButton>
)}
{showRemove && (
<RemoveButton onClick={handleRemove} style={buttonActiveStyle}>
{buttonUnprepareText}
</RemoveButton>
)}
{enableAdd && canAdd && (
<ThemeButton
size="small"
onClick={handleAdd}
disabled={isAddDisabled}
style={"outline"}
stopPropagation={true}
>
{addButtonText}
</ThemeButton>
)}
</div>
);
}
function renderHeader(): React.ReactNode {
return (
<CollapsibleHeaderContent
heading={
<SpellName
spell={spell.spell}
showExpandedType={showExpandedType}
dataOriginRefData={dataOriginRefData}
showLegacy={true}
/>
}
callout={renderButtons(["ct-spell-manager__spell-header-actions"])}
/>
);
}
return (
<Collapsible
layoutType={"minimal"}
header={renderHeader()}
className="ct-spell-manager__spell"
>
<SpellDetail
theme={theme}
spell={spell.spell}
isPreparedMaxed={isPrepareMaxed}
enableCaster={false}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
onPrepareToggle={handlePrepareToggle}
onRemove={handleRemove}
entityValueLookup={entityValueLookup}
showCustomize={showCustomize}
proficiencyBonus={proficiencyBonus}
/>
</Collapsible>
);
}
@@ -0,0 +1,4 @@
import SpellManagerItem from "./SpellManagerItem";
export default SpellManagerItem;
export { SpellManagerItem };
@@ -0,0 +1,5 @@
import SpellManager from "./SpellManager";
import SpellManagerItem from "./SpellManagerItem";
export default SpellManager;
export { SpellManager, SpellManagerItem };
@@ -0,0 +1,47 @@
import React from "react";
import { ThemeButton } from "../common/Button";
interface Props {
onPactChoose: () => void;
onSpellChoose: () => void;
isSpellSlotAvailable: boolean;
isPactSlotAvailable: boolean;
doesSpellSlotExist: boolean;
doesPactSlotExist: boolean;
}
export default class SpellSlotChooser extends React.PureComponent<Props> {
render() {
const {
onPactChoose,
onSpellChoose,
doesSpellSlotExist,
doesPactSlotExist,
isSpellSlotAvailable,
isPactSlotAvailable,
} = this.props;
return (
<div className="ct-spells-slot-chooser">
{doesSpellSlotExist && (
<ThemeButton
onClick={onSpellChoose}
size={"small"}
disabled={!isSpellSlotAvailable}
>
Spell Slot
</ThemeButton>
)}
{doesPactSlotExist && (
<ThemeButton
onClick={onPactChoose}
size={"small"}
disabled={!isPactSlotAvailable}
>
Pact Slot
</ThemeButton>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpellSlotChooser from "./SpellSlotChooser";
export default SpellSlotChooser;
export { SpellSlotChooser };
@@ -0,0 +1,238 @@
import React from "react";
import {
AbilityLookup,
CharacterTheme,
Constants,
DataOriginRefData,
ExperienceInfo,
RuleData,
ScaledSpell,
Spell,
SpellCasterInfo,
SpellSlotContract,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import SpellsSpell from "../SpellsSpell";
interface Props {
spells: Array<ScaledSpell>;
level: number;
spellCasterInfo: SpellCasterInfo;
ruleData: RuleData;
abilityLookup: AbilityLookup;
xpInfo: ExperienceInfo;
onSpellClick?: (spell: Spell, castLevel: number) => void;
onSpellSlotChange?: (castLevel: number, changeAmount: number) => void;
onPactSlotChange?: (castLevel: number, changeAmount: number) => void;
onSpellLimitedUseSet?: (
mappingId: number,
mappingTypeId: number,
uses: number,
dataOriginType: Constants.DataOriginTypeEnum
) => void;
onItemLimitedUseSet?: (
mappingId: number,
mappingTypeId: number,
uses: number
) => void;
showNotes: boolean;
isInteractive: boolean;
diceEnabled: boolean;
theme: CharacterTheme;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
rollContext: IRollContext;
}
export default class SpellsLevel extends React.PureComponent<Props> {
static defaultProps = {
showNotes: true,
diceEnabled: false,
};
handleSpellSlotChange = (changeAmount: number): void => {
const { onSpellSlotChange, level } = this.props;
if (onSpellSlotChange) {
onSpellSlotChange(level, changeAmount);
}
};
handlePactSlotChange = (changeAmount: number): void => {
const { onPactSlotChange, level } = this.props;
if (onPactSlotChange) {
onPactSlotChange(level, changeAmount);
}
};
handleSpellClick = (spell: Spell, castLevel: number): void => {
const { onSpellClick } = this.props;
if (onSpellClick) {
onSpellClick(spell, castLevel);
}
};
handleSpellUse = (
useSpellSlot: boolean,
usePactMagicSlot: boolean,
dataOriginType: Constants.DataOriginTypeEnum,
uses: number | null,
mappingId: number | null,
mappingTypeId: number | null
): void => {
const { onSpellLimitedUseSet, onItemLimitedUseSet } = this.props;
if (useSpellSlot) {
this.handleSpellSlotChange(1);
}
if (usePactMagicSlot) {
this.handlePactSlotChange(1);
}
if (uses !== null && mappingId !== null && mappingTypeId !== null) {
switch (dataOriginType) {
case Constants.DataOriginTypeEnum.ITEM:
if (onItemLimitedUseSet) {
onItemLimitedUseSet(mappingId, mappingTypeId, uses);
}
break;
default:
if (onSpellLimitedUseSet) {
onSpellLimitedUseSet(
mappingId,
mappingTypeId,
uses,
dataOriginType
);
}
}
}
};
getSpellSlotInfo = (): SpellSlotContract | null => {
const { spellCasterInfo, level } = this.props;
const { spellSlots } = spellCasterInfo;
let spellSlotInfo = spellSlots.find(
(spellSlotGroup) => spellSlotGroup.level === level
);
return spellSlotInfo ? spellSlotInfo : null;
};
getPactSlotInfo = (): SpellSlotContract | null => {
const { spellCasterInfo, level } = this.props;
const { pactMagicSlots } = spellCasterInfo;
let spellSlotInfo = pactMagicSlots.find(
(pactSlotGroup) => pactSlotGroup.level === level
);
return spellSlotInfo ? spellSlotInfo : null;
};
renderSpell = (spell: ScaledSpell): React.ReactNode => {
const {
ruleData,
abilityLookup,
xpInfo,
level,
showNotes,
isInteractive,
diceEnabled,
theme,
dataOriginRefData,
proficiencyBonus,
rollContext,
} = this.props;
let spellSlotLevel = this.getSpellSlotInfo();
let pactMagicLevel = this.getPactSlotInfo();
let doesSpellSlotExist: boolean =
!!spellSlotLevel && spellSlotLevel.available > 0;
let doesPactSlotExist: boolean =
!!pactMagicLevel && pactMagicLevel.available > 0;
let isSpellSlotAvailable: boolean = false;
if (doesSpellSlotExist && spellSlotLevel) {
isSpellSlotAvailable = spellSlotLevel.used < spellSlotLevel.available;
}
let isPactSlotAvailable: boolean = false;
if (doesPactSlotExist && pactMagicLevel) {
isPactSlotAvailable = pactMagicLevel.used < pactMagicLevel.available;
}
return (
<SpellsSpell
key={SpellUtils.getUniqueKey(spell)}
spell={spell}
abilityLookup={abilityLookup}
ruleData={ruleData}
castLevel={level}
characterLevel={xpInfo.currentLevel}
onClick={this.handleSpellClick}
onUse={this.handleSpellUse}
doesSpellSlotExist={doesSpellSlotExist}
doesPactSlotExist={doesPactSlotExist}
isSpellSlotAvailable={isSpellSlotAvailable}
isPactSlotAvailable={isPactSlotAvailable}
showNotes={showNotes}
isInteractive={isInteractive}
diceEnabled={diceEnabled}
theme={theme}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
rollContext={rollContext}
/>
);
};
render() {
const { spells, showNotes, theme } = this.props;
return (
<div className="ct-spells-level">
<div
className={`ct-spells-level__spells-row-header ${
theme.isDarkMode
? "ct-spells-level__spells-row-header--dark-mode"
: ""
}`}
>
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--action" />
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--name">
Name
</div>
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--activation">
Time
</div>
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--range">
Range
</div>
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--tohit">
Hit / DC
</div>
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--damage">
Effect
</div>
{showNotes && (
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--notes">
Notes
</div>
)}
</div>
<div className="ct-spells-level__spells-content">
{spells.length ? (
spells.map((spell) => this.renderSpell(spell))
) : (
<div className="ct-spells-level__empty">
No Spells Match the Current Filter
</div>
)}
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpellsLevel from "./SpellsLevel";
export default SpellsLevel;
export { SpellsLevel };
@@ -0,0 +1,211 @@
import React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
ClassUtils,
SpellCasterCastingEntry,
SpellCasterInfo,
SpellSlotContract,
} from "@dndbeyond/character-rules-engine/es";
import { NumberDisplay } from "~/components/NumberDisplay";
import SlotManager from "../SlotManager";
interface Props {
level: number;
spellCasterInfo: SpellCasterInfo;
onSpellSlotSet?: (level: number, uses: number) => void;
onPactSlotSet?: (level: number, uses: number) => void;
showSlots: boolean;
showCastingInfo: boolean;
isInteractive: boolean;
isDarkMode?: boolean;
}
export default class SpellsLevelCasting extends React.PureComponent<Props> {
static defaultProps = {
level: 1,
showSlots: true,
showCastingInfo: true,
isInteractive: true,
};
handleSpellSlotSet = (uses: number): void => {
const { onSpellSlotSet, level } = this.props;
if (onSpellSlotSet) {
onSpellSlotSet(level, uses);
}
};
handlePactSlotSet = (uses: number): void => {
const { onPactSlotSet, level } = this.props;
if (onPactSlotSet) {
onPactSlotSet(level, uses);
}
};
getSpellSlotInfo = (): SpellSlotContract | null => {
const { spellCasterInfo, level } = this.props;
const { spellSlots } = spellCasterInfo;
let spellSlotInfo = spellSlots.find(
(spellSlotGroup) => spellSlotGroup.level === level
);
return spellSlotInfo ? spellSlotInfo : null;
};
getPactSlotInfo = (): SpellSlotContract | null => {
const { spellCasterInfo, level } = this.props;
const { pactMagicSlots } = spellCasterInfo;
let spellSlotInfo = pactMagicSlots.find(
(pactSlotGroup) => pactSlotGroup.level === level
);
return spellSlotInfo ? spellSlotInfo : null;
};
hasLevelSlots = (): boolean => {
let spellSlotLevel = this.getSpellSlotInfo();
let pactMagicLevel = this.getPactSlotInfo();
return !!(spellSlotLevel || pactMagicLevel);
};
hasCastingInfo = (): boolean => {
const { spellCasterInfo } = this.props;
const { castingInfo } = spellCasterInfo;
return !!(
castingInfo.modifiers.length ||
castingInfo.spellAttacks.length ||
castingInfo.saveDcs.length
);
};
renderLevelSlots = (): React.ReactNode => {
const { isInteractive, isDarkMode } = this.props;
let spellSlotLevel = this.getSpellSlotInfo();
let pactMagicLevel = this.getPactSlotInfo();
if (!this.hasLevelSlots()) {
return null;
}
return (
<div className="ct-spells-level-casting__slot-groups">
{pactMagicLevel && (
<div className="ct-spells-level-casting__slot-group ct-spells-level-casting__slot-group--pact">
<div className="ct-spells-level-casting__slot-group-manager">
<SlotManager
onSet={this.handlePactSlotSet}
available={pactMagicLevel.available}
used={pactMagicLevel.used}
size={"small"}
isInteractive={isInteractive}
/>
</div>
<div className="ct-spells-level-casting__slot-group-label">
Pact
</div>
</div>
)}
{spellSlotLevel && (
<div className="ct-spells-level-casting__slot-group ct-spells-level-casting__slot-group--spells">
<div className="ct-spells-level-casting__slot-group-manager">
<SlotManager
onSet={this.handleSpellSlotSet}
available={spellSlotLevel.available}
used={spellSlotLevel.used}
size={"small"}
isInteractive={isInteractive}
/>
</div>
<div
className={`ct-spells-level-casting__slot-group-label ${
isDarkMode ? "ct-spells-level-casting--dark-mode" : ""
}`}
>
Slots
</div>
</div>
)}
</div>
);
};
renderCastingInfoGroup = (
label: React.ReactNode,
entries: Array<SpellCasterCastingEntry>,
isSignedNumber: boolean = true
): React.ReactNode => {
const { isDarkMode } = this.props;
return (
<div className="ct-spells-level-casting__info-group">
<div className="ct-spells-level-casting__info-items">
{entries.map((entry) => {
if (entry.value === null) {
return null;
}
let tooltip = entry.sources
.map((charClass) => ClassUtils.getName(charClass))
.join(", ");
return (
<Tooltip
title={tooltip}
className="ct-spells-level-casting__info-item"
key={entry.value}
tippyOpts={{ dynamicTitle: true }}
isDarkMode={isDarkMode}
>
{isSignedNumber ? (
<NumberDisplay type="signed" number={entry.value} />
) : (
entry.value
)}
</Tooltip>
);
})}
</div>
<div className="ct-spells-level-casting__info-label">{label}</div>
</div>
);
};
renderCastingInfo = (): React.ReactNode => {
const { spellCasterInfo } = this.props;
const { castingInfo } = spellCasterInfo;
if (!this.hasCastingInfo()) {
return null;
}
return (
<div className="ct-spells-level-casting__info">
{this.renderCastingInfoGroup("Modifier", castingInfo.modifiers)}
{this.renderCastingInfoGroup("Spell Attack", castingInfo.spellAttacks)}
{this.renderCastingInfoGroup("Save DC", castingInfo.saveDcs, false)}
</div>
);
};
render() {
const { showSlots, showCastingInfo } = this.props;
if (!this.hasCastingInfo() && !this.hasLevelSlots()) {
return null;
}
return (
<div className="ct-spells-level-casting">
{showSlots && this.renderLevelSlots()}
{showCastingInfo && this.renderCastingInfo()}
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpellsLevelCasting from "./SpellsLevelCasting";
export default SpellsLevelCasting;
export { SpellsLevelCasting };
@@ -0,0 +1,445 @@
import React, { useCallback, useContext, useEffect, useMemo } from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
DiceComponentUtils,
DigitalDiceWrapper,
NoteComponents,
SpellDamageEffect,
} from "@dndbeyond/character-components/es";
import {
AbilityLookup,
ActivationUtils,
CharacterTheme,
Constants,
DataOriginRefData,
EntityUtils,
FormatUtils,
RuleData,
ScaledSpell,
Spell,
LeveledSpellManager,
} from "@dndbeyond/character-rules-engine/es";
import {
Dice,
DiceEvent,
DiceTools,
IRollContext,
RollRequest,
RollType,
} from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { ItemName } from "~/components/ItemName";
import { NumberDisplay } from "~/components/NumberDisplay";
import { SpellName } from "~/components/SpellName";
import { TypeScriptUtils } from "../../utils";
import SpellSlotChooser from "../SpellSlotChooser";
import { ThemeButton } from "../common/Button";
interface Props {
className?: string;
spell: ScaledSpell;
castLevel: number;
characterLevel: number;
isSpellSlotAvailable: boolean;
isPactSlotAvailable: boolean;
doesSpellSlotExist: boolean;
doesPactSlotExist: boolean;
ruleData: RuleData;
abilityLookup: AbilityLookup;
dataOriginRefData: DataOriginRefData;
onClick?: (spell: Spell, castLevel: number) => void;
onUse?: (
useSpellSlot: boolean,
usePactMagicSlot: boolean,
dataOriginType: Constants.DataOriginTypeEnum,
uses: number | null,
mappingId: number | null,
mappingTypeId: number | null
) => void;
showNotes: boolean;
isInteractive: boolean;
diceEnabled: boolean;
theme: CharacterTheme;
proficiencyBonus: number;
rollContext: IRollContext;
}
function SpellsSpell({
className = "",
spell: spellData,
castLevel,
characterLevel,
isSpellSlotAvailable,
isPactSlotAvailable,
doesSpellSlotExist,
doesPactSlotExist,
ruleData,
abilityLookup,
dataOriginRefData,
onClick,
onUse,
showNotes = true,
isInteractive,
diceEnabled,
theme,
proficiencyBonus,
rollContext,
}: Props) {
const spell = useMemo(
() => new LeveledSpellManager({ spell: spellData }),
[spellData]
);
const [isSlotChooserOpen, setIsSlotChooserOpen] = React.useState(false);
const [isCriticalHit, setIsCriticalHit] = React.useState(false);
const spellsSpellNode = React.useRef<HTMLDivElement>(null);
const handleSpellClick = (evt: React.MouseEvent): void => {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
if (onClick) {
onClick(spell.spell, castLevel);
}
};
const handleDisabledCastClick = (): void => {
if (onClick) {
onClick(spell.spell, castLevel);
}
};
const handleSpellSlotChooserOpen = (): void => {
setIsSlotChooserOpen(true);
document.addEventListener("click", handleDocumentClick);
};
const handleSpellSlotChooserClose = (): void => {
setIsSlotChooserOpen(false);
};
const handleSpellSlotChosen = (): void => {
spell.handleSpellUse(true, false);
setIsSlotChooserOpen(false);
};
const handlePactSlotChosen = (): void => {
spell.handleSpellUse(false, true);
setIsSlotChooserOpen(false);
};
const handleRollResults = (result: RollRequest): void => {
let wasCrit = DiceComponentUtils.isCriticalRoll(result);
setIsCriticalHit(wasCrit);
};
const diceEventHandler = useMemo(() => {
if (!spellsSpellNode.current) {
return () => {
/* NOOP */
};
}
return DiceComponentUtils.setupResetCritStateOnRoll(
spell.getName(),
spellsSpellNode.current
);
}, [spell]);
// TODO: use mui click away listener
const handleDocumentClick = useCallback((): void => {
handleSpellSlotChooserClose();
document.removeEventListener("click", handleDocumentClick);
}, []);
useEffect(
() => () => {
if (isSlotChooserOpen) {
document.removeEventListener("click", handleDocumentClick);
}
Dice.removeEventListener(DiceEvent.ROLL, diceEventHandler);
},
[isSlotChooserOpen, handleDocumentClick, diceEventHandler]
);
// Todo: consider making a separate component for this
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const renderAttackInfo = (): React.ReactNode => {
let toHit: number | null = null;
let saveDcValue: number | null = null;
let saveDcLabel: string | null = null;
let asPartOfWeaponAttack = spell.asPartOfWeaponAttack();
let requiresAttackRoll = spell.getRequiresAttackRoll();
let requiresSavingThrow = spell.getRequiresSavingThrow();
if (requiresAttackRoll) {
toHit = spell.getToHit();
} else if (requiresSavingThrow) {
saveDcValue = spell.getAttackSaveValue();
saveDcLabel = spell.getSaveDcAbilityKey();
}
if (!asPartOfWeaponAttack) {
if (requiresAttackRoll && toHit !== null) {
return (
<div className="ct-spells-spell__tohit">
<DigitalDiceWrapper
diceNotation={DiceTools.CustomD20(toHit)}
rollType={RollType.ToHit}
rollAction={spell.getName()}
diceEnabled={diceEnabled}
themeColor={theme.themeColor}
onRollResults={handleRollResults}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions
? Object.values(messageTargetOptions.entities).filter(
TypeScriptUtils.isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId) || 0}
>
<NumberDisplay
number={toHit}
type="signed"
/>
</DigitalDiceWrapper>
</div>
);
} else if (requiresSavingThrow) {
return (
<div className="ct-spells-spell__save">
<span
className={`ct-spells-spell__save-label ${
theme?.isDarkMode ? "ct-spells-spell--dark-mode" : ""
}`}
>
{saveDcLabel}
</span>
<span
className={`ct-spells-spell__save-value ${
theme?.isDarkMode ? "ct-spells-spell--dark-mode" : ""
}`}
>
{saveDcValue}
</span>
</div>
);
}
}
return <div className="ct-spells-spell__empty-value">--</div>;
};
let range = spell.getRange();
let limitedUse = spell.getLimitedUse();
let activation = spell.getActivation();
let isScaled = spell.getCastLevel() !== spell.getLevel();
let isAtWill = spell.isAtWill();
const spellDataOriginType = spell.getDataOriginType();
const spellDataOrigin = spell.getDataOrigin();
let limitedUseButtonText: React.ReactNode;
if (limitedUse) {
let maxUses = spell.getMaxUses();
let consumedAmount = spell.getConsumedUses();
if (
maxUses === 1 &&
spellDataOriginType !== Constants.DataOriginTypeEnum.ITEM
) {
limitedUseButtonText = "Use";
} else {
limitedUseButtonText = `${consumedAmount} ${
spellDataOriginType === Constants.DataOriginTypeEnum.ITEM ? "C" : "U"
}`;
}
}
let usesSpellSlot = spell.getUsesSpellSlot();
let canCastSpell: boolean = true;
if (limitedUse) {
canCastSpell = spell.isLimitedUseAvailableAtScaledAmount();
} else if (usesSpellSlot) {
canCastSpell = isSpellSlotAvailable || isPactSlotAvailable;
}
let scaledInfoNode: React.ReactNode;
if (isScaled) {
scaledInfoNode = (
<span className="ct-spells-spell__scaled">
<span className="ct-spells-spell__scaled-level">
<span className="ct-spells-spell__scaled-level-number">
{spell.getLevel()}
</span>
<span className="ct-spells-spell__scaled-level-ordinal">
{FormatUtils.getOrdinalSuffix(spell.getLevel())}
</span>
</span>
</span>
);
}
let combinedMetaItems: Array<React.ReactNode> = [];
if (spell.isLegacy()) {
combinedMetaItems.push("Legacy");
}
switch (spellDataOriginType) {
case Constants.DataOriginTypeEnum.ITEM:
combinedMetaItems.push(
<ItemName item={spellDataOrigin.primary} showAttunement={false} />
);
break;
default:
combinedMetaItems.push(EntityUtils.getDataOriginName(spellDataOrigin));
}
let expandedDataOriginRef = spell.getExpandedDataOriginRef();
if (expandedDataOriginRef !== null) {
combinedMetaItems.push(
EntityUtils.getDataOriginRefName(expandedDataOriginRef, dataOriginRefData)
);
}
let castAsRitual = spell.isCastAsRitual();
let actionNode: React.ReactNode;
if (castAsRitual) {
actionNode = <span className="ct-spells-spell__as-ritual">As Ritual</span>;
} else if (isAtWill) {
actionNode = <span className="ct-spells-spell__at-will">At Will</span>;
} else {
actionNode = (
<React.Fragment>
<ThemeButton
size={"small"}
onClick={
canCastSpell
? () => spell.handleCastClick(handleSpellSlotChooserOpen)
: handleDisabledCastClick
}
disabled={!canCastSpell}
block={true}
isInteractive={isInteractive}
>
{scaledInfoNode}
{limitedUse ? limitedUseButtonText : "Cast"}
</ThemeButton>
{isSlotChooserOpen && canCastSpell && (
<SpellSlotChooser
onPactChoose={handlePactSlotChosen}
onSpellChoose={handleSpellSlotChosen}
isSpellSlotAvailable={isSpellSlotAvailable}
isPactSlotAvailable={isPactSlotAvailable}
doesSpellSlotExist={doesSpellSlotExist}
doesPactSlotExist={doesPactSlotExist}
/>
)}
</React.Fragment>
);
}
let classNames: Array<string> = ["ct-spells-spell", className];
if (isCriticalHit) {
classNames.push("ct-spells-spell--crit");
}
return (
<div
className={classNames.join(" ")}
onClick={handleSpellClick}
ref={spellsSpellNode}
>
<div className="ct-spells-spell__action">{actionNode}</div>
<div className="ct-spells-spell__name">
<div
className={`ct-spells-spell__label ${
isScaled ? "ct-spells-spell__label--scaled" : ""
}`}
>
<SpellName
spell={spell.spell}
showSpellLevel={false}
dataOriginRefData={dataOriginRefData}
/>
</div>
{combinedMetaItems.length > 0 && (
<div
className={`ct-spells-spell__meta ${
theme.isDarkMode ? "ct-spells-spell__meta--dark-mode" : ""
}`}
>
{combinedMetaItems.map((metaItem, idx) => (
<span className="ct-spells-spell__meta-item" key={idx}>
{metaItem}
</span>
))}
</div>
)}
</div>
{activation !== null && (
<div
className={`ct-spells-spell__activation ${
theme.isDarkMode ? "ct-spells-spell__activation--dark-mode" : ""
}`}
>
<Tooltip
isDarkMode={theme.isDarkMode}
title={ActivationUtils.renderCastingTime(
activation,
castAsRitual ? 10 : 0,
ruleData
)}
tippyOpts={{ dynamicTitle: true }}
>
{ActivationUtils.renderCastingTimeAbbreviation(activation)}
{castAsRitual && (
<span className="ct-spells-spell__activation-extra">+10m</span>
)}
</Tooltip>
</div>
)}
{range !== null && (
<div
className={`ct-spells-spell__range ${
theme?.isDarkMode ? "ct-spells-spell--dark-mode" : ""
}`}
>
{!!range.origin &&
range.origin !== Constants.SpellRangeTypeNameEnum.RANGED && (
<span className="ct-spells-spell__range-origin">
{range.origin}
</span>
)}
{!!range.rangeValue && (
<span className="ct-spells-spell__range-value">
<NumberDisplay type="distanceInFt" number={range.rangeValue} />
</span>
)}
</div>
)}
<div className="ct-spells-spell__attacking">{renderAttackInfo()}</div>
<div className="ct-spells-spell__damage">
<SpellDamageEffect
spell={spell.spell}
characterLevel={characterLevel}
castLevel={castLevel}
ruleData={ruleData}
diceEnabled={diceEnabled}
theme={theme}
isCriticalHit={isCriticalHit}
rollContext={rollContext}
/>
</div>
{showNotes && (
<div className="ct-spells-spell__notes">
<NoteComponents theme={theme} notes={spell.getNoteComponents()} />
</div>
)}
</div>
);
}
export default SpellsSpell;
@@ -0,0 +1,4 @@
import SpellsSpell from "./SpellsSpell";
export default SpellsSpell;
export { SpellsSpell };
@@ -0,0 +1,24 @@
import React from "react";
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
interface Props {
name: string;
className: string;
}
export default class Subsection extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
render() {
const { name, children, className } = this.props;
let classNames: Array<string> = [className, "ct-subsection"];
if (name) {
classNames.push(`ct-subsection--${FormatUtils.slugify(name)}`);
}
return <div className={classNames.join(" ")}>{children}</div>;
}
}
@@ -0,0 +1,9 @@
import React from "react";
export default class SubsectionFooter extends React.PureComponent {
render() {
const { children } = this.props;
return <div className="ct-subsection__footer">{children}</div>;
}
}
@@ -0,0 +1,4 @@
import SubsectionFooter from "./SubsectionFooter";
export default SubsectionFooter;
export { SubsectionFooter };
@@ -0,0 +1,9 @@
import React from "react";
export default class SubsectionHeader extends React.PureComponent {
render() {
const { children } = this.props;
return <div className="ct-subsection__header">{children}</div>;
}
}
@@ -0,0 +1,9 @@
import React from "react";
export default class SubsectionHeaderContent extends React.PureComponent {
render() {
const { children } = this.props;
return <div className="ct-subsection__header-content">{children}</div>;
}
}
@@ -0,0 +1,4 @@
import SubsectionHeaderContent from "./SubsectionHeaderContent";
export default SubsectionHeaderContent;
export { SubsectionHeaderContent };
@@ -0,0 +1,12 @@
import Subsection from "./Subsection";
import SubsectionFooter from "./SubsectionFooter";
import SubsectionHeader from "./SubsectionHeader";
import SubsectionHeaderContent from "./SubsectionHeaderContent";
export default Subsection;
export {
Subsection,
SubsectionFooter,
SubsectionHeader,
SubsectionHeaderContent,
};
@@ -0,0 +1,109 @@
import React from "react";
import { BackgroundCharacteristicContract } from "@dndbeyond/character-rules-engine/es";
import { ThemeButton } from "../common/Button";
interface Props {
suggestions: Array<BackgroundCharacteristicContract>;
tableLabel: React.ReactNode;
dieLabel: React.ReactNode;
useButtonLabel: React.ReactNode;
randomizeButtonLabel: React.ReactNode;
onSuggestionUse?: (
idx: number,
diceRoll: number,
description: string | null
) => void;
}
export default class SuggestionTable extends React.PureComponent<Props> {
static defaultProps = {
useButtonLabel: "+ Add",
randomizeButtonLabel: "Random",
suggestions: [],
};
handleSuggestionUse = (
idx: number,
diceRoll: number,
description: string | null
): void => {
const { onSuggestionUse } = this.props;
if (onSuggestionUse) {
onSuggestionUse(idx, diceRoll, description);
}
};
handleRandomClick = (): void => {
const { onSuggestionUse, suggestions } = this.props;
if (onSuggestionUse) {
let randomIdx: number = Math.floor(Math.random() * suggestions.length);
let randomSuggestion: BackgroundCharacteristicContract =
suggestions[randomIdx];
onSuggestionUse(
randomIdx,
randomSuggestion.diceRoll,
randomSuggestion.description
);
}
};
render() {
const {
suggestions,
dieLabel,
tableLabel,
useButtonLabel,
randomizeButtonLabel,
} = this.props;
return (
<div className="ct-suggestions-table">
<table className="ct-suggestions-table__table">
<thead>
<tr className="ct-suggestions-table__header">
<th className="ct-suggestions-table__header-col ct-suggestions-table__col--die">
{dieLabel}
</th>
<th className="ct-suggestions-table__header-col ct-suggestions-table__col--desc">
{tableLabel}
</th>
<th className="ct-suggestions-table__header-col ct-suggestions-table__col--action">
<ThemeButton size="small" onClick={this.handleRandomClick}>
{randomizeButtonLabel}
</ThemeButton>
</th>
</tr>
</thead>
<tbody>
{suggestions.map((suggestion, idx) => (
<tr className="ct-suggestions-table__item" key={idx}>
<td className="ct-suggestions-table__item-col ct-suggestions-table__col--die">
{suggestion.diceRoll}
</td>
<td className="ct-suggestions-table__item-col ct-suggestions-table__col--desc">
{suggestion.description}
</td>
<td className="ct-suggestions-table__item-col ct-suggestions-table__col--action">
<ThemeButton
size="small"
onClick={this.handleSuggestionUse.bind(
this,
idx,
suggestion.diceRoll,
suggestion.description
)}
>
{useButtonLabel}
</ThemeButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
}
@@ -0,0 +1,4 @@
import SuggestionTable from "./SuggestionTable";
export default SuggestionTable;
export { SuggestionTable };
@@ -0,0 +1,357 @@
import React from "react";
import {
Constants,
EntityAdjustmentConstraintContract,
RuleData,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine/es";
import {
SIGNED_32BIT_INT_MAX_VALUE,
SIGNED_32BIT_INT_MIN_VALUE,
} from "~/subApps/sheet/constants";
import ValueEditorCheckboxProperty from "./ValueEditorCheckboxProperty";
import ValueEditorNumberProperty from "./ValueEditorNumberProperty";
import ValueEditorSelectProperty from "./ValueEditorSelectProperty";
import ValueEditorTextProperty from "./ValueEditorTextProperty";
interface ConstraintProps {
minimumValue?: number;
maximumValue?: number;
}
interface Props {
dataLookup: Partial<Record<Constants.AdjustmentTypeEnum, any>>;
valueEditors: Array<number>; // TODO deal with enums not working with switch statement
labelOverrides: Partial<Record<Constants.AdjustmentTypeEnum, string>>;
optionRestrictions: Partial<Record<Constants.AdjustmentTypeEnum, Array<any>>>;
defaultValues: Partial<Record<Constants.AdjustmentTypeEnum, any>>;
layoutType: "standard" | "compact";
onDataUpdate?: (
propertyKey: Constants.AdjustmentTypeEnum,
value: any,
source: any
) => void;
ruleData: RuleData;
className: string;
}
interface State {
dataLookup: Partial<Record<Constants.AdjustmentTypeEnum, any>>;
}
export default class ValueEditor extends React.PureComponent<Props, State> {
static defaultProps = {
className: "",
labelOverrides: {},
optionRestrictions: {},
defaultValues: {},
layoutType: "standard",
};
constructor(props: Props) {
super(props);
this.state = this.generateStateData(props);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { dataLookup } = this.props;
if (dataLookup !== prevProps.dataLookup) {
this.setState(this.generateStateData(this.props));
}
}
generateStateData = (props: Props): State => {
const { dataLookup } = props;
return {
dataLookup,
};
};
getNumberConstraintProps = (
constraintLookup: Record<number, EntityAdjustmentConstraintContract>
): ConstraintProps => {
let constraintProps: ConstraintProps = {};
if (constraintLookup[Constants.AdjustmentConstraintTypeEnum.MINIMUM]) {
constraintProps.minimumValue = this.getConstraintValue(
Constants.AdjustmentConstraintTypeEnum.MINIMUM,
constraintLookup
);
} else {
constraintProps.minimumValue = SIGNED_32BIT_INT_MIN_VALUE;
}
if (constraintLookup[Constants.AdjustmentConstraintTypeEnum.MAXIMUM]) {
constraintProps.maximumValue = this.getConstraintValue(
Constants.AdjustmentConstraintTypeEnum.MAXIMUM,
constraintLookup
);
} else {
constraintProps.maximumValue = SIGNED_32BIT_INT_MAX_VALUE;
}
return constraintProps;
};
getConstraintValue = (
constraintTypeId: Constants.AdjustmentConstraintTypeEnum,
constraintLookup: Record<number, EntityAdjustmentConstraintContract>,
fallback: any = null
): any => {
let constraint = constraintLookup[constraintTypeId];
if (constraint) {
return constraint.value;
}
return fallback;
};
getPropertyValue = (key: Constants.AdjustmentTypeEnum): any => {
const { dataLookup, defaultValues } = this.props;
let property = dataLookup[key];
if (property) {
return property.value;
}
let defaultValue = defaultValues[key];
if (defaultValue) {
return defaultValue;
}
return null;
};
getPropertySource = (key: Constants.AdjustmentTypeEnum): string | null => {
const { dataLookup } = this.state;
let property = dataLookup[key];
if (property) {
return property.notes;
}
return null;
};
restrictOptions = <T extends any>(
key: Constants.AdjustmentTypeEnum,
options: Array<T>
): Array<T> => {
const { optionRestrictions } = this.props;
let typeOptionRestrictions = optionRestrictions[key];
if (!typeOptionRestrictions || typeOptionRestrictions.length === 0) {
return options;
}
return options.filter((option) => {
if (!typeOptionRestrictions) {
return true;
}
// @ts-ignore
return typeOptionRestrictions.includes(option.value);
});
};
handleDataUpdate = (
key: Constants.AdjustmentTypeEnum,
value: any,
source: any
): void => {
const { onDataUpdate } = this.props;
this.setState((prevState: State): State => {
let processedSource: string | null = source === "" ? null : source;
if (
onDataUpdate &&
(this.getPropertyValue(key) !== value ||
this.getPropertySource(key) !== processedSource)
) {
onDataUpdate(key, value, processedSource);
}
return {
...prevState,
dataLookup: {
...prevState.dataLookup,
[key]: {
value,
notes: processedSource,
},
},
};
});
};
render() {
const { valueEditors, className, ruleData, labelOverrides, layoutType } =
this.props;
let enableSource: boolean = layoutType === "standard";
let layoutClass: string = layoutType === "compact" ? "compact" : "standard";
let classNames: Array<string> = [
"ct-value-editor",
`ct-value-editor--${layoutClass}`,
className,
];
return (
<div className={classNames.join(" ")}>
{valueEditors.map((valueTypeId) => {
let valueDataTypeId = RuleDataUtils.getAdjustmentDataType(
valueTypeId,
ruleData
);
let constraintLookup = RuleDataUtils.getAdjustmentConstraintLookup(
valueTypeId,
ruleData
);
let valueName: string = "";
if (labelOverrides.hasOwnProperty(valueTypeId)) {
valueName = labelOverrides[valueTypeId];
} else {
let adjustmentName = RuleDataUtils.getAdjustmentName(
valueTypeId,
ruleData
);
if (adjustmentName) {
valueName = adjustmentName;
}
}
switch (valueDataTypeId) {
case Constants.AdjustmentDataTypeEnum.STRING:
return (
<ValueEditorTextProperty
key={valueTypeId}
propertyKey={valueTypeId}
defaultValue={this.getPropertyValue(valueTypeId)}
label={valueName}
onUpdate={this.handleDataUpdate}
enableSource={enableSource}
initialFocus={
valueTypeId === Constants.AdjustmentTypeEnum.NAME_OVERRIDE
}
/>
);
case Constants.AdjustmentDataTypeEnum.BOOLEAN:
return (
<ValueEditorCheckboxProperty
key={valueTypeId}
propertyKey={valueTypeId}
initiallyEnabled={this.getPropertyValue(valueTypeId)}
defaultSource={this.getPropertySource(valueTypeId)}
label={valueName}
onUpdate={this.handleDataUpdate}
enableSource={enableSource}
/>
);
case Constants.AdjustmentDataTypeEnum.INTEGER:
let baseProps = {
key: valueTypeId,
propertyKey: valueTypeId,
defaultValue: this.getPropertyValue(valueTypeId),
defaultSource: this.getPropertySource(valueTypeId),
label: valueName,
onUpdate: this.handleDataUpdate,
enableSource: enableSource,
};
switch (valueTypeId) {
case Constants.AdjustmentTypeEnum.SKILL_STAT_OVERRIDE:
return (
<ValueEditorSelectProperty
{...baseProps}
options={RuleDataUtils.getStatOptions(ruleData)}
/>
);
case Constants.AdjustmentTypeEnum.DICE_TYPE_OVERRIDE:
return (
<ValueEditorSelectProperty
{...baseProps}
options={RuleDataUtils.getDieTypeOptions(ruleData)}
/>
);
case Constants.AdjustmentTypeEnum.SKILL_PROFICIENCY_LEVEL:
case Constants.AdjustmentTypeEnum
.SAVING_THROW_PROFICIENCY_LEVEL:
return (
<ValueEditorSelectProperty
{...baseProps}
options={RuleDataUtils.getProficiencyLevelOptions(
ruleData
)}
/>
);
case Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT:
return (
<ValueEditorSelectProperty
{...baseProps}
options={RuleDataUtils.getAlignmentOptions(ruleData)}
/>
);
case Constants.AdjustmentTypeEnum.CREATURE_SIZE:
return (
<ValueEditorSelectProperty
{...baseProps}
options={RuleDataUtils.getCreatureSizeOptions(ruleData)}
/>
);
case Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE:
return (
<ValueEditorSelectProperty
{...baseProps}
options={this.restrictOptions(
valueTypeId,
RuleDataUtils.getMonsterTypeOptions(ruleData)
)}
/>
);
default:
let constraintProps =
this.getNumberConstraintProps(constraintLookup);
return (
<ValueEditorNumberProperty
{...baseProps}
{...constraintProps}
/>
);
}
case Constants.AdjustmentDataTypeEnum.DECIMAL:
let constraintProps =
this.getNumberConstraintProps(constraintLookup);
return (
<ValueEditorNumberProperty
key={valueTypeId}
step={0.01}
propertyKey={valueTypeId}
defaultValue={this.getPropertyValue(valueTypeId)}
defaultSource={this.getPropertySource(valueTypeId)}
label={valueName}
onUpdate={this.handleDataUpdate}
enableSource={enableSource}
{...constraintProps}
/>
);
default:
// not implemented
}
return null;
})}
</div>
);
}
}
@@ -0,0 +1,112 @@
import React from "react";
import { Checkbox } from "@dndbeyond/character-components/es";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import ValueEditorProperty from "../ValueEditorProperty";
import ValueEditorPropertySource from "../ValueEditorPropertySource";
import ValueEditorPropertyValue from "../ValueEditorPropertyValue";
interface Props {
label: string;
propertyKey: Constants.AdjustmentTypeEnum;
initiallyEnabled: boolean;
defaultSource: string | null;
onUpdate?: (
propertyKey: Constants.AdjustmentTypeEnum,
isEnabled: boolean,
source: string | null
) => void;
enableSource: boolean;
}
interface State {
value: boolean;
source: string | null;
}
export default class ValueEditorCheckboxProperty extends React.PureComponent<
Props,
State
> {
static defaultProps = {
enableSource: true,
};
constructor(props: Props) {
super(props);
this.state = {
value: props.initiallyEnabled,
source: props.defaultSource,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { initiallyEnabled } = this.props;
const { value } = this.state;
if (
initiallyEnabled !== prevProps.initiallyEnabled &&
initiallyEnabled !== value
) {
this.setState({
value: initiallyEnabled,
});
}
}
handleChange = (isEnabled: boolean): void => {
const { source } = this.state;
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
onUpdate(propertyKey, isEnabled, source);
}
};
handleSourceUpdate = (source: string | null): void => {
const { value } = this.state;
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
onUpdate(propertyKey, value, source);
}
this.setState({
source,
});
};
render() {
const {
propertyKey,
label,
initiallyEnabled,
defaultSource,
enableSource,
} = this.props;
return (
<ValueEditorProperty
className="ct-value-editor__property--checkbox"
propertyKey={propertyKey}
>
<ValueEditorPropertyValue>
<Checkbox
stopPropagation={true}
label={label}
initiallyEnabled={initiallyEnabled}
onChange={this.handleChange}
/>
</ValueEditorPropertyValue>
{enableSource && (
<ValueEditorPropertySource
onUpdate={this.handleSourceUpdate}
defaultValue={defaultSource}
/>
)}
</ValueEditorProperty>
);
}
}
@@ -0,0 +1,4 @@
import ValueEditorCheckboxProperty from "./ValueEditorCheckboxProperty";
export default ValueEditorCheckboxProperty;
export { ValueEditorCheckboxProperty };
@@ -0,0 +1,152 @@
import React from "react";
import { Constants, HelperUtils } from "@dndbeyond/character-rules-engine/es";
import ValueEditorProperty from "../ValueEditorProperty";
import ValueEditorPropertyLabel from "../ValueEditorPropertyLabel";
import ValueEditorPropertySource from "../ValueEditorPropertySource";
import ValueEditorPropertyValue from "../ValueEditorPropertyValue";
interface Props {
label: string;
propertyKey: Constants.AdjustmentTypeEnum;
defaultValue: number | null;
defaultSource: string | null;
minimumValue?: number;
maximumValue?: number;
onUpdate?: (
propertyKey: Constants.AdjustmentTypeEnum,
value: number | null,
source: string | null
) => void;
enableSource: boolean;
step: number;
}
interface State {
value: number | null;
source: string | null;
}
export default class ValueEditorNumberProperty extends React.PureComponent<
Props,
State
> {
static defaultProps = {
enableSource: true,
step: 1,
};
constructor(props: Props) {
super(props);
this.state = {
value: props.defaultValue,
source: props.defaultSource,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { defaultValue } = this.props;
const { value } = this.state;
if (defaultValue !== prevProps.defaultValue && defaultValue !== value) {
this.setState({
value: defaultValue,
});
}
}
getDataValue = (value: number | null): number | null => {
const { minimumValue, maximumValue } = this.props;
if (value === null) {
return null;
}
return HelperUtils.clampInt(
value,
minimumValue ? minimumValue : null,
maximumValue
);
};
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { source } = this.state;
const { propertyKey, onUpdate, step } = this.props;
let newValue = this.getDataValue(
step < 1
? HelperUtils.parseInputFloat(evt.target.value)
: HelperUtils.parseInputInt(evt.target.value)
);
if (onUpdate) {
onUpdate(propertyKey, newValue, source);
}
this.setState({
value: newValue,
});
};
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
const { step } = this.props;
this.setState({
value:
step < 1
? HelperUtils.parseInputFloat(evt.target.value)
: HelperUtils.parseInputInt(evt.target.value),
});
};
handleSourceUpdate = (source: string | null): void => {
const { value } = this.state;
const { propertyKey, onUpdate } = this.props;
if (onUpdate) {
onUpdate(propertyKey, this.getDataValue(value), source);
}
this.setState({
source,
});
};
render() {
const { value } = this.state;
const {
propertyKey,
label,
minimumValue,
maximumValue,
defaultSource,
enableSource,
step,
} = this.props;
return (
<ValueEditorProperty
className="ct-value-editor__property--number"
propertyKey={propertyKey}
>
<ValueEditorPropertyLabel>{label}</ValueEditorPropertyLabel>
<ValueEditorPropertyValue>
<input
className="ct-value-editor__property-input"
type="number"
min={minimumValue}
max={maximumValue}
step={step}
value={value === null ? "" : value}
onBlur={this.handleBlur}
onChange={this.handleChange}
/>
</ValueEditorPropertyValue>
{enableSource && (
<ValueEditorPropertySource
onUpdate={this.handleSourceUpdate}
defaultValue={defaultSource}
/>
)}
</ValueEditorProperty>
);
}
}
@@ -0,0 +1,4 @@
import ValueEditorNumberProperty from "./ValueEditorNumberProperty";
export default ValueEditorNumberProperty;
export { ValueEditorNumberProperty };
@@ -0,0 +1,28 @@
import React from "react";
interface Props {
isBlock: boolean;
propertyKey: number;
className: string;
}
export default class ValueEditorProperty extends React.PureComponent<Props> {
static defaultProps = {
isBlock: false,
className: "",
};
render() {
const { className, isBlock, propertyKey } = this.props;
let classNames: Array<string> = [
className,
"ct-value-editor__property",
`ct-value-editor__property--${propertyKey}`,
];
if (isBlock) {
classNames.push("ct-value-editor__property--block");
}
return <div className={classNames.join(" ")}>{this.props.children}</div>;
}
}
@@ -0,0 +1,4 @@
import ValueEditorProperty from "./ValueEditorProperty";
export default ValueEditorProperty;
export { ValueEditorProperty };
@@ -0,0 +1,11 @@
import React from "react";
export default class ValueEditorPropertyLabel extends React.PureComponent {
render() {
return (
<div className="ct-value-editor__property-label">
{this.props.children}
</div>
);
}
}
@@ -0,0 +1,4 @@
import ValueEditorPropertyLabel from "./ValueEditorPropertyLabel";
export default ValueEditorPropertyLabel;
export { ValueEditorPropertyLabel };
@@ -0,0 +1,76 @@
import React from "react";
interface Props {
defaultValue: string | null;
placeholder: string;
onUpdate?: (value: string) => void;
}
interface State {
value: string | null;
}
export default class ValueEditorPropertySource extends React.PureComponent<
Props,
State
> {
static defaultProps = {
placeholder: "Enter Source Notes...",
};
constructor(props) {
super(props);
this.state = {
value: props.defaultValue,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { defaultValue } = this.props;
const { value } = this.state;
if (defaultValue !== prevProps.defaultValue && defaultValue !== value) {
this.setState({
value: defaultValue,
});
}
}
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
value: evt.target.value,
});
};
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { onUpdate } = this.props;
let value = evt.target.value;
if (onUpdate) {
onUpdate(value);
}
this.setState({
value,
});
};
render() {
const { value } = this.state;
const { placeholder } = this.props;
return (
<div className="ct-value-editor__property-source">
<input
className="ct-value-editor__property-input"
type="text"
placeholder={placeholder}
onChange={this.handleChange}
onBlur={this.handleBlur}
value={value === null ? "" : value}
/>
</div>
);
}
}
@@ -0,0 +1,4 @@
import ValueEditorPropertySource from "./ValueEditorPropertySource";
export default ValueEditorPropertySource;
export { ValueEditorPropertySource };
@@ -0,0 +1,11 @@
import React from "react";
export default class ValueEditorPropertyValue extends React.PureComponent {
render() {
return (
<div className="ct-value-editor__property-value">
{this.props.children}
</div>
);
}
}
@@ -0,0 +1,4 @@
import ValueEditorPropertyValue from "./ValueEditorPropertyValue";
export default ValueEditorPropertyValue;
export { ValueEditorPropertyValue };

Some files were not shown because too many files have changed in this diff Show More