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,63 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
FeatureManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import { PaneIdentifiersBlessing } from "~/subApps/sheet/components/Sidebar/types";
|
||||
import { Snippet } from "~/tools/js/smartComponents";
|
||||
|
||||
import { CharacterFeaturesManagerContext } from "../../../managers/CharacterFeaturesManagerContext";
|
||||
|
||||
interface Props {
|
||||
identifiers: PaneIdentifiersBlessing | null;
|
||||
}
|
||||
const BlessingPane: React.FC<Props> = ({ identifiers }) => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
|
||||
const snippetData = useSelector(rulesEngineSelectors.getSnippetData);
|
||||
// const ruleData = useSelector(rulesEngineSelectors.getRuleData);
|
||||
// const abilityLookup = useSelector(rulesEngineSelectors.getAbilityLookup);
|
||||
// const dataOriginRefData = useSelector(rulesEngineSelectors.getDataOriginRefData);
|
||||
// const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
const proficiencyBonus = useSelector(
|
||||
rulesEngineSelectors.getProficiencyBonus
|
||||
);
|
||||
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
|
||||
|
||||
const [blessing, setBlessing] = useState<FeatureManager | null>(undefined!);
|
||||
|
||||
useEffect(() => {
|
||||
const id = identifiers?.id ?? null;
|
||||
|
||||
if (id) {
|
||||
const blessing = characterFeaturesManager.getBlessing(id);
|
||||
setBlessing(blessing);
|
||||
}
|
||||
}, [identifiers, characterFeaturesManager]);
|
||||
|
||||
if (blessing === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
|
||||
return blessing ? (
|
||||
<div className="ct-blessing-pane" key={blessing.getId()}>
|
||||
<Header>{blessing.getName()}</Header>
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
>
|
||||
{blessing.getDescription()}
|
||||
</Snippet>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default BlessingPane;
|
||||
@@ -0,0 +1,4 @@
|
||||
import BlessingPane from "./BlessingPane";
|
||||
|
||||
export default BlessingPane;
|
||||
export { BlessingPane };
|
||||
@@ -0,0 +1,341 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
BaseInventoryContract,
|
||||
rulesEngineSelectors,
|
||||
characterActions,
|
||||
ClassFeatureContract,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DataOriginRefData,
|
||||
EntityUtils,
|
||||
EntityValueLookup,
|
||||
FeatDetailsContract,
|
||||
FeatUtils,
|
||||
FormatUtils,
|
||||
Hack__BaseCharClass,
|
||||
HelperUtils,
|
||||
ItemUtils,
|
||||
InventoryLookup,
|
||||
RacialTraitContract,
|
||||
RacialTraitUtils,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellUtils,
|
||||
ValueUtils,
|
||||
AnySimpleDataType,
|
||||
RuleData,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { EditableName } from "~/components/EditableName";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiers,
|
||||
PaneIdentifiersCharacterSpell,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import SpellDetail from "../../../components/SpellDetail";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { PaneIdentifierUtils } from "../../../utils";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
spells: Array<Spell>;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
ruleData: RuleData;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
inventoryLookup: InventoryLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
identifiers: PaneIdentifiersCharacterSpell | null;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
interface State {
|
||||
spell: Spell | null;
|
||||
isCustomizeClosed: boolean;
|
||||
}
|
||||
class CharacterSpellPane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props, true);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { spells, identifiers } = this.props;
|
||||
|
||||
if (spells !== prevProps.spells || identifiers !== prevProps.identifiers) {
|
||||
this.setState(
|
||||
this.generateStateData(this.props, prevState.isCustomizeClosed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props, isCustomizeClosed: boolean): State => {
|
||||
const { spells, identifiers } = props;
|
||||
|
||||
let foundSpell: Spell | null | undefined = null;
|
||||
if (identifiers !== null) {
|
||||
foundSpell = spells.find(
|
||||
(spell) => identifiers.id === SpellUtils.getMappingId(spell)
|
||||
);
|
||||
}
|
||||
return {
|
||||
spell: foundSpell ? foundSpell : null,
|
||||
isCustomizeClosed,
|
||||
};
|
||||
};
|
||||
|
||||
handleOpenCustomize = () => {
|
||||
this.setState({ isCustomizeClosed: !this.state.isCustomizeClosed });
|
||||
};
|
||||
|
||||
handleItemShow = (): void => {
|
||||
const { spell } = this.state;
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
if (spell) {
|
||||
const dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.ITEM_DETAIL,
|
||||
PaneIdentifierUtils.generateItem(
|
||||
ItemUtils.getMappingId(dataOrigin.primary as BaseInventoryContract)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleCustomDataUpdate = (
|
||||
key: number,
|
||||
value: AnySimpleDataType,
|
||||
source: string | null
|
||||
): void => {
|
||||
const { spell } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (spell) {
|
||||
dispatch(
|
||||
characterActions.valueSet(
|
||||
key,
|
||||
value,
|
||||
source,
|
||||
ValueUtils.hack__toString(SpellUtils.getMappingId(spell)),
|
||||
ValueUtils.hack__toString(SpellUtils.getMappingEntityTypeId(spell))
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveCustomizations = (): void => {
|
||||
const { spell } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (spell) {
|
||||
let mappingId = SpellUtils.getMappingId(spell);
|
||||
let mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
|
||||
if (mappingId !== null && mappingEntityTypeId !== null) {
|
||||
let [contextId, contextTypeId] =
|
||||
SpellUtils.deriveExpandedContextIds(spell);
|
||||
|
||||
dispatch(
|
||||
characterActions.spellCustomizationsDelete(
|
||||
mappingId,
|
||||
mappingEntityTypeId,
|
||||
contextId,
|
||||
contextTypeId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleParentClick = (): void => {
|
||||
const { spell } = this.state;
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
if (spell === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let componentType: PaneComponentEnum | null = null;
|
||||
let componentIds: PaneIdentifiers | null = null;
|
||||
|
||||
const spellDataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
const spellDataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
switch (spellDataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
|
||||
componentType = PaneComponentEnum.CLASS_FEATURE_DETAIL;
|
||||
componentIds = PaneIdentifierUtils.generateClassFeature(
|
||||
ClassFeatureUtils.getId(
|
||||
spellDataOrigin.primary as ClassFeatureContract
|
||||
),
|
||||
ClassUtils.getMappingId(spellDataOrigin.parent as Hack__BaseCharClass)
|
||||
);
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.RACE:
|
||||
componentType = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
|
||||
componentIds = PaneIdentifierUtils.generateRacialTrait(
|
||||
RacialTraitUtils.getId(spellDataOrigin.primary as RacialTraitContract)
|
||||
);
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.FEAT:
|
||||
componentType = PaneComponentEnum.FEAT_DETAIL;
|
||||
componentIds = PaneIdentifierUtils.generateFeat(
|
||||
FeatUtils.getId(spellDataOrigin.primary as FeatDetailsContract)
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
if (componentType !== null && componentIds !== null) {
|
||||
paneHistoryPush(componentType, componentIds);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { spell, isCustomizeClosed } = this.state;
|
||||
const {
|
||||
spellCasterInfo,
|
||||
identifiers,
|
||||
ruleData,
|
||||
entityValueLookup,
|
||||
inventoryLookup,
|
||||
dataOriginRefData,
|
||||
isReadonly,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (spell === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
|
||||
let parentNode: React.ReactNode;
|
||||
const spellDataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
const spellDataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
|
||||
switch (spellDataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.ITEM:
|
||||
const mappingId = ItemUtils.getMappingId(
|
||||
spellDataOrigin.primary as BaseInventoryContract
|
||||
);
|
||||
const item = HelperUtils.lookupDataOrFallback(
|
||||
inventoryLookup,
|
||||
mappingId
|
||||
);
|
||||
if (item !== null) {
|
||||
parentNode = <ItemName item={item} onClick={this.handleItemShow} />;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (spellDataOrigin.primary !== null) {
|
||||
parentNode = (
|
||||
<div onClick={this.handleParentClick}>
|
||||
{EntityUtils.getDataOriginName(spellDataOrigin)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let school = SpellUtils.getSchool(spell);
|
||||
let schoolSlug = FormatUtils.slugify(school);
|
||||
const dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
const dataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
return (
|
||||
<div className="ct-spell-pane">
|
||||
<Header
|
||||
parent={parentNode}
|
||||
preview={
|
||||
<Preview>
|
||||
<div
|
||||
className={`ct-spell-pane__heading-preview ct-spell-pane__heading-preview--school-${schoolSlug}`}
|
||||
/>
|
||||
</Preview>
|
||||
}
|
||||
>
|
||||
{
|
||||
//If the spell is coming from a magic item we should not show the Edit button
|
||||
!dataOrigin ||
|
||||
(dataOrigin &&
|
||||
dataOriginType === Constants.DataOriginTypeEnum.ITEM) ? (
|
||||
<SpellName
|
||||
spell={spell}
|
||||
showSpellLevel={false}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
/>
|
||||
) : (
|
||||
<EditableName onClick={this.handleOpenCustomize}>
|
||||
<SpellName
|
||||
spell={spell}
|
||||
showSpellLevel={false}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
/>
|
||||
</EditableName>
|
||||
)
|
||||
}
|
||||
</Header>
|
||||
<SpellDetail
|
||||
theme={theme}
|
||||
key={SpellUtils.getUniqueKey(spell)}
|
||||
spell={spell}
|
||||
spellCasterInfo={spellCasterInfo}
|
||||
initialCastLevel={identifiers?.castLevel}
|
||||
ruleData={ruleData}
|
||||
onCustomDataUpdate={this.handleCustomDataUpdate}
|
||||
onCustomizationsRemove={this.handleRemoveCustomizations}
|
||||
entityValueLookup={entityValueLookup}
|
||||
isReadonly={isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
isCustomizeClosed={isCustomizeClosed}
|
||||
onCustomizeClick={this.handleOpenCustomize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
spells: rulesEngineSelectors.getCharacterSpells(state),
|
||||
spellCasterInfo: rulesEngineSelectors.getSpellCasterInfo(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
entityValueLookup:
|
||||
rulesEngineSelectors.getCharacterValueLookupByEntity(state),
|
||||
inventoryLookup: rulesEngineSelectors.getInventoryLookup(state),
|
||||
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
const CharacterSpellPaneContainer = (props) => {
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return <CharacterSpellPane paneHistoryPush={paneHistoryPush} {...props} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(CharacterSpellPaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import CharacterSpellPane from "./CharacterSpellPane";
|
||||
|
||||
export default CharacterSpellPane;
|
||||
export { CharacterSpellPane };
|
||||
@@ -0,0 +1,281 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
AnySimpleDataType,
|
||||
Hack__BaseCharClass,
|
||||
rulesEngineSelectors,
|
||||
characterActions,
|
||||
ClassUtils,
|
||||
DataOriginRefData,
|
||||
EntityUtils,
|
||||
EntityValueLookup,
|
||||
FormatUtils,
|
||||
RuleData,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellUtils,
|
||||
ValueUtils,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { EditableName } from "~/components/EditableName";
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import {
|
||||
getDataOriginComponentInfo,
|
||||
getDataOriginRefComponentInfo,
|
||||
} from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiersClassSpell,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import SpellDetail from "../../../components/SpellDetail";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
spells: Array<Spell>;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
ruleData: RuleData;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
identifiers: PaneIdentifiersClassSpell | null;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
interface State {
|
||||
spell: Spell | null;
|
||||
isCustomizeClosed: boolean;
|
||||
}
|
||||
class ClassSpellPane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props, true);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { spells, identifiers } = this.props;
|
||||
|
||||
if (spells !== prevProps.spells || identifiers !== prevProps.identifiers) {
|
||||
this.setState(
|
||||
this.generateStateData(this.props, prevState.isCustomizeClosed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props, isCustomizeClosed: boolean): State => {
|
||||
const { spells, identifiers } = props;
|
||||
|
||||
let foundSpell: Spell | null | undefined = null;
|
||||
if (identifiers !== null) {
|
||||
foundSpell = spells.find(
|
||||
(spell) =>
|
||||
identifiers.spellId === SpellUtils.getMappingId(spell) &&
|
||||
identifiers.classId ===
|
||||
ClassUtils.getMappingId(
|
||||
SpellUtils.getDataOrigin(spell).primary as Hack__BaseCharClass
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
spell: foundSpell ? foundSpell : null,
|
||||
isCustomizeClosed,
|
||||
};
|
||||
};
|
||||
|
||||
handleOpenCustomize = () => {
|
||||
this.setState({ isCustomizeClosed: !this.state.isCustomizeClosed });
|
||||
};
|
||||
|
||||
handleCustomDataUpdate = (
|
||||
key: number,
|
||||
value: AnySimpleDataType,
|
||||
source: string | null
|
||||
): void => {
|
||||
const { spell } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (spell === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mappingId = SpellUtils.getMappingId(spell);
|
||||
let mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
|
||||
let [contextId, contextTypeId] = SpellUtils.deriveExpandedContextIds(spell);
|
||||
|
||||
dispatch(
|
||||
characterActions.valueSet(
|
||||
key,
|
||||
value,
|
||||
source,
|
||||
ValueUtils.hack__toString(mappingId),
|
||||
ValueUtils.hack__toString(mappingEntityTypeId),
|
||||
ValueUtils.hack__toString(contextId),
|
||||
ValueUtils.hack__toString(contextTypeId)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
handleRemoveCustomizations = (): void => {
|
||||
const { spell } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (spell) {
|
||||
let mappingId = SpellUtils.getMappingId(spell);
|
||||
let mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
|
||||
if (mappingId !== null && mappingEntityTypeId !== null) {
|
||||
let [contextId, contextTypeId] =
|
||||
SpellUtils.deriveExpandedContextIds(spell);
|
||||
|
||||
dispatch(
|
||||
characterActions.spellCustomizationsDelete(
|
||||
mappingId,
|
||||
mappingEntityTypeId,
|
||||
contextId,
|
||||
contextTypeId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleParentClick = (): void => {
|
||||
const { spell } = this.state;
|
||||
const { paneHistoryPush, dataOriginRefData } = this.props;
|
||||
|
||||
if (spell === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
|
||||
let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell);
|
||||
if (expandedDataOriginRef !== null) {
|
||||
component = getDataOriginRefComponentInfo(
|
||||
expandedDataOriginRef,
|
||||
dataOriginRefData
|
||||
);
|
||||
}
|
||||
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { spell, isCustomizeClosed } = this.state;
|
||||
const {
|
||||
spellCasterInfo,
|
||||
identifiers,
|
||||
ruleData,
|
||||
entityValueLookup,
|
||||
dataOriginRefData,
|
||||
isReadonly,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (spell === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
|
||||
let school = SpellUtils.getSchool(spell);
|
||||
let schoolSlug = FormatUtils.slugify(school);
|
||||
|
||||
const spellDataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
|
||||
let parentNode: React.ReactNode =
|
||||
EntityUtils.getDataOriginName(spellDataOrigin);
|
||||
let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell);
|
||||
if (expandedDataOriginRef !== null) {
|
||||
parentNode = (
|
||||
<React.Fragment>
|
||||
{parentNode} •{" "}
|
||||
{EntityUtils.getDataOriginRefName(
|
||||
expandedDataOriginRef,
|
||||
dataOriginRefData
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spell-pane">
|
||||
<Header
|
||||
parent={parentNode}
|
||||
onClick={
|
||||
expandedDataOriginRef !== null ? this.handleParentClick : undefined
|
||||
}
|
||||
preview={
|
||||
<Preview>
|
||||
<div
|
||||
className={`ct-spell-pane__heading-preview ct-spell-pane__heading-preview--school-${schoolSlug}`}
|
||||
/>
|
||||
</Preview>
|
||||
}
|
||||
>
|
||||
<EditableName onClick={this.handleOpenCustomize}>
|
||||
<SpellName
|
||||
spell={spell}
|
||||
showSpellLevel={false}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
/>
|
||||
</EditableName>
|
||||
</Header>
|
||||
<SpellDetail
|
||||
theme={theme}
|
||||
key={SpellUtils.getUniqueKey(spell)}
|
||||
spell={spell}
|
||||
spellCasterInfo={spellCasterInfo}
|
||||
ruleData={ruleData}
|
||||
initialCastLevel={identifiers?.castLevel}
|
||||
showActions={false}
|
||||
onCustomDataUpdate={this.handleCustomDataUpdate}
|
||||
onCustomizationsRemove={this.handleRemoveCustomizations}
|
||||
entityValueLookup={entityValueLookup}
|
||||
isReadonly={isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
isCustomizeClosed={isCustomizeClosed}
|
||||
onCustomizeClick={this.handleOpenCustomize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
spells: rulesEngineSelectors.getClassSpells(state),
|
||||
spellCasterInfo: rulesEngineSelectors.getSpellCasterInfo(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
entityValueLookup:
|
||||
rulesEngineSelectors.getCharacterValueLookupByEntity(state),
|
||||
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
const ClassSpellPaneContainer = (props) => {
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return <ClassSpellPane paneHistoryPush={paneHistoryPush} {...props} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ClassSpellPaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClassSpellPane from "./ClassSpellPane";
|
||||
|
||||
export default ClassSpellPane;
|
||||
export { ClassSpellPane };
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
characterActions,
|
||||
ConditionUtils,
|
||||
Condition,
|
||||
Constants,
|
||||
ConditionContract,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
|
||||
import * as appEnvSelectors from "../../../selectors/appEnv";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import ConditionManagePaneSpecialConditions from "./ConditionManagePaneSpecialConditions";
|
||||
import ConditionManagePaneStandardConditions from "./ConditionManagePaneStandardConditions";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
activeConditions: Array<Condition>;
|
||||
ruleData: RuleData;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
class ConditionManagePane extends React.PureComponent<Props> {
|
||||
handleStandardConditionToggle = (
|
||||
condition: ConditionContract,
|
||||
isEnabled: boolean
|
||||
): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (isEnabled) {
|
||||
dispatch(characterActions.conditionAdd(ConditionUtils.getId(condition)));
|
||||
} else {
|
||||
dispatch(
|
||||
characterActions.conditionRemove(ConditionUtils.getId(condition))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleSpecialConditionLevelChange = (
|
||||
condition: ConditionContract,
|
||||
conditionLevel: number | null
|
||||
): void => {
|
||||
const { dispatch, activeConditions } = this.props;
|
||||
|
||||
const conditionId = ConditionUtils.getId(condition);
|
||||
if (conditionLevel === null) {
|
||||
dispatch(characterActions.conditionRemove(conditionId));
|
||||
} else {
|
||||
const foundActiveCondition = activeConditions.find(
|
||||
(activeCondition) =>
|
||||
ConditionUtils.getId(activeCondition) === conditionId
|
||||
);
|
||||
|
||||
if (foundActiveCondition) {
|
||||
dispatch(characterActions.conditionSet(conditionId, conditionLevel));
|
||||
} else {
|
||||
dispatch(characterActions.conditionAdd(conditionId, conditionLevel));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeConditions, ruleData, isReadonly, theme } = this.props;
|
||||
|
||||
const conditionData = RuleDataUtils.getConditions(ruleData);
|
||||
|
||||
const displayedConditions = orderBy(conditionData, (condition) =>
|
||||
ConditionUtils.getName(condition)
|
||||
);
|
||||
const activeConditionIds = activeConditions.map((condition) =>
|
||||
ConditionUtils.getId(condition)
|
||||
);
|
||||
|
||||
const standardConditions = displayedConditions.filter(
|
||||
(condition) =>
|
||||
ConditionUtils.getType(condition) ===
|
||||
Constants.ConditionTypeEnum.STANDARD
|
||||
);
|
||||
const specialConditions = displayedConditions.filter(
|
||||
(condition) =>
|
||||
ConditionUtils.getType(condition) ===
|
||||
Constants.ConditionTypeEnum.SPECIAL
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-condition-manage-pane">
|
||||
<Header>Conditions</Header>
|
||||
<div className="ct-condition-manage-pane__conditions">
|
||||
<ConditionManagePaneStandardConditions
|
||||
standardConditions={standardConditions}
|
||||
activeConditionIds={activeConditionIds}
|
||||
onConditionToggle={this.handleStandardConditionToggle}
|
||||
isReadonly={isReadonly}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-condition-manage-pane__conditions ct-condition-manage-pane__condition--special">
|
||||
<ConditionManagePaneSpecialConditions
|
||||
specialConditions={specialConditions}
|
||||
activeConditions={activeConditions}
|
||||
isReadonly={isReadonly}
|
||||
onConditionLevelChange={this.handleSpecialConditionLevelChange}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
activeConditions: rulesEngineSelectors.getActiveConditions(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ConditionManagePane);
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
ConditionIcon,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
ConditionContract,
|
||||
ConditionLevelUtils,
|
||||
ConditionUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { ProgressBar } from "~/subApps/sheet/components/Sidebar/components/ProgressBar";
|
||||
|
||||
import ConditionLevelsTable from "../../../../components/ConditionLevelsTable";
|
||||
|
||||
interface Props {
|
||||
condition: ConditionContract;
|
||||
conditionLevel: number | null;
|
||||
isActive: boolean;
|
||||
onConditionLevelChange: (
|
||||
condition: ConditionContract,
|
||||
level: number | null
|
||||
) => void;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ConditionManagePaneSpecialCondition extends React.PureComponent<Props> {
|
||||
handleLevelChange = (newLevel: number | null): void => {
|
||||
const { onConditionLevelChange, condition } = this.props;
|
||||
|
||||
onConditionLevelChange(condition, newLevel);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isActive, condition, conditionLevel, isReadonly, theme } =
|
||||
this.props;
|
||||
|
||||
let classNames: Array<string> = ["ct-condition-manage-pane__condition"];
|
||||
if (isActive) {
|
||||
classNames.push("ct-condition-manage-pane__condition--active");
|
||||
}
|
||||
|
||||
const level: React.ReactNode =
|
||||
conditionLevel !== null ? conditionLevel : "--";
|
||||
const name = ConditionUtils.getName(condition);
|
||||
|
||||
const headingNode: React.ReactNode = (
|
||||
<div className="ct-condition-manage-pane__condition-heading">
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-condition-manage-pane__condition-preview">
|
||||
<ConditionIcon
|
||||
conditionType={ConditionUtils.getId(condition)}
|
||||
isDarkMode={theme.isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-condition-manage-pane__condition-name">{name}</div>
|
||||
<div className="ct-condition-manage-pane__condition-level">
|
||||
Level {level}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
|
||||
const headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={headingNode} />
|
||||
);
|
||||
|
||||
const levels = ConditionUtils.getDefinitionLevels(condition);
|
||||
let levelNumbers = levels.map((level) =>
|
||||
ConditionLevelUtils.getLevel(level)
|
||||
);
|
||||
const levelEffectLookup = ConditionUtils.getLevelEffectLookup(condition);
|
||||
|
||||
const headerFooterNode: React.ReactNode = (
|
||||
<ProgressBar
|
||||
options={levelNumbers}
|
||||
value={conditionLevel}
|
||||
onClick={this.handleLevelChange}
|
||||
isInteractive={!isReadonly}
|
||||
/>
|
||||
);
|
||||
|
||||
const description = ConditionUtils.getDescription(condition);
|
||||
|
||||
return (
|
||||
<div key={ConditionUtils.getUniqueKey(condition)}>
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={headerNode}
|
||||
headerFooter={headerFooterNode}
|
||||
>
|
||||
<div className="ct-condition-manage-pane__condition-content">
|
||||
<HtmlContent
|
||||
className="ct-condition-manage-pane__condition-description"
|
||||
html={description ? description : ""}
|
||||
withoutTooltips
|
||||
/>
|
||||
<ConditionLevelsTable
|
||||
conditionName={name}
|
||||
levels={levelNumbers}
|
||||
activeLevel={conditionLevel}
|
||||
isInteractive={!isReadonly}
|
||||
onLevelChange={this.handleLevelChange}
|
||||
levelEffectLookup={levelEffectLookup}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ConditionManagePaneSpecialCondition from "./ConditionManagePaneSpecialCondition";
|
||||
|
||||
export default ConditionManagePaneSpecialCondition;
|
||||
export { ConditionManagePaneSpecialCondition };
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CharacterTheme,
|
||||
Condition,
|
||||
ConditionContract,
|
||||
ConditionUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ConditionManagePaneSpecialCondition from "../ConditionManagePaneSpecialCondition";
|
||||
|
||||
interface Props {
|
||||
specialConditions: Array<ConditionContract>;
|
||||
activeConditions: Array<Condition>;
|
||||
isReadonly: boolean;
|
||||
onConditionLevelChange: (
|
||||
condition: ConditionContract,
|
||||
level: number | null
|
||||
) => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ConditionManagePaneSpecialConditions extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
specialConditions,
|
||||
activeConditions,
|
||||
isReadonly,
|
||||
onConditionLevelChange,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{specialConditions.map((condition) => {
|
||||
let activeCondition = activeConditions.find(
|
||||
(activeCondition) =>
|
||||
ConditionUtils.getId(activeCondition) ===
|
||||
ConditionUtils.getId(condition)
|
||||
);
|
||||
let conditionLevel: number | null = activeCondition
|
||||
? ConditionUtils.getActiveLevel(activeCondition)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ConditionManagePaneSpecialCondition
|
||||
key={ConditionUtils.getUniqueKey(condition)}
|
||||
isActive={!!activeCondition}
|
||||
condition={condition}
|
||||
onConditionLevelChange={onConditionLevelChange}
|
||||
conditionLevel={conditionLevel}
|
||||
isReadonly={isReadonly}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ConditionManagePaneSpecialConditions from "./ConditionManagePaneSpecialConditions";
|
||||
|
||||
export default ConditionManagePaneSpecialConditions;
|
||||
export { ConditionManagePaneSpecialConditions };
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
ConditionContract,
|
||||
ConditionUtils,
|
||||
FormatUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import ConditionIcon from "~/tools/js/smartComponents/Icons/ConditionIcon";
|
||||
|
||||
interface Props {
|
||||
condition: ConditionContract;
|
||||
isActive: boolean;
|
||||
onConditionToggle: (condition: ConditionContract, isEnabled: boolean) => void;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ConditionManagePaneStandardCondition extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { isActive, condition, onConditionToggle, isReadonly, theme } =
|
||||
this.props;
|
||||
|
||||
let classNames: Array<string> = ["ct-condition-manage-pane__condition"];
|
||||
if (isActive) {
|
||||
classNames.push("ct-condition-manage-pane__condition--active");
|
||||
}
|
||||
|
||||
const name = ConditionUtils.getName(condition);
|
||||
const id = ConditionUtils.getId(condition);
|
||||
|
||||
const headingNode: React.ReactNode = (
|
||||
<div className="ct-condition-manage-pane__condition-heading">
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-condition-manage-pane__condition-preview">
|
||||
<ConditionIcon conditionType={id} isDarkMode={theme.isDarkMode} />
|
||||
</div>
|
||||
<div className="ct-condition-manage-pane__condition-name">{name}</div>
|
||||
<div className="ct-condition-manage-pane__condition-toggle">
|
||||
<Toggle
|
||||
checked={isActive}
|
||||
onClick={onConditionToggle.bind(this, condition)}
|
||||
color="themed"
|
||||
aria-label={`Enable ${name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
const headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={headingNode} />
|
||||
);
|
||||
|
||||
const description = ConditionUtils.getDescription(condition);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={headerNode}
|
||||
key={ConditionUtils.getUniqueKey(condition)}
|
||||
>
|
||||
<div className="ct-condition-manage-pane__condition-content">
|
||||
<HtmlContent
|
||||
className="ct-condition-manage-pane__condition-description"
|
||||
html={description ? description : ""}
|
||||
withoutTooltips
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ConditionManagePaneStandardCondition from "./ConditionManagePaneStandardCondition";
|
||||
|
||||
export default ConditionManagePaneStandardCondition;
|
||||
export { ConditionManagePaneStandardCondition };
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CharacterTheme,
|
||||
ConditionContract,
|
||||
ConditionUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ConditionManagePaneStandardCondition from "../ConditionManagePaneStandardCondition";
|
||||
|
||||
interface Props {
|
||||
standardConditions: Array<ConditionContract>;
|
||||
activeConditionIds: Array<number>;
|
||||
onConditionToggle: (condition: ConditionContract, isEnabled: boolean) => void;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ConditionManagePaneStandardConditions extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
standardConditions,
|
||||
activeConditionIds,
|
||||
onConditionToggle,
|
||||
isReadonly,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{standardConditions.map((condition) => {
|
||||
let isActive = activeConditionIds.includes(
|
||||
ConditionUtils.getId(condition)
|
||||
);
|
||||
|
||||
return (
|
||||
<ConditionManagePaneStandardCondition
|
||||
key={ConditionUtils.getUniqueKey(condition)}
|
||||
isActive={isActive}
|
||||
condition={condition}
|
||||
onConditionToggle={onConditionToggle}
|
||||
isReadonly={isReadonly}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ConditionManagePaneStandardConditions from "./ConditionManagePaneStandardConditions";
|
||||
|
||||
export default ConditionManagePaneStandardConditions;
|
||||
export { ConditionManagePaneStandardConditions };
|
||||
@@ -0,0 +1,14 @@
|
||||
import ConditionManagePane from "./ConditionManagePane";
|
||||
import ConditionManagePaneSpecialCondition from "./ConditionManagePaneSpecialCondition";
|
||||
import ConditionManagePaneSpecialConditions from "./ConditionManagePaneSpecialConditions";
|
||||
import ConditionManagePaneStandardCondition from "./ConditionManagePaneStandardCondition";
|
||||
import ConditionManagePaneStandardConditions from "./ConditionManagePaneStandardConditions";
|
||||
|
||||
export default ConditionManagePane;
|
||||
export {
|
||||
ConditionManagePane,
|
||||
ConditionManagePaneSpecialCondition,
|
||||
ConditionManagePaneSpecialConditions,
|
||||
ConditionManagePaneStandardCondition,
|
||||
ConditionManagePaneStandardConditions,
|
||||
};
|
||||
@@ -0,0 +1,513 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { Collapsible } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiResponse,
|
||||
BaseItemDefinitionContract,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
Container,
|
||||
ContainerLookup,
|
||||
ContainerUtils,
|
||||
DataOrigin,
|
||||
EntityValueLookup,
|
||||
InventoryManager,
|
||||
Infusion,
|
||||
InfusionChoiceLookup,
|
||||
InfusionUtils,
|
||||
Item,
|
||||
ItemUtils,
|
||||
Modifier,
|
||||
PartyInfo,
|
||||
RuleData,
|
||||
rulesEngineSelectors,
|
||||
serviceDataSelectors,
|
||||
SnippetData,
|
||||
Spell,
|
||||
TypeValueLookup,
|
||||
WeaponSpellDamageGroup,
|
||||
ItemManager,
|
||||
ContainerManager,
|
||||
CharacterCurrencyContract,
|
||||
FormatUtils,
|
||||
CoinManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { EditableName } from "~/components/EditableName";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { Link } from "~/components/Link";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import {
|
||||
getDataOriginComponentInfo,
|
||||
getSpellComponentInfo,
|
||||
} from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiersContainer,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import { CURRENCY_VALUE } from "../../../../Shared/constants/App";
|
||||
import { CoinManagerContext } from "../../../../Shared/managers/CoinManagerContext";
|
||||
import * as toastActions from "../../../actions/toastMessage/actions";
|
||||
import ContainerActions from "../../../components/ContainerActions";
|
||||
import ItemDetail from "../../../components/ItemDetail";
|
||||
import { InventoryManagerContext } from "../../../managers/InventoryManagerContext";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { AppNotificationUtils, PaneIdentifierUtils } from "../../../utils";
|
||||
import ItemDetailActions from "../../ItemDetailActions";
|
||||
import { CurrencyErrorTypeEnum } from "../CurrencyPane/CurrencyPaneConstants";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
|
||||
ruleData: RuleData;
|
||||
snippetData: SnippetData;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
infusionChoiceLookup: InfusionChoiceLookup;
|
||||
identifiers: PaneIdentifiersContainer | null;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
containers: Array<Container>;
|
||||
globalModifiers: Array<Modifier>;
|
||||
valueLookupByType: TypeValueLookup;
|
||||
inventory: Array<Item>;
|
||||
containerLookup: ContainerLookup;
|
||||
partyInfo: PartyInfo | null;
|
||||
inventoryManager: InventoryManager;
|
||||
coinManager: CoinManager;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
interface State {
|
||||
item: ItemManager | null;
|
||||
currentContainer: ContainerManager | null;
|
||||
isCustomizeClosed: boolean;
|
||||
}
|
||||
class ContainerPane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props, true);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { inventory, identifiers, containerLookup } = this.props;
|
||||
|
||||
if (
|
||||
inventory !== prevProps.inventory ||
|
||||
containerLookup !== prevProps.containerLookup ||
|
||||
identifiers !== prevProps.identifiers
|
||||
) {
|
||||
this.setState(
|
||||
this.generateStateData(this.props, prevState.isCustomizeClosed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props, isCustomizeClosed: boolean): State => {
|
||||
const { identifiers, inventoryManager } = props;
|
||||
|
||||
let foundItem: ItemManager | null = null;
|
||||
let foundContainer: ContainerManager | null = null;
|
||||
if (identifiers?.containerDefinitionKey) {
|
||||
foundContainer = inventoryManager.getContainer(
|
||||
identifiers.containerDefinitionKey
|
||||
);
|
||||
foundItem = foundContainer ? foundContainer.getContainerItem() : null;
|
||||
}
|
||||
|
||||
return {
|
||||
item: foundItem ?? null,
|
||||
currentContainer: foundContainer ?? null,
|
||||
isCustomizeClosed,
|
||||
};
|
||||
};
|
||||
|
||||
handleCustomDataUpdate = (
|
||||
adjustmentType: Constants.AdjustmentTypeEnum,
|
||||
value: any
|
||||
): void => {
|
||||
const { item } = this.state;
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (item) {
|
||||
inventoryManager.handleCustomizationSet({
|
||||
item: item.item,
|
||||
adjustmentType,
|
||||
value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveCustomizations = (): void => {
|
||||
const { item } = this.state;
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (item) {
|
||||
inventoryManager.handleCustomizationsRemove({ item: item.item });
|
||||
}
|
||||
};
|
||||
|
||||
handleAdd = (
|
||||
item: Item,
|
||||
amount: number,
|
||||
containerDefinitionKey: string
|
||||
): void => {
|
||||
const { inventoryManager } = this.props;
|
||||
inventoryManager.handleAdd(
|
||||
{ item, amount, containerDefinitionKey },
|
||||
AppNotificationUtils.handleItemAddAccepted.bind(this, item, amount),
|
||||
AppNotificationUtils.handleItemAddRejected.bind(this, item, amount)
|
||||
);
|
||||
};
|
||||
|
||||
handleItemMove = (item: Item, containerDefinitionKey: string): void => {
|
||||
const { inventoryManager } = this.props;
|
||||
inventoryManager.handleMove({ item, containerDefinitionKey });
|
||||
};
|
||||
|
||||
handleItemSetEquip = (item: Item, uses: number): void => {
|
||||
const { inventoryManager } = this.props;
|
||||
//TODO need different component than SlotManager for item equipped/unequipped
|
||||
if (uses === 0) {
|
||||
inventoryManager.handleUnequip({ item });
|
||||
}
|
||||
|
||||
if (uses === 1) {
|
||||
inventoryManager.handleEquip({ item });
|
||||
}
|
||||
};
|
||||
|
||||
handleDataOriginClick = (dataOrigin: DataOrigin) => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
handleSpellClick = (spell: Spell): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
let component = getSpellComponentInfo(spell);
|
||||
if (component.type) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
handleInfusionClick = (infusion: Infusion): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
const choiceKey = InfusionUtils.getChoiceKey(infusion);
|
||||
if (choiceKey !== null) {
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.INFUSION_CHOICE,
|
||||
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleManageShow = (): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
paneHistoryPush(PaneComponentEnum.EQUIPMENT_MANAGE);
|
||||
};
|
||||
|
||||
handleStartingEquipmentShow = (): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
paneHistoryPush(PaneComponentEnum.STARTING_EQUIPMENT);
|
||||
};
|
||||
|
||||
handleCurrencyShow = (): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
paneHistoryPush(PaneComponentEnum.CURRENCY);
|
||||
};
|
||||
|
||||
handleEncumbranceShow = (): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
paneHistoryPush(PaneComponentEnum.ENCUMBRANCE);
|
||||
};
|
||||
|
||||
sortInventoryItems = (inventory: Array<Item>): Array<Item> => {
|
||||
return orderBy(
|
||||
inventory,
|
||||
[
|
||||
(item) => ItemUtils.getRarityLevel(item),
|
||||
(item) => ItemUtils.getName(item),
|
||||
(item) => ItemUtils.getMappingId(item),
|
||||
],
|
||||
["desc", "asc", "asc"]
|
||||
);
|
||||
};
|
||||
|
||||
handleOpenCustomize = () => {
|
||||
this.setState({ isCustomizeClosed: !this.state.isCustomizeClosed });
|
||||
};
|
||||
|
||||
hasCurrencyValueChanged = (
|
||||
value: number,
|
||||
currencyKey: keyof CharacterCurrencyContract,
|
||||
coin: CharacterCurrencyContract
|
||||
): boolean => {
|
||||
return value !== coin[currencyKey];
|
||||
};
|
||||
|
||||
handleCurrencyChangeError = (
|
||||
currencyName: string,
|
||||
errorType: CurrencyErrorTypeEnum
|
||||
): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
let message: string = "";
|
||||
if (errorType === CurrencyErrorTypeEnum.MIN) {
|
||||
message =
|
||||
"Cannot set currency to a negative value, the previous amount has been set instead.";
|
||||
}
|
||||
|
||||
if (errorType === CurrencyErrorTypeEnum.MAX) {
|
||||
message = `The max amount allowed for each currency type is ${FormatUtils.renderLocaleNumber(
|
||||
CURRENCY_VALUE.MAX
|
||||
)}, the previous value has been set instead.`;
|
||||
}
|
||||
|
||||
if (errorType !== null) {
|
||||
dispatch(
|
||||
toastActions.toastError(
|
||||
`Unable to Set Currency: ${currencyName}`,
|
||||
message
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleCurrencyAdjust = (
|
||||
coin: Partial<CharacterCurrencyContract>,
|
||||
multiplier: 1 | -1,
|
||||
containerDefinitionKey: string
|
||||
): void => {
|
||||
const { coinManager, dispatch } = this.props;
|
||||
|
||||
let actionLabel: string = "";
|
||||
if (multiplier === 1) {
|
||||
actionLabel = "Add";
|
||||
} else if (multiplier === -1) {
|
||||
actionLabel = "Delete";
|
||||
}
|
||||
|
||||
coinManager.handleTransaction(
|
||||
{ coin, containerDefinitionKey, multiplier },
|
||||
() => {
|
||||
// dispatch(toastActions.toastSuccess(
|
||||
// `A ${containerDefinitionKey === coinManager.getPartyEquipmentContainerDefinitionKey() ? 'Party' : ''}Coin transaction was completed!`,
|
||||
// 'could list the updates?'
|
||||
// ));
|
||||
},
|
||||
() => {
|
||||
dispatch(
|
||||
toastActions.toastError(
|
||||
`Unable to make transaction: ${actionLabel} Coin`,
|
||||
"Cannot set currency to a negative value, the previous amount has been set instead."
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleAmountSet = (
|
||||
containerDefinitionKey: string,
|
||||
key: keyof CharacterCurrencyContract,
|
||||
amount: number
|
||||
): void => {
|
||||
const { coinManager } = this.props;
|
||||
const coin = coinManager.getContainerCoin(containerDefinitionKey);
|
||||
|
||||
if (coin && this.hasCurrencyValueChanged(amount, key, coin)) {
|
||||
coinManager.handleAmountSet({
|
||||
key,
|
||||
amount,
|
||||
containerDefinitionKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item, currentContainer, isCustomizeClosed } = this.state;
|
||||
const {
|
||||
weaponSpellDamageGroups,
|
||||
ruleData,
|
||||
snippetData,
|
||||
entityValueLookup,
|
||||
infusionChoiceLookup,
|
||||
isReadonly,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
containers,
|
||||
globalModifiers,
|
||||
valueLookupByType,
|
||||
inventory,
|
||||
identifiers,
|
||||
partyInfo,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
|
||||
if (currentContainer === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
|
||||
const shopOpenInitially = identifiers?.showAddItems ?? false;
|
||||
|
||||
const canCustomize = item
|
||||
? inventoryManager.canCustomizeItem(item.item)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="ct-container-pane">
|
||||
<Header
|
||||
preview={
|
||||
<Preview imageUrl={item ? item.getAvatarUrl() : undefined} />
|
||||
}
|
||||
>
|
||||
{item ? (
|
||||
<EditableName onClick={this.handleOpenCustomize}>
|
||||
<ItemName item={item.item} />
|
||||
</EditableName>
|
||||
) : (
|
||||
currentContainer.getName()
|
||||
)}
|
||||
</Header>
|
||||
{item && (
|
||||
<Collapsible
|
||||
header="Item Description"
|
||||
initiallyCollapsed={shopOpenInitially}
|
||||
overrideCollapsed={shopOpenInitially}
|
||||
className="ct-container-pane__content"
|
||||
>
|
||||
<ItemDetail
|
||||
className="ct-container-pane__item-detail"
|
||||
theme={theme}
|
||||
key={item.getUniqueKey()}
|
||||
item={item.item}
|
||||
weaponSpellDamageGroups={weaponSpellDamageGroups}
|
||||
ruleData={ruleData}
|
||||
snippetData={snippetData}
|
||||
onCustomDataUpdate={this.handleCustomDataUpdate}
|
||||
onCustomizationsRemove={this.handleRemoveCustomizations}
|
||||
onDataOriginClick={this.handleDataOriginClick}
|
||||
onSpellClick={this.handleSpellClick}
|
||||
onInfusionClick={this.handleInfusionClick}
|
||||
entityValueLookup={entityValueLookup}
|
||||
infusionChoiceLookup={infusionChoiceLookup}
|
||||
isReadonly={isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
showActions={false}
|
||||
showCustomize={canCustomize}
|
||||
container={currentContainer.container}
|
||||
isCustomizeClosed={isCustomizeClosed}
|
||||
onCustomizeClick={this.handleOpenCustomize}
|
||||
/>
|
||||
</Collapsible>
|
||||
)}
|
||||
<ContainerActions
|
||||
currentContainer={currentContainer.container}
|
||||
containers={containers}
|
||||
ruleData={ruleData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
globalModifiers={globalModifiers}
|
||||
valueLookupByType={valueLookupByType}
|
||||
onItemAdd={this.handleAdd}
|
||||
onItemMove={this.handleItemMove}
|
||||
onItemEquip={this.handleItemSetEquip}
|
||||
inventory={this.sortInventoryItems(
|
||||
ContainerUtils.getInventoryItems(
|
||||
currentContainer.container,
|
||||
inventory
|
||||
)
|
||||
)}
|
||||
shopOpenInitially={shopOpenInitially}
|
||||
isReadonly={isReadonly}
|
||||
partyInfo={partyInfo}
|
||||
handleCurrencyChangeError={this.handleCurrencyChangeError.bind(this)}
|
||||
handleCurrencyAdjust={this.handleCurrencyAdjust.bind(this)}
|
||||
handleAmountSet={this.handleAmountSet.bind(this)}
|
||||
/>
|
||||
{!isReadonly && item && <ItemDetailActions item={item.item} />}
|
||||
{!isReadonly && currentContainer.isCharacterContainer() && (
|
||||
<div className="ct-container-pane__links">
|
||||
<div className="ct-container-pane__link">
|
||||
<Link useTheme={true} onClick={this.handleManageShow}>
|
||||
Manage Inventory
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ct-container-pane__link">
|
||||
<Link useTheme={true} onClick={this.handleStartingEquipmentShow}>
|
||||
Starting Equipment
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ct-container-pane__link">
|
||||
<Link useTheme={true} onClick={this.handleCurrencyShow}>
|
||||
Currency
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ct-container-pane__link">
|
||||
<Link useTheme={true} onClick={this.handleEncumbranceShow}>
|
||||
Encumbrance
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
weaponSpellDamageGroups:
|
||||
rulesEngineSelectors.getWeaponSpellDamageGroups(state),
|
||||
entityValueLookup:
|
||||
rulesEngineSelectors.getCharacterValueLookupByEntity(state),
|
||||
snippetData: rulesEngineSelectors.getSnippetData(state),
|
||||
infusionChoiceLookup: rulesEngineSelectors.getInfusionChoiceLookup(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
containers: rulesEngineSelectors.getInventoryContainers(state),
|
||||
globalModifiers: rulesEngineSelectors.getValidGlobalModifiers(state),
|
||||
valueLookupByType:
|
||||
rulesEngineSelectors.getCharacterValueLookupByType(state),
|
||||
inventory: rulesEngineSelectors.getAllInventoryItems(state),
|
||||
containerLookup: rulesEngineSelectors.getContainerLookup(state),
|
||||
partyInfo: serviceDataSelectors.getPartyInfo(state),
|
||||
};
|
||||
}
|
||||
function ContainerPaneContainer(props) {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return (
|
||||
<ContainerPane
|
||||
inventoryManager={inventoryManager}
|
||||
coinManager={coinManager}
|
||||
paneHistoryPush={paneHistoryPush}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default connect(mapStateToProps)(ContainerPaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ContainerPane from "./ContainerPane";
|
||||
|
||||
export default ContainerPane;
|
||||
export { ContainerPane };
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useContext } from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { FeatureFlagContext } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
CharacterCurrencyContract,
|
||||
CharacterLifestyleContract,
|
||||
CoinManager,
|
||||
Container,
|
||||
ContainerUtils,
|
||||
FormatUtils,
|
||||
RuleData,
|
||||
rulesEngineSelectors,
|
||||
serviceDataSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { PaneIdentifiersCurrencyContext } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import CurrencyCollapsible from "../../../../CharacterSheet/components/CurrencyCollapsible";
|
||||
import Lifestyle from "../../../../CharacterSheet/components/Lifestyle";
|
||||
import SettingsButton from "../../../../CharacterSheet/components/SettingsButton";
|
||||
import * as toastActions from "../../../actions/toastMessage/actions";
|
||||
import { CURRENCY_VALUE } from "../../../constants/App";
|
||||
import { CoinManagerContext } from "../../../managers/CoinManagerContext";
|
||||
import * as appEnvSelectors from "../../../selectors/appEnv";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { SettingsContextsEnum } from "../SettingsPane/typings";
|
||||
import { CurrencyErrorTypeEnum } from "./CurrencyPaneConstants";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
lifestyle: CharacterLifestyleContract | null;
|
||||
coin: CharacterCurrencyContract;
|
||||
ruleData: RuleData;
|
||||
isReadonly: boolean;
|
||||
identifiers: PaneIdentifiersCurrencyContext | null;
|
||||
coinManager: CoinManager;
|
||||
characterContainers: Array<Container>;
|
||||
partyContainers: Array<Container>;
|
||||
}
|
||||
class CurrencyPane extends React.PureComponent<Props> {
|
||||
hasCurrencyValueChanged = (
|
||||
value: number,
|
||||
currencyKey: keyof CharacterCurrencyContract,
|
||||
coin: CharacterCurrencyContract
|
||||
): boolean => {
|
||||
return value !== coin[currencyKey];
|
||||
};
|
||||
|
||||
handleCurrencyChangeError = (
|
||||
currencyName: string,
|
||||
errorType: CurrencyErrorTypeEnum
|
||||
): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
let message: string = "";
|
||||
if (errorType === CurrencyErrorTypeEnum.MIN) {
|
||||
message =
|
||||
"Cannot set currency to a negative value, the previous amount has been set instead.";
|
||||
}
|
||||
|
||||
if (errorType === CurrencyErrorTypeEnum.MAX) {
|
||||
message = `The max amount allowed for each currency type is ${FormatUtils.renderLocaleNumber(
|
||||
CURRENCY_VALUE.MAX
|
||||
)}, the previous value has been set instead.`;
|
||||
}
|
||||
|
||||
if (errorType !== null) {
|
||||
dispatch(
|
||||
toastActions.toastError(
|
||||
`Unable to Set Currency: ${currencyName}`,
|
||||
message
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleCurrencyAdjust = (
|
||||
coin: Partial<CharacterCurrencyContract>,
|
||||
multiplier: 1 | -1,
|
||||
containerDefinitionKey: string
|
||||
): void => {
|
||||
const { coinManager, dispatch } = this.props;
|
||||
|
||||
let actionLabel: string = "";
|
||||
if (multiplier === 1) {
|
||||
actionLabel = "Add";
|
||||
} else if (multiplier === -1) {
|
||||
actionLabel = "Delete";
|
||||
}
|
||||
|
||||
coinManager.handleTransaction(
|
||||
{ coin, containerDefinitionKey, multiplier },
|
||||
() => {
|
||||
// dispatch(toastActions.toastSuccess(
|
||||
// `A ${containerDefinitionKey === coinManager.getPartyEquipmentContainerDefinitionKey() ? 'Party' : ''}Coin transaction was completed!`,
|
||||
// 'could list the updates?'
|
||||
// ));
|
||||
},
|
||||
() => {
|
||||
dispatch(
|
||||
toastActions.toastError(
|
||||
`Unable to make transaction: ${actionLabel} Coin`,
|
||||
"Cannot set currency to a negative value, the previous amount has been set instead."
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleAmountSet = (
|
||||
containerDefinitionKey: string,
|
||||
key: keyof CharacterCurrencyContract,
|
||||
amount: number
|
||||
): void => {
|
||||
const { coinManager } = this.props;
|
||||
const coin = coinManager.getContainerCoin(containerDefinitionKey);
|
||||
|
||||
if (coin && this.hasCurrencyValueChanged(amount, key, coin)) {
|
||||
coinManager.handleAmountSet({
|
||||
key,
|
||||
amount,
|
||||
containerDefinitionKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleLifestyleUpdate = (propertyKey: string, value: number | null): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(characterActions.lifestyleSet(value));
|
||||
};
|
||||
|
||||
renderCharacterCoin = (characterContainer: Container) => {
|
||||
const { isReadonly, ruleData, lifestyle } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
className="ct-currency-pane__subheader"
|
||||
>
|
||||
My Coin
|
||||
</div>
|
||||
<CurrencyCollapsible
|
||||
heading="Total (in gp)"
|
||||
initiallyCollapsed={false}
|
||||
isReadonly={isReadonly}
|
||||
container={characterContainer}
|
||||
handleCurrencyChangeError={this.handleCurrencyChangeError.bind(this)}
|
||||
handleCurrencyAdjust={this.handleCurrencyAdjust.bind(this)}
|
||||
handleAmountSet={this.handleAmountSet.bind(this)}
|
||||
/>
|
||||
<Lifestyle
|
||||
isReadonly={isReadonly}
|
||||
handleLifestyleUpdate={this.handleLifestyleUpdate.bind(this)}
|
||||
lifestyle={lifestyle}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithoutCointainers() {
|
||||
const {
|
||||
identifiers,
|
||||
coinManager,
|
||||
characterContainers,
|
||||
partyContainers,
|
||||
isReadonly,
|
||||
lifestyle,
|
||||
ruleData,
|
||||
} = this.props;
|
||||
|
||||
const containerDefinitionKeyContext =
|
||||
identifiers?.containerDefinitionKeyContext;
|
||||
const partyDefinitionKey =
|
||||
coinManager.getPartyEquipmentContainerDefinitionKey();
|
||||
|
||||
const characterContainer = characterContainers.find(
|
||||
(container) =>
|
||||
ContainerUtils.getDefinitionKey(container) ===
|
||||
coinManager.getCharacterContainerDefinitionKey()
|
||||
);
|
||||
const partyContainer = partyContainers.find(
|
||||
(container) =>
|
||||
ContainerUtils.getDefinitionKey(container) === partyDefinitionKey
|
||||
);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Consumer>
|
||||
{({ imsFlag }) => (
|
||||
<div className="ct-currency-pane">
|
||||
<Header
|
||||
callout={
|
||||
<SettingsButton
|
||||
context={SettingsContextsEnum.COIN}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Manage Coin
|
||||
</Header>
|
||||
{imsFlag &&
|
||||
coinManager.isSharingTurnedOnOrDeleteOnly() &&
|
||||
partyDefinitionKey ? (
|
||||
<React.Fragment>
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
className="ct-currency-pane__subheader"
|
||||
>
|
||||
My Coin
|
||||
</div>
|
||||
{characterContainer && (
|
||||
<CurrencyCollapsible
|
||||
heading={this.deriveCoinLabel(
|
||||
ContainerUtils.getName(characterContainer)
|
||||
)}
|
||||
initiallyCollapsed={
|
||||
containerDefinitionKeyContext !==
|
||||
ContainerUtils.getDefinitionKey(characterContainer)
|
||||
}
|
||||
isReadonly={isReadonly}
|
||||
container={characterContainer}
|
||||
handleCurrencyChangeError={this.handleCurrencyChangeError.bind(
|
||||
this
|
||||
)}
|
||||
handleCurrencyAdjust={this.handleCurrencyAdjust.bind(this)}
|
||||
handleAmountSet={this.handleAmountSet.bind(this)}
|
||||
/>
|
||||
)}
|
||||
<Lifestyle
|
||||
isReadonly={isReadonly}
|
||||
handleLifestyleUpdate={this.handleLifestyleUpdate.bind(this)}
|
||||
lifestyle={lifestyle}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
className="ct-currency-pane__subheader"
|
||||
>
|
||||
Party Coin
|
||||
</div>
|
||||
{partyContainer && (
|
||||
<CurrencyCollapsible
|
||||
heading={this.deriveCoinLabel(
|
||||
ContainerUtils.getName(partyContainer)
|
||||
)}
|
||||
initiallyCollapsed={
|
||||
containerDefinitionKeyContext !==
|
||||
ContainerUtils.getDefinitionKey(partyContainer)
|
||||
}
|
||||
isReadonly={isReadonly}
|
||||
container={partyContainer}
|
||||
handleCurrencyChangeError={this.handleCurrencyChangeError.bind(
|
||||
this
|
||||
)}
|
||||
handleCurrencyAdjust={this.handleCurrencyAdjust.bind(this)}
|
||||
handleAmountSet={this.handleAmountSet.bind(this)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
characterContainer && this.renderCharacterCoin(characterContainer)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FeatureFlagContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
deriveCoinLabel = (containerName): string => {
|
||||
switch (containerName) {
|
||||
case "Equipment":
|
||||
return "My Equipment";
|
||||
default:
|
||||
return containerName;
|
||||
}
|
||||
};
|
||||
|
||||
renderWithCointainers() {
|
||||
const {
|
||||
coinManager,
|
||||
partyContainers,
|
||||
identifiers,
|
||||
characterContainers,
|
||||
isReadonly,
|
||||
lifestyle,
|
||||
ruleData,
|
||||
} = this.props;
|
||||
const partyDefinitionKey =
|
||||
coinManager.getPartyEquipmentContainerDefinitionKey();
|
||||
const containerDefinitionKeyContext =
|
||||
identifiers?.containerDefinitionKeyContext;
|
||||
return (
|
||||
<FeatureFlagContext.Consumer>
|
||||
{({ imsFlag }) => (
|
||||
<div className="ct-currency-pane">
|
||||
<Header
|
||||
callout={
|
||||
<SettingsButton
|
||||
context={SettingsContextsEnum.COIN}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Manage Coin
|
||||
</Header>
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
className="ct-currency-pane__subheader"
|
||||
>
|
||||
My Coin
|
||||
</div>
|
||||
{characterContainers.map((characterContainer) =>
|
||||
ContainerUtils.getCoin(characterContainer) ? (
|
||||
<CurrencyCollapsible
|
||||
heading={this.deriveCoinLabel(
|
||||
ContainerUtils.getName(characterContainer)
|
||||
)}
|
||||
key={ContainerUtils.getDefinitionKey(characterContainer)}
|
||||
initiallyCollapsed={
|
||||
containerDefinitionKeyContext !==
|
||||
ContainerUtils.getDefinitionKey(characterContainer)
|
||||
}
|
||||
isReadonly={isReadonly}
|
||||
container={characterContainer}
|
||||
handleCurrencyChangeError={this.handleCurrencyChangeError.bind(
|
||||
this
|
||||
)}
|
||||
handleCurrencyAdjust={this.handleCurrencyAdjust.bind(this)}
|
||||
handleAmountSet={this.handleAmountSet.bind(this)}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
<Lifestyle
|
||||
isReadonly={isReadonly}
|
||||
handleLifestyleUpdate={this.handleLifestyleUpdate.bind(this)}
|
||||
lifestyle={lifestyle}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
{imsFlag &&
|
||||
coinManager.isSharingTurnedOnOrDeleteOnly() &&
|
||||
partyDefinitionKey && (
|
||||
<>
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
className="ct-currency-pane__subheader"
|
||||
>
|
||||
Party Coin
|
||||
</div>
|
||||
{partyContainers.map((partyContainer) =>
|
||||
ContainerUtils.getCoin(partyContainer) ? (
|
||||
<CurrencyCollapsible
|
||||
heading={this.deriveCoinLabel(
|
||||
ContainerUtils.getName(partyContainer)
|
||||
)}
|
||||
key={ContainerUtils.getDefinitionKey(partyContainer)}
|
||||
initiallyCollapsed={
|
||||
containerDefinitionKeyContext !==
|
||||
ContainerUtils.getDefinitionKey(partyContainer)
|
||||
}
|
||||
isReadonly={isReadonly}
|
||||
container={partyContainer}
|
||||
handleCurrencyChangeError={this.handleCurrencyChangeError.bind(
|
||||
this
|
||||
)}
|
||||
handleCurrencyAdjust={this.handleCurrencyAdjust.bind(
|
||||
this
|
||||
)}
|
||||
handleAmountSet={this.handleAmountSet.bind(this)}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FeatureFlagContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { coinManager } = this.props;
|
||||
return coinManager.canUseCointainers()
|
||||
? this.renderWithCointainers()
|
||||
: this.renderWithoutCointainers();
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
coin: rulesEngineSelectors.getCurrencies(state),
|
||||
partyInfo: serviceDataSelectors.getPartyInfo(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
lifestyle: rulesEngineSelectors.getLifestyle(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
characterContainers:
|
||||
rulesEngineSelectors.getCharacterInventoryContainers(state),
|
||||
partyContainers: rulesEngineSelectors.getPartyInventoryContainers(state),
|
||||
};
|
||||
}
|
||||
|
||||
const CurrencyPaneContainer = (props) => {
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
return <CurrencyPane coinManager={coinManager} {...props} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(CurrencyPaneContainer);
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import RemoveIcon from "@mui/icons-material/Remove";
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { Button } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterCurrencyContract,
|
||||
CoinManager,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
|
||||
import { ThemeButton } from "../../../../components/common/Button";
|
||||
import { CURRENCY_VALUE } from "../../../../constants/App";
|
||||
import { CoinManagerContext } from "../../../../managers/CoinManagerContext";
|
||||
import CurrencyPaneAdjusterType from "../CurrencyPaneAdjusterType";
|
||||
|
||||
interface Props {
|
||||
onAdjust?: (
|
||||
currencyTransaction: Partial<CharacterCurrencyContract>,
|
||||
multiplier: number,
|
||||
containerDefinitionKey: string
|
||||
) => void;
|
||||
containerDefinitionKey: string;
|
||||
isReadonly: boolean;
|
||||
coinManager: CoinManager;
|
||||
}
|
||||
interface CurrencyPaneAdjusterState {
|
||||
pp: number | null;
|
||||
gp: number | null;
|
||||
sp: number | null;
|
||||
ep: number | null;
|
||||
cp: number | null;
|
||||
}
|
||||
class CurrencyPaneAdjuster extends React.PureComponent<
|
||||
Props,
|
||||
CurrencyPaneAdjusterState
|
||||
> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
pp: null,
|
||||
gp: null,
|
||||
sp: null,
|
||||
ep: null,
|
||||
cp: null,
|
||||
};
|
||||
}
|
||||
|
||||
convertStateToCoin = (): Partial<CharacterCurrencyContract> => {
|
||||
const currencyAdjustments = {
|
||||
...this.state,
|
||||
};
|
||||
|
||||
return Object.keys(currencyAdjustments).reduce((acc, key) => {
|
||||
if (
|
||||
currencyAdjustments.hasOwnProperty(key) &&
|
||||
currencyAdjustments[key] !== null
|
||||
) {
|
||||
acc[key] = currencyAdjustments[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
handleAdjust = (multiplier: number): void => {
|
||||
const { onAdjust, containerDefinitionKey } = this.props;
|
||||
|
||||
this.handleReset();
|
||||
|
||||
if (onAdjust) {
|
||||
onAdjust(this.convertStateToCoin(), multiplier, containerDefinitionKey);
|
||||
}
|
||||
};
|
||||
|
||||
handleAdd = (): void => {
|
||||
this.handleAdjust(1);
|
||||
};
|
||||
|
||||
handleRemove = (): void => {
|
||||
this.handleAdjust(-1);
|
||||
};
|
||||
|
||||
handleChange = (currencyKey: string, value: number | null): void => {
|
||||
this.setState(
|
||||
(prevState: CurrencyPaneAdjusterState): CurrencyPaneAdjusterState => ({
|
||||
...prevState,
|
||||
[currencyKey]:
|
||||
value === null ? null : Math.min(value, CURRENCY_VALUE.MAX),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
handleBlur = (currencyKey: string, value: number | null): void => {
|
||||
this.setState(
|
||||
(prevState: CurrencyPaneAdjusterState): CurrencyPaneAdjusterState => ({
|
||||
...prevState,
|
||||
[currencyKey]:
|
||||
value === null ? null : Math.min(value, CURRENCY_VALUE.MAX),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
pp: null,
|
||||
gp: null,
|
||||
sp: null,
|
||||
ep: null,
|
||||
cp: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { pp, gp, sp, ep, cp } = this.state;
|
||||
const { isReadonly, containerDefinitionKey, coinManager } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-currency-pane__adjuster">
|
||||
<div className="ct-currency-pane__adjuster-heading">
|
||||
<Heading>{`Adjust${
|
||||
containerDefinitionKey ===
|
||||
coinManager.getPartyEquipmentContainerDefinitionKey()
|
||||
? " Party"
|
||||
: ""
|
||||
} Coin`}</Heading>
|
||||
</div>
|
||||
<div className="ct-currency-pane__adjuster-types">
|
||||
<CurrencyPaneAdjusterType
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
name="PP"
|
||||
currencyKey="pp"
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
coinType={Constants.CoinTypeEnum.pp}
|
||||
value={pp}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
<CurrencyPaneAdjusterType
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
name="GP"
|
||||
currencyKey="gp"
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
coinType={Constants.CoinTypeEnum.gp}
|
||||
value={gp}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
<CurrencyPaneAdjusterType
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
name="EP"
|
||||
currencyKey="ep"
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
coinType={Constants.CoinTypeEnum.ep}
|
||||
value={ep}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
<CurrencyPaneAdjusterType
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
name="SP"
|
||||
currencyKey="sp"
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
coinType={Constants.CoinTypeEnum.sp}
|
||||
value={sp}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
<CurrencyPaneAdjusterType
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
name="CP"
|
||||
currencyKey="cp"
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
coinType={Constants.CoinTypeEnum.cp}
|
||||
value={cp}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-currency-pane__adjuster-actions">
|
||||
<div className="ct-currency-pane__adjuster-action ct-currency-pane__adjuster-action--positive">
|
||||
<Button size="medium" onClick={this.handleAdd}>
|
||||
<span className="ct-currency-pane__adjuster-actions-button-content">
|
||||
<AddIcon sx={{ width: "13px", height: "13px" }} />
|
||||
Add
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ct-currency-pane__adjuster-action ct-currency-pane__adjuster-action--negative">
|
||||
<Button size="medium" onClick={this.handleRemove}>
|
||||
<span className="ct-currency-pane__adjuster-actions-button-content">
|
||||
<RemoveIcon sx={{ width: "13px", height: "13px" }} />
|
||||
Remove
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ct-currency-pane__adjuster-action">
|
||||
<ThemeButton
|
||||
size="medium"
|
||||
style="outline"
|
||||
onClick={this.handleReset}
|
||||
>
|
||||
<span className="ct-currency-pane__adjuster-actions-button-content">
|
||||
<CloseIcon sx={{ width: "13px", height: "13px" }} />
|
||||
Clear
|
||||
</span>
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CurrencyPaneAdjusterContainer = (props) => {
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
return <CurrencyPaneAdjuster coinManager={coinManager} {...props} />;
|
||||
};
|
||||
|
||||
export default CurrencyPaneAdjusterContainer;
|
||||
@@ -0,0 +1,4 @@
|
||||
import CurrencyPaneAdjuster from "./CurrencyPaneAdjuster";
|
||||
|
||||
export default CurrencyPaneAdjuster;
|
||||
export { CurrencyPaneAdjuster };
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
currencyKey: string;
|
||||
name: string;
|
||||
value: number | null;
|
||||
onBlur?: (currencyKey: string, value: number | null) => void;
|
||||
onChange?: (currencyKey: string, value: number | null) => void;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
isReadonly: boolean;
|
||||
coinType: Constants.CoinTypeEnum;
|
||||
containerDefinitionKey: string;
|
||||
}
|
||||
interface State {
|
||||
value: number | null;
|
||||
}
|
||||
export default class CurrencyPaneAdjusterType extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
minValue: CURRENCY_VALUE.MIN,
|
||||
maxValue: CURRENCY_VALUE.MAX,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.value,
|
||||
};
|
||||
}
|
||||
|
||||
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { onChange, currencyKey } = this.props;
|
||||
|
||||
if (onChange) {
|
||||
onChange(currencyKey, HelperUtils.parseInputInt(evt.target.value));
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { onBlur, currencyKey } = this.props;
|
||||
|
||||
if (onBlur) {
|
||||
onBlur(currencyKey, HelperUtils.parseInputInt(evt.target.value));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
name,
|
||||
isReadonly,
|
||||
minValue,
|
||||
maxValue,
|
||||
coinType,
|
||||
containerDefinitionKey,
|
||||
} = this.props;
|
||||
|
||||
let classNames: Array<string> = ["ct-currency-pane__adjuster-type"];
|
||||
if (name) {
|
||||
classNames.push(
|
||||
`ct-currency-pane__adjuster-type--${FormatUtils.slugify(name)}`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-currency-pane__adjuster-type-labels">
|
||||
<div className="ct-currency-pane__icon">
|
||||
<CoinIcon coinType={coinType} />
|
||||
</div>
|
||||
<div
|
||||
id={`${containerDefinitionKey}-${coinType}-label`}
|
||||
className="ct-currency-pane__adjuster-type-name"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-currency-pane__adjuster-type-value">
|
||||
<input
|
||||
aria-labelledby={`${containerDefinitionKey}-${coinType}-label`}
|
||||
type="number"
|
||||
className="ct-currency-pane__adjuster-type-value-input"
|
||||
value={value === null ? "" : value}
|
||||
onChange={this.handleChange}
|
||||
onBlur={this.handleBlur}
|
||||
readOnly={isReadonly}
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CurrencyPaneAdjusterType from "./CurrencyPaneAdjusterType";
|
||||
|
||||
export default CurrencyPaneAdjusterType;
|
||||
export { CurrencyPaneAdjusterType };
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CurrencyErrorTypeEnum {
|
||||
MIN = "min",
|
||||
MAX = "max",
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import React from "react";
|
||||
|
||||
import { CoinIcon } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
HelperUtils,
|
||||
FormatUtils,
|
||||
CharacterCurrencyContract,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { CURRENCY_VALUE } from "../../../../constants/App";
|
||||
import { CurrencyErrorTypeEnum } from "../CurrencyPaneConstants";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
value: number | null;
|
||||
conversion?: string;
|
||||
onChange: (value: number) => void;
|
||||
onError?: (errorType: CurrencyErrorTypeEnum) => void;
|
||||
isReadonly: boolean;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
coinType: Constants.CoinTypeEnum;
|
||||
}
|
||||
interface State {
|
||||
value: number | null;
|
||||
preValue: number | null;
|
||||
errorType: CurrencyErrorTypeEnum | null;
|
||||
isEditorVisible: boolean;
|
||||
}
|
||||
export default class Currency extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
minValue: CURRENCY_VALUE.MIN,
|
||||
maxValue: CURRENCY_VALUE.MAX,
|
||||
};
|
||||
|
||||
editorRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.value,
|
||||
preValue: props.value,
|
||||
errorType: null,
|
||||
isEditorVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { value } = this.props;
|
||||
|
||||
if (value !== prevProps.value) {
|
||||
this.setState({
|
||||
value,
|
||||
preValue: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { value, 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({
|
||||
value: targetValue,
|
||||
preValue: targetValue,
|
||||
errorType: null,
|
||||
isEditorVisible: false,
|
||||
});
|
||||
};
|
||||
|
||||
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({
|
||||
value: newValue,
|
||||
errorType,
|
||||
});
|
||||
};
|
||||
|
||||
handleCurrencyClick = (): void => {
|
||||
this.setState(
|
||||
{
|
||||
isEditorVisible: true,
|
||||
},
|
||||
() => {
|
||||
this.editorRef.current && this.editorRef.current.focus();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
renderError = (): React.ReactNode => {
|
||||
const { errorType } = this.state;
|
||||
|
||||
if (errorType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let errorMessage: React.ReactNode;
|
||||
if (errorType === CurrencyErrorTypeEnum.MIN) {
|
||||
errorMessage =
|
||||
"Cannot accept a negative value. To remove currency, please use the Currency Adjuster options below.";
|
||||
}
|
||||
if (errorType === CurrencyErrorTypeEnum.MAX) {
|
||||
errorMessage = `The max amount allowed is ${FormatUtils.renderLocaleNumber(
|
||||
CURRENCY_VALUE.MAX
|
||||
)}.`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-currency-pane__currency-error">
|
||||
<div className="ct-currency-pane__currency-error-text">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCurrencyValue = (): React.ReactNode => {
|
||||
const { value, isEditorVisible } = this.state;
|
||||
const { isReadonly, minValue, maxValue } = this.props;
|
||||
|
||||
if (isEditorVisible) {
|
||||
return (
|
||||
<input
|
||||
ref={this.editorRef}
|
||||
type="number"
|
||||
className="ct-currency-pane__currency-value-input"
|
||||
value={value === null ? "" : value}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
readOnly={isReadonly}
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-currency-pane__currency-value-text"
|
||||
onClick={this.handleCurrencyClick}
|
||||
>
|
||||
{value === null ? "" : FormatUtils.renderLocaleNumber(value)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, conversion, coinType } = this.props;
|
||||
|
||||
let classNames: Array<string> = ["ct-currency-pane__currency"];
|
||||
if (name) {
|
||||
classNames.push(
|
||||
`ct-currency-pane__currency--${FormatUtils.slugify(name)}`
|
||||
);
|
||||
}
|
||||
|
||||
let conversionNode: React.ReactNode;
|
||||
if (conversion) {
|
||||
conversionNode = (
|
||||
<div className="ct-currency-pane__currency-conversion">
|
||||
{conversion}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-currency-pane__currency-row">
|
||||
<div className="ct-currency-pane__currency-icon">
|
||||
<CoinIcon coinType={coinType} />
|
||||
</div>
|
||||
<div className="ct-currency-pane__currency-info">
|
||||
<div className="ct-currency-pane__currency-name">{name}</div>
|
||||
{conversionNode}
|
||||
</div>
|
||||
<div className="ct-currency-pane__currency-value">
|
||||
{this.renderCurrencyValue()}
|
||||
</div>
|
||||
</div>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CurrencyPaneCurrencyRow from "./CurrencyPaneCurrencyRow";
|
||||
|
||||
export default CurrencyPaneCurrencyRow;
|
||||
export { CurrencyPaneCurrencyRow };
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
propertyKey: string;
|
||||
className: string;
|
||||
}
|
||||
export default class CurrencyPaneEditor extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
};
|
||||
|
||||
render() {
|
||||
const { propertyKey, className } = this.props;
|
||||
|
||||
let classNames: Array<string> = [
|
||||
"ct-currency-pane__editor",
|
||||
`ct-currency-pane__editor--${FormatUtils.slugify(propertyKey)}`,
|
||||
className,
|
||||
];
|
||||
|
||||
return <div className={classNames.join(" ")}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import CurrencyPaneEditor from "./CurrencyPaneEditor";
|
||||
|
||||
export default CurrencyPaneEditor;
|
||||
export { CurrencyPaneEditor };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class CurrencyPaneEditorValue extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-currency-pane__editor-value">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CurrencyPaneEditorValue from "./CurrencyPaneEditorValue";
|
||||
|
||||
export default CurrencyPaneEditorValue;
|
||||
export { CurrencyPaneEditorValue };
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
|
||||
import CurrencyPaneEditor from "../CurrencyPaneEditor";
|
||||
import CurrencyPaneEditorValue from "../CurrencyPaneEditorValue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: number | null;
|
||||
options: Array<HtmlSelectOption>;
|
||||
onUpdate?: (propertyKey: string, value: number | null) => void;
|
||||
isReadonly: boolean;
|
||||
}
|
||||
interface State {
|
||||
value: number | null;
|
||||
}
|
||||
export default class CurrencyPaneSelectEditor extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
handleChange = (value) => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, HelperUtils.parseInputInt(value));
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, label, options, isReadonly } = this.props;
|
||||
|
||||
return (
|
||||
<CurrencyPaneEditor
|
||||
propertyKey={propertyKey}
|
||||
className="ct-currency-pane__editor--select"
|
||||
>
|
||||
<Heading>{label}</Heading>
|
||||
<CurrencyPaneEditorValue>
|
||||
<Select
|
||||
className="ct-currency-pane__editor-input"
|
||||
placeholder="--"
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
</CurrencyPaneEditorValue>
|
||||
</CurrencyPaneEditor>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CurrencyPaneSelectEditor from "./CurrencyPaneSelectEditor";
|
||||
|
||||
export default CurrencyPaneSelectEditor;
|
||||
export { CurrencyPaneSelectEditor };
|
||||
@@ -0,0 +1,18 @@
|
||||
import CurrencyPane from "./CurrencyPane";
|
||||
import CurrencyPaneAdjuster from "./CurrencyPaneAdjuster";
|
||||
import CurrencyPaneAdjusterType from "./CurrencyPaneAdjusterType";
|
||||
import CurrencyPaneCurrencyRow from "./CurrencyPaneCurrencyRow";
|
||||
import CurrencyPaneEditor from "./CurrencyPaneEditor";
|
||||
import CurrencyPaneEditorValue from "./CurrencyPaneEditorValue";
|
||||
import CurrencyPaneSelectEditor from "./CurrencyPaneSelectEditor";
|
||||
|
||||
export default CurrencyPane;
|
||||
export {
|
||||
CurrencyPane,
|
||||
CurrencyPaneAdjuster,
|
||||
CurrencyPaneAdjusterType,
|
||||
CurrencyPaneCurrencyRow,
|
||||
CurrencyPaneEditor,
|
||||
CurrencyPaneEditorValue,
|
||||
CurrencyPaneSelectEditor,
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { Collapsible } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
ActionUtils,
|
||||
ActivationUtils,
|
||||
characterActions,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
CustomActionContract,
|
||||
DiceUtils,
|
||||
EntityValueLookup,
|
||||
InventoryLookup,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiersCustomAction,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import { ActionDetail } from "../../../components/ActionDetail";
|
||||
import CustomizeDataEditor from "../../../components/CustomizeDataEditor";
|
||||
import EditorBox from "../../../components/EditorBox";
|
||||
import { RemoveButton } from "../../../components/common/Button";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
|
||||
type EditCustomContractProperties = Omit<
|
||||
CustomActionContract,
|
||||
"id" | "entityTypeId"
|
||||
>;
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
actions: Array<Action>;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
abilityLookup: AbilityLookup;
|
||||
inventoryLookup: InventoryLookup;
|
||||
ruleData: RuleData;
|
||||
identifiers: PaneIdentifiersCustomAction | null;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
paneContext: PaneInfo;
|
||||
}
|
||||
interface State {
|
||||
action: Action | null;
|
||||
}
|
||||
class CustomActionPane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { actions, identifiers } = this.props;
|
||||
|
||||
if (
|
||||
actions !== prevProps.actions ||
|
||||
identifiers !== prevProps.identifiers
|
||||
) {
|
||||
this.setState(this.generateStateData(this.props));
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props): State => {
|
||||
const { actions, identifiers } = props;
|
||||
|
||||
let foundAction: Action | null | undefined = null;
|
||||
if (identifiers !== null) {
|
||||
foundAction = actions.find((action) => identifiers.id === action.id);
|
||||
}
|
||||
|
||||
return {
|
||||
action: foundAction ? foundAction : null,
|
||||
};
|
||||
};
|
||||
|
||||
handleCustomDataUpdate = (data: EditCustomContractProperties): void => {
|
||||
const { action } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (action === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappingId = ActionUtils.getMappingId(action);
|
||||
if (mappingId !== null) {
|
||||
dispatch(characterActions.customActionSet(mappingId, data));
|
||||
}
|
||||
};
|
||||
|
||||
handleCustomActionsShow = (): void => {
|
||||
const {
|
||||
paneContext: { paneHistoryPush },
|
||||
} = this.props;
|
||||
|
||||
paneHistoryPush(PaneComponentEnum.CUSTOM_ACTIONS);
|
||||
};
|
||||
|
||||
handleRemove = (): void => {
|
||||
const { action } = this.state;
|
||||
const {
|
||||
dispatch,
|
||||
paneContext: { paneHistoryStart },
|
||||
} = this.props;
|
||||
|
||||
paneHistoryStart(PaneComponentEnum.CUSTOM_ACTIONS);
|
||||
if (action) {
|
||||
const mappingId = ActionUtils.getMappingId(action);
|
||||
|
||||
if (mappingId !== null) {
|
||||
dispatch(characterActions.customActionRemove(mappingId));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getData = (): Record<string, any> => {
|
||||
const { action } = this.state;
|
||||
|
||||
if (action === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let activation = ActionUtils.getDefinitionActivation(action);
|
||||
let range = ActionUtils.getDefinitionRange(action);
|
||||
let dice = ActionUtils.getDefinitionDice(action);
|
||||
let value = ActionUtils.getDefinitionFixedValue(action);
|
||||
|
||||
let diceCount: number | null = null;
|
||||
let diceType: number | null = null;
|
||||
let fixedValue: number | null = null;
|
||||
if (dice !== null) {
|
||||
diceCount = DiceUtils.getCount(dice);
|
||||
diceType = DiceUtils.getValue(dice);
|
||||
fixedValue = DiceUtils.getFixedValue(dice);
|
||||
} else if (value !== null) {
|
||||
fixedValue = value;
|
||||
}
|
||||
|
||||
let data: EditCustomContractProperties = {
|
||||
actionType: ActionUtils.getDefinitionActionTypeId(action),
|
||||
activationTime: ActivationUtils.getTime(activation),
|
||||
activationType: ActivationUtils.getType(activation),
|
||||
aoeSize: range?.aoeSize ?? null,
|
||||
aoeType: range?.aoeType ?? null,
|
||||
attackSubtype: ActionUtils.getDefinitionAttackSubtypeId(action),
|
||||
damageBonus: null, // not used
|
||||
damageTypeId: ActionUtils.getDefinitionDamageTypeId(action),
|
||||
description: ActionUtils.getDefinitionDescription(action),
|
||||
diceCount,
|
||||
diceType,
|
||||
fixedValue,
|
||||
displayAsAttack: ActionUtils.displayAsAttack(action),
|
||||
fixedSaveDc: ActionUtils.getDefinitionFixedSaveDc(action),
|
||||
isMartialArts: ActionUtils.getDefinitionIsMartialArts(action),
|
||||
isOffhand: ActionUtils.isOffhand(action),
|
||||
isProficient: ActionUtils.isProficient(action),
|
||||
isSilvered: ActionUtils.isSilvered(action),
|
||||
longRange: range?.longRange ?? null,
|
||||
name: ActionUtils.getDefinitionName(action),
|
||||
onMissDescription: ActionUtils.getDefinitionOnMissDescription(action),
|
||||
range: range?.range ?? null,
|
||||
rangeId: ActionUtils.getDefinitionAttackRangeId(action),
|
||||
saveFailDescription: ActionUtils.getDefinitionSaveFailDescription(action),
|
||||
saveStatId: ActionUtils.getDefinitionSaveStatId(action),
|
||||
saveSuccessDescription:
|
||||
ActionUtils.getDefinitionSaveSuccessDescription(action),
|
||||
snippet: ActionUtils.getDefinitionSnippet(action),
|
||||
spellRangeType: ActionUtils.getDefinitionSpellRangeType(action),
|
||||
statId: ActionUtils.getDefinitionAbilityModifierStatId(action),
|
||||
toHitBonus: null, // not used
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
renderCustomize = (): React.ReactNode => {
|
||||
const { isReadonly, ruleData } = this.props;
|
||||
const { action } = this.state;
|
||||
|
||||
if (isReadonly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const damageTypeOptions = RuleDataUtils.getDamageTypeOptions(ruleData);
|
||||
const rangeTypeOptions = RuleDataUtils.getAttackRangeTypeOptions(ruleData);
|
||||
const statOptions = RuleDataUtils.getStatOptions(ruleData);
|
||||
const dieTypeOptions = RuleDataUtils.getDieTypeOptions(ruleData);
|
||||
const attackSubtypeOptions =
|
||||
RuleDataUtils.getAttackSubtypeOptions(ruleData);
|
||||
const activationTypeOptions =
|
||||
RuleDataUtils.getActivationTypeOptions(ruleData);
|
||||
const aoeTypeOptions = RuleDataUtils.getAoeTypeOptions(ruleData);
|
||||
const spellRangeTypeOptions =
|
||||
RuleDataUtils.getSpellRangeTypeOptions(ruleData);
|
||||
|
||||
const actionType = ActionUtils.getActionTypeId(action);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header="Edit"
|
||||
className="ct-custom-action-pane__customize"
|
||||
>
|
||||
<EditorBox>
|
||||
<CustomizeDataEditor
|
||||
data={this.getData()}
|
||||
fallbackValues={{
|
||||
displayAsAttack: ActionUtils.isDefaultDisplayAsAttack(action),
|
||||
}}
|
||||
labelOverrides={{
|
||||
fixedValue: "Fixed Value",
|
||||
}}
|
||||
enableName={true}
|
||||
enableIsProficient={true}
|
||||
enableRangeType={true}
|
||||
enableDiceCount={true}
|
||||
enableDiceType={true}
|
||||
enableFixedValue={true}
|
||||
enableDamageType={true}
|
||||
enableIsOffhand={actionType === Constants.ActionTypeEnum.WEAPON}
|
||||
enableAttackSubtype={actionType === Constants.ActionTypeEnum.WEAPON}
|
||||
enableIsSilver={actionType === Constants.ActionTypeEnum.WEAPON}
|
||||
enableIsMartialArts={true}
|
||||
enableDescription={true}
|
||||
enableStat={true}
|
||||
enableSaveDc={true}
|
||||
enableActivationTime={true}
|
||||
enableActivationType={true}
|
||||
enableSaveType={true}
|
||||
enableDisplayAsAttack={true}
|
||||
enableAoeType={true}
|
||||
enableAoeSize={true}
|
||||
enableSpellRangeType={actionType === Constants.ActionTypeEnum.SPELL}
|
||||
enableRange={true}
|
||||
enableLongRange={actionType === Constants.ActionTypeEnum.WEAPON}
|
||||
enableSnippet={true}
|
||||
maxNameLength={1024}
|
||||
rangeOptions={rangeTypeOptions}
|
||||
damageTypeOptions={damageTypeOptions}
|
||||
diceTypeOptions={dieTypeOptions}
|
||||
statOptions={statOptions}
|
||||
attackSubtypeOptions={attackSubtypeOptions}
|
||||
activationTypeOptions={activationTypeOptions}
|
||||
aoeTypeOptions={aoeTypeOptions}
|
||||
spellRangeTypeOptions={spellRangeTypeOptions}
|
||||
onDataUpdate={this.handleCustomDataUpdate}
|
||||
/>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { action } = this.state;
|
||||
const {
|
||||
ruleData,
|
||||
entityValueLookup,
|
||||
abilityLookup,
|
||||
inventoryLookup,
|
||||
isReadonly,
|
||||
theme,
|
||||
proficiencyBonus,
|
||||
} = this.props;
|
||||
|
||||
if (action === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-custom-action-pane"
|
||||
key={ActionUtils.getUniqueKey(action)}
|
||||
>
|
||||
<Header parent="Custom Actions" onClick={this.handleCustomActionsShow}>
|
||||
{ActionUtils.getName(action)}
|
||||
</Header>
|
||||
{this.renderCustomize()}
|
||||
<ActionDetail
|
||||
theme={theme}
|
||||
action={action}
|
||||
ruleData={ruleData}
|
||||
entityValueLookup={entityValueLookup}
|
||||
inventoryLookup={inventoryLookup}
|
||||
showCustomize={false}
|
||||
abilityLookup={abilityLookup}
|
||||
isReadonly={isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
{!isReadonly && (
|
||||
<div className="ct-custom-action-pane__actions">
|
||||
<div
|
||||
className="ct-custom-action-pane__action ct-custom-action-pane__action--remove"
|
||||
onClick={this.handleRemove}
|
||||
>
|
||||
<RemoveButton onClick={this.handleRemove}>
|
||||
Remove Action
|
||||
</RemoveButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
actions: rulesEngineSelectors.getCustomActions(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
entityValueLookup:
|
||||
rulesEngineSelectors.getCharacterValueLookupByEntity(state),
|
||||
inventoryLookup: rulesEngineSelectors.getInventoryLookup(state),
|
||||
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
const CustomActionPaneContainer = (props) => {
|
||||
const { pane } = useSidebar();
|
||||
return <CustomActionPane paneContext={pane} {...props} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(CustomActionPaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import CustomActionPane from "./CustomActionPane";
|
||||
|
||||
export default CustomActionPane;
|
||||
export { CustomActionPane };
|
||||
@@ -0,0 +1,162 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
Action,
|
||||
ActionUtils,
|
||||
characterActions,
|
||||
Constants,
|
||||
HtmlSelectOption,
|
||||
RuleData,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { PaneIdentifierUtils } from "../../../utils";
|
||||
import CustomActionsPaneSummary from "./CustomActionsPaneSummary";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
actions: Array<Action>;
|
||||
ruleData: RuleData;
|
||||
paneContext: PaneInfo;
|
||||
}
|
||||
class CustomActionsPane extends React.PureComponent<Props> {
|
||||
handleCustomActionShow = (action: Action): void => {
|
||||
const {
|
||||
paneContext: { paneHistoryPush },
|
||||
} = this.props;
|
||||
|
||||
const mappingId = ActionUtils.getMappingId(action);
|
||||
if (mappingId !== null) {
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.CUSTOM_ACTION,
|
||||
PaneIdentifierUtils.generateCustomAction(mappingId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleCustomAttackRemove = (action: Action): void => {
|
||||
const {
|
||||
dispatch,
|
||||
paneContext: { paneHistoryStart },
|
||||
} = this.props;
|
||||
|
||||
const mappingId = ActionUtils.getMappingId(action);
|
||||
if (mappingId !== null) {
|
||||
paneHistoryStart(PaneComponentEnum.CUSTOM_ACTIONS);
|
||||
dispatch(characterActions.customActionRemove(mappingId));
|
||||
}
|
||||
};
|
||||
|
||||
handleActionAdd = (actionType: string): void => {
|
||||
const { dispatch, actions } = this.props;
|
||||
|
||||
dispatch(
|
||||
characterActions.customActionCreate(
|
||||
`Custom Action ${actions.length + 1}`,
|
||||
actionType as any
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
renderActions = (actions: Array<Action>): React.ReactNode => {
|
||||
const { ruleData } = this.props;
|
||||
|
||||
if (!actions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-custom-actions-pane__actions">
|
||||
{actions.map((action, idx) => (
|
||||
<CustomActionsPaneSummary
|
||||
key={`${action.id} + ${idx}`}
|
||||
action={action}
|
||||
ruleData={ruleData}
|
||||
onDetailShow={this.handleCustomActionShow}
|
||||
onRemove={this.handleCustomAttackRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderActionGroup = (
|
||||
heading: string,
|
||||
actionType: Constants.ActionTypeEnum
|
||||
): React.ReactNode => {
|
||||
const { actions } = this.props;
|
||||
|
||||
let filteredActions = actions.filter(
|
||||
(action) => ActionUtils.getActionTypeId(action) === actionType
|
||||
);
|
||||
if (!filteredActions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-custom-actions-pane__group">
|
||||
<div className="ct-custom-actions-pane__group-heading">
|
||||
<Heading>{heading}</Heading>
|
||||
</div>
|
||||
<div className="ct-custom-actions-pane__group-actions">
|
||||
{this.renderActions(filteredActions)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderAdd = (): React.ReactNode => {
|
||||
let customActionOptions: Array<HtmlSelectOption> = [
|
||||
{ label: "General", value: Constants.ActionTypeEnum.GENERAL },
|
||||
{ label: "Spell", value: Constants.ActionTypeEnum.SPELL },
|
||||
{ label: "Weapon", value: Constants.ActionTypeEnum.WEAPON },
|
||||
];
|
||||
return (
|
||||
<div className="ct-custom-actions-pane__add">
|
||||
<Heading>Add new Actions</Heading>
|
||||
<Select
|
||||
options={customActionOptions}
|
||||
resetAfterChoice={true}
|
||||
onChange={this.handleActionAdd}
|
||||
value={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {} = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-custom-actions-pane">
|
||||
<Header>Manage Custom Actions</Header>
|
||||
{this.renderActionGroup("General", Constants.ActionTypeEnum.GENERAL)}
|
||||
{this.renderActionGroup("Spells", Constants.ActionTypeEnum.SPELL)}
|
||||
{this.renderActionGroup("Weapons", Constants.ActionTypeEnum.WEAPON)}
|
||||
{this.renderAdd()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
actions: rulesEngineSelectors.getCustomActions(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
};
|
||||
}
|
||||
|
||||
const CustomActionsPaneContainer = (props) => {
|
||||
const { pane } = useSidebar();
|
||||
return <CustomActionsPane paneContext={pane} {...props} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(CustomActionsPaneContainer);
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Action,
|
||||
ActionUtils,
|
||||
ActivationUtils,
|
||||
Constants,
|
||||
RuleData,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { RemoveButton } from "../../../../components/common/Button";
|
||||
|
||||
interface Props {
|
||||
action: Action;
|
||||
ruleData: RuleData;
|
||||
onDetailShow?: (action: Action) => void;
|
||||
onRemove?: (action: Action) => void;
|
||||
}
|
||||
export default class CustomActionsPaneSummary extends React.PureComponent<Props> {
|
||||
handleDetailShow = (evt: React.MouseEvent): void => {
|
||||
const { onDetailShow, action } = this.props;
|
||||
|
||||
if (onDetailShow) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onDetailShow(action);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemove = (): void => {
|
||||
const { onRemove, action } = this.props;
|
||||
|
||||
if (onRemove) {
|
||||
onRemove(action);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ruleData, action } = this.props;
|
||||
|
||||
let name = ActionUtils.getName(action);
|
||||
let typeId = ActionUtils.getActionTypeId(action);
|
||||
let activationInfo = ActionUtils.getActivation(action);
|
||||
|
||||
let typeLabel: string = "";
|
||||
switch (typeId) {
|
||||
case Constants.ActionTypeEnum.GENERAL:
|
||||
typeLabel = "General";
|
||||
break;
|
||||
case Constants.ActionTypeEnum.SPELL:
|
||||
typeLabel = "Spell";
|
||||
break;
|
||||
case Constants.ActionTypeEnum.WEAPON:
|
||||
typeLabel = "Weapon";
|
||||
break;
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-custom-actions-pane__summary"
|
||||
onClick={this.handleDetailShow}
|
||||
>
|
||||
<div className="ct-custom-actions-pane__summary-content">
|
||||
<div className="ct-custom-actions-pane__summary-name">
|
||||
{name ? name : "--"}
|
||||
</div>
|
||||
{activationInfo !== null && (
|
||||
<div className="ct-custom-actions-pane__summary-meta">
|
||||
{ActivationUtils.renderActivation(activationInfo, ruleData)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ct-custom-actions-pane__summary-actions">
|
||||
<div className="ct-custom-actions-pane__summary-action ct-custom-actions-pane__summary-action--remove">
|
||||
<RemoveButton onClick={this.handleRemove}>
|
||||
Remove Action
|
||||
</RemoveButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CustomActionsPaneSummary from "./CustomActionsPaneSummary";
|
||||
|
||||
export default CustomActionsPaneSummary;
|
||||
export { CustomActionsPaneSummary };
|
||||
@@ -0,0 +1,5 @@
|
||||
import CustomActionsPane from "./CustomActionsPane";
|
||||
import CustomActionsPaneSummary from "./CustomActionsPaneSummary";
|
||||
|
||||
export default CustomActionsPane;
|
||||
export { CustomActionsPane, CustomActionsPaneSummary };
|
||||
@@ -0,0 +1,258 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
ProficiencyLevelIcon,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
characterActions,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
Skill,
|
||||
SkillUtils,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { InfoItem } from "~/components/InfoItem";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiersCustomSkill,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import CustomizeDataEditor from "../../../components/CustomizeDataEditor";
|
||||
import EditorBox from "../../../components/EditorBox";
|
||||
import { RemoveButton } from "../../../components/common/Button";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
customSkills: Array<Skill>;
|
||||
ruleData: RuleData;
|
||||
identifiers: PaneIdentifiersCustomSkill | null;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
paneContext: PaneInfo;
|
||||
}
|
||||
interface State {
|
||||
skill: Skill | null;
|
||||
}
|
||||
class CustomSkillPane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { customSkills, identifiers } = this.props;
|
||||
|
||||
if (
|
||||
customSkills !== prevProps.customSkills ||
|
||||
identifiers !== prevProps.identifiers
|
||||
) {
|
||||
this.setState(this.generateStateData(this.props));
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props): State => {
|
||||
const { customSkills, identifiers } = props;
|
||||
|
||||
let foundSkill: Skill | null | undefined = null;
|
||||
if (identifiers !== null) {
|
||||
foundSkill = customSkills.find((skill) => identifiers.id === skill.id);
|
||||
}
|
||||
|
||||
return {
|
||||
skill: foundSkill ? foundSkill : null,
|
||||
};
|
||||
};
|
||||
|
||||
handleSkillsManageShow = (): void => {
|
||||
const {
|
||||
paneContext: { paneHistoryPush },
|
||||
} = this.props;
|
||||
|
||||
paneHistoryPush(PaneComponentEnum.SKILLS);
|
||||
};
|
||||
|
||||
handleSkillRemove = (): void => {
|
||||
const { skill } = this.state;
|
||||
const {
|
||||
dispatch,
|
||||
paneContext: { paneHistoryStart },
|
||||
} = this.props;
|
||||
|
||||
paneHistoryStart(PaneComponentEnum.SKILLS);
|
||||
if (skill) {
|
||||
dispatch(characterActions.customProficiencyRemove(skill.id));
|
||||
}
|
||||
};
|
||||
|
||||
getData = (): Record<string, any> => {
|
||||
const { skill } = this.state;
|
||||
|
||||
if (skill === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const originalContract = SkillUtils.getOriginalContract(skill);
|
||||
|
||||
if (originalContract === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
notes,
|
||||
proficiencyLevel,
|
||||
statId,
|
||||
type,
|
||||
override,
|
||||
miscBonus,
|
||||
magicBonus,
|
||||
description,
|
||||
} = originalContract;
|
||||
|
||||
return {
|
||||
name,
|
||||
notes,
|
||||
proficiencyLevel,
|
||||
statId,
|
||||
type,
|
||||
override,
|
||||
miscBonus,
|
||||
magicBonus,
|
||||
description,
|
||||
};
|
||||
};
|
||||
|
||||
handleDataUpdate = (data: Record<string, any>): void => {
|
||||
const { skill } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (skill) {
|
||||
dispatch(characterActions.customProficiencySet(skill.id, data));
|
||||
}
|
||||
};
|
||||
|
||||
renderCustomize = (): React.ReactNode => {
|
||||
const { ruleData, isReadonly } = this.props;
|
||||
|
||||
if (isReadonly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header="Edit"
|
||||
className="ct-custom-skill-pane__edit"
|
||||
>
|
||||
<EditorBox>
|
||||
<CustomizeDataEditor
|
||||
data={this.getData()}
|
||||
enableName={true}
|
||||
enableNotes={true}
|
||||
enableDescription={true}
|
||||
enableStat={true}
|
||||
enableProficiencyLevel={true}
|
||||
enableMagicBonus={true}
|
||||
enableMiscBonus={true}
|
||||
enableOverride={true}
|
||||
maxNameLength={1024}
|
||||
onDataUpdate={this.handleDataUpdate}
|
||||
statOptions={RuleDataUtils.getStatOptions(ruleData)}
|
||||
proficiencyLevelOptions={RuleDataUtils.getProficiencyLevelOptions(
|
||||
ruleData
|
||||
)}
|
||||
/>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { skill } = this.state;
|
||||
const { isReadonly, ruleData, theme } = this.props;
|
||||
|
||||
if (skill === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
const notes = SkillUtils.getNotes(skill);
|
||||
const description = SkillUtils.getDescription(skill);
|
||||
const modifier = SkillUtils.getModifier(skill);
|
||||
const statName = RuleDataUtils.getAbilityShortName(
|
||||
SkillUtils.getStat(skill),
|
||||
ruleData
|
||||
);
|
||||
const proficiencyLevel = SkillUtils.getProficiencyLevel(skill);
|
||||
const name = SkillUtils.getName(skill);
|
||||
|
||||
const infoItemProps = { role: "listitem", inline: true };
|
||||
|
||||
return (
|
||||
<div className="ct-custom-skill-pane" key={skill.id}>
|
||||
<Header parent="Skills" onClick={this.handleSkillsManageShow}>
|
||||
<div className="ct-custom-skill-pane__header">
|
||||
<div className="ct-custom-skill-pane__header-icon">
|
||||
<ProficiencyLevelIcon
|
||||
theme={theme}
|
||||
proficiencyLevel={proficiencyLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-custom-skill-pane__header-ability">
|
||||
{statName === null ? "--" : statName}
|
||||
</div>
|
||||
<div className="ct-custom-skill-pane__header-name">{name}</div>
|
||||
<div className="ct-custom-skill-pane__header-modifier">
|
||||
<NumberDisplay type="signed" number={modifier} />
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
{this.renderCustomize()}
|
||||
{notes && (
|
||||
<div className="ct-custom-skill-pane__properties" role="list">
|
||||
<InfoItem label="Notes" {...infoItemProps}>
|
||||
{notes}
|
||||
</InfoItem>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="ct-custom-skill-pane__description">{description}</div>
|
||||
)}
|
||||
{!isReadonly && (
|
||||
<div className="ct-custom-skill-pane__actions">
|
||||
<RemoveButton onClick={this.handleSkillRemove}>Remove</RemoveButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
customSkills: rulesEngineSelectors.getCustomSkills(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
const CustomSkillPaneContainer = (props) => {
|
||||
const { pane } = useSidebar();
|
||||
|
||||
return <CustomSkillPane {...props} paneContext={pane} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(CustomSkillPaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import CustomSkillPane from "./CustomSkillPane";
|
||||
|
||||
export default CustomSkillPane;
|
||||
export { CustomSkillPane };
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import { uniq } from "lodash";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiRequests,
|
||||
ApiAdapterUtils,
|
||||
characterActions,
|
||||
CharacterBackdropContract,
|
||||
CharClass,
|
||||
ClassUtils,
|
||||
rulesEngineSelectors,
|
||||
DecorationInfo,
|
||||
DecorationUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
|
||||
import DataLoadingStatusEnum from "../../../../constants/DataLoadingStatusEnum";
|
||||
import { SharedAppState } from "../../../../stores/typings";
|
||||
import { AppLoggerUtils } from "../../../../utils";
|
||||
import { DecorationPreviewItem } from "../DecorationPreviewItem";
|
||||
|
||||
interface BackdropGroups {
|
||||
label: string;
|
||||
backdrops: Array<CharacterBackdropContract>;
|
||||
}
|
||||
interface Props extends DispatchProp {
|
||||
decorationInfo: DecorationInfo;
|
||||
startingClass: CharClass | null;
|
||||
}
|
||||
interface State {
|
||||
backdropData: Array<CharacterBackdropContract>;
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
}
|
||||
class BackdropManager extends React.PureComponent<Props, State> {
|
||||
loadBackdropsCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
backdropData: [],
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
});
|
||||
|
||||
ApiRequests.getCharacterGameDataBackdrops({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadBackdropsCanceler = c;
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
let apiData: Array<CharacterBackdropContract> = [];
|
||||
|
||||
const data = ApiAdapterUtils.getResponseData(response);
|
||||
if (data !== null) {
|
||||
apiData = data;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
backdropData: apiData,
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
});
|
||||
this.loadBackdropsCanceler = null;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadBackdropsCanceler !== null) {
|
||||
this.loadBackdropsCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
handleBackdropClick = (backdrop: CharacterBackdropContract): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(characterActions.backdropSet(backdrop));
|
||||
};
|
||||
|
||||
handleDefaultBackdropClick = (): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(
|
||||
characterActions.backdropSet({
|
||||
backdropAvatarId: null,
|
||||
backdropAvatarUrl: "",
|
||||
largeBackdropAvatarId: null,
|
||||
largeBackdropAvatarUrl: "",
|
||||
smallBackdropAvatarId: null,
|
||||
smallBackdropAvatarUrl: "",
|
||||
thumbnailBackdropAvatarId: null,
|
||||
thumbnailBackdropAvatarUrl: "",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
renderItems = (
|
||||
heading: string,
|
||||
backdrops: Array<CharacterBackdropContract>
|
||||
): React.ReactNode => {
|
||||
const { decorationInfo } = this.props;
|
||||
const currentBackdrop = DecorationUtils.getBackdropInfo(decorationInfo);
|
||||
return (
|
||||
<div className="ct-decoration-manager__group" key={heading}>
|
||||
<Heading>{heading}</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
{backdrops.map((backdrop) => (
|
||||
<DecorationPreviewItem
|
||||
key={backdrop.id}
|
||||
avatarId={backdrop.thumbnailBackdropAvatarId}
|
||||
avatarName={backdrop.name}
|
||||
isCurrent={
|
||||
backdrop.backdropAvatarId === currentBackdrop.backdropAvatarId
|
||||
}
|
||||
onSelected={() => this.handleBackdropClick(backdrop)}
|
||||
innerClassName="ct-decoration-manager__item-img"
|
||||
>
|
||||
<img
|
||||
className="ct-decoration-manager__item-img"
|
||||
src={backdrop.thumbnailBackdropAvatarUrl ?? ""}
|
||||
alt={`${backdrop.name} backdrop preview`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</DecorationPreviewItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderContent = (): React.ReactNode => {
|
||||
const { decorationInfo, startingClass } = this.props;
|
||||
const { backdropData } = this.state;
|
||||
|
||||
const currentBackdrop = DecorationUtils.getBackdropInfo(decorationInfo);
|
||||
let startingClassBackdrop: CharacterBackdropContract | null = null;
|
||||
|
||||
if (startingClass) {
|
||||
const startingClassBackdropData = backdropData.find(
|
||||
(data) => data.classId === ClassUtils.getId(startingClass)
|
||||
);
|
||||
startingClassBackdrop = startingClassBackdropData
|
||||
? startingClassBackdropData
|
||||
: null;
|
||||
}
|
||||
|
||||
let backdropTags: Array<string> = uniq(
|
||||
backdropData.reduce((acc, data) => {
|
||||
let tags = data.tags ? data.tags : [];
|
||||
return [...acc, ...tags];
|
||||
}, [])
|
||||
);
|
||||
|
||||
let backdropGroups: Array<BackdropGroups> = [];
|
||||
backdropTags.forEach((backdropTag) => {
|
||||
let backdrops = backdropData.filter(
|
||||
(data) => data.tags && data.tags.includes(backdropTag)
|
||||
);
|
||||
backdropGroups.push({
|
||||
label: backdropTag,
|
||||
backdrops,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{startingClassBackdrop && (
|
||||
<div className="ct-decoration-manager__group">
|
||||
<Heading>Default</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
<DecorationPreviewItem
|
||||
avatarId={startingClassBackdrop.backdropAvatarId}
|
||||
avatarName={startingClassBackdrop.name}
|
||||
isCurrent={currentBackdrop.backdropAvatarId === null}
|
||||
onSelected={this.handleDefaultBackdropClick}
|
||||
>
|
||||
<img
|
||||
className="ct-decoration-manager__item-img"
|
||||
src={startingClassBackdrop.thumbnailBackdropAvatarUrl ?? ""}
|
||||
alt={`${startingClassBackdrop.name} backdrop preview`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</DecorationPreviewItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{backdropGroups.map((backdropGroup) =>
|
||||
this.renderItems(backdropGroup.label, backdropGroup.backdrops)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loadingStatus } = this.state;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = this.renderContent();
|
||||
} else {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
return <div className="ct-decoration-manager">{contentNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
|
||||
startingClass: rulesEngineSelectors.getStartingClass(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(BackdropManager);
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
onClick?: (decorationKey: string) => void;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
isReadonly: boolean;
|
||||
decorationKey: string;
|
||||
}
|
||||
const CurrentDecorationItem: React.FC<Props> = ({
|
||||
label,
|
||||
onClick,
|
||||
isActive,
|
||||
isReadonly,
|
||||
decorationKey,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="ct-decorate-pane__grid-item"
|
||||
onClick={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
if (onClick) {
|
||||
onClick(decorationKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ct-decorate-pane__grid-item-label">{label}</div>
|
||||
<div
|
||||
className={`ct-decorate-pane__grid-item-inner ${
|
||||
isActive ? "ct-decorate-pane__grid-item-inner--is-active" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentDecorationItem;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
AbilitySummary,
|
||||
CharacterAvatar,
|
||||
CharacterAvatarPortrait,
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
LightLongRestSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
DecorationUtils,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { Dice } from "@dndbeyond/dice";
|
||||
|
||||
import { useAbilities } from "~/hooks/useAbilities";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
|
||||
import { appEnvActions } from "../../../actions/appEnv";
|
||||
import CtaPreferenceManager from "../../../components/CtaPreferenceManager";
|
||||
import { AttributesManagerContext } from "../../../managers/AttributesManagerContext";
|
||||
import { characterRollContextSelectors } from "../../../selectors";
|
||||
import * as appEnvSelectors from "../../../selectors/appEnv";
|
||||
import { BackdropManager } from "./BackdropManager";
|
||||
import { CurrentDecorationItem } from "./CurrentDecorationItem";
|
||||
import { FrameManager } from "./FrameManager";
|
||||
import { PortraitManager } from "./PortraitManager";
|
||||
import { ThemeManager } from "./ThemeManager";
|
||||
|
||||
enum SHOP_KEY {
|
||||
NONE = "NONE",
|
||||
FRAMES = "FRAMES",
|
||||
BACKDROPS = "BACKDROPS",
|
||||
PORTRAITS = "PORTRAITS",
|
||||
THEMES = "THEMES",
|
||||
PREFERENCES = "PREFERENCES",
|
||||
DICE = "DICE",
|
||||
}
|
||||
|
||||
export default function DecoratePane() {
|
||||
const { attributesManager } = useContext(AttributesManagerContext);
|
||||
const abilities = useAbilities();
|
||||
const highestAbility = attributesManager.getHighestAbilityScore(abilities);
|
||||
const dispatch = useDispatch();
|
||||
const decorationInfo = useSelector(rulesEngineSelectors.getDecorationInfo);
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
|
||||
const preferences = useSelector(rulesEngineSelectors.getCharacterPreferences);
|
||||
const characterRollContext = useSelector(
|
||||
characterRollContextSelectors.getCharacterRollContext
|
||||
);
|
||||
const [currentShop, setCurrentShop] = useState<SHOP_KEY>(SHOP_KEY.NONE);
|
||||
|
||||
const handleDarkModeToggle = (): void => {
|
||||
const { enableDarkMode } = preferences;
|
||||
|
||||
dispatch(
|
||||
characterActions.preferenceChoose("enableDarkMode", !enableDarkMode)
|
||||
);
|
||||
};
|
||||
|
||||
//TODO this is repeated a few times now.. reusable thing? hook?
|
||||
const handleDiceToggle = (): void => {
|
||||
const newDiceEnabledSetting: boolean = !diceEnabled;
|
||||
|
||||
try {
|
||||
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
|
||||
Dice.setEnabled(newDiceEnabledSetting);
|
||||
} catch (e) {}
|
||||
|
||||
dispatch(
|
||||
appEnvActions.dataSet({
|
||||
diceEnabled: newDiceEnabledSetting,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleClick = (type: SHOP_KEY): void => {
|
||||
setCurrentShop(type);
|
||||
};
|
||||
|
||||
const handleCollapseChange = (isCollapsed: boolean, type: SHOP_KEY): void => {
|
||||
setCurrentShop(!isCollapsed ? type : SHOP_KEY.NONE);
|
||||
};
|
||||
|
||||
const renderPreferences = (): React.ReactNode => {
|
||||
const { enableDarkMode } = preferences;
|
||||
|
||||
const headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={"Preferences"} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={headerNode}
|
||||
layoutType="minimal"
|
||||
className="ct-decorate-pane__preferences"
|
||||
initiallyCollapsed={false}
|
||||
>
|
||||
<div className="ct-decorate-pane__preferences-content">
|
||||
<CtaPreferenceManager
|
||||
preferenceEnabled={enableDarkMode}
|
||||
onPreferenceClick={handleDarkModeToggle}
|
||||
icon={<LightLongRestSvg />}
|
||||
backgroundImageUrl="https://www.dndbeyond.com/avatars/13574/100/637396156849705602.jpeg"
|
||||
borderColor="#715280"
|
||||
switchColor="#715280"
|
||||
preferenceTitle="Underdark Mode"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDecorateShop = (): React.ReactNode => {
|
||||
return (
|
||||
<div className="ct-decorate-pane__shop">
|
||||
<Heading>Browse Decorations</Heading>
|
||||
|
||||
{/*BACKDROPS*/}
|
||||
<Collapsible
|
||||
header="Backdrops"
|
||||
collapsed={currentShop !== SHOP_KEY.BACKDROPS}
|
||||
onChangeHandler={(isCollapsed) =>
|
||||
handleCollapseChange(isCollapsed, SHOP_KEY.BACKDROPS)
|
||||
}
|
||||
>
|
||||
<BackdropManager />
|
||||
</Collapsible>
|
||||
|
||||
{/*FRAMES*/}
|
||||
<Collapsible
|
||||
header="Frames"
|
||||
collapsed={currentShop !== SHOP_KEY.FRAMES}
|
||||
onChangeHandler={(isCollapsed) =>
|
||||
handleCollapseChange(isCollapsed, SHOP_KEY.FRAMES)
|
||||
}
|
||||
>
|
||||
<FrameManager />
|
||||
</Collapsible>
|
||||
|
||||
{/*THEMES*/}
|
||||
<Collapsible
|
||||
header="Themes"
|
||||
collapsed={currentShop !== SHOP_KEY.THEMES}
|
||||
onChangeHandler={(isCollapsed) =>
|
||||
handleCollapseChange(isCollapsed, SHOP_KEY.THEMES)
|
||||
}
|
||||
>
|
||||
<ThemeManager />
|
||||
</Collapsible>
|
||||
|
||||
{/*PORTRAITS*/}
|
||||
<Collapsible
|
||||
header="Portraits"
|
||||
collapsed={currentShop !== SHOP_KEY.PORTRAITS}
|
||||
onChangeHandler={(isCollapsed) =>
|
||||
handleCollapseChange(isCollapsed, SHOP_KEY.PORTRAITS)
|
||||
}
|
||||
>
|
||||
<PortraitManager />
|
||||
</Collapsible>
|
||||
|
||||
{/*DICE*/}
|
||||
{/*<Collapsible*/}
|
||||
{/* header="Dice"*/}
|
||||
{/* collapsed={currentShop !== SHOP_KEY.DICE}*/}
|
||||
{/* onChangeHandler={(isCollapsed) => handleCollapseChange(isCollapsed, SHOP_KEY.DICE)}*/}
|
||||
{/*>*/}
|
||||
{/* <div>*/}
|
||||
{/* DICE CHOICE FOR THIS CHARACTER??? HOW NICE!!*/}
|
||||
|
||||
{/* or could we show a picture at least?*/}
|
||||
{/* </div>*/}
|
||||
{/*</Collapsible>*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const avatarInfo = DecorationUtils.getAvatarInfo(decorationInfo);
|
||||
const backdropInfo = DecorationUtils.getBackdropInfo(decorationInfo);
|
||||
const characterTheme = DecorationUtils.getCharacterTheme(decorationInfo);
|
||||
|
||||
let avatarClasses: Array<string> = ["ct-decorate-pane__portrait"];
|
||||
if (!avatarInfo.avatarUrl) {
|
||||
avatarClasses.push("ct-decorate-pane__portrait--none");
|
||||
}
|
||||
|
||||
let backdropClasses: Array<string> = ["ct-decorate-pane__backdrop"];
|
||||
let backdropStyles: React.CSSProperties = {};
|
||||
if (backdropInfo.thumbnailBackdropAvatarUrl) {
|
||||
backdropStyles = {
|
||||
backgroundImage: `url(${backdropInfo.thumbnailBackdropAvatarUrl})`,
|
||||
};
|
||||
} else {
|
||||
backdropClasses.push("ct-decorate-pane__backdrop--none");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-decorate-pane">
|
||||
<div className="ct-decorate-pane__current-selections">
|
||||
<Header>Current Decorations</Header>
|
||||
<div className="ct-decorate-pane__grid">
|
||||
<CurrentDecorationItem
|
||||
label="Portrait"
|
||||
onClick={handleClick}
|
||||
decorationKey={SHOP_KEY.PORTRAITS}
|
||||
isActive={currentShop === SHOP_KEY.PORTRAITS}
|
||||
isReadonly={isReadonly}
|
||||
>
|
||||
<CharacterAvatarPortrait
|
||||
className={avatarClasses.join(" ")}
|
||||
avatarUrl={avatarInfo.avatarUrl}
|
||||
/>
|
||||
</CurrentDecorationItem>
|
||||
|
||||
<CurrentDecorationItem
|
||||
label="Frame"
|
||||
onClick={handleClick}
|
||||
decorationKey={SHOP_KEY.FRAMES}
|
||||
isActive={currentShop === SHOP_KEY.FRAMES}
|
||||
isReadonly={isReadonly}
|
||||
>
|
||||
<CharacterAvatar avatarInfo={avatarInfo} />
|
||||
</CurrentDecorationItem>
|
||||
|
||||
<CurrentDecorationItem
|
||||
label="Theme"
|
||||
onClick={handleClick}
|
||||
decorationKey={SHOP_KEY.THEMES}
|
||||
isActive={currentShop === SHOP_KEY.THEMES}
|
||||
isReadonly={isReadonly}
|
||||
>
|
||||
<div className="ct-decorate-pane__theme">
|
||||
{highestAbility && (
|
||||
<AbilitySummary
|
||||
theme={characterTheme}
|
||||
ability={highestAbility}
|
||||
preferences={preferences}
|
||||
diceEnabled={false}
|
||||
rollContext={characterRollContext}
|
||||
/>
|
||||
)}
|
||||
<div className="ct-decorate-pane__theme-color">
|
||||
{characterTheme.name}
|
||||
</div>
|
||||
</div>
|
||||
</CurrentDecorationItem>
|
||||
|
||||
<CurrentDecorationItem
|
||||
label="Backdrop"
|
||||
onClick={handleClick}
|
||||
decorationKey={SHOP_KEY.BACKDROPS}
|
||||
isActive={currentShop === SHOP_KEY.BACKDROPS}
|
||||
isReadonly={isReadonly}
|
||||
>
|
||||
<div className={backdropClasses.join(" ")} style={backdropStyles} />
|
||||
</CurrentDecorationItem>
|
||||
</div>
|
||||
</div>
|
||||
{renderPreferences()}
|
||||
{renderDecorateShop()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
avatarId: number | null;
|
||||
avatarName?: string | null;
|
||||
isSelected?: boolean;
|
||||
isCurrent: boolean;
|
||||
onSelected?: (avatarId: number | null) => void;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
}
|
||||
export default class DecorationPreviewItem extends React.PureComponent<Props> {
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onSelected, avatarId } = this.props;
|
||||
|
||||
if (onSelected) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onSelected(avatarId);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isSelected,
|
||||
isCurrent,
|
||||
children,
|
||||
avatarName,
|
||||
className,
|
||||
innerClassName,
|
||||
} = this.props;
|
||||
|
||||
let classNames: Array<string> = ["ct-decoration-manager__item"];
|
||||
if (className) {
|
||||
classNames.push(className);
|
||||
}
|
||||
if (isSelected) {
|
||||
classNames.push("ct-decoration-manager__item--selected");
|
||||
}
|
||||
if (isCurrent) {
|
||||
classNames.push("ct-decoration-manager__item--current");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")} onClick={this.handleClick}>
|
||||
<div
|
||||
className={`ct-decoration-manager__item-inner${
|
||||
innerClassName ? ` ${innerClassName}` : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{avatarName && (
|
||||
<div className="ct-decoration-manager__item-label">{avatarName}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import DecorationPreviewItem from "./DecorationPreviewItem";
|
||||
|
||||
export default DecorationPreviewItem;
|
||||
export { DecorationPreviewItem };
|
||||
@@ -0,0 +1,221 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import { uniq } from "lodash";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiRequests,
|
||||
ApiAdapterUtils,
|
||||
characterActions,
|
||||
CharacterPortraitFrameContract,
|
||||
rulesEngineSelectors,
|
||||
DecorationInfo,
|
||||
DecorationUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
|
||||
import DataLoadingStatusEnum from "../../../../constants/DataLoadingStatusEnum";
|
||||
import { SharedAppState } from "../../../../stores/typings";
|
||||
import { AppLoggerUtils } from "../../../../utils";
|
||||
import { DecorationPreviewItem } from "../DecorationPreviewItem";
|
||||
|
||||
interface FrameGroupInfo {
|
||||
label: string;
|
||||
frames: Array<CharacterPortraitFrameContract>;
|
||||
}
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
decorationInfo: DecorationInfo;
|
||||
}
|
||||
interface State {
|
||||
frameData: Array<CharacterPortraitFrameContract>;
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
}
|
||||
class FrameManager extends React.PureComponent<Props, State> {
|
||||
loadFramesCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
frameData: [],
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
});
|
||||
|
||||
ApiRequests.getCharacterGameDataFrames({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadFramesCanceler = c;
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
let apiData: Array<CharacterPortraitFrameContract> = [];
|
||||
|
||||
const data = ApiAdapterUtils.getResponseData(response);
|
||||
if (data !== null) {
|
||||
apiData = data;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
frameData: apiData,
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
});
|
||||
this.loadFramesCanceler = null;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadFramesCanceler !== null) {
|
||||
this.loadFramesCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
handleFrameClick = (frame: CharacterPortraitFrameContract): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.frameSet(frame));
|
||||
};
|
||||
|
||||
handleDefaultFrameClick = (): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(
|
||||
characterActions.frameSet({
|
||||
frameAvatarId: null,
|
||||
frameAvatarUrl: "",
|
||||
decorationKey: null,
|
||||
classId: null,
|
||||
id: -1,
|
||||
name: null,
|
||||
raceId: null,
|
||||
subRaceId: null,
|
||||
tags: null,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
renderFrames = (
|
||||
heading: string,
|
||||
frames: Array<CharacterPortraitFrameContract>
|
||||
): React.ReactNode => {
|
||||
const { decorationInfo } = this.props;
|
||||
const currentFrameId =
|
||||
DecorationUtils.getAvatarInfo(decorationInfo).frameId;
|
||||
|
||||
return (
|
||||
<div className="ct-decoration-manager__group" key={heading}>
|
||||
<Heading>{heading}</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
{frames.map((frame) => (
|
||||
<DecorationPreviewItem
|
||||
key={frame.id}
|
||||
avatarId={frame.frameAvatarId}
|
||||
isCurrent={frame.frameAvatarId === currentFrameId}
|
||||
onSelected={() => this.handleFrameClick(frame)}
|
||||
avatarName={frame.name}
|
||||
innerClassName="ct-decoration-manager__item-img"
|
||||
>
|
||||
<img
|
||||
className="ct-decoration-manager__item-frame"
|
||||
src={frame.frameAvatarUrl ?? ""}
|
||||
alt={`${frame.name} frame preview`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</DecorationPreviewItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderNoFrames = (): React.ReactNode => {
|
||||
//TODO DECO CTA
|
||||
return (
|
||||
<div className="ct-decoration-manager__no-frames">
|
||||
You have no extra frames available to choose from.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFrameChoices = (): React.ReactNode => {
|
||||
const { decorationInfo } = this.props;
|
||||
const { frameData } = this.state;
|
||||
const currentFrameId =
|
||||
DecorationUtils.getAvatarInfo(decorationInfo).frameId;
|
||||
|
||||
let frameTags: Array<string> = uniq(
|
||||
frameData.reduce((acc: Array<string>, data) => {
|
||||
let tags = data.tags ? data.tags : [];
|
||||
return [...acc, ...tags];
|
||||
}, [])
|
||||
);
|
||||
let frameGroups: Array<FrameGroupInfo> = [];
|
||||
frameTags.forEach((frameTag) => {
|
||||
let frames = frameData.filter(
|
||||
(data) => data.tags && data.tags.includes(frameTag)
|
||||
);
|
||||
frameGroups.push({
|
||||
label: frameTag,
|
||||
frames,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="ct-decoration-manager__group">
|
||||
<Heading>Default</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
<DecorationPreviewItem
|
||||
avatarId={null}
|
||||
isCurrent={currentFrameId === null}
|
||||
onSelected={this.handleDefaultFrameClick}
|
||||
avatarName={"Default"}
|
||||
>
|
||||
<div className="ct-decoration-manager__item--default" />
|
||||
</DecorationPreviewItem>
|
||||
</div>
|
||||
</div>
|
||||
{frameGroups.map((frameGroup) =>
|
||||
this.renderFrames(frameGroup.label, frameGroup.frames)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderContent = (): React.ReactNode => {
|
||||
const { frameData } = this.state;
|
||||
|
||||
if (frameData.length > 0) {
|
||||
return this.renderFrameChoices();
|
||||
} else {
|
||||
return this.renderNoFrames();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loadingStatus } = this.state;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = this.renderContent();
|
||||
} else {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
return <div className="ct-decoration-manager">{contentNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(FrameManager);
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
ApiRequests,
|
||||
ApiAdapterUtils,
|
||||
characterActions,
|
||||
CharacterPortraitContract,
|
||||
Race,
|
||||
RaceUtils,
|
||||
rulesEngineSelectors,
|
||||
DecorationInfo,
|
||||
DecorationUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ACCEPTED_IMAGE_TYPES, FILE_SIZE_3MB } from "~/constants";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
|
||||
import { SharedAppState } from "~/tools/js/Shared/stores/typings";
|
||||
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
|
||||
import { CharacterAvatarPortrait } from "~/tools/js/smartComponents/CharacterAvatar";
|
||||
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
|
||||
|
||||
import DecorationPreviewItem from "../DecorationPreviewItem";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
decorationInfo: DecorationInfo;
|
||||
species: Race | null;
|
||||
handleSelectPortrait?: (portrait: CharacterPortraitContract) => void;
|
||||
}
|
||||
interface State {
|
||||
isCustomPortrait: boolean;
|
||||
portraitUploadData: string | null;
|
||||
portraitUploadWidth: number | null;
|
||||
portraitUploadHeight: number | null;
|
||||
portraitUploadValid: boolean | null;
|
||||
portraitUploadValidSize: boolean | null;
|
||||
portraitData: Array<CharacterPortraitContract>;
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
currentSelection: CharacterPortraitContract | null;
|
||||
}
|
||||
class PortraitManager extends React.PureComponent<Props, State> {
|
||||
loadPortraitsCanceler: null | Canceler = null;
|
||||
portraitUpload = React.createRef<HTMLInputElement>();
|
||||
portraitUploadForm = React.createRef<HTMLFormElement>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
portraitUploadData: null,
|
||||
portraitUploadWidth: null,
|
||||
portraitUploadHeight: null,
|
||||
portraitUploadValid: null,
|
||||
portraitUploadValidSize: null,
|
||||
isCustomPortrait: false,
|
||||
portraitData: [],
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
|
||||
currentSelection: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
const { decorationInfo } = this.props;
|
||||
const currentAvatarId =
|
||||
DecorationUtils.getAvatarInfo(decorationInfo).avatarId;
|
||||
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
});
|
||||
|
||||
ApiRequests.getCharacterGameDataPortraits({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadPortraitsCanceler = c;
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
let apiData: Array<CharacterPortraitContract> = [];
|
||||
|
||||
const data = ApiAdapterUtils.getResponseData(response);
|
||||
if (data !== null) {
|
||||
apiData = data;
|
||||
}
|
||||
|
||||
const isCustomPortrait =
|
||||
currentAvatarId !== null &&
|
||||
!apiData.find((portrait) => portrait.avatarId === currentAvatarId);
|
||||
this.setState({
|
||||
portraitData: apiData,
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
isCustomPortrait,
|
||||
});
|
||||
this.loadPortraitsCanceler = null;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadPortraitsCanceler !== null) {
|
||||
this.loadPortraitsCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
handlePortraitClick = (portrait: CharacterPortraitContract): void => {
|
||||
const { dispatch, handleSelectPortrait } = this.props;
|
||||
|
||||
if (portrait.avatarId && portrait.avatarUrl) {
|
||||
if (handleSelectPortrait) {
|
||||
handleSelectPortrait(portrait);
|
||||
this.setState({
|
||||
currentSelection: portrait,
|
||||
});
|
||||
} else {
|
||||
dispatch(
|
||||
characterActions.portraitSet(portrait.avatarId, portrait.avatarUrl)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isCustomPortrait: false,
|
||||
});
|
||||
};
|
||||
|
||||
isPortraitAvailableForSpecies = (
|
||||
portrait: CharacterPortraitContract,
|
||||
species: Race | null
|
||||
): boolean => {
|
||||
let currentSpeciesId: number | null = null;
|
||||
let currentSpeciesOptionId: number | null = null;
|
||||
if (species) {
|
||||
const baseSpeciesId = RaceUtils.getBaseRaceId(species);
|
||||
const entitySpeciesId = RaceUtils.getEntityRaceId(species);
|
||||
currentSpeciesId =
|
||||
baseSpeciesId !== null ? baseSpeciesId : entitySpeciesId;
|
||||
currentSpeciesOptionId = baseSpeciesId !== null ? entitySpeciesId : null;
|
||||
}
|
||||
|
||||
if (portrait.raceId !== null && currentSpeciesId === portrait.raceId) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
portrait.subRaceId !== null &&
|
||||
currentSpeciesOptionId === portrait.subRaceId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
handlePortraitUpload = (): void => {
|
||||
const { dispatch } = this.props;
|
||||
const { portraitUploadData } = this.state;
|
||||
|
||||
if (portraitUploadData !== null) {
|
||||
dispatch(characterActions.portraitUpload(portraitUploadData));
|
||||
}
|
||||
|
||||
this.setState({
|
||||
portraitUploadData: null,
|
||||
portraitUploadWidth: null,
|
||||
portraitUploadHeight: null,
|
||||
portraitUploadValid: null,
|
||||
portraitUploadValidSize: null,
|
||||
isCustomPortrait: true,
|
||||
});
|
||||
|
||||
if (this.portraitUploadForm.current) {
|
||||
this.portraitUploadForm.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
handleCustomPortraitChange = (
|
||||
evt: React.ChangeEvent<HTMLInputElement>
|
||||
): void => {
|
||||
const fileInput = this.portraitUpload.current;
|
||||
|
||||
if (fileInput && fileInput.files && fileInput.files[0]) {
|
||||
let reader = new FileReader();
|
||||
let file = fileInput.files[0];
|
||||
|
||||
let validFile: boolean = true;
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
this.setState({
|
||||
portraitUploadValid: false,
|
||||
portraitUploadValidSize: null,
|
||||
});
|
||||
validFile = false;
|
||||
}
|
||||
if (file.size > FILE_SIZE_3MB) {
|
||||
this.setState({
|
||||
portraitUploadValid: null,
|
||||
portraitUploadValidSize: false,
|
||||
});
|
||||
validFile = false;
|
||||
}
|
||||
|
||||
if (validFile) {
|
||||
reader.onload = (e: ProgressEvent) => {
|
||||
let imageData = reader.result;
|
||||
if (typeof imageData === "string") {
|
||||
this.setState({
|
||||
portraitUploadData: imageData,
|
||||
portraitUploadValid: true,
|
||||
portraitUploadValidSize: true,
|
||||
});
|
||||
|
||||
let img = new Image();
|
||||
img.onload = () => {
|
||||
this.setState({
|
||||
portraitUploadWidth: img.width,
|
||||
portraitUploadHeight: img.height,
|
||||
});
|
||||
};
|
||||
|
||||
img.src = imageData;
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
portraitUploadData: null,
|
||||
portraitUploadWidth: null,
|
||||
portraitUploadHeight: null,
|
||||
portraitUploadValid: null,
|
||||
portraitUploadValidSize: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderPortraits = (
|
||||
heading: string,
|
||||
portraits: Array<CharacterPortraitContract>
|
||||
): React.ReactNode => {
|
||||
const { decorationInfo } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-decoration-manager__group">
|
||||
<Heading>{heading}</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
{portraits.map((portrait) => (
|
||||
<DecorationPreviewItem
|
||||
key={portrait.id}
|
||||
avatarId={portrait.avatarId}
|
||||
isSelected={this.state.currentSelection === portrait}
|
||||
isCurrent={
|
||||
DecorationUtils.getAvatarInfo(decorationInfo).avatarId ===
|
||||
portrait.avatarId
|
||||
}
|
||||
onSelected={() => this.handlePortraitClick(portrait)}
|
||||
innerClassName="ct-decoration-manager__item-img"
|
||||
>
|
||||
<CharacterAvatarPortrait avatarUrl={portrait.avatarUrl} />
|
||||
</DecorationPreviewItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderPortraitUploader = (): React.ReactNode => {
|
||||
const {
|
||||
isCustomPortrait,
|
||||
portraitUploadData,
|
||||
portraitUploadValid,
|
||||
portraitUploadValidSize,
|
||||
} = this.state;
|
||||
|
||||
let setButtonLabel: string = "Set Portrait";
|
||||
if (isCustomPortrait) {
|
||||
setButtonLabel = "Change Portrait";
|
||||
}
|
||||
|
||||
let confirmNode: React.ReactNode;
|
||||
if (portraitUploadValid !== null && !portraitUploadValid) {
|
||||
confirmNode = (
|
||||
<div className="ct-decoration-manager__upload-warning">
|
||||
Cannot upload the selected file type. <br />
|
||||
Acceptable file types are jpg, gif, and png.
|
||||
</div>
|
||||
);
|
||||
} else if (portraitUploadValidSize !== null && !portraitUploadValidSize) {
|
||||
confirmNode = (
|
||||
<div className="ct-decoration-manager__upload-warning">
|
||||
Cannot upload the selected file as it exceeds the maximum file size of
|
||||
3MB.
|
||||
</div>
|
||||
);
|
||||
} else if (portraitUploadData) {
|
||||
confirmNode = (
|
||||
<div className="ct-decoration-manager__upload-confirm">
|
||||
<div className="ct-decoration-manager__upload-image">
|
||||
<div className="ct-decoration-manager__upload-image-heading">
|
||||
Preview
|
||||
</div>
|
||||
<CharacterAvatarPortrait
|
||||
className="ct-decoration-manager__upload-image-preview"
|
||||
avatarUrl={portraitUploadData}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-decoration-manager__upload-action">
|
||||
<Button
|
||||
themed
|
||||
onClick={this.handlePortraitUpload}
|
||||
disabled={portraitUploadData === null}
|
||||
size="x-small"
|
||||
>
|
||||
{setButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-decoration-manager__upload">
|
||||
<div className="ct-decoration-manager__upload-heading">
|
||||
<Heading>Upload Portrait</Heading>
|
||||
<div className="ct-decoration-manager__upload-heading-rules">
|
||||
Recommended Size: 150 x 150 Pixels, Maximum File Size: 3MB
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="ct-decoration-manager__upload-form"
|
||||
ref={this.portraitUploadForm}
|
||||
>
|
||||
<div className="ct-decoration-manager__upload-file">
|
||||
<input
|
||||
className="ct-decoration-manager__upload-file-input"
|
||||
type="file"
|
||||
ref={this.portraitUpload}
|
||||
onChange={this.handleCustomPortraitChange}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{confirmNode}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderUploadedPortrait = (): React.ReactNode => {
|
||||
const { decorationInfo } = this.props;
|
||||
const { isCustomPortrait } = this.state;
|
||||
|
||||
if (!isCustomPortrait) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarUrl = DecorationUtils.getAvatarInfo(decorationInfo).avatarUrl;
|
||||
|
||||
return (
|
||||
<div className="ct-decoration-manager__custom">
|
||||
<Heading>Current Custom Portrait</Heading>
|
||||
<div className="ct-decoration-manager__custom-content">
|
||||
<div className="ct-decoration-manager__custom-preview">
|
||||
<CharacterAvatarPortrait avatarUrl={avatarUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderContent = (): React.ReactNode => {
|
||||
const { species } = this.props;
|
||||
const { portraitData } = this.state;
|
||||
|
||||
let speciesPortraits: CharacterPortraitContract[] = [];
|
||||
let otherPortraits = portraitData;
|
||||
if (species) {
|
||||
speciesPortraits = portraitData.filter((portrait) =>
|
||||
this.isPortraitAvailableForSpecies(portrait, species)
|
||||
);
|
||||
otherPortraits = portraitData.filter(
|
||||
(portrait) => !this.isPortraitAvailableForSpecies(portrait, species)
|
||||
);
|
||||
}
|
||||
|
||||
const otherPortraitLabel: string =
|
||||
speciesPortraits.length > 0 ? "Other Portraits" : "Portraits";
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.renderUploadedPortrait()}
|
||||
{this.renderPortraitUploader()}
|
||||
{species &&
|
||||
speciesPortraits.length > 0 &&
|
||||
this.renderPortraits(
|
||||
`${species.fullName} Portraits`,
|
||||
speciesPortraits
|
||||
)}
|
||||
{this.renderPortraits(otherPortraitLabel, otherPortraits)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loadingStatus } = this.state;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = this.renderContent();
|
||||
} else {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
return <div className="ct-decoration-manager">{contentNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
|
||||
species: rulesEngineSelectors.getRace(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PortraitManager);
|
||||
@@ -0,0 +1,4 @@
|
||||
import PortraitManager from "./PortraitManager";
|
||||
|
||||
export default PortraitManager;
|
||||
export { PortraitManager };
|
||||
@@ -0,0 +1,197 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import { uniq } from "lodash";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
AbilitySummary,
|
||||
LoadingPlaceholder,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterUtils,
|
||||
ApiRequests,
|
||||
characterActions,
|
||||
CharacterThemeColorContract,
|
||||
DecorationUtils,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useAbilities } from "~/hooks/useAbilities";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
import { AttributesManagerContext } from "~/tools/js/Shared/managers/AttributesManagerContext";
|
||||
|
||||
import DataLoadingStatusEnum from "../../../../constants/DataLoadingStatusEnum";
|
||||
import { characterRollContextSelectors } from "../../../../selectors";
|
||||
import { AppLoggerUtils } from "../../../../utils";
|
||||
import DecorationPreviewItem from "../DecorationPreviewItem";
|
||||
|
||||
interface ThemeGroupInfo {
|
||||
label: string;
|
||||
themes: Array<CharacterThemeColorContract>;
|
||||
}
|
||||
|
||||
export default function ThemeManager() {
|
||||
let loadThemesCanceler: null | Canceler = null;
|
||||
const [themeData, setThemeData] = useState<
|
||||
Array<CharacterThemeColorContract>
|
||||
>([]);
|
||||
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
|
||||
DataLoadingStatusEnum.NOT_INITIALIZED
|
||||
);
|
||||
|
||||
const { attributesManager } = useContext(AttributesManagerContext);
|
||||
const abilities = useAbilities();
|
||||
const highestAbility = attributesManager.getHighestAbilityScore(abilities);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const decorationInfo = useSelector(rulesEngineSelectors.getDecorationInfo);
|
||||
const startingClass = useSelector(rulesEngineSelectors.getStartingClass);
|
||||
const preferences = useSelector(rulesEngineSelectors.getCharacterPreferences);
|
||||
const characterRollContext = useSelector(
|
||||
characterRollContextSelectors.getCharacterRollContext
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
|
||||
ApiRequests.getCharacterGameDataThemeColors({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
loadThemesCanceler = c;
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
let apiData: Array<CharacterThemeColorContract> = [];
|
||||
|
||||
const data = ApiAdapterUtils.getResponseData(response);
|
||||
if (data !== null) {
|
||||
apiData = data;
|
||||
}
|
||||
|
||||
setThemeData(apiData);
|
||||
setLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
loadThemesCanceler = null;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
return () => {
|
||||
if (loadThemesCanceler !== null) {
|
||||
loadThemesCanceler();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleThemeClick = (theme: CharacterThemeColorContract): void => {
|
||||
dispatch(characterActions.themeSet(theme));
|
||||
};
|
||||
|
||||
const handleDefaultThemeClick = (): void => {
|
||||
dispatch(characterActions.themeSet(null));
|
||||
};
|
||||
|
||||
const renderThemes = (
|
||||
heading: string,
|
||||
themes: Array<CharacterThemeColorContract>
|
||||
): React.ReactNode => {
|
||||
const characterTheme = DecorationUtils.getCharacterTheme(decorationInfo);
|
||||
|
||||
return (
|
||||
<div className="ct-decoration-manager__group" key={heading}>
|
||||
<Heading>{heading}</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
{themes.map((theme) => {
|
||||
const isSelected =
|
||||
!characterTheme.isDefault &&
|
||||
theme.themeColorId === characterTheme.themeColorId;
|
||||
let classNames: Array<string> = [
|
||||
"ct-decoration-manager__item--theme",
|
||||
];
|
||||
if (isSelected) {
|
||||
classNames.push("ct-decoration-manager__item--current-theme");
|
||||
}
|
||||
return (
|
||||
<DecorationPreviewItem
|
||||
key={theme.themeColorId}
|
||||
avatarId={theme?.themeColorId ?? null}
|
||||
isCurrent={isSelected}
|
||||
onSelected={() => handleThemeClick(theme)}
|
||||
className={classNames.join(" ")}
|
||||
avatarName={theme.name}
|
||||
>
|
||||
<AbilitySummary
|
||||
theme={DecorationUtils.generateCharacterTheme(
|
||||
theme,
|
||||
preferences
|
||||
)}
|
||||
ability={highestAbility}
|
||||
preferences={preferences}
|
||||
diceEnabled={false}
|
||||
rollContext={characterRollContext}
|
||||
/>
|
||||
</DecorationPreviewItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = (): React.ReactNode => {
|
||||
let tags: Array<string> = uniq(
|
||||
themeData.reduce((acc: Array<string>, data) => {
|
||||
let newTags = data.tags ? data.tags : [];
|
||||
return [...acc, ...newTags];
|
||||
}, [])
|
||||
);
|
||||
let groups: Array<ThemeGroupInfo> = [];
|
||||
tags.forEach((tag) => {
|
||||
groups.push({
|
||||
label: tag,
|
||||
themes: themeData.filter(
|
||||
(data) => data.tags && data.tags.includes(tag)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
let classNames: Array<string> = ["ct-decoration-manager__item--theme"];
|
||||
if (DecorationUtils.isDefaultTheme(decorationInfo)) {
|
||||
classNames.push("ct-decoration-manager__item--current-theme");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ct-decoration-manager__group">
|
||||
<Heading>Default</Heading>
|
||||
<div className="ct-decoration-manager__list">
|
||||
<DecorationPreviewItem
|
||||
avatarId={null}
|
||||
isCurrent={DecorationUtils.isDefaultTheme(decorationInfo)}
|
||||
onSelected={handleDefaultThemeClick}
|
||||
className={classNames.join(" ")}
|
||||
avatarName={"DDB Red"}
|
||||
>
|
||||
<AbilitySummary
|
||||
theme={DecorationUtils.generateCharacterTheme(
|
||||
null,
|
||||
preferences
|
||||
)}
|
||||
ability={highestAbility}
|
||||
preferences={preferences}
|
||||
diceEnabled={false}
|
||||
rollContext={characterRollContext}
|
||||
/>
|
||||
</DecorationPreviewItem>
|
||||
</div>
|
||||
</div>
|
||||
{groups.map((group) => renderThemes(group.label, group.themes))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = renderContent();
|
||||
} else {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
return <div className="ct-decoration-manager">{contentNode}</div>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import BackdropManager from "./BackdropManager";
|
||||
import CurrentDecorationItem from "./CurrentDecorationItem";
|
||||
import DecoratePane from "./DecoratePane";
|
||||
import DecorationPreviewItem from "./DecorationPreviewItem";
|
||||
import FrameManager from "./FrameManager";
|
||||
import PortraitManager from "./PortraitManager";
|
||||
import ThemeManager from "./ThemeManager";
|
||||
|
||||
export default DecoratePane;
|
||||
export {
|
||||
DecoratePane,
|
||||
CurrentDecorationItem,
|
||||
DecorationPreviewItem,
|
||||
BackdropManager,
|
||||
FrameManager,
|
||||
ThemeManager,
|
||||
PortraitManager,
|
||||
};
|
||||
@@ -0,0 +1,581 @@
|
||||
import { sortBy } from "lodash";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
DamageTypeIcon,
|
||||
DataOriginName,
|
||||
Select,
|
||||
ComponentConstants,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
CharacterDefenseAdjustmentContract,
|
||||
ConditionContract,
|
||||
ConditionDefinitionContract,
|
||||
ConditionUtils,
|
||||
Constants,
|
||||
DamageAdjustmentContract,
|
||||
DataOrigin,
|
||||
DefenseAdjustment,
|
||||
DefenseAdjustmentContract,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
HtmlSelectOptionGroup,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
rulesEngineSelectors,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import * as appEnvSelectors from "../../../selectors/appEnv";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import DefenseManagePaneCustomItem from "./DefenseManagePaneCustomItem";
|
||||
|
||||
const DEFENSE_ADJUSTMENT_TYPE_OPTION = {
|
||||
RESISTANCE: 1,
|
||||
IMMUNITY: 2,
|
||||
VULNERABILITY: 3,
|
||||
};
|
||||
|
||||
interface AdjustmentGroupEntry extends DefenseAdjustmentContract {
|
||||
idx: number;
|
||||
definition: DamageAdjustmentContract | ConditionDefinitionContract;
|
||||
}
|
||||
interface AdjustmentGroupInfo {
|
||||
label: string;
|
||||
adjustments: Array<AdjustmentGroupEntry>;
|
||||
}
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
resistances: Array<DefenseAdjustment>;
|
||||
immunities: Array<DefenseAdjustment>;
|
||||
vulnerabilities: Array<DefenseAdjustment>;
|
||||
resistanceData: Array<DamageAdjustmentContract>;
|
||||
vulnerabilityData: Array<DamageAdjustmentContract>;
|
||||
conditionImmunityData: Array<ConditionContract>;
|
||||
damageImmunityData: Array<DamageAdjustmentContract>;
|
||||
customDefenseAdjustments: Array<CharacterDefenseAdjustmentContract>;
|
||||
ruleData: RuleData;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
interface State {
|
||||
adjustmentType: number | null;
|
||||
adjustmentSubType: string | null;
|
||||
}
|
||||
class DefenseManagePane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
adjustmentType: null,
|
||||
adjustmentSubType: null,
|
||||
};
|
||||
}
|
||||
|
||||
getNewDamageAdjustments = (): Array<CharacterDefenseAdjustmentContract> => {
|
||||
return this.props.customDefenseAdjustments.filter(
|
||||
(newData) =>
|
||||
newData.type === Constants.DefenseAdjustmentTypeEnum.DAMAGE_ADJUSTMENT
|
||||
);
|
||||
};
|
||||
|
||||
getNewConditionAdjustments =
|
||||
(): Array<CharacterDefenseAdjustmentContract> => {
|
||||
return this.props.customDefenseAdjustments.filter(
|
||||
(newData) =>
|
||||
newData.type ===
|
||||
Constants.DefenseAdjustmentTypeEnum.CONDITION_ADJUSTMENT
|
||||
);
|
||||
};
|
||||
|
||||
getAvailableResistances = (): Array<DamageAdjustmentContract> => {
|
||||
const { resistanceData } = this.props;
|
||||
|
||||
const newDefenseAdjustments = this.getNewDamageAdjustments();
|
||||
return resistanceData
|
||||
.filter((data) => !data.isMulti)
|
||||
.filter((data) =>
|
||||
newDefenseAdjustments.every(
|
||||
(newData) => newData.adjustmentId !== data.id
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
getAvailableVulnerabilities = (): Array<DamageAdjustmentContract> => {
|
||||
const { vulnerabilityData } = this.props;
|
||||
|
||||
const newDefenseAdjustments = this.getNewDamageAdjustments();
|
||||
return vulnerabilityData
|
||||
.filter((data) => !data.isMulti)
|
||||
.filter((data) =>
|
||||
newDefenseAdjustments.every(
|
||||
(newData) => newData.adjustmentId !== data.id
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
getAvailableDamageImmunities = (): Array<DamageAdjustmentContract> => {
|
||||
const { damageImmunityData } = this.props;
|
||||
|
||||
const newDefenseAdjustments = this.getNewDamageAdjustments();
|
||||
return damageImmunityData
|
||||
.filter((data) => !data.isMulti)
|
||||
.filter((data) =>
|
||||
newDefenseAdjustments.every(
|
||||
(newData) => newData.adjustmentId !== data.id
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
getAvailableConditionImmunities = (): Array<ConditionContract> => {
|
||||
const { conditionImmunityData } = this.props;
|
||||
|
||||
const newDefenseAdjustments = this.getNewConditionAdjustments();
|
||||
return conditionImmunityData.filter((data) =>
|
||||
newDefenseAdjustments.every(
|
||||
(newData) => newData.adjustmentId !== ConditionUtils.getId(data)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
handleAdjustmentRemove = (type: number, id: number): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(characterActions.customDefenseAdjustmentRemove(type, id));
|
||||
};
|
||||
|
||||
handleAdjustmentSourceUpdate = (
|
||||
type: number,
|
||||
id: number,
|
||||
source: string | null
|
||||
) => {
|
||||
const { dispatch, customDefenseAdjustments } = this.props;
|
||||
|
||||
let foundAdjustment = customDefenseAdjustments.find(
|
||||
(adjustment) => adjustment.type === type && adjustment.adjustmentId === id
|
||||
);
|
||||
if (foundAdjustment) {
|
||||
dispatch(
|
||||
characterActions.customDefenseAdjustmentSet({
|
||||
...foundAdjustment,
|
||||
source,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleChangeAdjustmentType = (value: string): void => {
|
||||
this.setState({
|
||||
adjustmentType: HelperUtils.parseInputInt(value),
|
||||
adjustmentSubType: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleChangeAdjustmentSubType = (subTypeValue: string): void => {
|
||||
const { adjustmentType } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
let type: number | null = null;
|
||||
let adjustmentId: number | null = null;
|
||||
switch (adjustmentType) {
|
||||
case DEFENSE_ADJUSTMENT_TYPE_OPTION.RESISTANCE:
|
||||
case DEFENSE_ADJUSTMENT_TYPE_OPTION.VULNERABILITY:
|
||||
type = Constants.DefenseAdjustmentTypeEnum.DAMAGE_ADJUSTMENT;
|
||||
adjustmentId = parseInt(subTypeValue);
|
||||
break;
|
||||
case DEFENSE_ADJUSTMENT_TYPE_OPTION.IMMUNITY:
|
||||
let [defenseTypeKey, defenseAdjustmentId] = subTypeValue.split("-");
|
||||
type = parseInt(defenseTypeKey);
|
||||
adjustmentId = parseInt(defenseAdjustmentId);
|
||||
break;
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
if (type !== null && adjustmentId !== null) {
|
||||
let newAdjustment: CharacterDefenseAdjustmentContract = {
|
||||
type,
|
||||
adjustmentId,
|
||||
source: null,
|
||||
};
|
||||
dispatch(characterActions.customDefenseAdjustmentAdd(newAdjustment));
|
||||
}
|
||||
};
|
||||
|
||||
handleDataOriginDetailShow = (dataOrigin: DataOrigin): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
renderExtraDataOrigin = (dataOrigin: DataOrigin): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
|
||||
return (
|
||||
<span className="ct-defense-manage-pane__item-extra">
|
||||
(
|
||||
<DataOriginName
|
||||
dataOrigin={dataOrigin}
|
||||
onClick={this.handleDataOriginDetailShow}
|
||||
theme={theme}
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
getDefenseAdjustmentIcon = (
|
||||
type: Constants.DefenseAdjustmentTypeEnum,
|
||||
slug: string
|
||||
): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
|
||||
let iconClassNames: Array<string> = [
|
||||
"ct-defense-manage-pane__item-icon",
|
||||
`ct-defense-manage-pane__item-icon--${slug}`,
|
||||
];
|
||||
|
||||
switch (type) {
|
||||
case Constants.DefenseAdjustmentTypeEnum.CONDITION_ADJUSTMENT:
|
||||
if (theme.isDarkMode) {
|
||||
iconClassNames.push(`i-condition-white-${slug}`);
|
||||
} else {
|
||||
iconClassNames.push(`i-condition-${slug}`);
|
||||
}
|
||||
|
||||
return <i className={iconClassNames.join(" ")} />;
|
||||
case Constants.DefenseAdjustmentTypeEnum.DAMAGE_ADJUSTMENT:
|
||||
return (
|
||||
<DamageTypeIcon
|
||||
type={slug as ComponentConstants.DamageTypePropType}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
renderDamageAdjustmentList = (
|
||||
defenseAdjustments: Array<DefenseAdjustment>
|
||||
): React.ReactNode => {
|
||||
return defenseAdjustments.map((defenseAdjustment, idx) => {
|
||||
const { dataOrigin, name, isCustom, slug, type } = defenseAdjustment;
|
||||
|
||||
return (
|
||||
<div className="ct-defense-manage-pane__item" key={idx}>
|
||||
<div className="ct-defense-manage-pane__item-value">
|
||||
{this.getDefenseAdjustmentIcon(type, slug)}
|
||||
</div>
|
||||
<div className="ct-defense-manage-pane__item-label">
|
||||
<span className="ct-defense-manage-pane__item-label-text">
|
||||
{name}
|
||||
{isCustom ? "*" : ""}
|
||||
</span>
|
||||
{this.renderExtraDataOrigin(dataOrigin)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
//`
|
||||
};
|
||||
|
||||
renderDamageAdjustmentGroup = (
|
||||
label: string,
|
||||
defenseAdjustments: Array<DefenseAdjustment>
|
||||
): React.ReactNode => {
|
||||
if (!defenseAdjustments.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="ct-defense-manage-pane__group">
|
||||
<div className="ct-defense-manage-pane__heading">{label}</div>
|
||||
<div className="ct-defense-manage-pane__group-items">
|
||||
{this.renderDamageAdjustmentList(defenseAdjustments)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCustomDefenseAdjustmentList = (): React.ReactNode => {
|
||||
const { customDefenseAdjustments, ruleData } = this.props;
|
||||
|
||||
const damageAdjustmentDataLookup =
|
||||
RuleDataUtils.getDamageAdjustmentsLookup(ruleData);
|
||||
const conditionDataLookup = RuleDataUtils.getConditionLookup(ruleData);
|
||||
|
||||
let adjustmentGroups: Array<AdjustmentGroupInfo> = [
|
||||
{ label: "Resistances", adjustments: [] },
|
||||
{ label: "Immunities", adjustments: [] },
|
||||
{ label: "Vulnerabilities", adjustments: [] },
|
||||
];
|
||||
customDefenseAdjustments.forEach((defenseAdjustment, newDefIdx) => {
|
||||
const { type, adjustmentId, source } = defenseAdjustment;
|
||||
|
||||
let groupIdx: number | null = null;
|
||||
let definition:
|
||||
| ConditionDefinitionContract
|
||||
| DamageAdjustmentContract
|
||||
| null = null;
|
||||
switch (type) {
|
||||
case Constants.DefenseAdjustmentTypeEnum.DAMAGE_ADJUSTMENT:
|
||||
definition = HelperUtils.lookupDataOrFallback(
|
||||
damageAdjustmentDataLookup,
|
||||
adjustmentId
|
||||
);
|
||||
if (definition !== null) {
|
||||
switch (definition.type) {
|
||||
case Constants.DamageAdjustmentTypeEnum.RESISTANCE:
|
||||
groupIdx = 0;
|
||||
break;
|
||||
case Constants.DamageAdjustmentTypeEnum.IMMUNITY:
|
||||
groupIdx = 1;
|
||||
break;
|
||||
case Constants.DamageAdjustmentTypeEnum.VULNERABILITY:
|
||||
groupIdx = 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Constants.DefenseAdjustmentTypeEnum.CONDITION_ADJUSTMENT:
|
||||
let conditionData = HelperUtils.lookupDataOrFallback(
|
||||
conditionDataLookup,
|
||||
adjustmentId
|
||||
);
|
||||
if (conditionData !== null) {
|
||||
definition = conditionData.definition;
|
||||
groupIdx = 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
if (groupIdx !== null && definition !== null) {
|
||||
adjustmentGroups[groupIdx].adjustments.push({
|
||||
id: defenseAdjustment.adjustmentId,
|
||||
type: defenseAdjustment.type,
|
||||
source: defenseAdjustment.source,
|
||||
idx: newDefIdx,
|
||||
definition,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return adjustmentGroups.map((adjustmentGroup, groupIdx) => {
|
||||
if (!adjustmentGroup.adjustments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sortedAdjustments: Array<AdjustmentGroupEntry> = sortBy(
|
||||
adjustmentGroup.adjustments,
|
||||
[(adjustment: any) => adjustment.definition.name]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-defense-manage-pane__custom-group" key={groupIdx}>
|
||||
<div className="ct-defense-manage-pane__heading">
|
||||
{adjustmentGroup.label}
|
||||
</div>
|
||||
{sortedAdjustments.map((defenseAdjustment) => {
|
||||
const { type, id, source, definition } = defenseAdjustment;
|
||||
|
||||
return (
|
||||
<DefenseManagePaneCustomItem
|
||||
key={`${type}-${id}`}
|
||||
id={id}
|
||||
type={type}
|
||||
initialValue={source}
|
||||
label={definition.name ? definition.name : ""}
|
||||
onRemove={this.handleAdjustmentRemove}
|
||||
onSourceUpdate={this.handleAdjustmentSourceUpdate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
//`
|
||||
};
|
||||
|
||||
renderCustomUi = (): React.ReactNode => {
|
||||
const { adjustmentType } = this.state;
|
||||
const { isReadonly } = this.props;
|
||||
|
||||
if (isReadonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adjustmentTypeOptions: Array<HtmlSelectOption> = [
|
||||
{ label: "Resistance", value: DEFENSE_ADJUSTMENT_TYPE_OPTION.RESISTANCE },
|
||||
{ label: "Immunity", value: DEFENSE_ADJUSTMENT_TYPE_OPTION.IMMUNITY },
|
||||
{
|
||||
label: "Vulnerability",
|
||||
value: DEFENSE_ADJUSTMENT_TYPE_OPTION.VULNERABILITY,
|
||||
},
|
||||
];
|
||||
|
||||
let adjustmentSubTypeOptions: Array<
|
||||
HtmlSelectOptionGroup | HtmlSelectOption
|
||||
> = [];
|
||||
let subTypePlaceholder: string = "";
|
||||
switch (adjustmentType) {
|
||||
case DEFENSE_ADJUSTMENT_TYPE_OPTION.RESISTANCE:
|
||||
adjustmentSubTypeOptions = this.getAvailableResistances().map(
|
||||
(data) => ({
|
||||
label: data.name,
|
||||
value: data.id,
|
||||
})
|
||||
);
|
||||
subTypePlaceholder = "-- Choose a Resistance --";
|
||||
break;
|
||||
case DEFENSE_ADJUSTMENT_TYPE_OPTION.VULNERABILITY:
|
||||
adjustmentSubTypeOptions = this.getAvailableVulnerabilities().map(
|
||||
(data) => ({
|
||||
label: data.name,
|
||||
value: data.id,
|
||||
})
|
||||
);
|
||||
subTypePlaceholder = "-- Choose a Vulnerability --";
|
||||
break;
|
||||
case DEFENSE_ADJUSTMENT_TYPE_OPTION.IMMUNITY:
|
||||
adjustmentSubTypeOptions = [
|
||||
{
|
||||
optGroupLabel: "Damage",
|
||||
options: this.getAvailableDamageImmunities().map((data) => ({
|
||||
label: data.name,
|
||||
value: `${Constants.DefenseAdjustmentTypeEnum.DAMAGE_ADJUSTMENT}-${data.id}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
optGroupLabel: "Conditions",
|
||||
options: this.getAvailableConditionImmunities().map((data) => ({
|
||||
label: ConditionUtils.getName(data),
|
||||
value: `${
|
||||
Constants.DefenseAdjustmentTypeEnum.CONDITION_ADJUSTMENT
|
||||
}-${ConditionUtils.getUniqueKey(data)}`,
|
||||
})),
|
||||
},
|
||||
];
|
||||
subTypePlaceholder = "-- Choose an Immunity --";
|
||||
//`
|
||||
break;
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header="Customize"
|
||||
className="ct-defense-manage-pane__custom"
|
||||
>
|
||||
<div className="ct-defense-manage-pane__custom-fields">
|
||||
<div className="dct-defense-manage-pane__custom-field">
|
||||
<Select
|
||||
options={adjustmentTypeOptions}
|
||||
onChange={this.handleChangeAdjustmentType}
|
||||
value={adjustmentType}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-defense-manage-pane__custom-field">
|
||||
{adjustmentType !== null && (
|
||||
<Select
|
||||
options={sortBy(adjustmentSubTypeOptions, "label")}
|
||||
onChange={this.handleChangeAdjustmentSubType}
|
||||
resetAfterChoice={true}
|
||||
placeholder={subTypePlaceholder}
|
||||
value={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-defense-manage-pane__custom-items">
|
||||
{this.renderCustomDefenseAdjustmentList()}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
renderDefault = (): React.ReactNode => {
|
||||
return (
|
||||
<div className="ct-defense-manage-pane__default">
|
||||
No Resistances, Immunities, or Vulnerabilities
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderDefenseGroups = (): React.ReactNode => {
|
||||
const { resistances, vulnerabilities, immunities } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.renderDamageAdjustmentGroup("Resistances", resistances)}
|
||||
{this.renderDamageAdjustmentGroup("Immunities", immunities)}
|
||||
{this.renderDamageAdjustmentGroup("Vulnerabilities", vulnerabilities)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { resistances, vulnerabilities, immunities } = this.props;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (!resistances.length && !vulnerabilities.length && !immunities.length) {
|
||||
contentNode = this.renderDefault();
|
||||
} else {
|
||||
contentNode = this.renderDefenseGroups();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-defense-manage-pane">
|
||||
<Header>Defenses</Header>
|
||||
{contentNode}
|
||||
{this.renderCustomUi()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
resistances: rulesEngineSelectors.getActiveResistances(state),
|
||||
immunities: rulesEngineSelectors.getActiveImmunities(state),
|
||||
vulnerabilities: rulesEngineSelectors.getActiveVulnerabilities(state),
|
||||
|
||||
resistanceData: rulesEngineSelectors.getResistanceData(state),
|
||||
vulnerabilityData: rulesEngineSelectors.getVulnerabilityData(state),
|
||||
conditionImmunityData: rulesEngineSelectors.getConditionImmunityData(state),
|
||||
damageImmunityData: rulesEngineSelectors.getDamageImmunityData(state),
|
||||
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
|
||||
customDefenseAdjustments:
|
||||
rulesEngineSelectors.getCustomDefenseAdjustments(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
};
|
||||
}
|
||||
|
||||
const DefenseManagePaneContainer = (props) => {
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return <DefenseManagePane {...props} paneHistoryPush={paneHistoryPush} />;
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(DefenseManagePaneContainer);
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
|
||||
import { RemoveButton } from "../../../../components/common/Button";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
type: number;
|
||||
id: number;
|
||||
initialValue: string | null;
|
||||
sourcePlaceholder: string;
|
||||
onRemove?: (type: number, id: number) => void;
|
||||
onSourceUpdate?: (type: number, id: number, value: string | null) => void;
|
||||
}
|
||||
interface State {
|
||||
value: string | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
export default class DefenseManagePaneCustomItem extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
sourcePlaceholder: "Enter Source Note...",
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.initialValue,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleRemove = (): void => {
|
||||
const { type, id, onRemove } = this.props;
|
||||
|
||||
if (onRemove) {
|
||||
onRemove(type, id);
|
||||
}
|
||||
};
|
||||
|
||||
handleSourceBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { value, isDirty } = this.state;
|
||||
const { type, id, onSourceUpdate } = this.props;
|
||||
|
||||
if (isDirty && onSourceUpdate) {
|
||||
onSourceUpdate(type, id, value);
|
||||
this.setState({
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleSourceChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
value: evt.target.value,
|
||||
isDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { label, sourcePlaceholder } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-defense-manage-pane__custom-item">
|
||||
<div className="ct-defense-manage-pane__custom-item-action">
|
||||
<RemoveButton onClick={this.handleRemove} />
|
||||
</div>
|
||||
<div className="ct-defense-manage-pane__custom-item-label">{label}</div>
|
||||
<div className="ct-defense-manage-pane__custom-item-source">
|
||||
<input
|
||||
type="text"
|
||||
onChange={this.handleSourceChange}
|
||||
onBlur={this.handleSourceBlur}
|
||||
value={value === null ? "" : value}
|
||||
placeholder={sourcePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DefenseManagePaneCustomItem from "./DefenseManagePaneCustomItem";
|
||||
|
||||
export default DefenseManagePaneCustomItem;
|
||||
export { DefenseManagePaneCustomItem };
|
||||
@@ -0,0 +1,5 @@
|
||||
import DefenseManagePane from "./DefenseManagePane";
|
||||
import DefenseManagePaneCustomItem from "./DefenseManagePaneCustomItem";
|
||||
|
||||
export default DefenseManagePane;
|
||||
export { DefenseManagePane, DefenseManagePaneCustomItem };
|
||||
@@ -0,0 +1,264 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
AlignmentContract,
|
||||
characterActions,
|
||||
CharacterLifestyleContract,
|
||||
HtmlSelectOption,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
rulesEngineSelectors,
|
||||
SizeContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { TypeScriptUtils } from "../../../utils";
|
||||
import DescriptionPaneEntry from "./DescriptionPaneEntry";
|
||||
import DescriptionPaneEntryContent from "./DescriptionPaneEntryContent";
|
||||
import DescriptionPaneNumberEditor from "./DescriptionPaneNumberEditor";
|
||||
import DescriptionPaneSelectEditor from "./DescriptionPaneSelectEditor";
|
||||
import DescriptionPaneTextEditor from "./DescriptionPaneTextEditor";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
height: string | null;
|
||||
weight: number | null;
|
||||
size: SizeContract | null;
|
||||
faith: string | null;
|
||||
skin: string | null;
|
||||
eyes: string | null;
|
||||
hair: string | null;
|
||||
age: number | null;
|
||||
gender: string | null;
|
||||
alignment: AlignmentContract | null;
|
||||
lifestyle: CharacterLifestyleContract | null;
|
||||
|
||||
ruleData: RuleData;
|
||||
}
|
||||
class DescriptionPane extends React.PureComponent<Props> {
|
||||
handleAlignmentUpdate = (propertyKey: string, value: number | null): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.alignmentSet(value));
|
||||
};
|
||||
|
||||
handleLifestyleUpdate = (propertyKey: string, value: number | null): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.lifestyleSet(value));
|
||||
};
|
||||
|
||||
handleFaithChange = (propertyKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.faithSet(value));
|
||||
};
|
||||
|
||||
handleHairChange = (propertyKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.hairSet(value));
|
||||
};
|
||||
|
||||
handleSkinChange = (propertyKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.skinSet(value));
|
||||
};
|
||||
|
||||
handleEyesChange = (propertyKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.eyesSet(value));
|
||||
};
|
||||
|
||||
handleHeightChange = (propertyKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.heightSet(value));
|
||||
};
|
||||
|
||||
handleWeightChange = (propertyKey: string, value: number | null): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.weightSet(value));
|
||||
};
|
||||
|
||||
handleAgeChange = (propertyKey: string, value: number | null): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.ageSet(value));
|
||||
};
|
||||
|
||||
handleGenderChange = (propertyKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.genderSet(value));
|
||||
};
|
||||
|
||||
renderCharacterDetails = (): React.ReactNode => {
|
||||
const { alignment, lifestyle, ruleData, faith } = this.props;
|
||||
|
||||
const alignmentData = RuleDataUtils.getAlignments(ruleData);
|
||||
const lifestyleData = RuleDataUtils.getLifestyles(ruleData);
|
||||
const alignmentOptions = RuleDataUtils.getAlignmentOptions(ruleData);
|
||||
|
||||
let alignmentDescriptionNode: React.ReactNode;
|
||||
if (alignment !== null && alignment.description) {
|
||||
alignmentDescriptionNode = (
|
||||
<HtmlContent html={alignment.description} withoutTooltips />
|
||||
);
|
||||
}
|
||||
|
||||
const lifestyleOptions: Array<HtmlSelectOption> = lifestyleData
|
||||
.map((lifestyle) => {
|
||||
if (lifestyle.id === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: `${lifestyle.name} ${
|
||||
lifestyle.cost === "-" ? "" : `(${lifestyle.cost})`
|
||||
}`,
|
||||
value: lifestyle.id,
|
||||
};
|
||||
})
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
let lifestyleDescriptionNode: React.ReactNode;
|
||||
if (lifestyle !== null) {
|
||||
lifestyleDescriptionNode = lifestyle.description;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneSelectEditor
|
||||
label="Alignment"
|
||||
options={alignmentOptions}
|
||||
defaultValue={alignment === null ? null : alignment.id}
|
||||
propertyKey="alignmentId"
|
||||
onUpdate={this.handleAlignmentUpdate}
|
||||
/>
|
||||
<DescriptionPaneEntryContent>
|
||||
{alignmentDescriptionNode}
|
||||
</DescriptionPaneEntryContent>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneTextEditor
|
||||
label="Faith"
|
||||
defaultValue={faith}
|
||||
propertyKey="faith"
|
||||
onUpdate={this.handleFaithChange}
|
||||
maxLength={512}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneSelectEditor
|
||||
label="Lifestyle"
|
||||
options={lifestyleOptions}
|
||||
defaultValue={lifestyle === null ? null : lifestyle.id}
|
||||
propertyKey="lifestyleId"
|
||||
onUpdate={this.handleLifestyleUpdate}
|
||||
/>
|
||||
<DescriptionPaneEntryContent>
|
||||
{lifestyleDescriptionNode}
|
||||
</DescriptionPaneEntryContent>
|
||||
</DescriptionPaneEntry>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderPhysicalCharacteristics = (): React.ReactNode => {
|
||||
const { height, weight, skin, eyes, hair, age, gender } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneTextEditor
|
||||
label="Hair"
|
||||
defaultValue={hair}
|
||||
propertyKey="hair"
|
||||
onUpdate={this.handleHairChange}
|
||||
maxLength={50}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneTextEditor
|
||||
label="Skin"
|
||||
defaultValue={skin}
|
||||
propertyKey="skin"
|
||||
onUpdate={this.handleSkinChange}
|
||||
maxLength={50}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneTextEditor
|
||||
label="Eyes"
|
||||
defaultValue={eyes}
|
||||
propertyKey="eyes"
|
||||
onUpdate={this.handleEyesChange}
|
||||
maxLength={50}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneTextEditor
|
||||
label="Height"
|
||||
defaultValue={height}
|
||||
propertyKey="height"
|
||||
onUpdate={this.handleHeightChange}
|
||||
maxLength={50}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneNumberEditor
|
||||
label="Weight (lbs)"
|
||||
defaultValue={weight}
|
||||
propertyKey="weight"
|
||||
onUpdate={this.handleWeightChange}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneNumberEditor
|
||||
label="Age (Years)"
|
||||
defaultValue={age}
|
||||
propertyKey="age"
|
||||
onUpdate={this.handleAgeChange}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
<DescriptionPaneEntry>
|
||||
<DescriptionPaneTextEditor
|
||||
label="Gender"
|
||||
defaultValue={gender}
|
||||
propertyKey="gender"
|
||||
onUpdate={this.handleGenderChange}
|
||||
maxLength={128}
|
||||
/>
|
||||
</DescriptionPaneEntry>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-description-pane">
|
||||
<Header>Characteristics and Details</Header>
|
||||
<div className="ct-description-pane__entries">
|
||||
{this.renderCharacterDetails()}
|
||||
{this.renderPhysicalCharacteristics()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
alignment: rulesEngineSelectors.getAlignment(state),
|
||||
height: rulesEngineSelectors.getHeight(state),
|
||||
weight: rulesEngineSelectors.getWeight(state),
|
||||
size: rulesEngineSelectors.getSize(state),
|
||||
faith: rulesEngineSelectors.getFaith(state),
|
||||
skin: rulesEngineSelectors.getSkin(state),
|
||||
eyes: rulesEngineSelectors.getEyes(state),
|
||||
hair: rulesEngineSelectors.getHair(state),
|
||||
age: rulesEngineSelectors.getAge(state),
|
||||
gender: rulesEngineSelectors.getGender(state),
|
||||
lifestyle: rulesEngineSelectors.getLifestyle(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(DescriptionPane);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
propertyKey: string;
|
||||
className: string;
|
||||
}
|
||||
export default class DescriptionPaneEditor extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { propertyKey, className } = this.props;
|
||||
|
||||
let classNames: Array<string> = [
|
||||
"ct-description-pane__editor",
|
||||
`ct-description-pane__editor--${FormatUtils.slugify(propertyKey)}`,
|
||||
className,
|
||||
];
|
||||
|
||||
return <div className={classNames.join(" ")}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneEditor from "./DescriptionPaneEditor";
|
||||
|
||||
export default DescriptionPaneEditor;
|
||||
export { DescriptionPaneEditor };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class DescriptionPaneEditorLabel extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-description-pane__editor-label">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneEditorLabel from "./DescriptionPaneEditorLabel";
|
||||
|
||||
export default DescriptionPaneEditorLabel;
|
||||
export { DescriptionPaneEditorLabel };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class DescriptionPaneEditorValue extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-description-pane__editor-value">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneEditorValue from "./DescriptionPaneEditorValue";
|
||||
|
||||
export default DescriptionPaneEditorValue;
|
||||
export { DescriptionPaneEditorValue };
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class DescriptionPaneEntry extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-description-pane__entry">{this.props.children}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneEntry from "./DescriptionPaneEntry";
|
||||
|
||||
export default DescriptionPaneEntry;
|
||||
export { DescriptionPaneEntry };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class DescriptionPaneEntryContent extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-description-pane__entry-content">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneEntryContent from "./DescriptionPaneEntryContent";
|
||||
|
||||
export default DescriptionPaneEntryContent;
|
||||
export { DescriptionPaneEntryContent };
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
|
||||
import { HelperUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { CHARACTER_DESCRIPTION_NUMBER_VALUE } from "../../../../constants/App";
|
||||
import DescriptionPaneEditor from "../DescriptionPaneEditor";
|
||||
import DescriptionPaneEditorLabel from "../DescriptionPaneEditorLabel";
|
||||
import DescriptionPaneEditorValue from "../DescriptionPaneEditorValue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: number | null;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
onUpdate?: (propertyKey: string, value: number | null) => void;
|
||||
}
|
||||
interface State {
|
||||
value: number | null;
|
||||
}
|
||||
export default class DescriptionPaneNumberEditor extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
minValue: CHARACTER_DESCRIPTION_NUMBER_VALUE.MIN,
|
||||
maxValue: CHARACTER_DESCRIPTION_NUMBER_VALUE.MAX,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { propertyKey, onUpdate, maxValue, minValue } = this.props;
|
||||
|
||||
let parsedValue = HelperUtils.parseInputInt(evt.target.value);
|
||||
let clampedValue: number | null =
|
||||
parsedValue === null
|
||||
? null
|
||||
: HelperUtils.clampInt(parsedValue, minValue, maxValue);
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, clampedValue);
|
||||
}
|
||||
this.setState({
|
||||
value: clampedValue,
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
value: HelperUtils.parseInputInt(evt.target.value),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, label, maxValue, minValue } = this.props;
|
||||
|
||||
return (
|
||||
<DescriptionPaneEditor
|
||||
propertyKey={propertyKey}
|
||||
className="ct-description-pane__editor--number"
|
||||
>
|
||||
<DescriptionPaneEditorLabel>{label}</DescriptionPaneEditorLabel>
|
||||
<DescriptionPaneEditorValue>
|
||||
<input
|
||||
className="ct-description-pane__editor-input"
|
||||
type="number"
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
value={value === null ? "" : value}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</DescriptionPaneEditorValue>
|
||||
</DescriptionPaneEditor>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneNumberEditor from "./DescriptionPaneNumberEditor";
|
||||
|
||||
export default DescriptionPaneNumberEditor;
|
||||
export { DescriptionPaneNumberEditor };
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import DescriptionPaneEditor from "../DescriptionPaneEditor";
|
||||
import DescriptionPaneEditorLabel from "../DescriptionPaneEditorLabel";
|
||||
import DescriptionPaneEditorValue from "../DescriptionPaneEditorValue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: number | null;
|
||||
options: Array<HtmlSelectOption>;
|
||||
onUpdate?: (propertyKey: string, value: number | null) => void;
|
||||
}
|
||||
interface State {
|
||||
value: number | null;
|
||||
}
|
||||
export default class DescriptionPaneSelectEditor extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
handleChange = (value: string): void => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
const parsedValue = HelperUtils.parseInputInt(value);
|
||||
onUpdate(propertyKey, parsedValue);
|
||||
this.setState({
|
||||
value: parsedValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, label, options } = this.props;
|
||||
|
||||
return (
|
||||
<DescriptionPaneEditor
|
||||
propertyKey={propertyKey}
|
||||
className="ct-description-pane__editor--select"
|
||||
>
|
||||
<DescriptionPaneEditorLabel>{label}</DescriptionPaneEditorLabel>
|
||||
<DescriptionPaneEditorValue>
|
||||
<Select
|
||||
className="ct-description-pane__editor-input"
|
||||
placeholder="--"
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</DescriptionPaneEditorValue>
|
||||
</DescriptionPaneEditor>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneSelectEditor from "./DescriptionPaneSelectEditor";
|
||||
|
||||
export default DescriptionPaneSelectEditor;
|
||||
export { DescriptionPaneSelectEditor };
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
|
||||
import DescriptionPaneEditor from "../DescriptionPaneEditor";
|
||||
import DescriptionPaneEditorLabel from "../DescriptionPaneEditorLabel";
|
||||
import DescriptionPaneEditorValue from "../DescriptionPaneEditorValue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: string | null;
|
||||
onUpdate?: (propertyKey: string, value: string) => void;
|
||||
maxLength: number | null;
|
||||
}
|
||||
export default class DescriptionPaneTextEditor extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
maxLength: null,
|
||||
};
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, evt.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { propertyKey, label, defaultValue, maxLength } = this.props;
|
||||
|
||||
return (
|
||||
<DescriptionPaneEditor
|
||||
propertyKey={propertyKey}
|
||||
className="ct-description-pane__editor--text"
|
||||
>
|
||||
<DescriptionPaneEditorLabel>{label}</DescriptionPaneEditorLabel>
|
||||
<DescriptionPaneEditorValue>
|
||||
<input
|
||||
className="ct-description-pane__editor-input"
|
||||
type="text"
|
||||
defaultValue={defaultValue === null ? "" : defaultValue}
|
||||
onBlur={this.handleBlur}
|
||||
maxLength={maxLength === null ? undefined : maxLength}
|
||||
/>
|
||||
</DescriptionPaneEditorValue>
|
||||
</DescriptionPaneEditor>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import DescriptionPaneTextEditor from "./DescriptionPaneTextEditor";
|
||||
|
||||
export default DescriptionPaneTextEditor;
|
||||
export { DescriptionPaneTextEditor };
|
||||
@@ -0,0 +1,22 @@
|
||||
import DescriptionPane from "./DescriptionPane";
|
||||
import DescriptionPaneEditor from "./DescriptionPaneEditor";
|
||||
import DescriptionPaneEditorLabel from "./DescriptionPaneEditorLabel";
|
||||
import DescriptionPaneEditorValue from "./DescriptionPaneEditorValue";
|
||||
import DescriptionPaneEntry from "./DescriptionPaneEntry";
|
||||
import DescriptionPaneEntryContent from "./DescriptionPaneEntryContent";
|
||||
import DescriptionPaneNumberEditor from "./DescriptionPaneNumberEditor";
|
||||
import DescriptionPaneSelectEditor from "./DescriptionPaneSelectEditor";
|
||||
import DescriptionPaneTextEditor from "./DescriptionPaneTextEditor";
|
||||
|
||||
export default DescriptionPane;
|
||||
export {
|
||||
DescriptionPane,
|
||||
DescriptionPaneEditor,
|
||||
DescriptionPaneEditorLabel,
|
||||
DescriptionPaneEditorValue,
|
||||
DescriptionPaneEntry,
|
||||
DescriptionPaneEntryContent,
|
||||
DescriptionPaneNumberEditor,
|
||||
DescriptionPaneSelectEditor,
|
||||
DescriptionPaneTextEditor,
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
CharacterPreferences,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
|
||||
import SettingsButton from "../../../../CharacterSheet/components/SettingsButton";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { SettingsContextsEnum } from "../SettingsPane/typings";
|
||||
|
||||
interface Props {
|
||||
preferences: CharacterPreferences;
|
||||
carryCapacity: number;
|
||||
pushDragLiftWeight: number;
|
||||
encumberedWeight: number;
|
||||
heavilyEncumberedWeight: number;
|
||||
coinWeight: number;
|
||||
itemWeight: number;
|
||||
isReadonly: boolean;
|
||||
theme?: CharacterTheme;
|
||||
}
|
||||
class EncumbrancePane extends React.PureComponent<Props> {
|
||||
renderWeight = (label, weight): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
return (
|
||||
<div className="ct-encumbrance-pane__weight">
|
||||
<span className="ct-encumbrance-pane__weight-label">{label}:</span>
|
||||
<span className="ct-encumbrance-pane__weight-value">
|
||||
<NumberDisplay type="weightInLb" number={weight} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderWeightDistributions = (): React.ReactNode => {
|
||||
const { preferences, coinWeight, itemWeight } = this.props;
|
||||
// When ignoreCoinWeight is off
|
||||
// item weight should be the weight of items without coins
|
||||
// and coin weight should be the weight of all coins in all equipped containers
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Heading>Current Weight Distribution</Heading>
|
||||
<div className="ct-encumbrance-pane__weights">
|
||||
{this.renderWeight("Items", itemWeight)}
|
||||
{!preferences.ignoreCoinWeight &&
|
||||
this.renderWeight("Coins", coinWeight)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
preferences,
|
||||
carryCapacity,
|
||||
pushDragLiftWeight,
|
||||
encumberedWeight,
|
||||
heavilyEncumberedWeight,
|
||||
isReadonly,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-encumbrance-pane">
|
||||
<Header
|
||||
callout={
|
||||
<SettingsButton
|
||||
context={SettingsContextsEnum.ENCUMBRANCE}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Encumbrance
|
||||
</Header>
|
||||
|
||||
<div className="ct-encumbrance-pane__info">
|
||||
{preferences.encumbranceType ===
|
||||
Constants.PreferenceEncumbranceTypeEnum.NONE && (
|
||||
<React.Fragment>
|
||||
<div className="ct-encumbrance-pane__weights">
|
||||
{this.renderWeight("Carrying Capacity", carryCapacity)}
|
||||
{this.renderWeight("Push, Drag, or Lift", pushDragLiftWeight)}
|
||||
</div>
|
||||
{this.renderWeightDistributions()}
|
||||
<Heading>No Encumbrance</Heading>
|
||||
<p>
|
||||
Encumbrance rules are not currently applied to this character.
|
||||
</p>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{preferences.encumbranceType ===
|
||||
Constants.PreferenceEncumbranceTypeEnum.ENCUMBRANCE && (
|
||||
<React.Fragment>
|
||||
<div className="ct-encumbrance-pane__weights">
|
||||
{this.renderWeight("Carrying Capacity", carryCapacity)}
|
||||
{this.renderWeight("Push, Drag, or Lift", pushDragLiftWeight)}
|
||||
</div>
|
||||
{this.renderWeightDistributions()}
|
||||
<Heading>Lifting and Carrying</Heading>
|
||||
<p>
|
||||
Your Strength score determines the amount of weight you can
|
||||
bear. The following terms define what you can lift or carry.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Carrying Capacity. Your carrying capacity is your Strength score
|
||||
multiplied by 15. This is the weight (in pounds) that you can
|
||||
carry, which is high enough that most characters don't usually
|
||||
have to worry about it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Push, Drag, or Lift. You can push, drag, or lift a weight in
|
||||
pounds up to twice your carrying capacity (or 30 times your
|
||||
Strength score). While pushing or dragging weight in excess of
|
||||
your carrying capacity, your speed drops to 5 feet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Size and Strength. Larger creatures can bear more weight,
|
||||
whereas Tiny creatures can carry less. For each size category
|
||||
above Medium, double the creature's carrying capacity and the
|
||||
amount it can push, drag, or lift. For a Tiny creature, halve
|
||||
these weights.
|
||||
</p>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{preferences.encumbranceType ===
|
||||
Constants.PreferenceEncumbranceTypeEnum.VARIANT && (
|
||||
<React.Fragment>
|
||||
<div className="ct-encumbrance-pane__weights">
|
||||
{this.renderWeight("Carrying Capacity", carryCapacity)}
|
||||
{this.renderWeight("Push, Drag, or Lift", pushDragLiftWeight)}
|
||||
{this.renderWeight("Encumbered Weight", encumberedWeight)}
|
||||
{this.renderWeight(
|
||||
"Heavily Encumbered Weight",
|
||||
heavilyEncumberedWeight
|
||||
)}
|
||||
</div>
|
||||
{this.renderWeightDistributions()}
|
||||
<Heading>Variant: Encumbrance</Heading>
|
||||
<p>
|
||||
The rules for lifting and carrying are intentionally simple.
|
||||
Here is a variant if you are looking for more detailed rules for
|
||||
determining how a character is hindered by the weight of
|
||||
equipment. When you use this variant, ignore the Strength column
|
||||
of the Armor table.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you carry weight in excess of 5 times your Strength score,
|
||||
you are encumbered, which means your speed drops by 10 feet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you carry weight in excess of 10 times your Strength score,
|
||||
up to your maximum carrying capacity, you are instead heavily
|
||||
encumbered, which means your speed drops by 20 feet and you have
|
||||
disadvantage on ability checks, attack rolls, and saving throws
|
||||
that use Strength, Dexterity, or Constitution.
|
||||
</p>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
carryCapacity: rulesEngineSelectors.getCarryCapacity(state),
|
||||
pushDragLiftWeight: rulesEngineSelectors.getPushDragLiftWeight(state),
|
||||
encumberedWeight: rulesEngineSelectors.getEncumberedWeight(state),
|
||||
heavilyEncumberedWeight:
|
||||
rulesEngineSelectors.getHeavilyEncumberedWeight(state),
|
||||
preferences: rulesEngineSelectors.getCharacterPreferences(state),
|
||||
coinWeight: rulesEngineSelectors.getCointainerCoinWeight(state),
|
||||
itemWeight: rulesEngineSelectors.getContainerItemWeight(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EncumbrancePane);
|
||||
@@ -0,0 +1,4 @@
|
||||
import EncumbrancePane from "./EncumbrancePane";
|
||||
|
||||
export default EncumbrancePane;
|
||||
export { EncumbrancePane };
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
CollapsibleHeading,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiResponse,
|
||||
rulesEngineSelectors,
|
||||
BaseItemDefinitionContract,
|
||||
RuleData,
|
||||
CampaignUtils,
|
||||
CharacterTheme,
|
||||
Container,
|
||||
ContainerUtils,
|
||||
InventoryManager,
|
||||
Constants,
|
||||
serviceDataSelectors,
|
||||
PartyInfo,
|
||||
ItemManager,
|
||||
} 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 { Popover } from "~/components/Popover";
|
||||
import { PopoverContent } from "~/components/PopoverContent";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { CustomItemCreator } from "../../../components/CustomItemCreator";
|
||||
import EquipmentShop from "../../../components/EquipmentShop";
|
||||
import ItemDetail from "../../../components/ItemDetail";
|
||||
import { ItemSlotManager } from "../../../components/ItemSlotManager";
|
||||
import { ThemeButtonWithMenu } from "../../../components/common/Button";
|
||||
import { InventoryManagerContext } from "../../../managers/InventoryManagerContext";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
|
||||
interface Props {
|
||||
loadAvailableItems: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<Array<BaseItemDefinitionContract>>>;
|
||||
ruleData: RuleData;
|
||||
proficiencyBonus: number;
|
||||
containers: Array<Container>;
|
||||
theme: CharacterTheme;
|
||||
partyInfo: PartyInfo | null;
|
||||
inventoryManager: InventoryManager;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
|
||||
class EquipmentManagePane extends React.PureComponent<Props> {
|
||||
renderInventory = (inventory: Array<ItemManager>): React.ReactNode => {
|
||||
const {
|
||||
theme,
|
||||
ruleData,
|
||||
proficiencyBonus,
|
||||
inventoryManager,
|
||||
partyInfo,
|
||||
containers,
|
||||
} = this.props;
|
||||
|
||||
return inventory.map((item) => {
|
||||
const isContainer = item.isContainer();
|
||||
const container = inventoryManager.getContainer(
|
||||
item.generateContainerDefinitionKey()
|
||||
);
|
||||
|
||||
const canMove = inventoryManager.canMoveItem(item.item);
|
||||
|
||||
const calloutButton: React.ReactNode = isContainer ? (
|
||||
<Popover
|
||||
trigger={
|
||||
<Button size="xx-small" variant="outline" themed>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
position="bottomRight"
|
||||
data-testid="remove-container-button"
|
||||
maxWidth={250}
|
||||
>
|
||||
<PopoverContent
|
||||
title={`Remove ${item.getName()}?`}
|
||||
content={`Removing the ${item.getName()} will also remove all of its ${
|
||||
container && container.hasInfusions() ? "infusions and " : " "
|
||||
} contents.`}
|
||||
confirmText="Delete"
|
||||
onConfirm={() => item.handleRemove()}
|
||||
withCancel
|
||||
/>
|
||||
</Popover>
|
||||
) : canMove ? (
|
||||
<ThemeButtonWithMenu
|
||||
showSingleOption={true}
|
||||
containerEl={
|
||||
document.querySelector(".ct-sidebar__portal") as HTMLElement
|
||||
}
|
||||
groupedOptions={ContainerUtils.getGroupedOptions(
|
||||
item.getContainerDefinitionKey(),
|
||||
containers,
|
||||
"Move To:",
|
||||
partyInfo
|
||||
? CampaignUtils.getSharingState(partyInfo)
|
||||
: Constants.PartyInventorySharingStateEnum.OFF
|
||||
)}
|
||||
buttonStyle="outline"
|
||||
onSelect={(containerDefinitionKey) =>
|
||||
item.handleMove({ containerDefinitionKey })
|
||||
}
|
||||
>
|
||||
Move
|
||||
</ThemeButtonWithMenu>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => item.handleRemove()}
|
||||
size="xx-small"
|
||||
variant="outline"
|
||||
themed
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={item.getUniqueKey()}
|
||||
className={`ct-equipment-manage-pane__item${
|
||||
item.isContainer()
|
||||
? " ct-equipment-manage-pane__item--is-container"
|
||||
: ""
|
||||
}`}
|
||||
layoutType={"minimal"}
|
||||
header={
|
||||
<CollapsibleHeaderContent
|
||||
heading={
|
||||
<div className="ct-equipment-manage-pane__item-header">
|
||||
<div className="ct-equipment-manage-pane__item-header-action">
|
||||
<ItemSlotManager
|
||||
isUsed={!!item.isEquipped()}
|
||||
theme={theme}
|
||||
canUse={inventoryManager.canEquipUnequipItem(item.item)}
|
||||
onSet={(uses) => {
|
||||
//TODO need different component than SlotManager for item equipped/unequipped
|
||||
if (uses === 0) {
|
||||
item.handleUnequip();
|
||||
}
|
||||
|
||||
if (uses === 1) {
|
||||
item.handleEquip();
|
||||
}
|
||||
}}
|
||||
useTooltip={false}
|
||||
showEmptySlot={!isContainer}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`ct-equipment-manage-pane__item-header-name${
|
||||
isContainer
|
||||
? " ct-equipment-manage-pane__item-header-name--is-container"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{isContainer ? (
|
||||
"Container details"
|
||||
) : (
|
||||
<ItemName item={item.item} showLegacy={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
callout={calloutButton}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ItemDetail
|
||||
theme={theme}
|
||||
item={item.item}
|
||||
ruleData={ruleData}
|
||||
showCustomize={false}
|
||||
showImage={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
renderContainers = (): React.ReactNode => {
|
||||
const { theme, inventoryManager } = this.props;
|
||||
|
||||
return inventoryManager.getAllContainers().map((container) => {
|
||||
const containerInventory = container.getInventoryItems({
|
||||
includeContainer: true,
|
||||
}).items;
|
||||
|
||||
const inventoryLength = containerInventory.length;
|
||||
let isEmpty: boolean = inventoryLength === 0;
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent
|
||||
heading={
|
||||
<CollapsibleHeading>{`${container.getName()} (${inventoryLength})`}</CollapsibleHeading>
|
||||
}
|
||||
callout={
|
||||
<div className="ct-equipment-manage-pane__callout">
|
||||
<NumberDisplay
|
||||
type="weightInLb"
|
||||
number={container.getWeightInfo().total}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible header={headerNode} key={container.getDefinitionKey()}>
|
||||
<div className="ct-equipment-manage-pane__inventory">
|
||||
{this.renderInventory(containerInventory)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="ct-equipment-manage-pane__empty-container">
|
||||
There are no items in your {container.getName()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
ruleData,
|
||||
proficiencyBonus,
|
||||
containers,
|
||||
theme,
|
||||
partyInfo,
|
||||
paneHistoryPush,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-equipment-manage-pane">
|
||||
<Header>Manage Inventory</Header>
|
||||
<Collapsible header="Add Items" initiallyCollapsed={false}>
|
||||
<EquipmentShop
|
||||
partyInfo={partyInfo}
|
||||
containers={containers}
|
||||
theme={theme}
|
||||
ruleData={ruleData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
<CustomItemCreator containers={containers} />
|
||||
</Collapsible>
|
||||
|
||||
{/*container collapsibles*/}
|
||||
{this.renderContainers()}
|
||||
|
||||
<div className="ct-equipment-manage-pane__links">
|
||||
<div className="ct-equipment-manage-pane__link">
|
||||
<Link
|
||||
useTheme={true}
|
||||
onClick={() =>
|
||||
paneHistoryPush(PaneComponentEnum.STARTING_EQUIPMENT)
|
||||
}
|
||||
>
|
||||
Starting Equipment
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ct-equipment-manage-pane__link">
|
||||
<Link
|
||||
useTheme={true}
|
||||
onClick={() => paneHistoryPush(PaneComponentEnum.CURRENCY)}
|
||||
>
|
||||
Currency
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ct-equipment-manage-pane__link">
|
||||
<Link
|
||||
useTheme={true}
|
||||
onClick={() => paneHistoryPush(PaneComponentEnum.ENCUMBRANCE)}
|
||||
>
|
||||
Encumbrance
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
containers: rulesEngineSelectors.getInventoryContainers(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
partyInfo: serviceDataSelectors.getPartyInfo(state),
|
||||
};
|
||||
}
|
||||
|
||||
function EquipmentManagePaneContainer(props) {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return (
|
||||
<EquipmentManagePane
|
||||
inventoryManager={inventoryManager}
|
||||
paneHistoryPush={paneHistoryPush}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EquipmentManagePaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import EquipmentManagePane from "./EquipmentManagePane";
|
||||
|
||||
export default EquipmentManagePane;
|
||||
export { EquipmentManagePane };
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FC, HTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
LoadingPlaceholder,
|
||||
ThemedExportSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { usePdfExport } from "~/hooks/usePdfExport";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
|
||||
import { ThemeLinkButton } from "../../../components/common/LinkButton";
|
||||
import { ClipboardUtils } from "../../../utils";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
export const ExportPdfPane: FC<Props> = ({ ...props }) => {
|
||||
const urlInput = useRef<HTMLInputElement>(null);
|
||||
const { characterTheme } = useCharacterEngine();
|
||||
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
const { exportPdf, pdfUrl, isLoading } = usePdfExport();
|
||||
|
||||
useEffect(() => {
|
||||
exportPdf();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pdfUrl) {
|
||||
selectUrlInput();
|
||||
}
|
||||
}, [pdfUrl]);
|
||||
|
||||
const selectUrlInput = (): void => {
|
||||
if (urlInput.current) {
|
||||
urlInput.current.focus();
|
||||
urlInput.current.setSelectionRange(0, urlInput.current.value.length);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (evt: React.MouseEvent): void => {
|
||||
ClipboardUtils.copyTextToClipboard(pdfUrl === null ? "" : pdfUrl);
|
||||
setHasCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ct-export-pdf-pane" {...props}>
|
||||
<div className="ct-export-pdf-pane__splash-icon">
|
||||
<ThemedExportSvg theme={characterTheme} />
|
||||
</div>
|
||||
<Header>PDF Generated</Header>
|
||||
<div className="ct-export-pdf-pane__url">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={pdfUrl ? pdfUrl.replace(/https*:\/\//, "") : ""}
|
||||
readOnly
|
||||
className="ct-export-pdf-pane__input"
|
||||
ref={urlInput}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <LoadingPlaceholder label={"Loading PDF..."} />}
|
||||
<div className="ct-export-pdf-pane__clipboard" onClick={handleClick}>
|
||||
{hasCopied ? <>Copied! ✔</> : "Click to Copy"}
|
||||
</div>
|
||||
{pdfUrl !== null && (
|
||||
<div className="ct-export-pdf-pane__download">
|
||||
<ThemeLinkButton
|
||||
url={pdfUrl}
|
||||
size={"large"}
|
||||
download={true}
|
||||
className="ct-export-pdf-pane__download-link"
|
||||
>
|
||||
Click to Download
|
||||
</ThemeLinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,752 @@
|
||||
//TODO: REI need to convert DB constants to enum
|
||||
import axios, { Canceler } from "axios";
|
||||
import { orderBy } from "lodash";
|
||||
import React, { useContext } from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
Collapsible,
|
||||
LoadingPlaceholder,
|
||||
Select,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
Creature,
|
||||
CreatureGroupRulesLookup,
|
||||
CreatureRule,
|
||||
CreatureUtils,
|
||||
ExtraManager,
|
||||
ExtrasManager,
|
||||
HelperUtils,
|
||||
MonsterDefinitionContract,
|
||||
RuleData,
|
||||
rulesEngineSelectors,
|
||||
SnippetData,
|
||||
VehicleManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { useExtras } from "~/hooks/useExtras";
|
||||
import { CreatureBlock } from "~/subApps/sheet/components/CreatureBlock";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
|
||||
import VehicleBlock from "../../../components/VehicleBlock";
|
||||
import { ThemeButton } from "../../../components/common/Button";
|
||||
import { ExtrasManagerContext } from "../../../managers/ExtrasManagerContext";
|
||||
import * as appEnvSelectors from "../../../selectors/appEnv";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import {
|
||||
AppLoggerUtils,
|
||||
AppNotificationUtils,
|
||||
ComponentUtils,
|
||||
FilterUtils,
|
||||
GD_VehicleBlockProps,
|
||||
} from "../../../utils";
|
||||
import ExtraManagePaneAddListing from "./ExtraManagePaneAddListing";
|
||||
import ExtraManagePaneCurrentListing from "./ExtraManagePaneCurrentListing";
|
||||
|
||||
//TODO: this comes from ExtrasManager types
|
||||
interface ShoppeState {
|
||||
creatureDefinitions: Array<MonsterDefinitionContract>;
|
||||
creatureDefinitionLookup: Record<number, MonsterDefinitionContract>;
|
||||
creatures: Array<ExtraManager>;
|
||||
vehicles: Array<ExtraManager>;
|
||||
}
|
||||
|
||||
interface CollapsibleComponentProps {
|
||||
metaItems: Array<React.ReactNode>;
|
||||
heading: string | null;
|
||||
}
|
||||
interface CreatureBlockProps {
|
||||
variant: "default" | "basic";
|
||||
creature: Creature;
|
||||
ruleData: RuleData;
|
||||
className: string;
|
||||
}
|
||||
export interface PagedListingProps {
|
||||
key: number | string;
|
||||
extra: ExtraManager;
|
||||
onAdd: (extra: ExtraManager, quantity: number) => void;
|
||||
collapsibleComponentProps: CollapsibleComponentProps;
|
||||
ContentComponent: React.ComponentType<any>;
|
||||
contentComponentProps: GD_VehicleBlockProps | CreatureBlockProps;
|
||||
showQuantity?: boolean;
|
||||
}
|
||||
|
||||
enum DataRetrievalStatusEnum {
|
||||
NONE = "NONE",
|
||||
LOADING = "LOADING",
|
||||
LOADED = "LOADED",
|
||||
}
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
ruleData: RuleData;
|
||||
pageSize: number;
|
||||
creatureGroupRulesLookup: CreatureGroupRulesLookup;
|
||||
isReadonly: boolean;
|
||||
snippetData: SnippetData;
|
||||
theme: CharacterTheme;
|
||||
extras: Array<ExtraManager>;
|
||||
extrasManager: ExtrasManager;
|
||||
}
|
||||
interface State {
|
||||
selectedGroup: number | null;
|
||||
dataStatus: DataRetrievalStatusEnum;
|
||||
currentPage: number;
|
||||
filterQuery: string;
|
||||
filterRules: boolean;
|
||||
filterChallengeMin: number | null;
|
||||
filterChallengeMax: number | null;
|
||||
isCurrentExtrasCollapsed: boolean;
|
||||
|
||||
extrasShoppe: ShoppeState;
|
||||
}
|
||||
class ExtraManagePane extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
loadMonstersCanceler: null | Canceler = null;
|
||||
loadVehiclesCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedGroup: null,
|
||||
dataStatus: DataRetrievalStatusEnum.NONE,
|
||||
currentPage: 0,
|
||||
filterQuery: "",
|
||||
filterRules: true,
|
||||
filterChallengeMin: null,
|
||||
filterChallengeMax: null,
|
||||
isCurrentExtrasCollapsed: false,
|
||||
|
||||
extrasShoppe: {
|
||||
creatureDefinitions: [],
|
||||
creatureDefinitionLookup: {},
|
||||
creatures: [],
|
||||
vehicles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
|
||||
// const { definitionPool } = this.props;
|
||||
|
||||
// if (definitionPool !== prevProps.definitionPool) {
|
||||
// //TODO: maybe need to handle this with the Shoppe
|
||||
// // this.setState(this.generateVehicleStateData(this.props));
|
||||
// }
|
||||
// }
|
||||
|
||||
componentDidMount() {
|
||||
const { extrasManager } = this.props;
|
||||
|
||||
if (extrasManager) {
|
||||
this.setState({
|
||||
dataStatus: DataRetrievalStatusEnum.LOADING,
|
||||
});
|
||||
extrasManager
|
||||
.getExtrasShoppe({
|
||||
groupId: null,
|
||||
onSuccess: (shoppeState: ShoppeState) => {
|
||||
this.setState({
|
||||
dataStatus: DataRetrievalStatusEnum.LOADED,
|
||||
extrasShoppe: shoppeState,
|
||||
});
|
||||
},
|
||||
additionalConfig: {
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadMonstersCanceler = c;
|
||||
}),
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
this.loadMonstersCanceler = null;
|
||||
return response;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
} else {
|
||||
this.setState({
|
||||
dataStatus: DataRetrievalStatusEnum.LOADED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
//TODO do these cancelers even work?
|
||||
if (this.loadMonstersCanceler !== null) {
|
||||
this.loadMonstersCanceler();
|
||||
}
|
||||
if (this.loadVehiclesCanceler !== null) {
|
||||
this.loadVehiclesCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
getLastPageIdx = <T extends any>(filteredItems: Array<T>): number => {
|
||||
const { pageSize } = this.props;
|
||||
|
||||
return Math.ceil(filteredItems.length / pageSize) - 1;
|
||||
};
|
||||
|
||||
getFilteredCreatures = (): Array<ExtraManager> => {
|
||||
const { extrasManager } = this.props;
|
||||
const {
|
||||
filterRules,
|
||||
filterQuery,
|
||||
filterChallengeMin,
|
||||
filterChallengeMax,
|
||||
selectedGroup,
|
||||
extrasShoppe,
|
||||
} = this.state;
|
||||
|
||||
const { creatures, creatureDefinitionLookup } = extrasShoppe;
|
||||
|
||||
let creatureToFilter: Array<ExtraManager> = filterRules
|
||||
? extrasManager.getCreaturesFilteredByRules(
|
||||
creatures,
|
||||
creatureDefinitionLookup,
|
||||
selectedGroup
|
||||
)
|
||||
: creatures;
|
||||
let filteredCreatures = creatureToFilter.filter((extra) => {
|
||||
if (
|
||||
filterQuery !== "" &&
|
||||
!FilterUtils.doesQueryMatchData(
|
||||
filterQuery,
|
||||
extra.getName(),
|
||||
extra.getSearchTags()
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const creature = extra.simulateExtraData(
|
||||
selectedGroup,
|
||||
creatureDefinitionLookup
|
||||
) as Creature;
|
||||
let challengeInfo = CreatureUtils.getChallengeInfo(creature);
|
||||
const hideCr = CreatureUtils.getHideCr(creature);
|
||||
// Treat hide Challenge Rating like a 0 for challenge rating values
|
||||
if (hideCr) {
|
||||
if (filterChallengeMin !== null && 0 < filterChallengeMin) {
|
||||
return false;
|
||||
}
|
||||
if (filterChallengeMax !== null && 0 > filterChallengeMax) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
filterChallengeMin !== null &&
|
||||
challengeInfo &&
|
||||
challengeInfo.value < filterChallengeMin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filterChallengeMax !== null &&
|
||||
challengeInfo &&
|
||||
challengeInfo.value > filterChallengeMax
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return orderBy(filteredCreatures, (extra) => extra.getName());
|
||||
};
|
||||
|
||||
getCreateNames = (name: string, quantity: number): Array<string | null> => {
|
||||
let names: Array<string | null> = [];
|
||||
if (quantity > 1) {
|
||||
for (let i = 1; i <= quantity; i++) {
|
||||
names.push(`${name} ${i}`);
|
||||
}
|
||||
} else {
|
||||
names.push(null);
|
||||
}
|
||||
return names;
|
||||
};
|
||||
|
||||
isFiltered = (): boolean => {
|
||||
const { filterQuery, filterRules } = this.state;
|
||||
|
||||
if (filterRules) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
handlePageMore = (): void => {
|
||||
this.setState((prevState) => ({
|
||||
currentPage: prevState.currentPage + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
handleQueryChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = evt.target.value;
|
||||
|
||||
this.setState({
|
||||
filterQuery: query,
|
||||
currentPage: 0,
|
||||
});
|
||||
};
|
||||
|
||||
handleChallengeMinChange = (value: string): void => {
|
||||
this.setState({
|
||||
filterChallengeMin: HelperUtils.parseInputFloat(value),
|
||||
});
|
||||
};
|
||||
|
||||
handleChallengeMaxChange = (value: string): void => {
|
||||
this.setState({
|
||||
filterChallengeMax: HelperUtils.parseInputFloat(value),
|
||||
});
|
||||
};
|
||||
|
||||
handleRawToggle = (): void => {
|
||||
this.setState((prevState) => ({
|
||||
filterRules: !prevState.filterRules,
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
handleSelectedGroupChange = (value: string): void => {
|
||||
const { extrasManager } = this.props;
|
||||
const { extrasShoppe } = this.state;
|
||||
|
||||
let selectedGroup = HelperUtils.parseInputInt(value, null);
|
||||
|
||||
extrasManager.updateExtrasShoppe({
|
||||
currentShoppe: extrasShoppe,
|
||||
groupId: selectedGroup,
|
||||
onSuccess: (shoppeState: ShoppeState) => {
|
||||
this.setState((prevState) => {
|
||||
let filterChallengeMin = prevState.filterChallengeMin;
|
||||
let filterChallengeMax = prevState.filterChallengeMax;
|
||||
if (
|
||||
selectedGroup !== null &&
|
||||
extrasManager.hack__isSidekickGroup(selectedGroup)
|
||||
) {
|
||||
filterChallengeMin = null;
|
||||
filterChallengeMax = null;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedGroup,
|
||||
isCurrentExtrasCollapsed: true,
|
||||
filterChallengeMax,
|
||||
filterChallengeMin,
|
||||
extrasShoppe: shoppeState,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handleCreateExtra = (extra: ExtraManager, quantity: number): void => {
|
||||
const { selectedGroup } = this.state;
|
||||
|
||||
extra.handleAdd(
|
||||
{ quantity, selectedGroup },
|
||||
AppNotificationUtils.handleExtraCreateAccepted.bind(this, extra.extra)
|
||||
);
|
||||
};
|
||||
|
||||
handleRemoveExtra = (extra: ExtraManager): void => {
|
||||
extra.handleRemove();
|
||||
};
|
||||
|
||||
handleCurrentExtrasVisibilityChange = (isCollapsed: boolean): void => {
|
||||
this.setState({
|
||||
isCurrentExtrasCollapsed: isCollapsed,
|
||||
});
|
||||
};
|
||||
|
||||
renderFilterUi = (): React.ReactNode => {
|
||||
const {
|
||||
filterQuery,
|
||||
filterChallengeMin,
|
||||
filterChallengeMax,
|
||||
selectedGroup,
|
||||
} = this.state;
|
||||
const { extrasManager } = this.props;
|
||||
|
||||
let challengeOptions = extrasManager.getChallengeOptions();
|
||||
let isSidekickGroup =
|
||||
selectedGroup !== null &&
|
||||
extrasManager.hack__isSidekickGroup(selectedGroup);
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__filters">
|
||||
<div className="ct-extra-manage-pane__filter">
|
||||
<div className="ct-extra-manage-pane__filter-heading">Filter</div>
|
||||
<input
|
||||
type="search"
|
||||
className="ct-filter__query"
|
||||
value={filterQuery}
|
||||
onChange={this.handleQueryChange}
|
||||
placeholder="Name, Type, Subtype, Habitat, Tag, etc."
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{!isSidekickGroup && (
|
||||
<div className="ct-extra-manage-pane__filters-challenge">
|
||||
<div className="ct-extra-manage-pane__filters-challenge-heading">
|
||||
Challenge Range
|
||||
</div>
|
||||
<div className="ct-extra-manage-pane__filters-challenge-fields">
|
||||
<div className="ct-extra-manage-pane__filters-challenge-field">
|
||||
<Select
|
||||
value={filterChallengeMin}
|
||||
options={challengeOptions}
|
||||
onChange={this.handleChallengeMinChange}
|
||||
placeholder="--"
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-extra-manage-pane__filters-challenge-sep">
|
||||
--
|
||||
</div>
|
||||
<div className="ct-extra-manage-pane__filters-challenge-field">
|
||||
<Select
|
||||
value={filterChallengeMax}
|
||||
options={challengeOptions}
|
||||
onChange={this.handleChallengeMaxChange}
|
||||
placeholder="--"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderPagedListing = <T extends ExtraManager>(
|
||||
extras: Array<T>,
|
||||
listingComponentPropMappingFunc: (extra: T) => PagedListingProps
|
||||
): React.ReactNode => {
|
||||
const { currentPage } = this.state;
|
||||
const { pageSize, isReadonly } = this.props;
|
||||
|
||||
const pagedExtras = extras.slice(0, (currentPage + 1) * pageSize);
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__listing">
|
||||
{pagedExtras.map((extra) => (
|
||||
<ExtraManagePaneAddListing
|
||||
{...listingComponentPropMappingFunc(extra)}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCreatures = (): React.ReactNode => {
|
||||
const { ruleData, theme, extrasManager } = this.props;
|
||||
const { extrasShoppe, selectedGroup } = this.state;
|
||||
const { creatureDefinitionLookup } = extrasShoppe;
|
||||
|
||||
let filteredCreatures = this.getFilteredCreatures();
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__extras">
|
||||
{this.renderFilterUi()}
|
||||
{this.renderPagedListing(filteredCreatures, (extraManager) => {
|
||||
const creature = extraManager.simulateExtraData(
|
||||
selectedGroup,
|
||||
creatureDefinitionLookup
|
||||
) as Creature;
|
||||
const sourceNames = extraManager.getSourceNames();
|
||||
const References = extraManager.isHomebrew()
|
||||
? [<Reference isDarkMode={theme.isDarkMode} name="Homebrew" />]
|
||||
: sourceNames.map((source) => (
|
||||
<Reference
|
||||
name={source}
|
||||
isDarkMode={theme.isDarkMode}
|
||||
key={uuidv4()}
|
||||
/>
|
||||
));
|
||||
return {
|
||||
key: extraManager.getId(),
|
||||
extra: extraManager,
|
||||
onAdd: this.handleCreateExtra,
|
||||
collapsibleComponentProps: {
|
||||
metaItems: [
|
||||
...extrasManager.generateCreatureMeta(creature),
|
||||
...References,
|
||||
],
|
||||
heading: extraManager.getName(),
|
||||
},
|
||||
ContentComponent: CreatureBlock,
|
||||
contentComponentProps: {
|
||||
variant: "basic",
|
||||
creature,
|
||||
ruleData: ruleData,
|
||||
className: "ddbc-creature-block",
|
||||
},
|
||||
};
|
||||
})}
|
||||
{this.renderPager(filteredCreatures)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderVehicles = (): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
const { selectedGroup, extrasShoppe } = this.state;
|
||||
const { vehicles } = extrasShoppe;
|
||||
|
||||
let orderedVehicles = orderBy(vehicles, (vehicle) => vehicle.getName());
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__extras">
|
||||
{this.renderPagedListing(orderedVehicles, (vehicleExtra) => {
|
||||
const vehicle = vehicleExtra.simulateExtraData(
|
||||
selectedGroup
|
||||
) as VehicleManager;
|
||||
const sourceNames = vehicleExtra.getSourceNames();
|
||||
const References = vehicleExtra.isHomebrew()
|
||||
? [<Reference isDarkMode={theme.isDarkMode} name="Homebrew" />]
|
||||
: sourceNames.map((source) => (
|
||||
<Reference name={source} isDarkMode={theme.isDarkMode} />
|
||||
));
|
||||
return {
|
||||
key: vehicleExtra.getId(),
|
||||
extra: vehicleExtra,
|
||||
onAdd: this.handleCreateExtra,
|
||||
collapsibleComponentProps: {
|
||||
metaItems: [...vehicle.generateVehicleMeta(), ...References],
|
||||
heading: vehicleExtra.getName(),
|
||||
},
|
||||
ContentComponent: VehicleBlock,
|
||||
contentComponentProps:
|
||||
ComponentUtils.generateVehicleBlockProps(vehicle),
|
||||
showQuantity: false,
|
||||
};
|
||||
})}
|
||||
{this.renderPager(vehicles)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderGroupSelector = (): React.ReactNode => {
|
||||
const { selectedGroup, filterRules, dataStatus } = this.state;
|
||||
const { creatureGroupRulesLookup, extrasManager } = this.props;
|
||||
|
||||
const selectedGroupInfo = extrasManager.getCreatureGroupInfo(selectedGroup);
|
||||
|
||||
let groupRules: Array<CreatureRule> | null =
|
||||
selectedGroup === null
|
||||
? null
|
||||
: HelperUtils.lookupDataOrFallback(
|
||||
creatureGroupRulesLookup,
|
||||
selectedGroup
|
||||
);
|
||||
let isSidekickGroup: boolean =
|
||||
selectedGroup !== null &&
|
||||
extrasManager.hack__isSidekickGroup(selectedGroup);
|
||||
|
||||
let hasGroupRules: boolean = !!groupRules || isSidekickGroup;
|
||||
let showNotDefaultNoRules: boolean =
|
||||
selectedGroupInfo !== null &&
|
||||
!selectedGroupInfo.enabledByDefault &&
|
||||
!hasGroupRules;
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__selected-group">
|
||||
<div className="ct-extra-manage-pane__selected-group-chooser">
|
||||
<Select
|
||||
options={extrasManager.getGroupOptions()}
|
||||
value={selectedGroup}
|
||||
onChange={this.handleSelectedGroupChange}
|
||||
placeholder="-- Choose a Category --"
|
||||
disabled={dataStatus === DataRetrievalStatusEnum.LOADING}
|
||||
/>
|
||||
</div>
|
||||
{selectedGroup && (
|
||||
<div className="ct-extra-manage-pane__selected-group-content">
|
||||
{selectedGroupInfo && selectedGroupInfo.description && (
|
||||
<HtmlContent
|
||||
className="ct-extra-manage-pane__selected-group-description"
|
||||
html={selectedGroupInfo.description}
|
||||
withoutTooltips
|
||||
/>
|
||||
)}
|
||||
{showNotDefaultNoRules && (
|
||||
<div className="ct-extra-manage-pane__selected-group-warning">
|
||||
Adding a creature of this category will be outside of normal
|
||||
game rules.
|
||||
</div>
|
||||
)}
|
||||
{selectedGroupInfo && hasGroupRules && (
|
||||
<div className="ct-extra-manage-pane__selected-group-rules">
|
||||
<Checkbox
|
||||
initiallyEnabled={filterRules}
|
||||
onChange={this.handleRawToggle}
|
||||
label={`Filter Using ${selectedGroupInfo.name} Rules`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
};
|
||||
|
||||
renderManager = (): React.ReactNode => {
|
||||
const { dataStatus, selectedGroup } = this.state;
|
||||
|
||||
if (dataStatus !== DataRetrievalStatusEnum.LOADED) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
let extrasNode: React.ReactNode;
|
||||
if (selectedGroup !== null) {
|
||||
if (selectedGroup === Constants.HACK_VEHICLE_GROUP_ID) {
|
||||
extrasNode = this.renderVehicles();
|
||||
} else {
|
||||
extrasNode = this.renderCreatures();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__manager">
|
||||
{this.renderGroupSelector()}
|
||||
{extrasNode}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderPager = <T extends any>(filteredItems: Array<T>): React.ReactNode => {
|
||||
const { currentPage } = this.state;
|
||||
const lastPageIdx = this.getLastPageIdx(filteredItems);
|
||||
|
||||
if (lastPageIdx < 1 || currentPage >= lastPageIdx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__pager">
|
||||
<ThemeButton block={true} onClick={this.handlePageMore}>
|
||||
Load More
|
||||
</ThemeButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCurrentExtras = (
|
||||
extras: Array<ExtraManager>,
|
||||
heading: string | null,
|
||||
groupId: number | Constants.ExtraGroupTypeEnum
|
||||
): React.ReactNode => {
|
||||
const { ruleData, theme } = this.props;
|
||||
|
||||
if (!extras.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let orderedExtras = orderBy(extras, (extra) => extra.getName());
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__group" key={groupId}>
|
||||
<div className="ct-extra-manage-pane__group-heading">{heading}</div>
|
||||
<div className="ct-extra-manage-pane__group-items">
|
||||
{orderedExtras.map((extra) => (
|
||||
<ExtraManagePaneCurrentListing
|
||||
theme={theme}
|
||||
key={extra.getUniqueKey()}
|
||||
extra={extra}
|
||||
ruleData={ruleData}
|
||||
onRemove={this.handleRemoveExtra}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCharacterExtras = (): React.ReactNode => {
|
||||
const { extrasManager, extras } = this.props;
|
||||
|
||||
if (!extras.length) {
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__default">
|
||||
Extras that you add will display here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = extrasManager.getExtrasGroups();
|
||||
const orderedCreatureGroupInfos = extrasManager.getGroupInfosForExtras();
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane__inventory">
|
||||
{orderedCreatureGroupInfos.map((groupInfo) =>
|
||||
this.renderCurrentExtras(
|
||||
groups[groupInfo.id],
|
||||
groupInfo.name,
|
||||
groupInfo.id
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isCurrentExtrasCollapsed } = this.state;
|
||||
|
||||
return (
|
||||
<div className="ct-extra-manage-pane">
|
||||
<Header>Manage Extras</Header>
|
||||
<Collapsible header="Add an Extra" initiallyCollapsed={false}>
|
||||
{this.renderManager()}
|
||||
</Collapsible>
|
||||
<Collapsible
|
||||
header="Current Extras"
|
||||
collapsed={isCurrentExtrasCollapsed}
|
||||
onChangeHandler={this.handleCurrentExtrasVisibilityChange}
|
||||
>
|
||||
{this.renderCharacterExtras()}
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
creatureGroupRulesLookup:
|
||||
rulesEngineSelectors.getCreatureGroupRulesLookup(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
snippetData: rulesEngineSelectors.getSnippetData(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
function ExtraManagePaneContainer(props) {
|
||||
const { extrasManager } = useContext(ExtrasManagerContext);
|
||||
|
||||
const extras = useExtras();
|
||||
|
||||
return (
|
||||
<ExtraManagePane extrasManager={extrasManager} extras={extras} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ExtraManagePaneContainer);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { HelperUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import SimpleQuantity from "../../../../components/SimpleQuantity";
|
||||
import { ThemeButton } from "../../../../components/common/Button";
|
||||
import { PagedListingProps } from "../ExtraManagePane";
|
||||
import ExtraManagePaneListingExtra from "../ExtraManagePaneListingExtra";
|
||||
|
||||
interface Props extends PagedListingProps {
|
||||
isReadonly: boolean;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
}
|
||||
export const ExtraManagePaneAddListing: React.FC<Props> = ({
|
||||
isReadonly,
|
||||
minimum = 1,
|
||||
maximum = 10,
|
||||
extra,
|
||||
collapsibleComponentProps,
|
||||
ContentComponent,
|
||||
contentComponentProps,
|
||||
onAdd,
|
||||
showQuantity = true,
|
||||
}) => {
|
||||
const [quantity, setQuantity] = useState<number>(1);
|
||||
|
||||
return (
|
||||
<ExtraManagePaneListingExtra
|
||||
headerCallout={
|
||||
<ThemeButton onClick={() => onAdd(extra, 1)} size="small">
|
||||
Add
|
||||
</ThemeButton>
|
||||
}
|
||||
{...collapsibleComponentProps}
|
||||
>
|
||||
<ContentComponent {...contentComponentProps} />
|
||||
{!isReadonly && showQuantity && (
|
||||
<div className="ct-extra-manage-pane__quantity">
|
||||
<div className="ct-extra-manage-pane__quantity-manager">
|
||||
<SimpleQuantity
|
||||
quantity={quantity}
|
||||
minimum={minimum}
|
||||
maximum={maximum}
|
||||
onUpdate={(quantity) => setQuantity(quantity)}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-extra-manage-pane__quantity-action">
|
||||
<ThemeButton
|
||||
onClick={() =>
|
||||
onAdd(extra, HelperUtils.clampInt(quantity, minimum, maximum))
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
Add {quantity}
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ExtraManagePaneListingExtra>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtraManagePaneAddListing;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtraManagePaneAddListing from "./ExtraManagePaneAddListing";
|
||||
|
||||
export default ExtraManagePaneAddListing;
|
||||
export { ExtraManagePaneAddListing };
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
|
||||
import { MarketplaceCta } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Creature,
|
||||
ExtraManager,
|
||||
RuleData,
|
||||
VehicleManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { CreatureBlock } from "~/subApps/sheet/components/CreatureBlock";
|
||||
|
||||
import VehicleBlock from "../../../../components/VehicleBlock";
|
||||
import { RemoveButton } from "../../../../components/common/Button";
|
||||
import { ComponentUtils } from "../../../../utils";
|
||||
import ExtraManagePaneListingExtra from "../ExtraManagePaneListingExtra";
|
||||
|
||||
interface Props {
|
||||
extra: ExtraManager;
|
||||
ruleData: RuleData;
|
||||
onRemove: (extra: ExtraManager) => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
|
||||
export const ExtraManagePaneCurrentListing: React.FC<Props> = ({
|
||||
extra,
|
||||
ruleData,
|
||||
onRemove,
|
||||
theme,
|
||||
}) => {
|
||||
const extraData = extra.getExtraData();
|
||||
|
||||
let label: string = "Extra";
|
||||
let ContentComponent: React.ReactNode;
|
||||
|
||||
if (extra.isCreature()) {
|
||||
label = "Creature";
|
||||
ContentComponent = (
|
||||
<CreatureBlock
|
||||
variant="default"
|
||||
creature={extraData as Creature}
|
||||
ruleData={ruleData}
|
||||
className="ddbc-creature-block"
|
||||
/>
|
||||
);
|
||||
} else if (extra.isVehicle()) {
|
||||
label = "Vehicle";
|
||||
const vehicle = extraData as VehicleManager;
|
||||
ContentComponent = vehicle.isAccessible() ? (
|
||||
<VehicleBlock
|
||||
theme={theme}
|
||||
{...ComponentUtils.generateVehicleBlockProps(vehicle)}
|
||||
/>
|
||||
) : (
|
||||
<MarketplaceCta
|
||||
sourceNames={extra.getSourceNames()}
|
||||
description={`To unlock this ${label}, check out the Marketplace to view purchase options.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const References = extra.isHomebrew()
|
||||
? [<Reference isDarkMode={theme.isDarkMode} name="Homebrew" />]
|
||||
: extra
|
||||
.getSourceNames()
|
||||
.map((sourceName) => (
|
||||
<Reference name={sourceName} isDarkMode={theme.isDarkMode} />
|
||||
));
|
||||
|
||||
return (
|
||||
<ExtraManagePaneListingExtra
|
||||
headerCallout={<RemoveButton onClick={() => onRemove(extra)} />}
|
||||
metaItems={[...extra.getMetaText(), ...References]}
|
||||
heading={extra.getName()}
|
||||
>
|
||||
{ContentComponent}
|
||||
</ExtraManagePaneListingExtra>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtraManagePaneCurrentListing;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtraManagePaneCurrentListing from "./ExtraManagePaneCurrentListing";
|
||||
|
||||
export default ExtraManagePaneCurrentListing;
|
||||
export { ExtraManagePaneCurrentListing };
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
interface Props {
|
||||
headerCallout: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
metaItems: Array<React.ReactNode>;
|
||||
}
|
||||
export const ExtraManagePaneListingExtra: React.FC<Props> = ({
|
||||
headerCallout,
|
||||
heading,
|
||||
metaItems,
|
||||
children,
|
||||
}) => {
|
||||
let header: React.ReactNode = (
|
||||
<CollapsibleHeaderContent
|
||||
heading={heading}
|
||||
metaItems={metaItems}
|
||||
callout={headerCallout}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={header}
|
||||
className="ct-extra-manage-pane__extra"
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtraManagePaneListingExtra;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtraManagePaneListingExtra from "./ExtraManagePaneListingExtra";
|
||||
|
||||
export default ExtraManagePaneListingExtra;
|
||||
export { ExtraManagePaneListingExtra };
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExtraManagePane from "./ExtraManagePane";
|
||||
import ExtraManagePaneAddListing from "./ExtraManagePaneAddListing";
|
||||
import ExtraManagePaneCurrentListing from "./ExtraManagePaneCurrentListing";
|
||||
import ExtraManagePaneListingExtra from "./ExtraManagePaneListingExtra";
|
||||
|
||||
export default ExtraManagePane;
|
||||
export {
|
||||
ExtraManagePane,
|
||||
ExtraManagePaneAddListing,
|
||||
ExtraManagePaneCurrentListing,
|
||||
ExtraManagePaneListingExtra,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+6
@@ -0,0 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import InfusionChoicePaneStore from "../InfusionChoicePaneStore";
|
||||
import withAvailableItems from "../withAvailableItems";
|
||||
|
||||
export default withAvailableItems(InfusionChoicePaneStore);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InfusionChoicePaneNewStore from "./InfusionChoicePaneNewStore";
|
||||
|
||||
export default InfusionChoicePaneNewStore;
|
||||
export { InfusionChoicePaneNewStore };
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface Props {
|
||||
header: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
export class InfusionChoicePaneStep extends React.PureComponent<Props> {
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { header, extra, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-infusion-choice-pane__step" onClick={this.handleClick}>
|
||||
<div className="ct-infusion-choice-pane__step-primary">
|
||||
<div className="ct-infusion-choice-pane__step-header">{header}</div>
|
||||
<div className="ct-infusion-choice-pane__step-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{extra && (
|
||||
<div className="ct-infusion-choice-pane__step-extra">{extra}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InfusionChoicePaneStep;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InfusionChoicePaneStep from "./InfusionChoicePaneStep";
|
||||
|
||||
export default InfusionChoicePaneStep;
|
||||
export { InfusionChoicePaneStep };
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { orderBy } from "lodash";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
LoadingPlaceholder,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
InfusionChoice,
|
||||
InfusionChoiceUtils,
|
||||
InfusionUtils,
|
||||
Item,
|
||||
ItemUtils,
|
||||
KnownInfusionUtils,
|
||||
RuleData,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
|
||||
import ItemDetail from "../../../../components/ItemDetail";
|
||||
import { ThemeButton } from "../../../../components/common/Button";
|
||||
import DataLoadingStatusEnum from "../../../../constants/DataLoadingStatusEnum";
|
||||
|
||||
interface Props {
|
||||
itemLoadingStatus: DataLoadingStatusEnum;
|
||||
items: Array<Item>;
|
||||
itemCanBeAddedLookup: Record<number, boolean>;
|
||||
infusionChoice: InfusionChoice;
|
||||
onItemSelected: (item: Item) => void;
|
||||
ruleData: RuleData;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export class InfusionChoicePaneStore extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
items: [],
|
||||
itemLoadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
|
||||
itemCanBeAddedLookup: {},
|
||||
};
|
||||
|
||||
renderLoading = (): React.ReactNode => {
|
||||
return <LoadingPlaceholder />;
|
||||
};
|
||||
|
||||
renderEmpty = (): React.ReactNode => {
|
||||
return (
|
||||
<div className="ct-infusion-choice-pane__inventory-empty">
|
||||
There are no items that match the requirements for infusion.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
infusionChoice,
|
||||
itemCanBeAddedLookup,
|
||||
itemLoadingStatus,
|
||||
items,
|
||||
onItemSelected,
|
||||
ruleData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
itemLoadingStatus !== DataLoadingStatusEnum.NOT_INITIALIZED &&
|
||||
itemLoadingStatus !== DataLoadingStatusEnum.LOADED
|
||||
) {
|
||||
return this.renderLoading();
|
||||
}
|
||||
|
||||
const knownInfusion = InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
if (knownInfusion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (simulatedInfusion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableItems = InfusionUtils.filterAvailableItems(
|
||||
items,
|
||||
simulatedInfusion,
|
||||
KnownInfusionUtils.getItemId(knownInfusion),
|
||||
itemCanBeAddedLookup
|
||||
);
|
||||
const orderedItems = orderBy(availableItems, (item) =>
|
||||
ItemUtils.getName(item)
|
||||
);
|
||||
|
||||
if (!orderedItems.length) {
|
||||
return this.renderEmpty();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-infusion-choice-pane__inventory">
|
||||
{orderedItems.map((item) => {
|
||||
let headerNode = (
|
||||
<CollapsibleHeaderContent
|
||||
heading={<ItemName item={item} showLegacy={true} />}
|
||||
callout={
|
||||
<ThemeButton
|
||||
onClick={onItemSelected.bind(this, item)}
|
||||
size="small"
|
||||
data-testid={`infusion-choose-item-button-${ItemUtils.getName(
|
||||
item
|
||||
)}`
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-")}
|
||||
>
|
||||
Choose
|
||||
</ThemeButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={ItemUtils.getId(item)}
|
||||
layoutType="minimal"
|
||||
header={headerNode}
|
||||
className="ct-infusion-choice-pane__item"
|
||||
>
|
||||
<ItemDetail
|
||||
theme={theme}
|
||||
item={item}
|
||||
ruleData={ruleData}
|
||||
showCustomize={false}
|
||||
showActions={false}
|
||||
showImage={false}
|
||||
showAbilities={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InfusionChoicePaneStore;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InfusionChoicePaneStore from "./InfusionChoicePaneStore";
|
||||
|
||||
export default InfusionChoicePaneStore;
|
||||
export { InfusionChoicePaneStore };
|
||||
@@ -0,0 +1,14 @@
|
||||
import InfusionChoicePane from "./InfusionChoicePane";
|
||||
import InfusionChoicePaneNewStore from "./InfusionChoicePaneNewStore";
|
||||
import InfusionChoicePaneStep from "./InfusionChoicePaneStep";
|
||||
import InfusionChoicePaneStore from "./InfusionChoicePaneStore";
|
||||
import withAvailableItems from "./withAvailableItems";
|
||||
|
||||
export default InfusionChoicePane;
|
||||
export {
|
||||
InfusionChoicePane,
|
||||
InfusionChoicePaneNewStore,
|
||||
InfusionChoicePaneStep,
|
||||
InfusionChoicePaneStore,
|
||||
withAvailableItems,
|
||||
};
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
CharacterTheme,
|
||||
ContainerManager,
|
||||
InfusionChoice,
|
||||
InventoryManager,
|
||||
Item,
|
||||
ItemUtils,
|
||||
RuleData,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import DataLoadingStatusEnum from "../../../../constants/DataLoadingStatusEnum";
|
||||
import { AppLoggerUtils } from "../../../../utils";
|
||||
|
||||
function getDisplayName(WrappedComponent: React.ComponentType) {
|
||||
return WrappedComponent.displayName || WrappedComponent.name || "Component";
|
||||
}
|
||||
|
||||
export interface RequiredWithAvailableItemsProps {
|
||||
inventoryManager: InventoryManager;
|
||||
theme?: CharacterTheme;
|
||||
infusionChoice: InfusionChoice;
|
||||
onItemSelected: (item: Item) => void;
|
||||
ruleData: RuleData;
|
||||
}
|
||||
interface State {
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
availableItems: Array<Item>;
|
||||
}
|
||||
export function withAvailableItems<
|
||||
C extends React.ComponentType<React.ComponentProps<C>>,
|
||||
ResolvedProps = JSX.LibraryManagedAttributes<C, React.ComponentProps<C>>
|
||||
>(WrappedComponent) {
|
||||
return class withAvailableItems extends React.PureComponent<
|
||||
ResolvedProps & RequiredWithAvailableItemsProps,
|
||||
State
|
||||
> {
|
||||
static displayName = `withAvailableItems(${getDisplayName(
|
||||
WrappedComponent
|
||||
)})`;
|
||||
|
||||
loadAvailableItemsCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
|
||||
availableItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { loadingStatus } = this.state;
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (loadingStatus === DataLoadingStatusEnum.NOT_LOADED) {
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
});
|
||||
|
||||
inventoryManager
|
||||
.getInventoryShoppe({
|
||||
onSuccess: (shoppeContainer: ContainerManager) => {
|
||||
const storeItems = shoppeContainer
|
||||
.getInventoryItems()
|
||||
.items.map((item) => item.getItem());
|
||||
|
||||
this.setState({
|
||||
availableItems: storeItems,
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
});
|
||||
},
|
||||
additionalApiConfig: {
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadAvailableItemsCanceler = c;
|
||||
}),
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
this.loadAvailableItemsCanceler = null;
|
||||
return res;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
} else {
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadAvailableItemsCanceler !== null) {
|
||||
this.loadAvailableItemsCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loadingStatus, availableItems } = this.state;
|
||||
|
||||
let itemCanBeAddedLookup: Record<number, boolean> = availableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[ItemUtils.getId(item)] = ItemUtils.canBeAddedToInventory(item);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
items={availableItems}
|
||||
itemCanBeAddedLookup={itemCanBeAddedLookup}
|
||||
itemLoadingStatus={loadingStatus}
|
||||
{...(this.props as JSX.LibraryManagedAttributes<
|
||||
C,
|
||||
React.ComponentProps<C>
|
||||
>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default withAvailableItems;
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useContext } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
Action,
|
||||
ActionUtils,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
ContainerLookup,
|
||||
DataOrigin,
|
||||
EntityValueLookup,
|
||||
HelperUtils,
|
||||
Infusion,
|
||||
InfusionChoiceLookup,
|
||||
InfusionUtils,
|
||||
InventoryManager,
|
||||
Item,
|
||||
ItemUtils,
|
||||
PartyInfo,
|
||||
RuleData,
|
||||
rulesEngineSelectors,
|
||||
serviceDataSelectors,
|
||||
SnippetData,
|
||||
Spell,
|
||||
WeaponSpellDamageGroup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { EditableName } from "~/components/EditableName";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import {
|
||||
getDataOriginComponentInfo,
|
||||
getSpellComponentInfo,
|
||||
} from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiersItem,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import ItemDetail from "../../../components/ItemDetail";
|
||||
import ItemListInformationCollapsible from "../../../components/ItemListInformationCollapsible";
|
||||
import { InventoryManagerContext } from "../../../managers/InventoryManagerContext";
|
||||
import { appEnvSelectors } from "../../../selectors";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { PaneIdentifierUtils } from "../../../utils";
|
||||
|
||||
interface Props {
|
||||
items: Array<Item>;
|
||||
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
|
||||
ruleData: RuleData;
|
||||
snippetData: SnippetData;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
infusionChoiceLookup: InfusionChoiceLookup;
|
||||
identifiers: PaneIdentifiersItem | null;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
containerLookup: ContainerLookup;
|
||||
inventoryManager: InventoryManager;
|
||||
partyInfo: PartyInfo;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
interface State {
|
||||
item: Item | null;
|
||||
isCustomizeClosed: boolean;
|
||||
}
|
||||
class ItemPane extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props, true);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { items, identifiers } = this.props;
|
||||
|
||||
if (items !== prevProps.items || identifiers !== prevProps.identifiers) {
|
||||
this.setState(
|
||||
this.generateStateData(this.props, prevState.isCustomizeClosed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props, isCustomizeClosed: boolean): State => {
|
||||
const { items, identifiers } = props;
|
||||
|
||||
let foundItem: Item | null | undefined = null;
|
||||
if (identifiers !== null) {
|
||||
foundItem = items.find(
|
||||
(item) => identifiers.id === ItemUtils.getMappingId(item)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
item: foundItem ? foundItem : null,
|
||||
isCustomizeClosed,
|
||||
};
|
||||
};
|
||||
|
||||
handleCustomItemEdit = (adjustments: Record<string, any>): void => {
|
||||
const { item } = this.state;
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (item === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (adjustments.notes === "") {
|
||||
adjustments.notes = null;
|
||||
}
|
||||
|
||||
inventoryManager.handleCustomEdit({
|
||||
item,
|
||||
adjustments,
|
||||
});
|
||||
};
|
||||
|
||||
handleCustomDataUpdate = (
|
||||
adjustmentType: Constants.AdjustmentTypeEnum,
|
||||
value: any
|
||||
): void => {
|
||||
const { item } = this.state;
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (item) {
|
||||
inventoryManager.handleCustomizationSet({
|
||||
item,
|
||||
adjustmentType,
|
||||
value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveCustomizations = (): void => {
|
||||
const { item } = this.state;
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (item) {
|
||||
inventoryManager.handleCustomizationsRemove({ item });
|
||||
}
|
||||
};
|
||||
|
||||
handleDataOriginClick = (dataOrigin: DataOrigin) => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
handleSpellClick = (spell: Spell): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
let component = getSpellComponentInfo(spell);
|
||||
if (component.type) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
handleInfusionClick = (infusion: Infusion): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
const choiceKey = InfusionUtils.getChoiceKey(infusion);
|
||||
if (choiceKey !== null) {
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.INFUSION_CHOICE,
|
||||
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleActionClick = (action: Action): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
const dataOrigin = ActionUtils.getDataOrigin(action);
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
handleOpenCustomize = () => {
|
||||
this.setState({ isCustomizeClosed: !this.state.isCustomizeClosed });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item, isCustomizeClosed } = this.state;
|
||||
const {
|
||||
weaponSpellDamageGroups,
|
||||
ruleData,
|
||||
snippetData,
|
||||
entityValueLookup,
|
||||
infusionChoiceLookup,
|
||||
isReadonly,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
containerLookup,
|
||||
inventoryManager,
|
||||
partyInfo,
|
||||
} = this.props;
|
||||
|
||||
if (item === null) {
|
||||
return (
|
||||
<PaneInitFailureContent errorMessage="That item is no longer in your inventory! Please try again." />
|
||||
);
|
||||
}
|
||||
|
||||
const canCustomize = inventoryManager.canCustomizeItem(item) && !isReadonly;
|
||||
|
||||
return (
|
||||
<div className="ct-item-pane">
|
||||
<Header preview={<Preview imageUrl={ItemUtils.getAvatarUrl(item)} />}>
|
||||
<EditableName onClick={this.handleOpenCustomize}>
|
||||
<ItemName item={item} />
|
||||
</EditableName>
|
||||
</Header>
|
||||
<ItemListInformationCollapsible item={item} ruleData={ruleData} />
|
||||
<ItemDetail
|
||||
theme={theme}
|
||||
key={ItemUtils.getUniqueKey(item)}
|
||||
item={item}
|
||||
weaponSpellDamageGroups={weaponSpellDamageGroups}
|
||||
ruleData={ruleData}
|
||||
snippetData={snippetData}
|
||||
onCustomDataUpdate={this.handleCustomDataUpdate}
|
||||
onCustomizationsRemove={this.handleRemoveCustomizations}
|
||||
onDataOriginClick={this.handleDataOriginClick}
|
||||
onSpellClick={this.handleSpellClick}
|
||||
onInfusionClick={this.handleInfusionClick}
|
||||
onMasteryActionClick={this.handleActionClick}
|
||||
entityValueLookup={entityValueLookup}
|
||||
infusionChoiceLookup={infusionChoiceLookup}
|
||||
isReadonly={isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
container={HelperUtils.lookupDataOrFallback(
|
||||
containerLookup,
|
||||
ItemUtils.getContainerDefinitionKey(item)
|
||||
)}
|
||||
showCustomize={canCustomize}
|
||||
onPostRemoveNavigation={PaneComponentEnum.EQUIPMENT_MANAGE}
|
||||
partyInfo={partyInfo}
|
||||
onCustomItemEdit={this.handleCustomItemEdit}
|
||||
isCustomizeClosed={isCustomizeClosed}
|
||||
onCustomizeClick={this.handleOpenCustomize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
items: rulesEngineSelectors.getAllInventoryItems(state),
|
||||
weaponSpellDamageGroups:
|
||||
rulesEngineSelectors.getWeaponSpellDamageGroups(state),
|
||||
entityValueLookup:
|
||||
rulesEngineSelectors.getCharacterValueLookupByEntity(state),
|
||||
snippetData: rulesEngineSelectors.getSnippetData(state),
|
||||
infusionChoiceLookup: rulesEngineSelectors.getInfusionChoiceLookup(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
containerLookup: rulesEngineSelectors.getContainerLookup(state),
|
||||
partyInfo: serviceDataSelectors.getPartyInfo(state),
|
||||
};
|
||||
}
|
||||
|
||||
const ItemPaneContainer = (props) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return (
|
||||
<ItemPane
|
||||
paneHistoryPush={paneHistoryPush}
|
||||
inventoryManager={inventoryManager}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ItemPaneContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ItemPane from "./ItemPane";
|
||||
|
||||
export default ItemPane;
|
||||
export { ItemPane };
|
||||
@@ -0,0 +1,243 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { Checkbox } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterUtils,
|
||||
ApiRequests,
|
||||
rulesEngineSelectors,
|
||||
characterActions,
|
||||
Condition,
|
||||
RuleData,
|
||||
ShortModelInfoContract,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { RestoreLifeManager } from "~/subApps/sheet/components/Sidebar/panes/HitPointsManagePane/RestoreLifeManager/RestoreLifeManager";
|
||||
|
||||
import { toastMessageActions } from "../../../actions/toastMessage";
|
||||
import { ThemeButton } from "../../../components/common/Button";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
import { AppLoggerUtils } from "../../../utils";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
activeConditions: Array<Condition>;
|
||||
ruleData: RuleData;
|
||||
isDead: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
interface State {
|
||||
resetMaxHpModifier: boolean;
|
||||
adjustConditionLevel: boolean;
|
||||
restMessage: string | null;
|
||||
}
|
||||
class LongRestPane extends React.PureComponent<Props, State> {
|
||||
loadMessageCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
resetMaxHpModifier: true,
|
||||
adjustConditionLevel: false,
|
||||
restMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadMessage();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadMessageCanceler !== null) {
|
||||
this.loadMessageCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
loadMessage = (): void => {
|
||||
if (this.loadMessageCanceler !== null) {
|
||||
this.loadMessageCanceler();
|
||||
}
|
||||
|
||||
ApiRequests.getCharacterRestLong({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadMessageCanceler = c;
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
let message = ApiAdapterUtils.getResponseData(response);
|
||||
if (message !== null) {
|
||||
this.setState({
|
||||
restMessage: message,
|
||||
});
|
||||
}
|
||||
this.loadMessageCanceler = null;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
};
|
||||
|
||||
reset = (): void => {
|
||||
this.setState({
|
||||
resetMaxHpModifier: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
resetMaxHpModifier: true,
|
||||
adjustConditionLevel: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleSave = (): void => {
|
||||
const { resetMaxHpModifier, adjustConditionLevel } = this.state;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(
|
||||
characterActions.longRest(resetMaxHpModifier, adjustConditionLevel)
|
||||
);
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Long Rest Taken",
|
||||
"You have completed a long rest. Relevant abilities have been reset."
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
handleResetMaxHpChange = (enabled: boolean): void => {
|
||||
this.setState({
|
||||
resetMaxHpModifier: enabled,
|
||||
});
|
||||
};
|
||||
|
||||
handleAdjustExhaustionLevel = (enabled: boolean): void => {
|
||||
this.setState({
|
||||
adjustConditionLevel: enabled,
|
||||
});
|
||||
};
|
||||
|
||||
handleRestoreToLife = (restoreType: ShortModelInfoContract): void => {
|
||||
const { dispatch } = this.props;
|
||||
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
|
||||
|
||||
dispatch(characterActions.restoreLife(restoreType.id));
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Character Restored to Life",
|
||||
`You have been restored to life with ${restoreChoice} HP.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
renderRecover = (): React.ReactNode => {
|
||||
const { resetMaxHpModifier, adjustConditionLevel, restMessage } =
|
||||
this.state;
|
||||
|
||||
const { isDead, activeConditions } = this.props;
|
||||
|
||||
if (isDead) {
|
||||
return (
|
||||
<div className="ct-reset-pane__recover-sources">
|
||||
Your character is dead
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const exhaustionIsActive = activeConditions.find(
|
||||
(condition) => condition.level !== null
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="ct-reset-pane__recover-sources">
|
||||
{restMessage === null
|
||||
? "Asking the server what will be reset..."
|
||||
: restMessage}
|
||||
</div>
|
||||
<div className="ct-reset-pane__recover-max-hp">
|
||||
<Checkbox
|
||||
label="Reset Maximum HP changes during this rest"
|
||||
initiallyEnabled={resetMaxHpModifier}
|
||||
onChange={this.handleResetMaxHpChange}
|
||||
/>
|
||||
</div>
|
||||
{exhaustionIsActive && (
|
||||
<div className="ct-reset-pane__reset-exhaustion-level">
|
||||
<Checkbox
|
||||
label="Recover 1 Level of Exhaustion during this rest (requires food and drink)"
|
||||
initiallyEnabled={adjustConditionLevel}
|
||||
onChange={this.handleAdjustExhaustionLevel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderActions = (): React.ReactNode => {
|
||||
const { isDead, ruleData, theme } = this.props;
|
||||
|
||||
if (isDead) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RestoreLifeManager onSave={this.handleRestoreToLife} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-reset-pane__actions">
|
||||
<div className="ct-reset-pane__action">
|
||||
<ThemeButton onClick={this.handleSave} enableConfirm={true}>
|
||||
Take Long Rest
|
||||
</ThemeButton>
|
||||
</div>
|
||||
<div className="ct-reset-pane__action">
|
||||
<ThemeButton onClick={this.handleReset} style="outline">
|
||||
Reset
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-reset-pane">
|
||||
<Header>Long Rest</Header>
|
||||
|
||||
<div className="ct-reset-pane__intro">
|
||||
<p>
|
||||
A long rest is a period of extended downtime, at least 8 hours long,
|
||||
during which a character sleeps for at least 6 hours and performs no
|
||||
more than 2 hours of light activity, such as reading, talking,
|
||||
eating, or standing watch.
|
||||
</p>
|
||||
<p>
|
||||
If the rest is interrupted by a period of strenuous activity — at
|
||||
least 1 hour of walking, fighting, casting spells, or similar
|
||||
adventuring activity — the characters must begin the rest again to
|
||||
gain any benefit from it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ct-reset-pane__recover">
|
||||
<div className="ct-reset-pane__recover-heading">Recover</div>
|
||||
{this.renderRecover()}
|
||||
</div>
|
||||
{this.renderActions()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
activeConditions: rulesEngineSelectors.getActiveConditions(state),
|
||||
isDead: rulesEngineSelectors.isDead(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(LongRestPane);
|
||||
@@ -0,0 +1,4 @@
|
||||
import LongRestPane from "./LongRestPane";
|
||||
|
||||
export default LongRestPane;
|
||||
export { LongRestPane };
|
||||
@@ -0,0 +1,240 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
Constants,
|
||||
RuleDataUtils,
|
||||
rulesEngineSelectors,
|
||||
CharacterNotes,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Textarea } from "~/components/Textarea";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { PaneIdentifiersNote } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
|
||||
import { ThemeButton } from "../../../components/common/Button";
|
||||
import { SharedAppState } from "../../../stores/typings";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
notes: CharacterNotes;
|
||||
placeholder: string;
|
||||
autoSaveDelay: number;
|
||||
identifiers: PaneIdentifiersNote | null;
|
||||
}
|
||||
interface StateData {
|
||||
noteType: string | null;
|
||||
}
|
||||
interface State extends StateData {
|
||||
hasBeenEdited: boolean;
|
||||
isSaveDirty: boolean;
|
||||
}
|
||||
class NoteManagePane extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
placeholder: "Enter any notes here!",
|
||||
autoSaveDelay: 5000,
|
||||
};
|
||||
|
||||
autoSaveTimeoutId: number | null = null;
|
||||
textareaInput = React.createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
...this.generateStateData(props),
|
||||
hasBeenEdited: false,
|
||||
isSaveDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { identifiers } = this.props;
|
||||
|
||||
if (identifiers !== prevProps.identifiers) {
|
||||
this.setState({
|
||||
...this.generateStateData(this.props),
|
||||
hasBeenEdited: false,
|
||||
isSaveDirty: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props): StateData => {
|
||||
const { identifiers } = props;
|
||||
|
||||
return {
|
||||
noteType: identifiers !== null ? identifiers.noteType : null,
|
||||
};
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.textareaInput.current) {
|
||||
this.textareaInput.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { dispatch } = this.props;
|
||||
const { isSaveDirty, noteType } = this.state;
|
||||
|
||||
this.handleStopAutoSaveTimer();
|
||||
|
||||
if (noteType !== null && isSaveDirty && this.textareaInput.current) {
|
||||
dispatch(
|
||||
characterActions.noteSet(
|
||||
noteType,
|
||||
this.textareaInput.current?.dataset.value || ""
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
startAutoSaveTimer = (): void => {
|
||||
const { autoSaveDelay } = this.props;
|
||||
|
||||
if (this.autoSaveTimeoutId === null) {
|
||||
this.autoSaveTimeoutId = window.setTimeout(() => {
|
||||
this.handleSave();
|
||||
}, autoSaveDelay);
|
||||
}
|
||||
};
|
||||
|
||||
handleStopAutoSaveTimer = (): void => {
|
||||
if (this.autoSaveTimeoutId !== null) {
|
||||
clearTimeout(this.autoSaveTimeoutId);
|
||||
this.autoSaveTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
handleSave = (): void => {
|
||||
const { dispatch } = this.props;
|
||||
const { noteType } = this.state;
|
||||
|
||||
if (noteType !== null && this.textareaInput.current) {
|
||||
dispatch(
|
||||
characterActions.noteSet(
|
||||
noteType,
|
||||
this.textareaInput.current?.dataset.value || ""
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.handleStopAutoSaveTimer();
|
||||
this.setState({
|
||||
isSaveDirty: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleInputKeyUp = (): void => {
|
||||
const { isSaveDirty } = this.state;
|
||||
|
||||
if (!isSaveDirty) {
|
||||
this.setState(
|
||||
{
|
||||
isSaveDirty: true,
|
||||
hasBeenEdited: true,
|
||||
},
|
||||
this.startAutoSaveTimer
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleInputBlur = (text: string): void => {
|
||||
this.handleSave();
|
||||
};
|
||||
|
||||
getNoteHelpText = (): string => {
|
||||
const { noteType } = this.state;
|
||||
|
||||
let text = "";
|
||||
switch (noteType) {
|
||||
case Constants.NoteKeyEnum.ORGANIZATIONS:
|
||||
text =
|
||||
"Are there any important organizations your character belongs to? Any societies, orders, cults, or agencies?";
|
||||
break;
|
||||
case Constants.NoteKeyEnum.ALLIES:
|
||||
text =
|
||||
"With whom does your character associate? When in need of aid, do they seek out their kinfolk, followers of their deity, or other members of their order? What groups or individuals are they aligned with?";
|
||||
break;
|
||||
case Constants.NoteKeyEnum.ENEMIES:
|
||||
text =
|
||||
"Who does your character fear or fight? Have they sworn a vow to rid the world of undead? Is there an order or organization they are opposed to? Are there any specific foes from their past?";
|
||||
break;
|
||||
case Constants.NoteKeyEnum.BACKSTORY:
|
||||
text =
|
||||
"Talk about your character's origins. Where are they from? How did they end up adventuring? How did they choose their class?";
|
||||
break;
|
||||
case Constants.NoteKeyEnum.OTHER:
|
||||
text = "Anything at all you'd like to mention about your character.";
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSaveDirty, hasBeenEdited, noteType } = this.state;
|
||||
const { notes, placeholder } = this.props;
|
||||
|
||||
if (noteType === null) {
|
||||
return <PaneInitFailureContent />;
|
||||
}
|
||||
|
||||
let content: string | null = notes[noteType] ? notes[noteType] : "";
|
||||
|
||||
let calloutNode: React.ReactNode;
|
||||
if (hasBeenEdited) {
|
||||
if (isSaveDirty) {
|
||||
calloutNode = (
|
||||
<React.Fragment>
|
||||
<div className="ct-note-manage-pane__status ct-note-manage-pane__status--dirty">
|
||||
<div className="ct-note-manage-pane__status-text">
|
||||
Unsaved changes
|
||||
</div>
|
||||
</div>
|
||||
<ThemeButton size="small" onClick={this.handleSave}>
|
||||
Save
|
||||
</ThemeButton>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
calloutNode = (
|
||||
<div className="ct-note-manage-pane__status ct-note-manage-pane__status--clean">
|
||||
<div className="ct-note-manage-pane__status-icon" />
|
||||
<div className="ct-note-manage-pane__status-text">Saved</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-note-manage-pane">
|
||||
<Header callout={calloutNode}>
|
||||
{RuleDataUtils.getNoteKeyName(noteType as Constants.NoteKeyEnum)}
|
||||
</Header>
|
||||
<Textarea
|
||||
ref={this.textareaInput}
|
||||
placeholder={placeholder}
|
||||
value={content === null ? "" : content}
|
||||
onInputKeyUp={this.handleInputKeyUp}
|
||||
onInputBlur={this.handleInputBlur}
|
||||
/>
|
||||
<p className="ct-note-manage-pane__text">{this.getNoteHelpText()}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
notes: rulesEngineSelectors.getCharacterNotes(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(NoteManagePane);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user