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,82 @@
import { FC } from "react";
import { Accordion } from "~/components/Accordion";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { EquipmentActions } from "~/subApps/builder/components/EquipmentActions";
import { useEquipmentMethods } from "~/subApps/builder/hooks/useEquipmentMethods";
import { Item } from "~/types";
import ItemDetail from "../../ItemDetail";
import { ItemListInformationCollapsible } from "../../ItemListInformationCollapsible";
import { EquipmentListItemActions } from "../EquipmentListItem";
interface ArmorListItemProps {
item: Item;
unequipLabel: string;
equipLabel: string;
showRemove?: boolean;
showEquip?: boolean;
showUnequip?: boolean;
showAttuning?: boolean;
showHeaderAction: boolean;
}
export const ArmorListItem: FC<ArmorListItemProps> = ({
item,
equipLabel,
unequipLabel,
...props
}) => {
const actions = useEquipmentMethods();
const {
ruleData,
proficiencyBonus,
characterTheme,
itemUtils,
helperUtils,
containerLookup,
} = useCharacterEngine();
const image = itemUtils.getAvatarUrl(item);
const altText = itemUtils.getName(item);
const container = helperUtils.lookupDataOrFallback(
containerLookup,
itemUtils.getContainerDefinitionKey(item)
);
const getMetaItems = () => {
const type = itemUtils.getType(item);
const baseArmorName = itemUtils.getBaseArmorName(item);
return [...(type ? [type] : []), ...(baseArmorName ? [baseArmorName] : [])];
};
return (
<Accordion
summary={<ItemName item={item} showLegacyBadge={true} />}
summaryAction={
<EquipmentActions
item={item}
equipLabel={equipLabel}
unequipLabel={unequipLabel}
/>
}
summaryImage={image}
summaryImageAlt={altText}
summaryMetaItems={getMetaItems()}
variant="paper"
>
<ItemListInformationCollapsible ruleData={ruleData} item={item} />
<ItemDetail
container={container}
theme={characterTheme}
item={item}
ruleData={ruleData}
showCustomize={false}
showActions={false}
proficiencyBonus={proficiencyBonus}
/>
<EquipmentListItemActions item={item} {...actions} {...props} />
</Accordion>
);
};
@@ -0,0 +1,560 @@
import { orderBy } from "lodash";
import React from "react";
import { Button } from "@dndbeyond/character-components/es";
import {
BaseSpellContract,
CharacterTheme,
CharClass,
ClassUtils,
Constants,
DataOriginRefData,
FormatUtils,
RuleData,
Spell,
SpellCasterInfo,
SpellUtils,
} from "@dndbeyond/character-rules-engine";
import { HtmlContent } from "~/components/HtmlContent";
import { ApiSpellsRequest } from "../../../selectors/composite/apiCreator";
import { SpellList, SpellListItem } from "../SpellList";
import SpellManagerGroup from "../SpellManagerGroup";
export default class ClassSpellListManager extends React.PureComponent<
{
charClass: CharClass;
isSpellsKnownMaxed: boolean;
isCantripsKnownMaxed: boolean;
isPrepareMaxed: boolean;
spells: Array<Spell>;
activeSpells: Array<Spell>;
ritualSpells: Array<Spell>;
spellList: Array<any>;
prepareMax: number;
spellcastingModifier: number;
classHeader: string;
classSpellListIdx: number;
onSpellPrepare: (spell: Spell, classMappingId: number) => void;
onSpellUnprepare: (spell: Spell, classMappingId: number) => void;
onSpellRemove: (spell: Spell, classMappingId: number) => void;
onSpellAdd: (spell: Spell, classMappingId: number) => void;
onAlwaysKnownLoad: (
spells: Array<BaseSpellContract>,
classId: number
) => void;
showHeader: boolean;
spellCasterInfo: SpellCasterInfo;
ruleData: RuleData;
dataOriginRefData: DataOriginRefData;
loadRemainingSpellList: ApiSpellsRequest | null;
loadAlwaysKnownSpells?: ApiSpellsRequest | null;
overallSpellInfo: any;
preparedSpellcaster: boolean;
knownSpellcaster: boolean;
spellbookSpellcaster: boolean;
knownSpellIds: Array<any>;
knownSpellCount: number;
activeCantripsCount: number;
preparedSpellCount: number;
knownCantripsMax: number;
knownSpellsMax: number;
proficiencyBonus: number;
theme: CharacterTheme;
activeSourceCategories: Array<number>;
},
{
showRitualSpells: boolean;
visibleSpellGroups: {
activeSpells: boolean;
spellbook: boolean;
addSpells: boolean;
};
}
> {
static defaultProps = {
classHeader: "",
showHeader: true,
};
constructor(props) {
super(props);
const { spellbookSpellcaster, knownSpellCount, activeCantripsCount } =
props;
const isSpellbookEmpty = knownSpellCount === 0 && activeCantripsCount === 0;
this.state = {
showRitualSpells: false,
visibleSpellGroups: {
activeSpells:
!spellbookSpellcaster || (spellbookSpellcaster && !isSpellbookEmpty),
spellbook: spellbookSpellcaster && isSpellbookEmpty,
addSpells: false,
},
};
}
handlePrepare = (spell) => {
const { onSpellPrepare, charClass } = this.props;
if (onSpellPrepare) {
onSpellPrepare(spell, ClassUtils.getMappingId(charClass));
}
};
handleUnprepare = (spell) => {
const { onSpellUnprepare, charClass } = this.props;
if (onSpellUnprepare) {
onSpellUnprepare(spell, ClassUtils.getMappingId(charClass));
}
};
handleRemove = (spell) => {
const { onSpellRemove, charClass } = this.props;
if (onSpellRemove) {
onSpellRemove(spell, ClassUtils.getMappingId(charClass));
}
};
handleAdd = (spell) => {
const { onSpellAdd, charClass } = this.props;
if (onSpellAdd) {
onSpellAdd(spell, ClassUtils.getMappingId(charClass));
}
};
handleAlwaysKnownLoad = (spells: Array<BaseSpellContract>): void => {
const { onAlwaysKnownLoad, charClass } = this.props;
if (onAlwaysKnownLoad) {
onAlwaysKnownLoad(spells, ClassUtils.getActiveId(charClass));
}
};
handleShowSpellGroup = (key) => {
this.setState((prevState) => {
let resetState = {
activeSpells: false,
addSpells: false,
spellbook: false,
};
return {
visibleSpellGroups: {
...resetState,
[key]: !prevState.visibleSpellGroups[key],
},
};
});
};
handleToggleRitualSpells = () => {
this.setState((prevState) => ({
showRitualSpells: !prevState.showRitualSpells,
}));
};
doesAvailableSpellsHaveNotifications = () => {
const {
prepareMax,
knownCantripsMax,
knownSpellsMax,
knownSpellCount,
activeCantripsCount,
preparedSpellCount,
} = this.props;
if (knownCantripsMax !== null && activeCantripsCount > knownCantripsMax) {
return true;
}
if (prepareMax && preparedSpellCount > prepareMax) {
return true;
}
if (knownSpellsMax && knownSpellCount > knownSpellsMax) {
return true;
}
return false;
};
renderSpellListSpellStatus = () => {
const {
charClass,
prepareMax,
knownCantripsMax,
knownSpellsMax,
knownSpellCount,
activeCantripsCount,
preparedSpellCount,
} = this.props;
const spellCastingLearningStyle = ClassUtils.getSpellCastingLearningStyle(charClass);
let cantripsDisplay = "";
let cantripsClsNames = ["spell-manager-info-cantrips"];
if (knownCantripsMax !== null) {
if (activeCantripsCount === knownCantripsMax) {
cantripsDisplay = `Cantrips: ${knownCantripsMax}`;
} else {
cantripsDisplay = `Cantrips: ${activeCantripsCount}/${knownCantripsMax}`;
if (activeCantripsCount > knownCantripsMax) {
cantripsClsNames.push("spell-manager-info-exceeded");
}
}
}
const spellDisplayListType = spellCastingLearningStyle === Constants.SpellCastingLearningStyle.Prepared
? "Prepared"
: "Known";
let spellsDisplay = "";
let spellsClsNames = ["spell-manager-info-spells"];
if (prepareMax) {
if (preparedSpellCount === prepareMax) {
spellsDisplay = `Prepared Spells: ${preparedSpellCount}`;
} else {
spellsDisplay = `Prepared Spells: ${preparedSpellCount}/${prepareMax}`;
if (preparedSpellCount > prepareMax) {
spellsClsNames.push("spell-manager-info-exceeded");
}
}
spellsDisplay += `<span class="spell-manager-info-extra">(${knownSpellCount} Known)</span>`;
} else if (knownSpellsMax) {
if (knownSpellCount === knownSpellsMax) {
spellsDisplay = `${spellDisplayListType} Spells: ${knownSpellCount}`;
} else {
spellsDisplay = `${spellDisplayListType} Spells: ${knownSpellCount}/${knownSpellsMax}`;
if (knownSpellCount > knownSpellsMax) {
spellsClsNames.push("spell-manager-info-exceeded");
}
}
}
return (
<div className="spell-manager-info">
<div className={cantripsClsNames.join(" ")}>{cantripsDisplay}</div>
<HtmlContent
className={spellsClsNames.join(" ")}
html={spellsDisplay}
withoutTooltips
/>
</div>
);
};
render() {
const { visibleSpellGroups, showRitualSpells } = this.state;
const {
charClass,
spells,
prepareMax,
isSpellsKnownMaxed,
isCantripsKnownMaxed,
isPrepareMaxed,
activeSpells,
ritualSpells,
spellcastingModifier,
loadRemainingSpellList,
loadAlwaysKnownSpells,
preparedSpellcaster,
knownSpellcaster,
spellbookSpellcaster,
knownSpellIds,
knownSpellCount,
activeCantripsCount,
showHeader,
spellCasterInfo,
ruleData,
overallSpellInfo,
dataOriginRefData,
proficiencyBonus,
theme,
activeSourceCategories,
} = this.props;
const spellCastingLearningStyle = ClassUtils.getSpellCastingLearningStyle(charClass);
const activeGroupHeading = knownSpellcaster && spellCastingLearningStyle !== Constants.SpellCastingLearningStyle.Prepared
? "Known Spells"
: "Prepared Spells";
const addGroupHeading = knownSpellcaster || spellbookSpellcaster
? "Add Spells"
: "Known Spells";
const addButtonText = Constants.SpellCastingLearningStyleAddText[spellCastingLearningStyle];
const removeButtonText = Constants.SpellCastingLearningStyleRemoveText[spellCastingLearningStyle];
let addGroupHeadingNotificationNode;
if (this.doesAvailableSpellsHaveNotifications()) {
addGroupHeadingNotificationNode = (
<div className="class-spell-list-heading-notification">!</div>
);
}
let activeSpellsConClsNames = ["class-spell-list"];
if (visibleSpellGroups.activeSpells) {
activeSpellsConClsNames.push("class-spell-list-opened");
} else {
activeSpellsConClsNames.push("class-spell-list-collapsed");
}
let ritualSpellsConClsNames = ["class-spell-list-rituals"];
if (showRitualSpells) {
ritualSpellsConClsNames.push("class-spell-list-opened");
} else {
ritualSpellsConClsNames.push("class-spell-list-collapsed");
}
let spellbookConClsNames = ["class-spell-list"];
if (visibleSpellGroups.spellbook) {
spellbookConClsNames.push("class-spell-list-opened");
} else {
spellbookConClsNames.push("class-spell-list-collapsed");
}
let addSpellsConClsNames = ["class-spell-list"];
if (visibleSpellGroups.addSpells) {
addSpellsConClsNames.push("class-spell-list-opened");
} else {
addSpellsConClsNames.push("class-spell-list-collapsed");
}
let classPortraitUrl = ClassUtils.getPortraitUrl(charClass);
let className = ClassUtils.getName(charClass);
const isSpellbookEmpty = knownSpellCount === 0 && activeCantripsCount === 0;
const orderedActiveSpells = orderBy(activeSpells, [
(spell) => SpellUtils.getLevel(spell),
(spell) => SpellUtils.getName(spell),
(spell) => SpellUtils.getExpandedDataOriginRef(spell) !== null,
(spell) => SpellUtils.getUniqueKey(spell),
]);
return (
<div className="class-spell-list-manager">
{showHeader && (
<div className="class-spell-list-manager-header">
<div className="class-spell-list-manager-preview">
<img
className="class-spell-list-manager-preview-img"
src={classPortraitUrl}
alt=""
/>
</div>
<div className="class-spell-list-manager-heading">{className}</div>
<div className="class-spell-list-manager-modifier">
Spellcasting Modifier:{" "}
{FormatUtils.renderSignedNumber(spellcastingModifier)}
</div>
</div>
)}
<div className={activeSpellsConClsNames.join(" ")}>
<div
className="class-spell-list-header"
onClick={this.handleShowSpellGroup.bind(this, "activeSpells")}
>
<div className="class-spell-list-heading">
{activeGroupHeading}
<span className="class-spell-list-heading-extra">
({activeSpells.length})
</span>
</div>
<div className="class-spell-list-trigger" />
</div>
{visibleSpellGroups.activeSpells && (
<div className="class-spell-list-content">
<div className="class-spell-list-actives">
{orderedActiveSpells.map((spell) => {
const { canRemove, canPrepare, alwaysPrepared } = spell;
const showUnprepare = !alwaysPrepared && canPrepare;
let footerNode;
if (canRemove || showUnprepare) {
footerNode = (
<div className="spell-list-item-content-actions">
{showUnprepare && (
<Button
size="small"
style={"outline"}
onClick={this.handleUnprepare.bind(this, spell)}
>
Unprepare Spell
</Button>
)}
{canRemove && (
<div
className="spell-manager-spell-remove"
onClick={this.handleRemove.bind(this, spell)}
>
<span className="spell-manager-spell-remove-icon" />{" "}
Remove Spell
</div>
)}
</div>
);
}
return (
<SpellListItem
theme={theme}
spell={spell}
key={SpellUtils.getUniqueKey(spell)}
footerNode={footerNode}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
/>
);
})}
{orderedActiveSpells.length === 0 && (
<div className="class-spell-list-empty">
{spellbookSpellcaster && (
<div>You currently have no prepared spells.</div>
)}
{preparedSpellcaster && (
<div>
You currently have no prepared spells. Learn and prepare
spells from your list of available spells below.
</div>
)}
{knownSpellcaster && (
<div>
You currently have no known spells. Learn spells from
your list of available spells below.
</div>
)}
</div>
)}
</div>
{ritualSpells.length > 0 && (
<div className={ritualSpellsConClsNames.join(" ")}>
<div
className="class-spell-list-header"
onClick={this.handleToggleRitualSpells}
>
<div className="class-spell-list-heading">
Ritual Spells
<span className="class-spell-list-heading-extra">
({ritualSpells.length})
</span>
</div>
<div className="class-spell-list-trigger" />
</div>
{showRitualSpells && (
<SpellList
theme={theme}
spells={ritualSpells}
castAsRitual={true}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
/>
)}
</div>
)}
</div>
)}
</div>
{spellbookSpellcaster && (
<div className={spellbookConClsNames.join(" ")}>
<div
className="class-spell-list-header"
onClick={this.handleShowSpellGroup.bind(this, "spellbook")}
>
<div className="class-spell-list-heading">Spellbook</div>
<div className="class-spell-list-trigger" />
</div>
{visibleSpellGroups.spellbook && (
<div className="class-spell-list-content">
{isSpellbookEmpty && (
<div className="class-spell-list-empty">
You currently have no known spells. Learn spells from your
list of available spells below.
</div>
)}
{!isSpellbookEmpty && this.renderSpellListSpellStatus()}
{!isSpellbookEmpty && (
<SpellManagerGroup
theme={theme}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
enableAdd={false}
enableSpellRemove={false}
spells={spells}
knownSpellIds={knownSpellIds}
onPrepare={this.handlePrepare}
onUnprepare={this.handleUnprepare}
onRemove={this.handleRemove}
onAdd={this.handleAdd}
addButtonText={addButtonText}
removeButtonText={"Remove"}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
overallSpellInfo={overallSpellInfo}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
activeSourceCategories={activeSourceCategories}
/>
)}
</div>
)}
</div>
)}
<div className={addSpellsConClsNames.join(" ")}>
<div
className="class-spell-list-header"
onClick={this.handleShowSpellGroup.bind(this, "addSpells")}
>
<div className="class-spell-list-heading">
{addGroupHeading}
{addGroupHeadingNotificationNode}
</div>
<div className="class-spell-list-trigger" />
</div>
{visibleSpellGroups.addSpells && (
<div className="class-spell-list-content">
{this.renderSpellListSpellStatus()}
<SpellManagerGroup
theme={theme}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
loadAlwaysKnownSpells={loadAlwaysKnownSpells}
loadSpells={loadRemainingSpellList}
spells={spells}
enablePrepare={!spellbookSpellcaster}
enableUnprepare={!spellbookSpellcaster}
knownSpellIds={knownSpellIds}
onAlwaysKnownLoad={this.handleAlwaysKnownLoad}
onPrepare={this.handlePrepare}
onUnprepare={this.handleUnprepare}
onRemove={this.handleRemove}
onAdd={this.handleAdd}
addButtonText={addButtonText}
removeButtonText={removeButtonText}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
overallSpellInfo={overallSpellInfo}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
activeSourceCategories={activeSourceCategories}
/>
</div>
)}
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import ClassSpellListManager from "./ClassSpellListManager";
export default ClassSpellListManager;
export { ClassSpellListManager };
@@ -0,0 +1,314 @@
import React from "react";
import { CoinIcon } from "@dndbeyond/character-components/es";
import {
Constants,
FormatUtils,
HelperUtils,
} from "@dndbeyond/character-rules-engine/es";
import { CURRENCY_VALUE } from "../../../constants/App";
import { CurrencyErrorTypeEnum } from "../../../containers/panes/CurrencyPane/CurrencyPaneConstants";
interface CurrencyListItemProps {
name: string;
value: number;
conversionAmount?: number;
conversionType?: string;
onChange: (value: number) => void;
onError?: (errorType: CurrencyErrorTypeEnum) => void;
minValue: number;
maxValue: number;
coinType: Constants.CoinTypeEnum;
}
interface CurrencyListItemState {
latestValue: number | null;
preValue: number | null;
errorType: CurrencyErrorTypeEnum | null;
}
export class CurrencyListItem extends React.PureComponent<
CurrencyListItemProps,
CurrencyListItemState
> {
static defaultProps = {
minValue: CURRENCY_VALUE.MIN,
maxValue: CURRENCY_VALUE.MAX,
};
constructor(props) {
super(props);
this.state = {
latestValue: props.value,
preValue: props.value,
errorType: null,
};
}
componentDidUpdate(
prevProps: Readonly<CurrencyListItemProps>,
prevState: Readonly<CurrencyListItemState>
): void {
const { value } = this.props;
if (value !== prevProps.value) {
this.setState({
latestValue: value,
preValue: value,
});
}
}
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
const { latestValue, preValue, errorType } = this.state;
const { onChange, onError } = this.props;
let parsedValue = HelperUtils.parseInputInt(evt.target.value);
let targetValue: number | null =
errorType === null && parsedValue !== null ? parsedValue : preValue;
if (targetValue !== null && onChange) {
onChange(targetValue);
}
if (errorType !== null && onError) {
onError(errorType);
}
this.setState({
latestValue: targetValue,
preValue: targetValue,
errorType: null,
});
};
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
const { minValue, maxValue } = this.props;
let newValue = HelperUtils.parseInputInt(evt.target.value);
let errorType: CurrencyErrorTypeEnum | null = null;
if (newValue !== null) {
if (newValue < minValue) {
errorType = CurrencyErrorTypeEnum.MIN;
} else if (newValue > maxValue) {
errorType = CurrencyErrorTypeEnum.MAX;
}
}
this.setState({
latestValue: newValue,
errorType,
});
};
renderError = (): React.ReactNode => {
const { errorType } = this.state;
const { name } = this.props;
if (errorType === null) {
return null;
}
let errorMessage: React.ReactNode;
if (errorType === CurrencyErrorTypeEnum.MIN) {
errorMessage = `Cannot set ${name} to a negative value.`;
}
if (errorType === CurrencyErrorTypeEnum.MAX) {
errorMessage = `The max amount of ${name} allowed is ${FormatUtils.renderLocaleNumber(
CURRENCY_VALUE.MAX
)}.`;
}
return (
<div className="currency-list-item__error">
<div className="currency-list-item__error-text">{errorMessage}</div>
</div>
);
};
render() {
const { latestValue } = this.state;
const {
name,
conversionAmount,
conversionType,
minValue,
maxValue,
coinType,
} = this.props;
let clsNames = ["currency-list-item"];
if (name) {
clsNames.push(`currency-list-item-${FormatUtils.slugify(name)}`);
}
let conversionNode;
if (conversionAmount && conversionType) {
conversionNode = (
<div className="currency-list-item-conversion">
= {conversionAmount} {conversionType}
</div>
);
}
return (
<div className={clsNames.join(" ")}>
<div className="currency-list-item-row">
<div className="currency-list-item-icon">
<CoinIcon coinType={coinType} />
</div>
<div className="currency-list-item-info">
<div className="currency-list-item-name">{name}</div>
{conversionNode}
</div>
<div className="currency-list-item-value">
<input
className="currency-list-item-value-input"
type="number"
onBlur={this.handleBlur}
value={latestValue === null ? "" : latestValue}
onChange={this.handleChange}
min={minValue}
max={maxValue}
/>
</div>
</div>
{this.renderError()}
</div>
);
}
}
export default class CurrencyList extends React.PureComponent<
{
pp: number;
gp: number;
ep: number;
sp: number;
cp: number;
onChange: (currencies: any) => void;
onError?: (currencyName: string, errorType: CurrencyErrorTypeEnum) => void;
totalGp: number;
},
{
currencies: {
pp: number;
gp: number;
ep: number;
sp: number;
cp: number;
};
}
> {
constructor(props) {
super(props);
const { pp, gp, ep, sp, cp } = props;
this.state = {
currencies: {
pp,
gp,
ep,
sp,
cp,
},
};
}
handleCurrencyChange = (currencyKey: string, value: number): void => {
const { onChange } = this.props;
const { currencies } = this.state;
const newCurrencies = {
...currencies,
[currencyKey]: value,
};
this.setState({
currencies: newCurrencies,
});
onChange(newCurrencies);
};
handleCurrencyChangeError = (
currencyName: string,
errorType: CurrencyErrorTypeEnum
): void => {
const { onError } = this.props;
if (onError) {
onError(currencyName, errorType);
}
};
render() {
const { props } = this;
const { pp, gp, ep, sp, cp, totalGp } = props;
return (
<div className="currency-list">
<div className="currency-list-gp-total">
<div className="currency-list-gp-total-heading">
Total Currency in GP
</div>
<div className="currency-list-gp-total-info">
<div className="currency-list-gp-total-icon">
<CoinIcon coinType={Constants.CoinTypeEnum.gp} />
</div>
<div className="currency-list-gp-total-count">
{totalGp.toLocaleString ? totalGp.toLocaleString() : totalGp}
</div>
</div>
</div>
<div className="currency-list-items">
<CurrencyListItem
name="Platinum"
onChange={this.handleCurrencyChange.bind(this, "pp")}
onError={this.handleCurrencyChangeError.bind(this, "Platinum")}
value={pp}
coinType={Constants.CoinTypeEnum.pp}
conversionAmount={10}
conversionType="gp"
/>
<CurrencyListItem
name="Gold"
onChange={this.handleCurrencyChange.bind(this, "gp")}
onError={this.handleCurrencyChangeError.bind(this, "Gold")}
value={gp}
coinType={Constants.CoinTypeEnum.gp}
conversionAmount={10}
conversionType="sp"
/>
<CurrencyListItem
name="Electrum"
onChange={this.handleCurrencyChange.bind(this, "ep")}
onError={this.handleCurrencyChangeError.bind(this, "Electrum")}
value={ep}
coinType={Constants.CoinTypeEnum.ep}
conversionAmount={5}
conversionType="sp"
/>
<CurrencyListItem
name="Silver"
onChange={this.handleCurrencyChange.bind(this, "sp")}
onError={this.handleCurrencyChangeError.bind(this, "Silver")}
value={sp}
coinType={Constants.CoinTypeEnum.sp}
conversionAmount={10}
conversionType="cp"
/>
<CurrencyListItem
name="Copper"
onChange={this.handleCurrencyChange.bind(this, "cp")}
onError={this.handleCurrencyChangeError.bind(this, "Copper")}
value={cp}
coinType={Constants.CoinTypeEnum.cp}
/>
</div>
</div>
);
}
}
@@ -0,0 +1,4 @@
import CurrencyList, { CurrencyListItem } from "./CurrencyList";
export default CurrencyList;
export { CurrencyList, CurrencyListItem };
@@ -0,0 +1,418 @@
import React from "react";
import { Button } from "@dndbeyond/character-components/es";
import {
HelperUtils,
Item,
ItemUtils,
} from "@dndbeyond/character-rules-engine/es";
import Collapsible, { CollapsibleHeader } from "../common/Collapsible";
export class EquipmentListItemHeaderAction extends React.PureComponent<{
item: Item;
heading: any;
metaItems: Array<string>;
imageUrl: string;
onUnequip?: (item: Item) => void;
onEquip?: (item: Item) => void;
unequipLabel: string;
equipLabel: string;
}> {
static defaultProps = {
showHeaderAction: false,
consumeLabel: "Consume",
};
handleUnequip = () => {
const { onUnequip, item } = this.props;
if (onUnequip) {
onUnequip(item);
}
};
handleEquip = () => {
const { onEquip, item } = this.props;
if (onEquip) {
onEquip(item);
}
};
render() {
const { item, heading, imageUrl, metaItems, unequipLabel, equipLabel } =
this.props;
const isEquipped = ItemUtils.isEquipped(item);
const isStackable = ItemUtils.isStackable(item);
const canEquip = ItemUtils.canEquip(item);
const quantity = ItemUtils.getQuantity(item);
const callout = (
<div className="equipment-list-item-callout">
{isStackable && (
<div className="equipment-list-item-callout-quantity">
<span className="equipment-list-item-callout-quantity-extra">
Qty
</span>
<span className="equipment-list-item-callout-quantity-value">
{quantity}
</span>
</div>
)}
{canEquip && (
<div className="equipment-list-item-callout-action">
<Button
onClick={isEquipped ? this.handleUnequip : this.handleEquip}
style={isEquipped ? "" : "outline"}
size="small"
>
{isEquipped ? unequipLabel : equipLabel}
</Button>
</div>
)}
</div>
);
return (
<CollapsibleHeader
imgSrc={imageUrl}
heading={heading}
metaItems={metaItems}
callout={callout}
/>
);
}
}
interface EquipmentListItemProps {
item: Item;
atAttuneMax: boolean;
showRemove: boolean;
showEquip: boolean;
showUnequip: boolean;
showAttuning: boolean;
removeLabel: string;
removeInfusionLabel: string;
unequipLabel: string;
equipLabel: string;
attuneLabel: string;
unattuneLabel: string;
consumeLabel: string;
onRemove?: (item: Item) => void;
onRemoveInfusion: (item: Item) => void;
onUnequip?: (item: Item) => void;
onEquip?: (item: Item) => void;
onAttune?: (item: Item) => void;
onUnattune?: (item: Item) => void;
onQuantitySet: (item: Item, quantity: number) => void;
}
interface EquipmentListItemState {
quantity: number;
newQuantity: number;
}
export class EquipmentListItemActions extends React.PureComponent<
EquipmentListItemProps,
EquipmentListItemState
> {
static defaultProps = {
consumeLabel: "Consume",
removeLabel: "Remove Item",
removeInfusionLabel: "Remove Infusion",
unequipLabel: "Unequip",
equipLabel: "Equip",
attuneLabel: "Attune",
unattuneLabel: "Attuned",
showRemove: false,
showEquip: false,
showUnequip: false,
showAttuning: false,
atAttuneMax: false,
};
constructor(props) {
super(props);
this.state = {
quantity: ItemUtils.getQuantity(props.item),
newQuantity: ItemUtils.getQuantity(props.item),
};
}
componentDidUpdate(
prevProps: Readonly<EquipmentListItemProps>,
prevState: Readonly<EquipmentListItemState>,
snapshot?: any
): void {
const { item } = this.props;
const nextQuantity = ItemUtils.getQuantity(item);
if (nextQuantity !== prevState.quantity) {
this.setState({
quantity: nextQuantity,
newQuantity: nextQuantity,
});
}
}
handleQuantitySave = (newValue) => {
const { onQuantitySet, item } = this.props;
const quantity = ItemUtils.getQuantity(item);
const quantityDiff = newValue - quantity;
if (quantityDiff === 0) {
return;
}
if (onQuantitySet) {
onQuantitySet(item, newValue);
}
this.setState((prevState) => ({
quantity: newValue,
newQuantity: newValue,
}));
};
handleAttune = () => {
const { onAttune, item } = this.props;
if (onAttune) {
onAttune(item);
}
};
handleUnattune = () => {
const { onUnattune, item } = this.props;
if (onUnattune) {
onUnattune(item);
}
};
handleUnequip = () => {
const { onUnequip, item } = this.props;
if (onUnequip) {
onUnequip(item);
}
};
handleEquip = () => {
const { onEquip, item } = this.props;
if (onEquip) {
onEquip(item);
}
};
handleRemove = () => {
const { onRemove, item } = this.props;
if (onRemove) {
onRemove(item);
}
};
handleRemoveInfusion = () => {
const { onRemoveInfusion, item } = this.props;
if (onRemoveInfusion) {
onRemoveInfusion(item);
}
};
handleAmountChange = (evt) => {
this.setState({
newQuantity: evt.target.value,
});
};
handleAmountKeyUp = (evt) => {
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
const clampedValue = HelperUtils.clampInt(parsedValue ? parsedValue : 0, 0);
if (evt.key === "Enter") {
this.handleQuantitySave(clampedValue);
}
};
handleAmountBlur = (evt) => {
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
const clampedValue = HelperUtils.clampInt(parsedValue ? parsedValue : 0, 0);
this.handleQuantitySave(clampedValue);
};
handleIncrementClick = () => {
const { onQuantitySet, item } = this.props;
this.setState((prevState, props) => {
const quantity = prevState.quantity + 1;
if (onQuantitySet) {
onQuantitySet(item, quantity);
}
return {
quantity,
newQuantity: quantity,
};
});
};
handleDecrementClick = () => {
const { onQuantitySet, item } = this.props;
this.setState((prevState, props) => {
const quantity = prevState.quantity - 1;
if (onQuantitySet) {
onQuantitySet(item, quantity);
}
return {
quantity: Math.max(prevState.quantity - 1, 0),
newQuantity: Math.max(prevState.quantity - 1, 0),
};
});
};
render() {
const { quantity, newQuantity } = this.state;
const {
item,
removeLabel,
removeInfusionLabel,
unequipLabel,
equipLabel,
attuneLabel,
unattuneLabel,
showRemove,
showEquip,
showUnequip,
showAttuning,
atAttuneMax,
} = this.props;
const hasActions = showRemove || showEquip || showUnequip || showAttuning;
if (!hasActions) {
return null;
}
const isEquipped = ItemUtils.isEquipped(item);
const isAttuned = ItemUtils.isAttuned(item);
const isStackable = ItemUtils.isStackable(item);
const canAttune = ItemUtils.canAttune(item);
const canEquip = ItemUtils.canEquip(item);
const infusion = ItemUtils.getInfusion(item);
return (
<div className="equipment-list-item-actions">
{isStackable && (
<div className="equipment-list-item-amount">
<div className="equipment-list-item-amount-label">
Total Quantity
</div>
<div className="equipment-list-item-amount-controls">
<div className="equipment-list-item-amount-decrease">
<Button
clsNames={["button-action-decrease"]}
onClick={this.handleDecrementClick}
disabled={quantity === 0}
/>
</div>
<div className="equipment-list-item-amount-value">
<input
type="number"
value={newQuantity}
className="character-input equipment-list-item-amount-input"
onChange={this.handleAmountChange}
onBlur={this.handleAmountBlur}
onKeyUp={this.handleAmountKeyUp}
min={0}
/>
</div>
<div className="equipment-list-item-amount-increase">
<Button
clsNames={["button-action-increase"]}
onClick={this.handleIncrementClick}
/>
</div>
</div>
</div>
)}
{canEquip && showEquip && showUnequip && (
<Button
onClick={isEquipped ? this.handleUnequip : this.handleEquip}
style={isEquipped ? "" : "outline"}
size="small"
>
{isEquipped ? unequipLabel : equipLabel}
</Button>
)}
{canEquip && showEquip && !showUnequip && (
<Button onClick={this.handleEquip} style={"outline"} size="small">
{equipLabel}
</Button>
)}
{canEquip && !showEquip && showUnequip && (
<Button onClick={this.handleUnequip} style={"outline"} size="small">
{unequipLabel}
</Button>
)}
{canAttune && showAttuning && (
<Button
onClick={isAttuned ? this.handleUnattune : this.handleAttune}
style={isAttuned ? "" : "outline"}
size="small"
disabled={atAttuneMax && !isAttuned}
>
{isAttuned ? unattuneLabel : attuneLabel}
</Button>
)}
{infusion && (
<div
className="equipment-list-item-remove"
onClick={this.handleRemoveInfusion}
data-testid="remove-infusion-button"
>
<span className="equipment-list-item-remove-icon" />{" "}
{removeInfusionLabel}
</div>
)}
{showRemove && !infusion && (
<div
className="equipment-list-item-remove"
onClick={this.handleRemove}
>
<span className="equipment-list-item-remove-icon" /> {removeLabel}
</div>
)}
</div>
);
}
}
export default class EquipmentListItem extends React.PureComponent<{
header: any;
clsNames: Array<string>;
}> {
static defaultProps = {
clsNames: [],
};
render() {
let { header, children, clsNames } = this.props;
return (
<Collapsible
trigger={header}
clsNames={["equipment-list-item", ...clsNames]}
>
{children}
</Collapsible>
);
}
}
@@ -0,0 +1,11 @@
import EquipmentListItem, {
EquipmentListItemActions,
EquipmentListItemHeaderAction,
} from "./EquipmentListItem";
export default EquipmentListItem;
export {
EquipmentListItem,
EquipmentListItemActions,
EquipmentListItemHeaderAction,
};
@@ -0,0 +1,581 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder, Button } from "@dndbeyond/character-components/es";
import {
BaseItemDefinitionContract,
HelperUtils,
Item,
ItemUtils,
Modifier,
RuleData,
RuleDataUtils,
TypeValueLookup,
CharacterTheme,
InventoryManager,
ContainerManager,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemFilter } from "~/components/ItemFilter";
import { ItemName } from "~/components/ItemName";
import { LegacyBadge } from "~/components/LegacyBadge";
import { AppLoggerUtils } from "../../../utils";
import ItemDetail from "../../ItemDetail";
import EquipmentListItem from "../EquipmentListItem";
import { CollapsibleHeader, CollapsibleHeading } from "../common/Collapsible";
//TODO: This component should be removed and replaced with the EquipmentShop component
export class EquipmentManagerShopItem extends React.PureComponent<
{
item: Item;
onItemAdd: (
item: Item,
amount: number,
containerDefinitionKey: string
) => void;
minAddAmount: number;
maxAddAmount: number;
ruleData: RuleData;
proficiencyBonus: number;
containerDefinitionKey: string;
theme: CharacterTheme;
},
{
amount: number | null;
}
> {
static defaultProps = {
enableAdd: true,
minAddAmount: 1,
maxAddAmount: 10,
};
constructor(props) {
super(props);
this.state = {
amount: 1,
};
}
handleAmountChange = (evt) => {
this.setState({
amount: evt.target.value,
});
};
handleAmountBlur = (evt) => {
const { minAddAmount, maxAddAmount } = this.props;
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
let clampedValue: number | null = null;
if (parsedValue) {
clampedValue = HelperUtils.clampInt(
parsedValue,
minAddAmount,
maxAddAmount
);
}
this.setState({
amount: clampedValue,
});
};
handleDecrementCountClick = () => {
const { minAddAmount } = this.props;
this.setState((prevState, props) => ({
amount: Math.max(
(prevState.amount === null ? 0 : prevState.amount) - 1,
minAddAmount
),
}));
};
handleIncrementCountClick = () => {
this.setState((prevState, props) => ({
amount: (prevState.amount === null ? 0 : prevState.amount) + 1,
}));
};
handleAdd = () => {
const { amount } = this.state;
const { item, onItemAdd, minAddAmount, containerDefinitionKey } =
this.props;
onItemAdd(
item,
amount === null ? minAddAmount : amount,
containerDefinitionKey
);
};
renderButtons = () => {
const { item } = this.props;
return (
<div className="equipment-list-header-actions">
<Button size="small" onClick={this.handleAdd}>
Add
</Button>
</div>
);
};
renderHeader(item, metaItems, defaultImageUrl) {
const { theme } = this.props;
const { canAttune, avatarUrl } = item.definition;
const isLegacy = ItemUtils.isLegacy(item);
const heading = (
<CollapsibleHeading>
<div className="equipment-list-heading">
<span className="equipment-list-heading-text">
<ItemName item={item} />
</span>
<span className="equipment-list-heading-icons">
{canAttune ? (
<i className="equipment-list-heading-icon i-req-attunement" />
) : (
""
)}
</span>
{isLegacy && <LegacyBadge variant="margin-left" />}
</div>
</CollapsibleHeading>
);
let imageUrl = avatarUrl;
if (!imageUrl) {
imageUrl = defaultImageUrl;
}
return (
<CollapsibleHeader
imgSrc={imageUrl ? imageUrl : ""}
heading={heading}
metaItems={metaItems}
callout={this.renderButtons()}
/>
);
}
render() {
const { amount } = this.state;
const {
item,
minAddAmount,
maxAddAmount,
ruleData,
proficiencyBonus,
theme,
} = this.props;
let metaItems = [ItemUtils.getType(item)];
let defaultImageUrl: string | null = null;
if (ItemUtils.isWeaponContract(item)) {
defaultImageUrl = RuleDataUtils.getDefaultWeaponImageUrl(ruleData);
} else if (ItemUtils.isGearContract(item)) {
const subType = ItemUtils.getSubType(item);
if (subType) {
metaItems.push(subType);
}
defaultImageUrl = RuleDataUtils.getDefaultGearImageUrl(ruleData);
} else if (ItemUtils.isArmorContract(item)) {
const baseArmorName = ItemUtils.getBaseArmorName(item);
if (baseArmorName) {
metaItems.push(baseArmorName);
}
defaultImageUrl = RuleDataUtils.getDefaultArmorImageUrl(ruleData);
}
const totalAmount: number =
(amount === null ? 1 : amount) * ItemUtils.getBundleSize(item);
return (
<EquipmentListItem
header={this.renderHeader(item, metaItems, defaultImageUrl)}
>
<ItemDetail
theme={theme}
item={item}
ruleData={ruleData}
showCustomize={false}
showActions={false}
proficiencyBonus={proficiencyBonus}
/>
<div className="equipment-list-item-actions">
<div className="equipment-list-item-action">
<div className="equipment-list-item-amount">
<div className="equipment-list-item-amount-label">
Amount to add
</div>
<div className="equipment-list-item-amount-controls">
<div className="equipment-list-item-amount-decrease">
<Button
clsNames={["button-action-decrease"]}
onClick={this.handleDecrementCountClick}
disabled={amount !== null && amount <= minAddAmount}
/>
</div>
<div className="equipment-list-item-amount-value">
<input
type="number"
value={amount === null ? "" : amount}
className="character-input equipment-list-item-amount-input"
onChange={this.handleAmountChange}
onBlur={this.handleAmountBlur}
min={minAddAmount}
max={maxAddAmount}
/>
</div>
<div className="equipment-list-item-amount-increase">
<Button
clsNames={["button-action-increase"]}
onClick={this.handleIncrementCountClick}
disabled={amount !== null && amount >= maxAddAmount}
/>
</div>
</div>
</div>
</div>
<div className="equipment-list-item-action">
<Button onClick={this.handleAdd}>
Add {totalAmount === 1 ? " Item" : ` ${totalAmount} Items`}
</Button>
</div>
</div>
</EquipmentListItem>
);
}
}
interface EquipmentManagerShopProps {
globalModifiers: Array<Modifier>;
valueLookupByType: TypeValueLookup;
pageSize: number;
onItemAdd: (
item: Item,
amount: number,
containerDefinitionKey: string
) => void;
ruleData: RuleData;
proficiencyBonus: number;
containerDefinitionKey: string;
theme: CharacterTheme;
activeSourceCategories: Array<number>;
inventoryManager: InventoryManager;
}
interface EquipmentManagerShopState {
query: string;
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>;
shoppeContainer: ContainerManager | null;
}
export default class EquipmentManagerShop extends React.PureComponent<
EquipmentManagerShopProps,
EquipmentManagerShopState
> {
static defaultProps = {
pageSize: 30,
};
loadItemsCanceler: null | Canceler = null;
constructor(props) {
super(props);
const { items } = props;
this.state = {
query: "",
filteredItems: items,
currentPage: 0,
loading: false,
loaded: false,
filterTypes: [],
filterQuery: "",
filterProficient: false,
filterBasic: false,
filterMagic: false,
filterContainer: false,
filterSourceCategories: [],
shoppeContainer: null,
};
}
componentDidMount() {
const { inventoryManager } = this.props;
if (inventoryManager) {
this.setState({
loading: true,
});
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();
}
}
getLastPageIdx = (filteredItems) => {
const { pageSize } = this.props;
return Math.ceil(filteredItems.length / pageSize) - 1;
};
handlePageMore = () => {
this.setState((prevState) => ({
currentPage: prevState.currentPage + 1,
}));
};
handleQueryChange = (evt) => {
const query = evt.target.value;
this.setState({
filterQuery: query,
currentPage: 0,
});
};
handleFilterItemType = (type) => {
this.setState((prevState) => ({
filterTypes: prevState.filterTypes.includes(type)
? prevState.filterTypes.filter((t) => t !== type)
: [...prevState.filterTypes, type],
currentPage: 0,
}));
};
handleSourceCategoryClick = (categoryId: number) => {
this.setState((prevState) => ({
filterSourceCategories: prevState.filterSourceCategories.includes(
categoryId
)
? prevState.filterSourceCategories.filter((cat) => cat !== categoryId)
: [...prevState.filterSourceCategories, categoryId],
}));
};
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 = () => {
this.setState((prevState) => ({
filterProficient: !prevState.filterProficient,
currentPage: 0,
}));
};
handleBasicToggle = () => {
this.setState((prevState) => ({
filterBasic: !prevState.filterBasic,
currentPage: 0,
}));
};
handleMagicToggle = () => {
this.setState((prevState) => ({
filterMagic: !prevState.filterMagic,
currentPage: 0,
}));
};
handleContainerToggle = (): void => {
this.setState((prevState) => ({
filterContainer: !prevState.filterContainer,
currentPage: 0,
}));
};
renderPager = (
filteredItems: Array<ItemManager>,
totalItems: number
): React.ReactNode => {
if (filteredItems.length >= totalItems) {
return null;
}
return (
<div className="equipment-manager-shop-pager">
<Button block={true} onClick={this.handlePageMore}>
Load More
</Button>
</div>
);
};
renderPagedListing = (filteredItems: Array<ItemManager>): React.ReactNode => {
const { currentPage } = this.state;
const {
pageSize,
onItemAdd,
ruleData,
proficiencyBonus,
containerDefinitionKey,
theme,
} = this.props;
const pagedFilteredItems: Array<ItemManager> = filteredItems.slice(
0,
(currentPage + 1) * pageSize
);
return (
<div className="equipment-manager-shop-items">
{pagedFilteredItems.length ? (
pagedFilteredItems.map((item, idx) => (
<EquipmentManagerShopItem
theme={theme}
item={item.getItem()}
key={`${item.getMappingId()}-${idx}`}
onItemAdd={onItemAdd}
ruleData={ruleData}
proficiencyBonus={proficiencyBonus}
containerDefinitionKey={containerDefinitionKey}
/>
))
) : (
<div className="equipment-manager-shop-no-results">
No Results Found
</div>
)}
</div>
);
};
renderUi = () => {
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 (
<div>
<ItemFilter
sourceCategories={sourceCategories}
filterQuery={filterQuery}
onQueryChange={(value) =>
this.setState({
filterQuery: value,
})
}
filterTypes={filterTypes}
onFilterButtonClick={this.handleFilterItemType}
onCheckboxChange={this.handleToggleFilter}
onSourceCategoryClick={this.handleSourceCategoryClick}
filterSourceCategories={filterSourceCategories}
filterProficient={filterProficient}
filterBasic={filterBasic}
filterMagic={filterMagic}
filterContainer={filterContainer}
buttonSize="x-small"
filterStyle="builder"
/>
{this.renderPagedListing(items)}
{this.renderPager(items, totalItems)}
</div>
);
};
renderLoading = () => {
return <LoadingPlaceholder />;
};
render() {
const { loaded, loading } = this.state;
return (
<div className="equipment-manager-shop">
{loading && this.renderLoading()}
{!loading && loaded && this.renderUi()}
</div>
);
}
}
@@ -0,0 +1,6 @@
import EquipmentManagerShop, {
EquipmentManagerShopItem,
} from "./EquipmentManagerShop";
export default EquipmentManagerShop;
export { EquipmentManagerShop, EquipmentManagerShopItem };
@@ -0,0 +1,89 @@
import { FC } from "react";
import { Accordion } from "~/components/Accordion";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { EquipmentActions } from "~/subApps/builder/components/EquipmentActions";
import { useEquipmentMethods } from "~/subApps/builder/hooks/useEquipmentMethods";
import { Item } from "~/types";
import ItemDetail from "../../ItemDetail";
import { ItemListInformationCollapsible } from "../../ItemListInformationCollapsible";
import { EquipmentListItemActions } from "../EquipmentListItem";
interface GearListProps {
item: Item;
unequipLabel: string;
equipLabel: string;
showRemove?: boolean;
showEquip?: boolean;
showUnequip?: boolean;
showAttuning?: boolean;
showHeaderAction: boolean;
}
export const GearListItem: FC<GearListProps> = ({
item,
equipLabel,
unequipLabel,
...props
}) => {
const {
ruleData,
proficiencyBonus,
characterTheme,
itemUtils,
helperUtils,
containerLookup,
} = useCharacterEngine();
const actions = useEquipmentMethods();
const image = itemUtils.getAvatarUrl(item);
const altText = itemUtils.getName(item);
const container = helperUtils.lookupDataOrFallback(
containerLookup,
itemUtils.getContainerDefinitionKey(item)
);
const getMetaItems = () => {
const { isCustomized } = item;
const type = itemUtils.getType(item);
const subType = itemUtils.getSubType(item);
const isOffhand = itemUtils.isOffhand(item);
return [
...(type ? [type] : []),
...(subType ? [subType] : []),
...(isOffhand ? ["Dual Wield"] : []),
...(isCustomized ? ["Customized"] : []),
];
};
return (
<Accordion
summary={<ItemName item={item} showLegacyBadge={true} />}
summaryAction={
<EquipmentActions
item={item}
equipLabel={equipLabel}
unequipLabel={unequipLabel}
/>
}
summaryImage={image}
summaryImageAlt={altText}
summaryMetaItems={getMetaItems()}
variant="paper"
>
<ItemListInformationCollapsible ruleData={ruleData} item={item} />
<ItemDetail
container={container}
theme={characterTheme}
item={item}
ruleData={ruleData}
showCustomize={false}
showActions={false}
proficiencyBonus={proficiencyBonus}
/>
<EquipmentListItemActions item={item} {...actions} {...props} />
</Accordion>
);
};
@@ -0,0 +1,203 @@
import React from "react";
import {
CharacterTheme,
Constants,
DataOriginRefData,
EntityUtils,
FormatUtils,
RuleData,
Spell,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { SpellName } from "~/components/SpellName";
import SpellDetail from "../../SpellDetail";
import styles from "./styles.module.css";
export class SpellListItem extends React.PureComponent<
{
spell: Spell;
castAsRitual: boolean;
footerNode?: any;
spellCasterInfo: any;
ruleData: RuleData;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
theme: CharacterTheme;
},
{
customizeCollapsed: boolean;
}
> {
static defaultProps = {
castAsRitual: false,
};
constructor(props) {
super(props);
this.state = {
customizeCollapsed: true,
};
}
getActions = () => {
const { spell, ruleData } = this.props;
const { toHit, attackSaveValue } = spell;
const requiresAttackRoll = SpellUtils.getRequiresAttackRoll(spell);
const requiresSavingThrow = SpellUtils.getRequiresSavingThrow(spell);
if (requiresAttackRoll && toHit !== null) {
return (
<p className={styles.callout}>
<span>To Hit</span>
<span>{FormatUtils.renderSignedNumber(toHit)}</span>
</p>
);
}
if (requiresSavingThrow) {
return (
<p className={styles.callout}>
<span>{SpellUtils.getSaveDcAbilityKey(spell, ruleData)}</span>
<span>{attackSaveValue}</span>
</p>
);
}
};
getMetaItems = () => {
const { spell, dataOriginRefData } = this.props;
const level = SpellUtils.getLevel(spell);
const concentration = SpellUtils.getConcentration(spell);
const rangeArea = SpellUtils.getDefinitionRangeArea(spell);
const attackType = SpellUtils.getAttackType(spell);
let metaItems: Array<string> = [];
metaItems.push(FormatUtils.renderSpellLevelName(level));
let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell);
if (expandedDataOriginRef !== null)
metaItems.push(
EntityUtils.getDataOriginRefName(
expandedDataOriginRef,
dataOriginRefData
)
);
if (concentration) metaItems.push("Concentration");
if (rangeArea)
metaItems.push(
attackType && attackType === Constants.AttackTypeRangeEnum.RANGED
? `Range ${rangeArea}`
: rangeArea
);
return metaItems;
};
render() {
const { customizeCollapsed } = this.state;
const {
spell,
castAsRitual,
footerNode,
spellCasterInfo,
ruleData,
proficiencyBonus,
theme,
} = this.props;
let clsNames = ["attack-list-customize-header"];
if (customizeCollapsed) {
clsNames.push("attack-list-customize-header-closed");
} else {
clsNames.push("attack-list-customize-header-opened");
}
const school = SpellUtils.getSchool(spell);
const spellImage = `https://www.dndbeyond.com/Content/Skins/Waterdeep/images/spell-schools/35/${school?.toLowerCase()}.png`;
return (
<Accordion
className={styles.spell}
summary={
<SpellName
spell={spell}
showSpellLevel={false}
showLegacyBadge={true}
/>
}
summaryAction={this.getActions()}
summaryImage={spellImage}
summaryImageAlt={`Icon for the ${school} school of magic`}
summaryMetaItems={this.getMetaItems()}
variant="paper"
>
<SpellDetail
theme={theme}
spell={spell}
castAsRitual={castAsRitual}
spellCasterInfo={spellCasterInfo}
enableCaster={false}
ruleData={ruleData}
showCustomize={false}
showActions={false}
proficiencyBonus={proficiencyBonus}
/>
{footerNode}
</Accordion>
);
}
}
export default class SpellList extends React.PureComponent<{
spells: Array<Spell>;
hideRemaining: boolean;
castAsRitual: boolean;
spellCasterInfo: any;
ruleData: RuleData;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
theme: CharacterTheme;
}> {
static defaultProps = {
hideRemaining: false,
castAsRitual: false,
};
render() {
const {
spells,
castAsRitual,
spellCasterInfo,
ruleData,
dataOriginRefData,
proficiencyBonus,
theme,
} = this.props;
if (!spells.length) {
return null;
}
return (
<div className="spell-list">
<div className="spell-list-items">
{spells.map((spell) => (
<SpellListItem
theme={theme}
spell={spell}
key={`${spell.id}-${SpellUtils.getId(spell)}`}
castAsRitual={castAsRitual}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
/>
))}
</div>
</div>
);
}
}
@@ -0,0 +1,614 @@
import axios, { Canceler } from "axios";
import sortBy from "lodash/sortBy";
import React from "react";
import {
Button,
LoadingPlaceholder,
TypeScriptUtils,
} from "@dndbeyond/character-components/es";
import {
ApiAdapterUtils,
BaseSpellContract,
CharacterTheme,
Constants,
DataOriginRefData,
EntityUtils,
FormatUtils,
HelperUtils,
RuleData,
RuleDataUtils,
SourceUtils,
Spell,
SpellCasterInfo,
SpellUtils,
} from "@dndbeyond/character-rules-engine/";
import { Accordion } from "~/components/Accordion";
import { Link } from "~/components/Link";
import { SpellFilter } from "~/components/SpellFilter";
import { SpellName } from "~/components/SpellName";
import DataLoadingStatusEnum from "../../../constants/DataLoadingStatusEnum";
import {
ApiSpellsPromise,
ApiSpellsRequest,
} from "../../../selectors/composite/apiCreator";
import { AppLoggerUtils, FilterUtils } from "../../../utils";
import SpellDetail from "../../SpellDetail";
import styles from "./styles.module.css";
export class SpellManagerGroupItem extends React.PureComponent<{
spell: Spell;
spellCasterInfo: any;
ruleData: RuleData;
dataOriginRefData: DataOriginRefData;
enableRemove: boolean;
enableSpellRemove: boolean;
enableAdd: boolean;
enablePrepare: boolean;
enableUnprepare: boolean;
isPrepareMaxed: boolean;
isCantripsKnownMaxed: boolean;
isSpellsKnownMaxed: boolean;
addButtonText: string;
removeButtonText: string;
proficiencyBonus: number;
theme: CharacterTheme;
onPrepare: (spell: Spell) => void;
onUnprepare: (spell: Spell) => void;
onAdd: (spell: Spell) => void;
onRemove: (spell: Spell) => void;
}> {
static defaultProps = {
enableRemove: true,
enableSpellRemove: true,
enableAdd: true,
enablePrepare: true,
enableUnprepare: true,
};
handlePrepareToggle = () => {
const { spell, onPrepare, onUnprepare } = this.props;
if (spell.prepared) {
onUnprepare(spell);
} else {
onPrepare(spell);
}
};
handleRemove = (evt) => {
const { spell, onRemove } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onRemove(spell);
};
handleAdd = () => {
const { spell, onAdd } = this.props;
onAdd(spell);
};
renderButtons = (clsNames, includeRemove) => {
const {
spell,
isPrepareMaxed,
isCantripsKnownMaxed,
isSpellsKnownMaxed,
enableRemove,
enableAdd,
enablePrepare,
} = this.props;
let { addButtonText, removeButtonText } = this.props;
const { alwaysPrepared, canRemove, canPrepare, canAdd } = spell;
let isAddDisabled = false;
if (SpellUtils.getLevel(spell) === 0 && isCantripsKnownMaxed) {
isAddDisabled = true;
}
if (SpellUtils.getLevel(spell) > 0 && isSpellsKnownMaxed) {
isAddDisabled = true;
}
let showAlwaysPrepared =
(!includeRemove && alwaysPrepared) ||
(includeRemove && !canRemove && alwaysPrepared);
if (SpellUtils.isCantrip(spell)) {
addButtonText =
Constants.SpellCastingLearningStyleAddText[
Constants.SpellCastingLearningStyle.Learned
];
removeButtonText =
Constants.SpellCastingLearningStyleRemoveText[
Constants.SpellCastingLearningStyle.Learned
];
}
return (
<div className={clsNames.join(" ")}>
{showAlwaysPrepared && (
<div className="spell-manager-item-always-prepared">
Always Prepared
</div>
)}
{!alwaysPrepared && enablePrepare && canPrepare && (
<Button
size="small"
disabled={isPrepareMaxed && !spell.prepared}
style={spell.prepared ? "" : "outline"}
onClick={this.handlePrepareToggle}
>
{spell.prepared ? removeButtonText : addButtonText}
</Button>
)}
{includeRemove && enableRemove && canRemove && (
<div
className="spell-manager-spell-remove"
onClick={this.handleRemove}
>
<span className="spell-manager-spell-remove-icon" />{" "}
{removeButtonText}
</div>
)}
{enableAdd && canAdd && (
<Button
size="small"
onClick={this.handleAdd}
disabled={isAddDisabled}
style={"outline"}
>
{addButtonText}
</Button>
)}
</div>
);
};
getMetaItems = () => {
const { spell, dataOriginRefData } = this.props;
const level = SpellUtils.getLevel(spell);
const concentration = SpellUtils.getConcentration(spell);
const rangeArea = SpellUtils.getDefinitionRangeArea(spell);
const attackType = SpellUtils.getAttackType(spell);
let metaItems: Array<string> = [];
metaItems.push(FormatUtils.renderSpellLevelName(level));
let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell);
if (expandedDataOriginRef !== null) {
metaItems.push(
EntityUtils.getDataOriginRefName(
expandedDataOriginRef,
dataOriginRefData
)
);
}
if (concentration) {
metaItems.push("Concentration");
}
if (rangeArea) {
metaItems.push(
attackType && attackType === Constants.AttackTypeRangeEnum.RANGED
? `Range ${rangeArea}`
: rangeArea
);
}
return metaItems;
};
render() {
const {
spell,
spellCasterInfo,
ruleData,
isPrepareMaxed,
proficiencyBonus,
theme,
enableSpellRemove,
} = this.props;
const school = SpellUtils.getSchool(spell);
const spellImage = `https://www.dndbeyond.com/Content/Skins/Waterdeep/images/spell-schools/35/${school?.toLowerCase()}.png`;
return (
<Accordion
className={styles.spell}
summary={
<SpellName
spell={spell}
showSpellLevel={false}
showLegacyBadge={true}
/>
}
summaryAction={this.renderButtons(
["spell-list-item-header-actions"],
enableSpellRemove
)}
summaryImage={spellImage}
summaryImageAlt={`Icon for the ${school} school of magic`}
summaryMetaItems={this.getMetaItems()}
variant="paper"
>
<SpellDetail
theme={theme}
spell={spell}
isPreparedMaxed={isPrepareMaxed}
enableCaster={false}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
showActions={false}
showCustomize={false}
proficiencyBonus={proficiencyBonus}
/>
<div className="spell-list-item-actions">
{this.renderButtons(["spell-list-item-content-actions"], true)}
</div>
</Accordion>
);
}
}
enum LoadSpellType {
ADDITIONAL = "ADDITIONAL",
ALWAYS_KNOWN = "ALWAYS_KNOWN",
}
interface SpellManagerGroupProps {
spells: Array<Spell>;
spellCasterInfo: SpellCasterInfo;
overallSpellInfo: any;
knownSpellIds: Array<any>;
ruleData: RuleData;
dataOriginRefData: DataOriginRefData;
loadSpells?: ApiSpellsRequest | null;
loadAlwaysKnownSpells?: ApiSpellsRequest | null;
enableRemove: boolean;
enableSpellRemove: boolean;
enableAdd: boolean;
enablePrepare: boolean;
enableUnprepare: boolean;
isPrepareMaxed: boolean;
isCantripsKnownMaxed: boolean;
isSpellsKnownMaxed: boolean;
addButtonText: string;
removeButtonText: string;
proficiencyBonus: number;
theme: CharacterTheme;
onAlwaysKnownLoad?: (spells: Array<BaseSpellContract>) => void;
onPrepare: (spell: Spell) => void;
onUnprepare: (spell: Spell) => void;
onAdd: (spell: Spell) => void;
onRemove: (spell: Spell) => void;
activeSourceCategories: Array<number>;
}
interface SpellManagerGroupState {
query: string;
lazySpells: Array<Spell>;
staticSpells: Array<Spell>;
filteredSpells: Array<Spell>;
currentPage: number;
loadingStatus: DataLoadingStatusEnum;
filterLevels: Array<number>;
filterSourceCategories: Array<number>;
filterQuery: string;
}
export default class SpellManagerGroup extends React.PureComponent<
SpellManagerGroupProps,
SpellManagerGroupState
> {
static defaultProps = {
enablePrepare: true,
enableUnprepare: true,
enableRemove: true,
enableSpellRemove: true,
enableAdd: true,
};
loadRuleCancelers: Array<Canceler> = [];
constructor(props) {
super(props);
const { spells } = props;
this.state = {
query: "",
lazySpells: [],
staticSpells: spells,
filteredSpells: spells,
currentPage: 0,
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
filterLevels: [],
filterSourceCategories: [],
filterQuery: "",
};
}
fetchSpells = () => {
const { loadSpells, loadAlwaysKnownSpells, onAlwaysKnownLoad } = this.props;
if (!loadSpells && !loadAlwaysKnownSpells) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
return;
}
let requests: Array<ApiSpellsPromise> = [];
let loadingRequestTypes: Array<LoadSpellType> = [];
if (loadSpells) {
requests.push(
loadSpells({
cancelToken: new axios.CancelToken((c) => {
this.loadRuleCancelers.push(c);
}),
})
);
loadingRequestTypes.push(LoadSpellType.ADDITIONAL);
}
if (loadAlwaysKnownSpells) {
requests.push(
loadAlwaysKnownSpells({
cancelToken: new axios.CancelToken((c) => {
this.loadRuleCancelers.push(c);
}),
})
);
loadingRequestTypes.push(LoadSpellType.ALWAYS_KNOWN);
}
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
axios
.all(requests)
.then((responses) => {
let lazySpells: Array<Spell> = [];
for (let i = 0; i < responses.length; i++) {
let response = responses[i];
let requestType = loadingRequestTypes[i];
let spells = ApiAdapterUtils.getResponseData(response);
switch (requestType) {
case LoadSpellType.ADDITIONAL:
lazySpells =
spells !== null ? this.transformLoadedSpells(spells) : [];
break;
case LoadSpellType.ALWAYS_KNOWN:
if (spells !== null && onAlwaysKnownLoad) {
onAlwaysKnownLoad(spells);
}
break;
default:
// not implemented
}
}
this.setState({
lazySpells,
loadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadRuleCancelers = [];
})
.catch(AppLoggerUtils.handleAdhocApiError);
};
componentDidMount() {
this.fetchSpells();
}
componentWillUnmount(): void {
if (this.loadRuleCancelers.length > 0) {
this.loadRuleCancelers.forEach((cancel) => {
cancel();
});
}
}
componentDidUpdate(
prevProps: Readonly<SpellManagerGroupProps>,
prevState: Readonly<SpellManagerGroupState>,
snapshot?: any
): void {
const { spells, spellCasterInfo } = this.props;
if (spells !== prevProps.spells) {
this.setState((prevState) => ({
staticSpells: spells,
}));
}
if (
spellCasterInfo.characterLevel !==
prevProps.spellCasterInfo.characterLevel
) {
this.fetchSpells();
}
}
getCombinedSpells = () => {
const { lazySpells, staticSpells } = this.state;
const { knownSpellIds } = this.props;
const remainingLazySpells = lazySpells.filter(
(spell) => !knownSpellIds.includes(SpellUtils.deriveKnownKey(spell))
);
return sortBy(
[...remainingLazySpells, ...staticSpells],
[
(spell) => SpellUtils.getLevel(spell),
(spell) => SpellUtils.getName(spell),
(spell) => SpellUtils.getExpandedDataOriginRef(spell) !== null,
(spell) => SpellUtils.getUniqueKey(spell),
]
);
};
getFilteredSpells = (combinedSpells) => {
const { filterLevels, filterQuery, filterSourceCategories } = this.state;
const { ruleData } = this.props;
const filteredSpells = combinedSpells.filter((spell) => {
if (filterSourceCategories.length !== 0) {
const spellSources = SpellUtils.getSources(spell);
const sources = spellSources
.map((source) =>
HelperUtils.lookupDataOrFallback(
RuleDataUtils.getSourceDataLookup(ruleData),
source.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
const sourceCategoryId = sources[0]?.sourceCategory?.id || 0;
if (!filterSourceCategories.includes(sourceCategoryId)) {
return false;
}
}
if (
filterLevels.length !== 0 &&
!filterLevels.includes(SpellUtils.getLevel(spell))
) {
return false;
}
if (
filterQuery !== "" &&
!FilterUtils.doesQueryMatchData(filterQuery, SpellUtils.getName(spell))
) {
return false;
}
return true;
});
return filteredSpells;
};
transformLoadedSpells = (data) => {
const { ruleData, overallSpellInfo } = this.props;
return data.map((spell) =>
SpellUtils.simulateSpell(spell, overallSpellInfo, ruleData, null, null)
);
};
handleFilterSpellLevel = (level: number) => {
this.setState((prevState) => ({
filterLevels: prevState.filterLevels.includes(level)
? prevState.filterLevels.filter((l) => l !== level)
: [...prevState.filterLevels, level],
}));
};
handleSourceCategoryClick = (categoryId: number) => {
this.setState((prevState) => ({
filterSourceCategories: prevState.filterSourceCategories.includes(
categoryId
)
? prevState.filterSourceCategories.filter((cat) => cat !== categoryId)
: [...prevState.filterSourceCategories, categoryId],
}));
};
render() {
const {
onPrepare,
onUnprepare,
onRemove,
onAdd,
isPrepareMaxed,
isCantripsKnownMaxed,
isSpellsKnownMaxed,
addButtonText,
removeButtonText,
enableRemove,
enableAdd,
enablePrepare,
enableUnprepare,
enableSpellRemove,
spellCasterInfo,
ruleData,
dataOriginRefData,
proficiencyBonus,
theme,
activeSourceCategories,
} = this.props;
const { filterQuery, filterLevels, loadingStatus, filterSourceCategories } =
this.state;
const combinedSpells = this.getCombinedSpells();
const filteredSpells = this.getFilteredSpells(combinedSpells);
const spellDefinitions = combinedSpells
.map((spell) => SpellUtils.getDefinition(spell))
.filter(TypeScriptUtils.isNotNullOrUndefined);
const sourceCategories = SourceUtils.getSimpleSourceCategoriesData(
spellDefinitions,
ruleData,
activeSourceCategories
);
return (
<div className="spell-manager-group">
<SpellFilter
spells={combinedSpells}
sourceCategories={sourceCategories}
filterQuery={filterQuery}
filterLevels={filterLevels}
onLevelFilterClick={this.handleFilterSpellLevel}
onQueryChange={(value) =>
this.setState({
filterQuery: value,
})
}
onSourceCategoryClick={this.handleSourceCategoryClick}
filterSourceCategories={filterSourceCategories}
buttonSize="x-small"
filterStyle="builder"
/>
<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>
{loadingStatus === DataLoadingStatusEnum.LOADING && (
<LoadingPlaceholder />
)}
{loadingStatus === DataLoadingStatusEnum.LOADED &&
(filteredSpells.length > 0 ? (
filteredSpells.map((spell, idx) => (
<SpellManagerGroupItem
theme={theme}
spell={spell}
key={`${spell.id}-${idx}`}
onPrepare={onPrepare}
onUnprepare={onUnprepare}
onRemove={onRemove}
onAdd={onAdd}
isPrepareMaxed={isPrepareMaxed}
isCantripsKnownMaxed={isCantripsKnownMaxed}
isSpellsKnownMaxed={isSpellsKnownMaxed}
addButtonText={addButtonText}
removeButtonText={removeButtonText}
enableRemove={enableRemove}
enableAdd={enableAdd}
enablePrepare={enablePrepare}
enableUnprepare={enableUnprepare}
enableSpellRemove={enableSpellRemove}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
/>
))
) : (
<div>No Results Found</div>
))}
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpellManagerGroup, { SpellManagerGroupItem } from "./SpellManagerGroup";
export default SpellManagerGroup;
export { SpellManagerGroup, SpellManagerGroupItem };
@@ -0,0 +1,88 @@
import { FC } from "react";
import { Accordion } from "~/components/Accordion";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { EquipmentActions } from "~/subApps/builder/components/EquipmentActions";
import { useEquipmentMethods } from "~/subApps/builder/hooks/useEquipmentMethods";
import { Item } from "~/types";
import ItemDetail from "../../ItemDetail";
import { ItemListInformationCollapsible } from "../../ItemListInformationCollapsible";
import { EquipmentListItemActions } from "../EquipmentListItem";
interface WeaponListItemProps {
item: Item;
unequipLabel: string;
equipLabel: string;
showRemove?: boolean;
showEquip?: boolean;
showUnequip?: boolean;
showAttuning?: boolean;
showHeaderAction: boolean;
}
export const WeaponListItem: FC<WeaponListItemProps> = ({
item,
equipLabel,
unequipLabel,
...props
}) => {
const actions = useEquipmentMethods();
const {
ruleData,
proficiencyBonus,
characterTheme,
itemUtils,
helperUtils,
containerLookup,
} = useCharacterEngine();
const image = itemUtils.getAvatarUrl(item);
const altText = itemUtils.getName(item);
const type = itemUtils.getType(item);
const isOffhand = itemUtils.isOffhand(item);
const container = helperUtils.lookupDataOrFallback(
containerLookup,
itemUtils.getContainerDefinitionKey(item)
);
const getMetaItems = () => {
const { metaItems, isCustomized } = item;
return [
...(type ? [type] : []),
...(isOffhand ? ["Dual Wield"] : []),
...(isCustomized ? ["Customized"] : []),
...metaItems,
];
};
return (
<Accordion
summary={<ItemName item={item} showLegacyBadge />}
summaryAction={
<EquipmentActions
item={item}
equipLabel={equipLabel}
unequipLabel={unequipLabel}
/>
}
summaryImage={image}
summaryImageAlt={altText}
summaryMetaItems={getMetaItems()}
variant="paper"
>
<ItemListInformationCollapsible ruleData={ruleData} item={item} />
<ItemDetail
container={container}
theme={characterTheme}
item={item}
ruleData={ruleData}
showCustomize={false}
showActions={false}
proficiencyBonus={proficiencyBonus}
/>
<EquipmentListItemActions item={item} {...actions} {...props} />
</Accordion>
);
};
@@ -0,0 +1,228 @@
import React from "react";
import {
DisabledChevronDownSvg,
DisabledChevronUpSvg,
} from "@dndbeyond/character-components/es";
export class CollapsibleHeading extends React.PureComponent<{
className: string;
}> {
static defaultProps = {
className: "",
};
render() {
const { className, children } = this.props;
return (
<div className={["collapsible-heading", className].join(" ")}>
{children}
</div>
);
}
}
export class CollapsibleHeaderCallout extends React.PureComponent<{
extra: any;
value: any;
}> {
render() {
let { extra, value } = this.props;
return (
<span className="collapsible-header-callout">
<span className="collapsible-header-callout-extra">{extra}</span>
<span className="collapsible-header-callout-value">{value}</span>
</span>
);
}
}
export class CollapsibleHeader extends React.PureComponent<{
clsIdent?: string;
imgSrc?: string;
imgKey?: string;
iconClsNames: Array<string>;
heading: any;
metaItems: Array<string>;
callout?: any;
}> {
static defaultProps = {
iconClsNames: [],
metaItems: [],
};
constructMetaItems() {
let { metaItems } = this.props;
if (!metaItems) {
return null;
}
return (
<div className="collapsible-header-meta">
{metaItems.map((item, idx) => {
return (
<span className="collapsible-header-meta-item" key={item + idx}>
{item}
</span>
);
})}
</div>
);
}
render() {
const { clsIdent, heading, callout, imgSrc, imgKey, iconClsNames } =
this.props;
let calloutNode, headingNode;
if (typeof heading === "string") {
headingNode = (
<CollapsibleHeading className="collapsible-heading">
{heading}
</CollapsibleHeading>
);
} else {
headingNode = heading;
}
if (typeof callout === "string") {
calloutNode = (
<div className="collapsible-heading-callout">{callout}</div>
);
} else {
calloutNode = (
<div className="collapsible-heading-callout">{callout}</div>
);
}
//TODO clean this up and use all of the same thing
let iconStyles = {};
let allIconClsNames = ["collapsible-header-icon"];
let hasIcon = false;
if (imgSrc) {
iconStyles["backgroundImage"] = `url(${imgSrc})`;
hasIcon = true;
} else if (imgKey) {
allIconClsNames.push(`collapsible-header-icon-${imgKey}`);
hasIcon = true;
} else if (iconClsNames.length) {
allIconClsNames = [...allIconClsNames, ...iconClsNames];
hasIcon = true;
}
//TODO fix name to conClsNames for consistency
let conClassNames = [`collapsible-header-el`];
if (clsIdent) {
conClassNames.push(`collapsible-header-el-${clsIdent}`);
}
return (
<div className={conClassNames.join(" ")}>
{hasIcon && (
<div className={allIconClsNames.join(" ")} style={iconStyles} />
)}
<div className="collapsible-header-info">
{headingNode}
{this.constructMetaItems()}
</div>
{calloutNode}
</div>
);
}
}
export class Collapsible extends React.PureComponent<
{
trigger: any;
clsNames: Array<string>;
initiallyCollapsed: boolean;
onChange?: (isCollapsed: boolean) => void;
onOpened?: () => void;
onClosed?: () => void;
},
{
collapsed: boolean;
mouseOverHeading: boolean;
}
> {
static defaultProps = {
clsNames: [],
initiallyCollapsed: true,
};
constructor(props) {
super(props);
this.state = {
collapsed: props.initiallyCollapsed,
mouseOverHeading: false,
};
}
handleUpdate = () => {
const { onChange, onOpened, onClosed } = this.props;
const { collapsed } = this.state;
if (collapsed) {
if (onClosed) {
onClosed();
}
} else {
if (onOpened) {
onOpened();
}
}
if (onChange) {
onChange(collapsed);
}
};
handleClick = (evt) => {
this.setState(
(prevState, preProps) => ({
collapsed: !prevState.collapsed,
}),
this.handleUpdate
);
};
render() {
const { collapsed } = this.state;
const { trigger, clsNames, children } = this.props;
let headerContent = trigger;
if (typeof headerContent == "string") {
headerContent = (
<div className="collapsible-heading">{headerContent}</div>
);
}
let conClassNames = ["collapsible"];
if (clsNames) {
conClassNames = [...clsNames, ...conClassNames];
}
conClassNames.push(
this.state.collapsed ? "collapsible-collapsed" : "collapsible-opened"
);
let headerClassNames = ["collapsible-header"];
if (this.state.mouseOverHeading) {
headerClassNames.push("collapsible-header-over");
}
return (
<div className={conClassNames.join(" ")}>
<div className={headerClassNames.join(" ")} onClick={this.handleClick}>
<div className="collapsible-header-content">{headerContent}</div>
{collapsed ? <DisabledChevronDownSvg /> : <DisabledChevronUpSvg />}
</div>
{collapsed ? null : <div className="collapsible-body">{children}</div>}
</div>
);
}
}
export default Collapsible;
@@ -0,0 +1,13 @@
import Collapsible, {
CollapsibleHeader,
CollapsibleHeaderCallout,
CollapsibleHeading,
} from "./Collapsible";
export default Collapsible;
export {
Collapsible,
CollapsibleHeader,
CollapsibleHeading,
CollapsibleHeaderCallout,
};