Files
dndbeyond_src/ddb_main/tools/js/CharacterSheet/containers/Equipment/Equipment.tsx
T

745 lines
22 KiB
TypeScript

import clsx from "clsx";
import { useContext, ReactNode, PureComponent } from "react";
import React from "react";
import { connect } from "react-redux";
import { DarkBuilderSvg } from "@dndbeyond/character-components/es";
import {
CharacterNotes,
CharacterTheme,
Constants,
ContainerManager,
Creature,
CreatureUtils,
InfusionChoice,
InfusionChoiceUtils,
Item,
ItemUtils,
SnippetData,
RuleData,
rulesEngineSelectors,
ContainerUtils,
InventoryManager,
FormatUtils,
serviceDataSelectors,
PartyInfo,
CoinManager,
ClassUtils,
CharClass,
ItemPlan,
ItemPlanUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { ItemName } from "~/components/ItemName";
import { Link } from "~/components/Link";
import { NumberDisplay } from "~/components/NumberDisplay";
import { TabFilter } from "~/components/TabFilter";
import { TabList } from "~/components/TabList";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import a11yStyles from "~/styles/accessibility.module.css";
import { EquipmentOverview } from "~/subApps/sheet/components/Equipment/EquipmentOverview";
import { MagicItemPlans } from "~/subApps/sheet/components/MagicItemPlans";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import CurrencyButton from "../../../CharacterSheet/components/CurrencyButton";
import { ItemSlotManager } from "../../../Shared/components/ItemSlotManager";
import { ThemeButton } from "../../../Shared/components/common/Button";
import { CoinManagerContext } from "../../../Shared/managers/CoinManagerContext";
import { InventoryManagerContext } from "../../../Shared/managers/InventoryManagerContext";
import { appEnvSelectors } from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import ContentGroup from "../../components/ContentGroup";
import Infusions from "../../components/Infusions";
import InventoryFilter from "../../components/InventoryFilter";
import InventoryTableHeader from "../../components/InventoryTableHeader";
import OtherPossessions from "../../components/OtherPossessions";
import { sheetAppSelectors } from "../../selectors";
import { SheetAppState } from "../../typings";
import Attunement from "../Attunement";
import Inventory from "../Inventory";
import styles from "./styles.module.css";
interface Props {
showNotes: boolean;
builderUrl: string;
inventory: Array<Item>;
partyInventory: Array<Item>;
creatures: Array<Creature>;
notes: CharacterNotes;
infusionChoices: Array<InfusionChoice>;
ruleData: RuleData;
snippetData: SnippetData;
isReadonly: boolean;
isMobile: boolean;
theme: CharacterTheme;
proficiencyBonus: number;
classes: Array<CharClass>;
inventoryManager: InventoryManager;
campaignInfo: PartyInfo | null;
coinManager: CoinManager;
paneHistoryStart: PaneInfo["paneHistoryStart"];
itemPlans: Array<ItemPlan>;
maxReplicatedItemsCount: number | null;
}
interface StateFilterData {
filteredInventory: Array<Item>;
filteredPartyInventory: Array<Item>;
showAdvancedFilters: boolean;
isFiltering: boolean;
}
interface State {
infusableChoices: Array<InfusionChoice>;
filterData: StateFilterData;
filteredEquippedInventory: Array<Item>;
shouldShowPartyInventory: number;
}
class Equipment extends PureComponent<Props, State> {
static defaultProps = {
showNotes: true,
};
constructor(props: Props) {
super(props);
this.state = {
infusableChoices: props.infusionChoices.filter((infusionChoice) =>
InfusionChoiceUtils.canInfuse(infusionChoice)
),
filterData: {
filteredInventory: [],
filteredPartyInventory: [],
showAdvancedFilters: false,
isFiltering: false,
},
filteredEquippedInventory: [],
shouldShowPartyInventory: 0, // mui used 0,1,2 for tabs so 0 is off 1 is on...
};
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.infusionChoices !== this.props.infusionChoices) {
this.setState({
infusableChoices: this.props.infusionChoices.filter((infusionChoice) =>
InfusionChoiceUtils.canInfuse(infusionChoice)
),
});
}
}
handleCurrencyClick = (
evt: React.MouseEvent | React.KeyboardEvent,
containerDefinitionKey: string
): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(
PaneComponentEnum.CURRENCY,
PaneIdentifierUtils.generateCurrencyContext(containerDefinitionKey)
);
};
handlePossessionsManage = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(
PaneComponentEnum.NOTE_MANAGE,
PaneIdentifierUtils.generateNote(
Constants.NoteKeyEnum.PERSONAL_POSSESSIONS
)
);
}
};
handleManageClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.EQUIPMENT_MANAGE);
};
handleFilterUpdate = (filterData: StateFilterData): void => {
this.setState({
filterData,
filteredEquippedInventory: [
...filterData.filteredInventory.filter(ItemUtils.isEquipped),
...filterData.filteredPartyInventory.filter(ItemUtils.isEquipped),
],
});
};
handleInfusionChoiceShow = (infusionChoice: InfusionChoice): void => {
const { paneHistoryStart } = this.props;
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
paneHistoryStart(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
};
handleItemShow = (item: Item): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(ItemUtils.getMappingId(item))
);
};
handleContainerShow = (container: ContainerManager): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CONTAINER,
PaneIdentifierUtils.generateContainer(container.getDefinitionKey())
);
};
handleCreatureShow = (creature: Creature): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CREATURE,
PaneIdentifierUtils.generateCreature(CreatureUtils.getMappingId(creature))
);
};
handleItemPlansManagePaneShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.ITEM_PLANS_MANAGE);
};
shouldRenderInfusions = (): boolean => {
const { infusionChoices } = this.props;
return infusionChoices.length > 0;
};
shouldRenderMagicItemPlans = (): boolean => {
const { classes } = this.props;
return classes.some((charClass) =>
ClassUtils.getEnablesReplicateMagicItem(charClass)
);
};
renderContainer = (
container: ContainerManager,
inventory: Array<Item>
): ReactNode => {
const { shouldShowPartyInventory } = this.state;
const { isReadonly, showNotes, theme, coinManager } = this.props;
const containerMappingId = container.getMappingId();
const containerName = container.getName();
const weightInfo = container.getWeightInfo();
const containerInventory = container.getInventoryItems({
filteredInventory: inventory,
}).items;
const containerItem = container.getContainerItem();
const quantityNode = (
<span className="ct-equipment__container-quantity">{`(${containerInventory.length})`}</span>
);
const nameNode: ReactNode = containerItem ? (
<>
<ItemName item={containerItem.item} /> {quantityNode}
</>
) : (
<>
{containerName} {quantityNode}
</>
);
const headerNode: ReactNode = (
<div
className="ct-equipment__container ct-inventory-item"
onClick={(evt) => {
evt.nativeEvent.stopImmediatePropagation();
this.handleContainerShow(container);
}}
role="button"
data-testid={`${containerItem?.getName() || containerName}-section`
.toLowerCase()
.replace(/\s/g, "-")}
>
<div className="ct-equipment__container-action ct-inventory-item__action">
{containerItem && (
<ItemSlotManager
isUsed={!!containerItem.isEquipped()}
isReadonly={isReadonly}
canUse={
!(
containerItem.isEquipped() &&
!containerItem.isEquippedToCurrentCharacter()
)
}
onSet={(uses) => {
if (uses === 0) {
containerItem.handleUnequip();
}
if (uses === 1) {
containerItem.handleEquip();
}
}}
theme={theme}
useTooltip={false}
/>
)}
</div>
<div className="ct-equipment__container-name ct-inventory-item__name">
{nameNode}
</div>
<div className="ct-equipment__container-weight ct-inventory-item__weight">
<NumberDisplay type="weightInLb" number={weightInfo.applied} />
{weightInfo.capacity > 0 && (
<span className="ct-equipment__container-weight-capacity">
({Math.ceil(weightInfo.total)}/
{FormatUtils.renderWeight(weightInfo.capacity)})
</span>
)}
</div>
{coinManager.canUseCointainers() ? (
<div className="ct-equipment__container-coin">
<CurrencyButton
coin={ContainerUtils.getCoin(container.container)}
isDarkMode={theme.isDarkMode}
shouldShowPartyInventory={shouldShowPartyInventory === 1}
handleCurrencyClick={(evt) =>
this.handleCurrencyClick(
evt,
ContainerUtils.getDefinitionKey(container.container)
)
}
/>
</div>
) : (
containerItem &&
container.isEquipped() &&
!containerItem.isEquippedToCurrentCharacter() && (
<div className="ct-equipment__container-equipped">
Equipped by {containerItem.getEquippedCharacterName()}
</div>
)
)}
</div>
);
return (
<ContentGroup key={containerMappingId} header={headerNode}>
<Inventory
container={container}
inventory={containerInventory}
showNotes={showNotes}
showTableHeader={false}
isReadonly={isReadonly}
theme={theme}
/>
</ContentGroup>
);
};
renderCharacterContainers = (): ReactNode => {
const { filterData } = this.state;
const { inventoryManager } = this.props;
return inventoryManager
.getCharacterContainers()
.map((container) =>
this.renderContainer(container, filterData.filteredInventory)
);
};
renderPartyContainers = (): ReactNode => {
const { filterData } = this.state;
const { inventoryManager } = this.props;
return inventoryManager
.getPartyContainers()
.map((container) =>
this.renderContainer(container, filterData.filteredPartyInventory)
);
};
renderAttunement = (): ReactNode => {
const { isMobile } = this.props;
return (
<ContentGroup header="Attunement">
<Attunement isMobile={isMobile} />
</ContentGroup>
);
};
renderMagicItemPlans = (): ReactNode => {
const { isReadonly, itemPlans, maxReplicatedItemsCount } = this.props;
if (!this.shouldRenderMagicItemPlans()) {
return null;
}
const itemPlanWithMappingsCount = itemPlans.filter((plan) =>
ItemPlanUtils.getItem(plan)
).length;
const headerNode: ReactNode = (
<>
Magic Item Plans{" "}
{maxReplicatedItemsCount !== null && (
<span className={styles.replicatedCount}>
({itemPlanWithMappingsCount}/{maxReplicatedItemsCount} Replicated)
</span>
)}
</>
);
let extraNode: ReactNode = null;
if (!isReadonly) {
extraNode = (
<Button
variant="text"
themed
size="x-small"
className="ct-equipment__builder-link-text"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
this.handleItemPlansManagePaneShow();
}}
>
Manage Plans
</Button>
);
}
return (
<ContentGroup header={headerNode} extra={extraNode}>
<MagicItemPlans />
</ContentGroup>
);
};
renderInfusions = (): ReactNode => {
const { infusableChoices } = this.state;
const {
builderUrl,
infusionChoices,
isReadonly,
snippetData,
inventory,
partyInventory,
creatures,
ruleData,
proficiencyBonus,
theme,
} = this.props;
const headerNode: ReactNode = (
<>
Infusions{" "}
{infusableChoices.length !== infusionChoices.length && (
<>
({infusableChoices.length}/{infusionChoices.length} Known Infusions)
</>
)}
</>
);
let extraNode: ReactNode = null;
if (!isReadonly) {
extraNode = (
<Link
href={builderUrl}
className="ct-equipment__builder-link"
userouter
>
<DarkBuilderSvg />
<span className="ct-equipment__builder-link-text">
Manage Infusions
</span>
</Link>
);
}
return (
<ContentGroup header={headerNode} extra={extraNode}>
<Infusions
theme={theme}
infusionChoices={infusableChoices}
inventory={[...inventory, ...partyInventory]}
creatures={creatures}
snippetData={snippetData}
onCreatureShow={this.handleCreatureShow}
onItemShow={this.handleItemShow}
onInfusionChoiceShow={this.handleInfusionChoiceShow}
ruleData={ruleData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
/>
</ContentGroup>
);
};
renderOtherPossessions = (): ReactNode => {
const { notes, isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<ContentGroup header="Other Possessions">
<OtherPossessions
notes={notes}
onClick={this.handlePossessionsManage}
/>
</ContentGroup>
);
};
renderContent = (): ReactNode => {
const {
isReadonly,
inventoryManager,
inventory,
partyInventory,
showNotes,
} = this.props;
const { shouldShowPartyInventory } = this.state;
const headerNode: ReactNode = (
<div
className="ct-equipment__container"
onClick={(evt) => {
evt.nativeEvent.stopImmediatePropagation();
this.handleManageClick();
}}
>
Party Inventory
</div>
);
return (
<div className={styles.tabFilter}>
<TabFilter
sharedChildren={<InventoryTableHeader showNotes={showNotes} />}
filters={[
{
label: "All",
content: (
<>
{shouldShowPartyInventory === 0 ? (
<>
{this.renderCharacterContainers()}
{this.renderAttunement()}
{this.shouldRenderInfusions() && this.renderInfusions()}
{this.renderMagicItemPlans()}
{this.renderOtherPossessions()}
</>
) : null}
{shouldShowPartyInventory === 1 &&
inventoryManager.getPartyContainers().length > 0 ? (
<ContentGroup header={headerNode}>
{this.renderPartyContainers()}
</ContentGroup>
) : null}
</>
),
},
...(shouldShowPartyInventory === 0
? inventoryManager
.getCharacterContainers()
.map((characterContainer) => ({
label: characterContainer.getName(),
content: this.renderContainer(
characterContainer,
inventory
),
}))
: []),
...(shouldShowPartyInventory === 1
? inventoryManager.getPartyContainers().map((partyContainer) => ({
label: partyContainer.getName(),
content: this.renderContainer(partyContainer, partyInventory),
}))
: []),
...(shouldShowPartyInventory === 0
? [
{
label: "Attunement",
content: this.renderAttunement(),
},
...(this.shouldRenderInfusions()
? [
{
label: "Infusions",
content: this.renderInfusions(),
},
]
: []),
...(this.shouldRenderMagicItemPlans()
? [
{
label: "Magic Item Plans",
content: this.renderMagicItemPlans(),
},
]
: []),
...(!isReadonly
? [
{
label: "Other Possessions",
content: this.renderOtherPossessions(),
},
]
: []),
]
: []),
]}
showAllTab={false}
/>
</div>
);
};
renderManageButton = (): ReactNode => {
const { isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<ThemeButton
style="outline"
size="medium"
onClick={this.handleManageClick}
data-testid="open-manage-equipment"
>
Manage Inventory
</ThemeButton>
);
};
toggleShouldShowPartyInventory = (event, newValue): void => {
this.setState({
shouldShowPartyInventory: newValue,
});
};
renderOverviewFiltersAndContent = (): ReactNode => {
const { filterData, shouldShowPartyInventory } = this.state;
const { inventory, ruleData, theme, partyInventory } = this.props;
return (
<>
<div className="ct-equipment__overview">
<EquipmentOverview
activeEquipmentTab={
shouldShowPartyInventory === 1 ? "party" : "character"
}
/>
</div>
<div className="ct-equipment__filter">
<InventoryFilter
partyInventory={partyInventory}
inventory={inventory}
ruleData={ruleData}
onDataUpdate={this.handleFilterUpdate}
callout={this.renderManageButton()}
theme={theme}
/>
</div>
{!filterData.showAdvancedFilters && this.renderContent()}
</>
);
};
handleChangeTab = (id) =>
this.setState({
shouldShowPartyInventory: id === "party-inventory" ? 1 : 0,
});
render() {
const { campaignInfo } = this.props;
return (
<section className={clsx(["ct-equipment", styles.section])}>
<h2 className={a11yStyles.screenreaderOnly}>Inventory</h2>
{campaignInfo !== null ? (
<TabList
className={styles.tabList}
variant="fullwidth"
themed={true}
tabs={[
{
label: "My Inventory",
content: this.renderOverviewFiltersAndContent(),
id: "my-inventory",
},
{
label: "Party Inventory",
content: this.renderOverviewFiltersAndContent(),
id: "party-inventory",
},
]}
onChangeTab={this.handleChangeTab}
/>
) : (
this.renderOverviewFiltersAndContent()
)}
</section>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
builderUrl: sheetAppSelectors.getBuilderUrl(state),
inventory: rulesEngineSelectors.getInventory(state),
partyInventory: rulesEngineSelectors.getPartyInventory(state),
creatures: rulesEngineSelectors.getCreatures(state),
notes: rulesEngineSelectors.getCharacterNotes(state),
infusionChoices: rulesEngineSelectors.getAvailableInfusionChoices(state),
ruleData: rulesEngineSelectors.getRuleData(state),
snippetData: rulesEngineSelectors.getSnippetData(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
isMobile: appEnvSelectors.getIsMobile(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
classes: rulesEngineSelectors.getClasses(state),
campaignInfo: serviceDataSelectors.getPartyInfo(state),
itemPlans: rulesEngineSelectors.getAvailableItemPlans(state),
maxReplicatedItemsCount:
rulesEngineSelectors.getMaxReplicatedItemsCount(state),
};
}
const EquipmentContainer = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const { coinManager } = useContext(CoinManagerContext);
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<Equipment
inventoryManager={inventoryManager}
coinManager={coinManager}
paneHistoryStart={paneHistoryStart}
{...props}
/>
);
};
export default connect(mapStateToProps)(EquipmentContainer);