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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+560
@@ -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,
|
||||
};
|
||||
+581
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user