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,167 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
BuilderChoiceContract,
|
||||
Choice,
|
||||
ChoiceData,
|
||||
ChoiceUtils,
|
||||
Constants,
|
||||
FeatureChoiceOption,
|
||||
HtmlSelectOptionGroup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { CollapsibleContent } from "~/components/CollapsibleContent";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useRuleData } from "~/hooks/useRuleData";
|
||||
import Collapsible from "~/tools/js/smartComponents/Collapsible";
|
||||
import { Select } from "~/tools/js/smartComponents/legacy";
|
||||
|
||||
// TODO this needs to not extend builder contract and options typing needs to be fixed
|
||||
interface Props extends Omit<BuilderChoiceContract, "options"> {
|
||||
choice: Choice;
|
||||
description?: string;
|
||||
choiceInfo: ChoiceData;
|
||||
classId?: number | null;
|
||||
maxDescriptionLength?: number;
|
||||
showBackgroundProficiencyOptions?: boolean;
|
||||
hideWhenOnlyDefaultSelected?: boolean;
|
||||
className?: string;
|
||||
options: Array<FeatureChoiceOption | HtmlSelectOptionGroup>;
|
||||
onChange?: (
|
||||
id: string,
|
||||
type: number,
|
||||
subType: number | null,
|
||||
value: any,
|
||||
parentChoiceId: string | null
|
||||
) => void;
|
||||
collapseDescription?: boolean;
|
||||
}
|
||||
|
||||
export const DetailChoice: FC<Props> = ({
|
||||
choice,
|
||||
description,
|
||||
optionValue,
|
||||
parentChoiceId,
|
||||
maxDescriptionLength = 750,
|
||||
hideWhenOnlyDefaultSelected = true,
|
||||
type,
|
||||
className = "",
|
||||
options,
|
||||
collapseDescription,
|
||||
classId = null,
|
||||
label,
|
||||
subType,
|
||||
choiceInfo,
|
||||
defaultSubtypes = [],
|
||||
showBackgroundProficiencyOptions = false,
|
||||
onChange,
|
||||
id,
|
||||
}) => {
|
||||
const { entityRestrictionData, ruleData } = useCharacterEngine();
|
||||
const { languages } = useRuleData();
|
||||
|
||||
const handleChoiceChange = (value: string): void => {
|
||||
if (onChange && id !== null) {
|
||||
onChange(id, type, subType, value, parentChoiceId);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
hideWhenOnlyDefaultSelected &&
|
||||
ChoiceUtils.isOnlyDefaultSelected(choice)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let classNames: Array<string> = ["detail-choice", className];
|
||||
if (
|
||||
(ChoiceUtils.isInfinite(choice) && !ChoiceUtils.isOptionSelected(choice)) ||
|
||||
ChoiceUtils.isTodo(choice)
|
||||
) {
|
||||
classNames.push("detail-choice--todo");
|
||||
}
|
||||
|
||||
// Use the description prop or, if a chosenOption is found and a description wasn't passed in, use the chosenOption description instead.
|
||||
let choiceDescription = description;
|
||||
|
||||
//Look for a chosenOption from options that are not already grouped
|
||||
const chosenOption = options.find(
|
||||
(option) => option["value"] === optionValue
|
||||
);
|
||||
|
||||
if (chosenOption) {
|
||||
choiceDescription = description || chosenOption["description"];
|
||||
}
|
||||
|
||||
if (parentChoiceId !== null) {
|
||||
classNames.push("detail-choice--child");
|
||||
}
|
||||
|
||||
let choiceNode: ReactNode;
|
||||
if (choiceDescription) {
|
||||
switch (type) {
|
||||
case Constants.BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION:
|
||||
choiceNode = (
|
||||
<Collapsible layoutType={"minimal"} header="Spell Details">
|
||||
<HtmlContent
|
||||
className="detail-choice-description"
|
||||
html={choiceDescription}
|
||||
withoutTooltips
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
if (collapseDescription) {
|
||||
choiceNode = (
|
||||
<Collapsible layoutType={"minimal"} header="Show Details">
|
||||
<HtmlContent
|
||||
className="detail-choice-description"
|
||||
html={choiceDescription}
|
||||
withoutTooltips
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
} else if (choiceDescription.length > maxDescriptionLength) {
|
||||
choiceNode = (
|
||||
<CollapsibleContent className="detail-choice-description">
|
||||
{choiceDescription}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
} else {
|
||||
choiceNode = (
|
||||
<HtmlContent
|
||||
className="detail-choice-description"
|
||||
html={choiceDescription}
|
||||
withoutTooltips
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<Select
|
||||
className="detail-choice-input"
|
||||
options={ChoiceUtils.getSortedRenderOptions(
|
||||
options,
|
||||
classId,
|
||||
subType,
|
||||
entityRestrictionData,
|
||||
languages ?? [],
|
||||
ruleData,
|
||||
defaultSubtypes,
|
||||
showBackgroundProficiencyOptions,
|
||||
choiceInfo,
|
||||
optionValue
|
||||
)}
|
||||
value={optionValue}
|
||||
placeholder={label ? `- ${label} -` : "- Choose an Option -"}
|
||||
onChange={handleChoiceChange}
|
||||
/>
|
||||
{choiceNode}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { FC } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
ChoiceUtils,
|
||||
FeatUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import { DetailChoice } from "../DetailChoice";
|
||||
|
||||
//This component is a wrapper for DetailChoice that is specifically for Feats
|
||||
//given a featId, it will render the choices for that feat
|
||||
//used in FeatDetail, FeatPane and FeatureChoice (for feats that are given by Class Features, Species Traits and Backgrounds)
|
||||
|
||||
export interface Props {
|
||||
featId: number;
|
||||
}
|
||||
|
||||
export const DetailChoiceFeat: FC<Props> = ({ featId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { entityRestrictionData, choiceInfo, feats, ruleData } =
|
||||
useCharacterEngine();
|
||||
|
||||
const handleChoiceChange = (
|
||||
id: string | null,
|
||||
type: number,
|
||||
subType: number | null,
|
||||
value: any
|
||||
): void => {
|
||||
if (id !== null) {
|
||||
dispatch(characterActions.featChoiceSetRequest(featId, type, id, value));
|
||||
}
|
||||
};
|
||||
|
||||
const feat = feats.find((feat) => FeatUtils.getId(feat) === featId);
|
||||
|
||||
if (!feat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const choices = FeatUtils.getChoices(feat);
|
||||
if (choices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-detail-choice-feat">
|
||||
{choices.map((choice) => {
|
||||
const id = ChoiceUtils.getId(choice);
|
||||
|
||||
const { description, options: featChoiceOptions } =
|
||||
ChoiceUtils.getSortedChoiceOptionsInfo(
|
||||
choice,
|
||||
ruleData,
|
||||
entityRestrictionData
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailChoice
|
||||
{...choice}
|
||||
label={choice.label}
|
||||
choice={choice}
|
||||
key={id !== null ? id : ""}
|
||||
options={featChoiceOptions}
|
||||
onChange={handleChoiceChange}
|
||||
choiceInfo={choiceInfo}
|
||||
showBackgroundProficiencyOptions={true}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { DispatchProp, useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { TypeScriptUtils } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterUtils,
|
||||
CharacterTheme,
|
||||
ConfigUtils,
|
||||
rulesEngineSelectors,
|
||||
Constants,
|
||||
ApiException,
|
||||
CampaignDataContract,
|
||||
CampaignUtils,
|
||||
characterEnvSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { Dice, DiceNotifier, IRollContext } from "@dndbeyond/dice";
|
||||
import { DiceToolbar } from "@dndbeyond/dice-components";
|
||||
import { GameLogContext } from "@dndbeyond/game-log-components";
|
||||
import { getMessageBroker } from "@dndbeyond/message-broker-lib";
|
||||
|
||||
import { useAuth } from "~/contexts/Authentication";
|
||||
|
||||
import { appEnvActions } from "../../actions/appEnv";
|
||||
import {
|
||||
appEnvSelectors,
|
||||
characterRollContextSelectors,
|
||||
} from "../../selectors";
|
||||
import {
|
||||
DiceFeatureConfigurationState,
|
||||
SharedAppState,
|
||||
} from "../../stores/typings";
|
||||
import { AppLoggerUtils } from "../../utils";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
campaign: CampaignDataContract | null;
|
||||
canShow: boolean;
|
||||
theme: CharacterTheme;
|
||||
diceEnabled: boolean;
|
||||
diceFeatureConfiguration: DiceFeatureConfigurationState;
|
||||
characterRollContext: IRollContext;
|
||||
}
|
||||
|
||||
// <canvas id="dice-rolling-canvas" className={'dice-rolling-panel__container'} />
|
||||
// this is the canvas element that we will use to draw the dice
|
||||
// TODO: this is a temporary solution, we need to find a better way to do this
|
||||
// waiting for adventure team to provide a better solution
|
||||
const canvasElement = document.createElement("canvas");
|
||||
canvasElement.id = "dice-rolling-canvas";
|
||||
canvasElement.classList.add("dice-rolling-panel__container");
|
||||
|
||||
export default function DiceContainer({ canShow = true }) {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
|
||||
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
|
||||
const diceFeatureConfiguration = useSelector(
|
||||
appEnvSelectors.getDiceFeatureConfiguration
|
||||
);
|
||||
const characterRollContext = useSelector(
|
||||
characterRollContextSelectors.getCharacterRollContext
|
||||
);
|
||||
const campaign = useSelector(rulesEngineSelectors.getCampaign);
|
||||
const userConfigApi = "/diceuserconfig/v1/get";
|
||||
const authUser = useAuth();
|
||||
let userId: string | null = null;
|
||||
if (authUser?.id) {
|
||||
userId = authUser.id;
|
||||
}
|
||||
const [{ messageTargetOptions, defaultMessageTargetOption }] =
|
||||
useContext(GameLogContext);
|
||||
const canvasContainer = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isSheet =
|
||||
useSelector(characterEnvSelectors.getContext) ===
|
||||
Constants.AppContextTypeEnum.SHEET;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasContainer.current?.contains(canvasElement)) {
|
||||
canvasContainer.current?.appendChild(canvasElement);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getMessageBroker().then((mb) => {
|
||||
if (mb && campaign && userId) {
|
||||
mb.gameId = isSheet ? CampaignUtils.getId(campaign).toString() : "0";
|
||||
mb.userId = userId;
|
||||
}
|
||||
ConfigUtils.configureRulesEngine({ messageBroker: mb });
|
||||
if (diceFeatureConfiguration.enabled) {
|
||||
const rulesEngineConfig = ConfigUtils.getCurrentRulesEngineConfig();
|
||||
if (rulesEngineConfig.apiAdapter) {
|
||||
rulesEngineConfig.apiAdapter
|
||||
.get(`${diceFeatureConfiguration.apiEndpoint}${userConfigApi}`)
|
||||
.then((x) => {
|
||||
const diceCanvas = canvasElement;
|
||||
Dice.init({
|
||||
isDebug: false,
|
||||
assetBaseLocation: diceFeatureConfiguration.assetBaseLocation,
|
||||
trackingId: diceFeatureConfiguration.trackingId,
|
||||
sessionName: "Dice",
|
||||
notifier: new DiceNotifier(),
|
||||
canvas: diceCanvas || undefined,
|
||||
previewMode: false,
|
||||
diceUserConfig: ApiAdapterUtils.getResponseData(x) as any,
|
||||
})
|
||||
.then(() =>
|
||||
document.addEventListener("click", () => Dice.clear())
|
||||
)
|
||||
.catch((error: Error) => {
|
||||
AppLoggerUtils.logMessage(
|
||||
error.message,
|
||||
Constants.LogMessageType.WARNING
|
||||
);
|
||||
dispatch(
|
||||
appEnvActions.dataSet({
|
||||
diceEnabled: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((error: ApiException) => {
|
||||
AppLoggerUtils.logMessage(
|
||||
error.message,
|
||||
Constants.LogMessageType.WARNING
|
||||
);
|
||||
dispatch(
|
||||
appEnvActions.dataSet({
|
||||
diceEnabled: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Dice.analyticsInit(diceFeatureConfiguration.trackingId, "Dice");
|
||||
}
|
||||
});
|
||||
}, [diceFeatureConfiguration, dispatch]);
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={"dice-rolling-panel"} ref={canvasContainer}>
|
||||
{canShow && diceEnabled && (
|
||||
<DiceToolbar
|
||||
rollContext={characterRollContext}
|
||||
rollTargetOptions={
|
||||
messageTargetOptions && isSheet
|
||||
? Object.values(messageTargetOptions?.entities).filter(
|
||||
TypeScriptUtils.isNotNullOrUndefined
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rollTargetDefault={defaultMessageTargetOption}
|
||||
themeColor={theme.themeColor}
|
||||
userId={parseInt(userId || "0", 10)}
|
||||
/>
|
||||
)}
|
||||
{/* We want this one so it is sticking around,
|
||||
also this is where the canvas goes when we append */}
|
||||
{/* <canvas className={'dice-rolling-panel__container'} ref={canvas} /> */}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { StepType, TourContext, TourProvider } from "@reactour/tour";
|
||||
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { checkContrast } from "../../utils/Color";
|
||||
import GuidedTourButton from "./GuidedTourButton";
|
||||
|
||||
interface GuidedTourProps {
|
||||
children?: ReactNode;
|
||||
steps?: StepType[];
|
||||
step?: number;
|
||||
setStep?: SetStateAction<any>;
|
||||
beforeClose?: (target: Element | null) => void;
|
||||
afterOpen?: (target: Element | null) => void;
|
||||
showOnFirstLoad?: boolean;
|
||||
cookieName?: string;
|
||||
}
|
||||
|
||||
export default ({
|
||||
children,
|
||||
step,
|
||||
setStep,
|
||||
steps = [],
|
||||
beforeClose,
|
||||
afterOpen,
|
||||
showOnFirstLoad,
|
||||
cookieName = "ddbGuidedTour",
|
||||
}: GuidedTourProps) => {
|
||||
const [defaultStep, setDefaultStep] = useState(0);
|
||||
const { isDarkMode, backgroundColor, themeColor } = useSelector(
|
||||
rulesEngineSelectors.getCharacterTheme
|
||||
);
|
||||
const bgColor = backgroundColor.slice(0, 7);
|
||||
const accentColor = checkContrast(backgroundColor, themeColor)
|
||||
? themeColor
|
||||
: "#C53131";
|
||||
|
||||
const handleBeforeClose = () => {
|
||||
const html = document.querySelector("html");
|
||||
if (html) html.style.overflowY = "visible";
|
||||
if (beforeClose) beforeClose(null);
|
||||
};
|
||||
|
||||
const handleAfterOpen = () => {
|
||||
const html = document.querySelector("html");
|
||||
if (html) html.style.overflowY = "hidden";
|
||||
if (afterOpen) afterOpen(null);
|
||||
};
|
||||
|
||||
const handleLoad = (setIsOpen: Dispatch<SetStateAction<boolean>>) => {
|
||||
if (showOnFirstLoad) {
|
||||
const viewedTour = localStorage.getItem(cookieName);
|
||||
|
||||
if (!viewedTour) {
|
||||
setIsOpen(true);
|
||||
localStorage.setItem(cookieName, "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
".reactour__popover": {
|
||||
backgroundColor: `${bgColor} !important`,
|
||||
color: isDarkMode
|
||||
? "rgba(255,255,255,0.9) !important"
|
||||
: "rgba(0,0,0,0.9) !important",
|
||||
borderRadius: 1,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: isDarkMode
|
||||
? "rgba(255,255,255,0.3) !important"
|
||||
: "rgba(0,0,0,0.3) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TourProvider
|
||||
steps={steps}
|
||||
currentStep={step || defaultStep}
|
||||
setCurrentStep={setStep || setDefaultStep}
|
||||
afterOpen={handleAfterOpen}
|
||||
beforeClose={handleBeforeClose}
|
||||
showDots={false}
|
||||
styles={{
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: accentColor,
|
||||
color: bgColor,
|
||||
fontWeight: 700,
|
||||
}),
|
||||
close: (base) => ({
|
||||
...base,
|
||||
color: isDarkMode ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.9)",
|
||||
}),
|
||||
dot: (base, status) => ({
|
||||
...base,
|
||||
background: status?.current ? accentColor : "transparent",
|
||||
color: status?.current ? accentColor : "transparent",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<TourContext.Consumer>
|
||||
{({ setIsOpen }) => (
|
||||
<div onLoad={() => handleLoad(setIsOpen)}>
|
||||
{children}
|
||||
<GuidedTourButton />
|
||||
</div>
|
||||
)}
|
||||
</TourContext.Consumer>
|
||||
</TourProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import QuestionMarkIcon from "@mui/icons-material/QuestionMark";
|
||||
import { Button } from "@mui/material";
|
||||
import { useTour } from "@reactour/tour";
|
||||
|
||||
export default () => {
|
||||
const { setIsOpen } = useTour();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="ct-character-guided-tour"
|
||||
variant="contained"
|
||||
color="info"
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
position: "fixed",
|
||||
zIndex: 9,
|
||||
p: 1,
|
||||
borderRadius: "100%",
|
||||
minWidth: 0,
|
||||
bottom: { xs: 100, md: 20 },
|
||||
right: 20,
|
||||
}}
|
||||
>
|
||||
<QuestionMarkIcon />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import GuidedTourButton from "./GuidedTourButton";
|
||||
|
||||
export default GuidedTourButton;
|
||||
export { GuidedTourButton };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Typography } from "@mui/material";
|
||||
import { useTour } from "@reactour/tour";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { checkContrast } from "../../../utils/Color";
|
||||
|
||||
export default ({ title, content, showClose = false }) => {
|
||||
const { themeColor, backgroundColor, isDarkMode } = useSelector(
|
||||
rulesEngineSelectors.getCharacterTheme
|
||||
);
|
||||
const { setIsOpen } = useTour();
|
||||
const accentColor = checkContrast(backgroundColor, themeColor)
|
||||
? themeColor
|
||||
: "#C53131";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h4" component="p" mb={1}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="div"
|
||||
variant="body2"
|
||||
sx={{ a: { color: accentColor, textDecoration: "none" } }}
|
||||
>
|
||||
{content}
|
||||
</Typography>
|
||||
{showClose && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsOpen(false)}
|
||||
sx={{
|
||||
display: "inherit",
|
||||
my: 1,
|
||||
mx: "auto",
|
||||
fontSize: 12,
|
||||
color: isDarkMode
|
||||
? "rgba(255,255,255,0.9) !important"
|
||||
: "rgba(0,0,0,0.9) !important",
|
||||
borderColor: accentColor,
|
||||
}}
|
||||
>
|
||||
Start Playing!
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useContext } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
AbilityLookup,
|
||||
DataOriginRefData,
|
||||
InventoryManager,
|
||||
Item,
|
||||
ItemUtils,
|
||||
LimitedUseUtils,
|
||||
Spell,
|
||||
SpellUtils,
|
||||
RuleData,
|
||||
ItemManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import SlotManager from "../../components/SlotManager";
|
||||
import SlotManagerLarge from "../../components/SlotManagerLarge";
|
||||
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
|
||||
import { appEnvSelectors } from "../../selectors";
|
||||
import { SharedAppState } from "../../stores/typings";
|
||||
import { PaneIdentifierUtils } from "../../utils";
|
||||
|
||||
interface Props {
|
||||
item: Item;
|
||||
largePoolMinAmount: number;
|
||||
abilityLookup: AbilityLookup;
|
||||
ruleData: RuleData;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
inventoryManager: InventoryManager;
|
||||
paneHistoryPush: PaneInfo["paneHistoryPush"];
|
||||
}
|
||||
class ItemDetailAbilities extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
largePoolMinAmount: 11,
|
||||
};
|
||||
|
||||
handleSlotUseSet = (uses: number): void => {
|
||||
const { item: itemData } = this.props;
|
||||
|
||||
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
|
||||
|
||||
item.handleItemLimitedUseSet(uses);
|
||||
};
|
||||
|
||||
handleSpellDetailClick = (id: number): void => {
|
||||
const { paneHistoryPush } = this.props;
|
||||
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
|
||||
PaneIdentifierUtils.generateCharacterSpell(id)
|
||||
);
|
||||
};
|
||||
|
||||
renderSmallAmountSlotPool = (): React.ReactNode => {
|
||||
const { ruleData, abilityLookup, item, proficiencyBonus } = this.props;
|
||||
|
||||
let limitedUse = ItemUtils.getLimitedUse(item);
|
||||
if (limitedUse === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
|
||||
return (
|
||||
<SlotManager
|
||||
used={numberUsed}
|
||||
available={maxUses}
|
||||
size={"small"}
|
||||
onSet={this.handleSlotUseSet}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderLargeAmountSlotPool = (): React.ReactNode => {
|
||||
const { ruleData, abilityLookup, item, isReadonly, proficiencyBonus } =
|
||||
this.props;
|
||||
|
||||
let limitedUse = ItemUtils.getLimitedUse(item);
|
||||
if (limitedUse === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
|
||||
return (
|
||||
<SlotManagerLarge
|
||||
available={maxUses}
|
||||
used={numberUsed}
|
||||
onSet={this.handleSlotUseSet}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderLimitedUses = (): React.ReactNode => {
|
||||
const {
|
||||
item,
|
||||
largePoolMinAmount,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
|
||||
let limitedUse = ItemUtils.getLimitedUse(item);
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let initialMaxUses = LimitedUseUtils.getInitialMaxUses(limitedUse);
|
||||
if (initialMaxUses === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
if (!maxUses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUseCharges = inventoryManager.canUseItemCharges(item);
|
||||
|
||||
return canUseCharges ? (
|
||||
<div className="ct-item-detail-abilities__limited-uses">
|
||||
<div className="ct-item-detail-abilities__limited-uses-label">
|
||||
Charges
|
||||
</div>
|
||||
<div className="ct-item-detail-abilities__limited-uses-manager">
|
||||
{maxUses >= largePoolMinAmount
|
||||
? this.renderLargeAmountSlotPool()
|
||||
: this.renderSmallAmountSlotPool()}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
renderSpell = (spell: Spell): React.ReactNode => {
|
||||
const { dataOriginRefData } = this.props;
|
||||
|
||||
let limitedUse = SpellUtils.getLimitedUse(spell);
|
||||
|
||||
let limitedUseNode: React.ReactNode = "At Will";
|
||||
if (limitedUse) {
|
||||
let minConsumed = LimitedUseUtils.getMinNumberConsumed(limitedUse);
|
||||
let maxConsumed = LimitedUseUtils.getMaxNumberConsumed(limitedUse);
|
||||
|
||||
if (minConsumed === maxConsumed || minConsumed === null) {
|
||||
limitedUseNode = `${maxConsumed} Charge${maxConsumed === 1 ? "" : "s"}`;
|
||||
} else {
|
||||
limitedUseNode = `${minConsumed}-${maxConsumed} Charges`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-item-detail-abilities__spell"
|
||||
key={SpellUtils.getUniqueKey(spell)}
|
||||
>
|
||||
<div
|
||||
className="ct-item-detail-abilities__spell-name"
|
||||
onClick={this.handleSpellDetailClick.bind(
|
||||
this,
|
||||
SpellUtils.getMappingId(spell)
|
||||
)}
|
||||
>
|
||||
<SpellName
|
||||
spell={spell}
|
||||
showLegacy={true}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-item-detail-abilities__spell-usage">
|
||||
{limitedUseNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSpells = (): React.ReactNode => {
|
||||
const { item } = this.props;
|
||||
|
||||
const spells = ItemUtils.getSpells(item);
|
||||
if (!spells || !spells.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-item-detail-abilities__spells">
|
||||
{spells.map((spell) => this.renderSpell(spell))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
let limitedUseNode = this.renderLimitedUses();
|
||||
let spellsNode = this.renderSpells();
|
||||
|
||||
if (!limitedUseNode && !spellsNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-item-detail-abilities">
|
||||
{limitedUseNode}
|
||||
{spellsNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
};
|
||||
}
|
||||
|
||||
const ItemDetailAbilitiesWrapper = (props) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
return (
|
||||
<ItemDetailAbilities
|
||||
inventoryManager={inventoryManager}
|
||||
paneHistoryPush={paneHistoryPush}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ItemDetailAbilitiesWrapper);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ItemDetailAbilities from "./ItemDetailAbilities";
|
||||
|
||||
export default ItemDetailAbilities;
|
||||
export { ItemDetailAbilities };
|
||||
@@ -0,0 +1,450 @@
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
CampaignUtils,
|
||||
serviceDataActions,
|
||||
Constants,
|
||||
Container,
|
||||
ContainerUtils,
|
||||
InventoryManager,
|
||||
InfusionUtils,
|
||||
Item,
|
||||
ItemUtils,
|
||||
RuleData,
|
||||
GroupedMenuOption,
|
||||
serviceDataSelectors,
|
||||
PartyInfo,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { Popover } from "~/components/Popover";
|
||||
import { PopoverContent } from "~/components/PopoverContent";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
|
||||
import SimpleQuantity from "../../components/SimpleQuantity";
|
||||
import {
|
||||
RemoveButton,
|
||||
ThemeButton,
|
||||
ThemeButtonWithMenu,
|
||||
} from "../../components/common/Button";
|
||||
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
|
||||
import * as appEnvSelectors from "../../selectors/appEnv";
|
||||
import { SharedAppState } from "../../stores/typings";
|
||||
import { PaneIdentifierUtils } from "../../utils";
|
||||
|
||||
interface LabelLookup {
|
||||
consume: string;
|
||||
remove: string;
|
||||
unequip: string;
|
||||
equip: string;
|
||||
attune: string;
|
||||
unattune: string;
|
||||
}
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
item: Item;
|
||||
hasMaxAttunedItems: boolean;
|
||||
ruleData: RuleData;
|
||||
isReadonly: boolean;
|
||||
containers: Array<Container>;
|
||||
onPostRemoveNavigation?: PaneComponentEnum;
|
||||
partyInfo: PartyInfo | null;
|
||||
inventoryManager: InventoryManager;
|
||||
paneHistoryStart: PaneInfo["paneHistoryStart"];
|
||||
}
|
||||
interface State {
|
||||
quantity: number;
|
||||
newQuantity: number;
|
||||
}
|
||||
class ItemDetailActions extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
hasMaxAttunedItems: false,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
quantity: ItemUtils.getQuantity(props.item),
|
||||
newQuantity: ItemUtils.getQuantity(props.item),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { item } = this.props;
|
||||
|
||||
const nextQuantity = ItemUtils.getQuantity(item);
|
||||
if (nextQuantity !== prevState.quantity) {
|
||||
this.setState({
|
||||
quantity: nextQuantity,
|
||||
newQuantity: nextQuantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleQuantityUpdate = (newValue: number): void => {
|
||||
const { item, inventoryManager } = this.props;
|
||||
|
||||
inventoryManager.handleQuantitySet({ item, quantity: newValue });
|
||||
};
|
||||
|
||||
handleRemove = (): void => {
|
||||
const { paneHistoryStart, item, onPostRemoveNavigation, inventoryManager } =
|
||||
this.props;
|
||||
|
||||
if (onPostRemoveNavigation) {
|
||||
paneHistoryStart(onPostRemoveNavigation);
|
||||
}
|
||||
|
||||
inventoryManager.handleRemove({ item });
|
||||
|
||||
if (ItemUtils.isContainer(item)) {
|
||||
paneHistoryStart(PaneComponentEnum.EQUIPMENT_MANAGE);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveInfusion = (): void => {
|
||||
const { dispatch, paneHistoryStart, item } = this.props;
|
||||
|
||||
const infusion = ItemUtils.getInfusion(item);
|
||||
if (infusion) {
|
||||
const infusionId = InfusionUtils.getId(infusion);
|
||||
if (infusionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
InfusionUtils.getType(infusion) === Constants.InfusionTypeEnum.REPLICATE
|
||||
) {
|
||||
const choiceKey = InfusionUtils.getChoiceKey(infusion);
|
||||
if (choiceKey !== null) {
|
||||
paneHistoryStart(
|
||||
PaneComponentEnum.INFUSION_CHOICE,
|
||||
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(
|
||||
serviceDataActions.infusionMappingDestroy(
|
||||
infusionId,
|
||||
InfusionUtils.getInventoryMappingId(infusion)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveInfusions = (itemIds: Array<number>): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
itemIds.forEach((id) => {
|
||||
dispatch(serviceDataActions.infusionMappingsDestroy(itemIds));
|
||||
});
|
||||
};
|
||||
|
||||
handleUnequip = (): void => {
|
||||
const { inventoryManager, item } = this.props;
|
||||
inventoryManager.handleUnequip({ item });
|
||||
};
|
||||
|
||||
handleEquip = (): void => {
|
||||
const { inventoryManager, item } = this.props;
|
||||
inventoryManager.handleEquip({ item });
|
||||
};
|
||||
|
||||
handleAttune = (): void => {
|
||||
const { inventoryManager, item } = this.props;
|
||||
|
||||
inventoryManager.handleEquip({ item });
|
||||
inventoryManager.handleAttune({ item });
|
||||
};
|
||||
|
||||
handleUnattune = (): void => {
|
||||
const { inventoryManager, item } = this.props;
|
||||
|
||||
inventoryManager.handleUnattune({ item });
|
||||
};
|
||||
|
||||
handleMove = (containerDefinitionKey: string): void => {
|
||||
const { inventoryManager, item } = this.props;
|
||||
|
||||
inventoryManager.handleMove({ item, containerDefinitionKey });
|
||||
};
|
||||
|
||||
getLabels = (): LabelLookup => {
|
||||
return {
|
||||
consume: "Consume",
|
||||
remove: "Delete Item",
|
||||
unequip: "Unequip",
|
||||
equip: "Equip",
|
||||
attune: "Attune",
|
||||
unattune: "Unattune",
|
||||
};
|
||||
};
|
||||
|
||||
getContainerOptions = (): Array<GroupedMenuOption> => {
|
||||
const { containers, item, partyInfo } = this.props;
|
||||
|
||||
return ContainerUtils.getGroupedOptions(
|
||||
ItemUtils.getContainerDefinitionKey(item),
|
||||
containers,
|
||||
"Move To:",
|
||||
partyInfo
|
||||
? CampaignUtils.getSharingState(partyInfo)
|
||||
: Constants.PartyInventorySharingStateEnum.OFF
|
||||
);
|
||||
};
|
||||
|
||||
renderRemove = (): React.ReactNode => {
|
||||
const { item, containers, isReadonly, inventoryManager } = this.props;
|
||||
|
||||
const canRemoveItem = inventoryManager.canRemoveItem(item);
|
||||
if (isReadonly || !canRemoveItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infusion = ItemUtils.getInfusion(item);
|
||||
|
||||
const name = ItemUtils.getName(item);
|
||||
const isContainer = ItemUtils.isContainer(item);
|
||||
|
||||
if (isContainer) {
|
||||
const container = containers.find((container) => {
|
||||
return (
|
||||
ContainerUtils.getContainerType(container) ===
|
||||
Constants.ContainerTypeEnum.ITEM &&
|
||||
ContainerUtils.getMappingId(container) ===
|
||||
ItemUtils.getMappingId(item)
|
||||
);
|
||||
});
|
||||
|
||||
if (container) {
|
||||
if (infusion) {
|
||||
return (
|
||||
<RemoveButton
|
||||
onClick={this.handleRemove}
|
||||
className="ct-item-detail__action"
|
||||
data-testid="remove-infusion-button"
|
||||
>
|
||||
Remove Infusion
|
||||
</RemoveButton>
|
||||
);
|
||||
}
|
||||
const hasInfusions = ContainerUtils.hasInfusions(container);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger={
|
||||
<Button size="xx-small" variant="outline" themed>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
position="bottom"
|
||||
data-testid="delete-item-button"
|
||||
maxWidth={250}
|
||||
>
|
||||
<PopoverContent
|
||||
title={`Remove ${name}?`}
|
||||
content={`Removing the ${name} will also remove all of its ${
|
||||
hasInfusions ? "infusions and " : " "
|
||||
} contents.`}
|
||||
confirmText="Delete"
|
||||
onConfirm={() => this.handleRemove()}
|
||||
withCancel
|
||||
/>
|
||||
</Popover>
|
||||
// <RemoveButtonWithModal
|
||||
// className="ct-item-detail__action"
|
||||
// style={"outline"}
|
||||
// size="small"
|
||||
// modalTitle={`Delete ${name}?`}
|
||||
// modalContent={`Deleting the ${name} will also delete all of its${
|
||||
// hasInfusions ? " infusions and" : ""
|
||||
// } contents.`}
|
||||
// confirmCallback={() => {
|
||||
// this.handleRemove();
|
||||
// }}
|
||||
// data-testid="delete-item-button"
|
||||
// />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (infusion) {
|
||||
return (
|
||||
<RemoveButton
|
||||
onClick={this.handleRemoveInfusion}
|
||||
className="ct-item-detail__action"
|
||||
data-testid="remove-infusion-button"
|
||||
>
|
||||
Remove Infusion
|
||||
</RemoveButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RemoveButton
|
||||
onClick={this.handleRemove}
|
||||
className="ct-item-detail__action"
|
||||
data-testid="delete-item-button"
|
||||
>
|
||||
Delete
|
||||
</RemoveButton>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
item,
|
||||
hasMaxAttunedItems,
|
||||
isReadonly,
|
||||
containers,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
|
||||
const labels = this.getLabels();
|
||||
const containerMoveOptions = this.getContainerOptions();
|
||||
const isEquipped = ItemUtils.isEquipped(item);
|
||||
const isAttuned = ItemUtils.isAttuned(item);
|
||||
const isStackable = ItemUtils.isStackable(item);
|
||||
const quantity = ItemUtils.getQuantity(item);
|
||||
const isContainer = ItemUtils.getDefinitionIsContainer(item);
|
||||
const parentContainer = ContainerUtils.getItemParentContainer(
|
||||
containers,
|
||||
ItemUtils.getContainerDefinitionKey(item)
|
||||
);
|
||||
if (!parentContainer) {
|
||||
return null;
|
||||
}
|
||||
const canEquip = inventoryManager.canEquipUnequipItem(item);
|
||||
const canAttune = inventoryManager.canAttuneUnattuneItem(item);
|
||||
const canModifyQuantity = inventoryManager.canModifyQuantity(item);
|
||||
const canMoveItem =
|
||||
inventoryManager.canMoveItem(item) &&
|
||||
!isContainer &&
|
||||
!isReadonly &&
|
||||
!!containers.length;
|
||||
const canShareItem = inventoryManager.canShareItem(item) && !isReadonly;
|
||||
const canClaimItem = inventoryManager.canClaimItem(item) && !isReadonly;
|
||||
|
||||
return (
|
||||
<div className="ct-item-detail__actions">
|
||||
{isStackable && (
|
||||
<SimpleQuantity
|
||||
quantity={quantity}
|
||||
onUpdate={this.handleQuantityUpdate}
|
||||
isReadonly={isReadonly || !canModifyQuantity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEquip && !isReadonly && (
|
||||
<ThemeButton
|
||||
onClick={isEquipped ? this.handleUnequip : this.handleEquip}
|
||||
style={isEquipped ? "" : "outline"}
|
||||
size="small"
|
||||
className="ct-item-detail__action"
|
||||
data-testid="equip-item-button"
|
||||
>
|
||||
{isEquipped ? labels.unequip : labels.equip}
|
||||
</ThemeButton>
|
||||
)}
|
||||
{canAttune && !isReadonly && (
|
||||
<ThemeButton
|
||||
onClick={isAttuned ? this.handleUnattune : this.handleAttune}
|
||||
style={isAttuned ? "" : "outline"}
|
||||
size="small"
|
||||
disabled={hasMaxAttunedItems && !isAttuned}
|
||||
className="ct-item-detail__action"
|
||||
data-testid="attune-item-button"
|
||||
>
|
||||
{isAttuned ? labels.unattune : labels.attune}
|
||||
</ThemeButton>
|
||||
)}
|
||||
{canShareItem && (
|
||||
<ThemeButton
|
||||
style="outline"
|
||||
onClick={() => {
|
||||
const definitionKey =
|
||||
inventoryManager.getPartyEquipmentContainerDefinitionKey();
|
||||
if (definitionKey) {
|
||||
this.handleMove(definitionKey);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="ct-item-detail__action"
|
||||
data-testid="move-item-button"
|
||||
>
|
||||
Move
|
||||
</ThemeButton>
|
||||
)}
|
||||
{canClaimItem && (
|
||||
<ThemeButton
|
||||
style="outline"
|
||||
onClick={() => {
|
||||
const definitionKey =
|
||||
inventoryManager.getCharacterContainerDefinitionKey();
|
||||
if (definitionKey) {
|
||||
this.handleMove(definitionKey);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="ct-item-detail__action"
|
||||
data-testid="move-item-button"
|
||||
>
|
||||
Move
|
||||
</ThemeButton>
|
||||
)}
|
||||
{canMoveItem && (
|
||||
<ThemeButtonWithMenu
|
||||
buttonStyle="outline"
|
||||
containerEl={
|
||||
document.querySelector(".ct-sidebar__portal") as HTMLElement
|
||||
}
|
||||
className="ct-item-detail__action"
|
||||
groupedOptions={containerMoveOptions}
|
||||
showSingleOption={true}
|
||||
onSelect={this.handleMove}
|
||||
>
|
||||
Move
|
||||
</ThemeButtonWithMenu>
|
||||
)}
|
||||
{this.renderRemove()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
hasMaxAttunedItems: rulesEngineSelectors.hasMaxAttunedItems(state),
|
||||
containers: rulesEngineSelectors.getInventoryContainers(state),
|
||||
partyInfo: serviceDataSelectors.getPartyInfo(state),
|
||||
};
|
||||
}
|
||||
|
||||
function ItemDetailActionsContainer(props) {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
return (
|
||||
<ItemDetailActions
|
||||
inventoryManager={inventoryManager}
|
||||
paneHistoryStart={paneHistoryStart}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ItemDetailActionsContainer);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ItemDetailActions from "./ItemDetailActions";
|
||||
|
||||
export default ItemDetailActions;
|
||||
export { ItemDetailActions };
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import SpellCaster from "../../components/SpellCaster";
|
||||
import * as appEnvSelectors from "../../selectors/appEnv";
|
||||
import { SharedAppState } from "../../stores/typings";
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
|
||||
spellCasterInfo: rulesEngineSelectors.getSpellCasterInfo(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
};
|
||||
}
|
||||
export default connect(mapStateToProps)(SpellCaster);
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellDetailCaster from "./SpellDetailCaster";
|
||||
|
||||
export default SpellDetailCaster;
|
||||
export { SpellDetailCaster };
|
||||
@@ -0,0 +1,908 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
Button,
|
||||
LoadingPlaceholder,
|
||||
Select,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiAdapterUtils,
|
||||
ApiResponse,
|
||||
Background,
|
||||
BackgroundUtils,
|
||||
BaseItemDefinitionContract,
|
||||
characterActions,
|
||||
CharClass,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
ContainerUtils,
|
||||
DiceContract,
|
||||
DiceUtils,
|
||||
HelperUtils,
|
||||
Modifier,
|
||||
RuleData,
|
||||
rulesEngineSelectors,
|
||||
StartingEquipmentContract,
|
||||
StartingEquipmentSlotContract,
|
||||
TypeValueLookup,
|
||||
DefinitionUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
|
||||
// TODO need to remove this builder reference and replace with better heading
|
||||
import PageSubHeader from "../../../CharacterBuilder/components/PageSubHeader";
|
||||
import { ThemeButton } from "../../components/common/Button";
|
||||
import DataLoadingStatusEnum from "../../constants/DataLoadingStatusEnum";
|
||||
import * as apiCreatorSelectors from "../../selectors/composite/apiCreator";
|
||||
import { SharedAppState } from "../../stores/typings";
|
||||
import { AppLoggerUtils, AppNotificationUtils } from "../../utils";
|
||||
import StartingEquipmentSlots from "./StartingEquipmentSlots";
|
||||
import { StartingEquipmentRuleSlotSelection } from "./typings";
|
||||
|
||||
//duplicated from characterActions typingParts to avoid import issues
|
||||
interface StartingEquipmentAddRequestPayloadItem {
|
||||
containerEntityId: number | null;
|
||||
containerEntityTypeId: number | null;
|
||||
entityId: number;
|
||||
entityTypeId: number;
|
||||
quantity: number;
|
||||
}
|
||||
//duplicated from characterActions typingParts to avoid import issues
|
||||
interface StartingEquipmentAddRequestPayload {
|
||||
items: Array<StartingEquipmentAddRequestPayloadItem>;
|
||||
gold: number;
|
||||
custom: Array<string>;
|
||||
}
|
||||
|
||||
type StartingEquipmentChoice = "equipment" | "gold";
|
||||
type RuleSlotChoiceKey = "startingClass" | "background";
|
||||
|
||||
interface RuleChoiceGroupSelectionPayload {
|
||||
groupKey: RuleSlotChoiceKey;
|
||||
slotIdx: number;
|
||||
ruleSlotIdx: number;
|
||||
ruleIdx: number;
|
||||
dataId: number | null;
|
||||
activeRuleSlotIdx: number | null;
|
||||
}
|
||||
|
||||
interface RuleChoiceGroupClearPayload {
|
||||
groupKey: RuleSlotChoiceKey;
|
||||
}
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
isInitialView: boolean;
|
||||
onStartingEquipmentChoose?: () => void;
|
||||
startingClass: CharClass | null;
|
||||
background: Background | null;
|
||||
loadClassStartingEquipment: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<StartingEquipmentContract>>;
|
||||
loadBackgroundStartingEquipment: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<StartingEquipmentContract>>;
|
||||
globalModifiers: Array<Modifier>;
|
||||
valueLookupByType: TypeValueLookup;
|
||||
ruleData: RuleData;
|
||||
characterId: number;
|
||||
}
|
||||
|
||||
interface SlotChoiceInfoState {
|
||||
activeRuleSlots: Array<number | null>;
|
||||
dataValues: Record<string, number | null>;
|
||||
}
|
||||
type RuleSlotChoicesState = Record<RuleSlotChoiceKey, SlotChoiceInfoState>;
|
||||
interface State {
|
||||
activeChoice: StartingEquipmentChoice | null;
|
||||
startingRules: Record<RuleSlotChoiceKey, StartingEquipmentContract>;
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
loadingRuleTypes: Array<RuleSlotChoiceKey>;
|
||||
ruleSlotChoices: RuleSlotChoicesState;
|
||||
rolledGoldTotal: number | null;
|
||||
}
|
||||
class StartingEquipment extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
isInitialView: false,
|
||||
};
|
||||
|
||||
loadRuleCancelers: Array<Canceler> = [];
|
||||
defaultStartingRules: Record<RuleSlotChoiceKey, StartingEquipmentContract> = {
|
||||
startingClass: {
|
||||
slots: [],
|
||||
},
|
||||
background: {
|
||||
slots: [],
|
||||
},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeChoice: null,
|
||||
startingRules: this.defaultStartingRules,
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
|
||||
loadingRuleTypes: [],
|
||||
ruleSlotChoices: {
|
||||
startingClass: {
|
||||
activeRuleSlots: [],
|
||||
dataValues: {},
|
||||
},
|
||||
background: {
|
||||
activeRuleSlots: [],
|
||||
dataValues: {},
|
||||
},
|
||||
},
|
||||
rolledGoldTotal: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
background,
|
||||
startingClass,
|
||||
loadClassStartingEquipment,
|
||||
loadBackgroundStartingEquipment,
|
||||
} = this.props;
|
||||
|
||||
let requests: Array<
|
||||
ApiAdapterPromise<ApiResponse<StartingEquipmentContract>>
|
||||
> = [];
|
||||
let loadingRuleTypes: Array<RuleSlotChoiceKey> = [];
|
||||
if (
|
||||
background !== null &&
|
||||
!BackgroundUtils.getHasCustomBackground(background)
|
||||
) {
|
||||
requests.push(
|
||||
loadBackgroundStartingEquipment({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadRuleCancelers.push(c);
|
||||
}),
|
||||
})
|
||||
);
|
||||
loadingRuleTypes.push("background");
|
||||
}
|
||||
if (startingClass !== null) {
|
||||
requests.push(
|
||||
loadClassStartingEquipment({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadRuleCancelers.push(c);
|
||||
}),
|
||||
})
|
||||
);
|
||||
loadingRuleTypes.push("startingClass");
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
loadingRuleTypes,
|
||||
});
|
||||
|
||||
axios
|
||||
.all(requests)
|
||||
.then((responses) => {
|
||||
this.setState((prevState: State) => {
|
||||
let startingRules: Record<
|
||||
RuleSlotChoiceKey,
|
||||
StartingEquipmentContract
|
||||
> = prevState.startingRules;
|
||||
|
||||
for (var i = 0; i < responses.length; i++) {
|
||||
let response = responses[i];
|
||||
let loadingRuleType = prevState.loadingRuleTypes[i];
|
||||
let rules = ApiAdapterUtils.getResponseData(response);
|
||||
|
||||
startingRules = {
|
||||
...startingRules,
|
||||
[loadingRuleType]: rules,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startingRules,
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
};
|
||||
});
|
||||
this.loadRuleCancelers = [];
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadRuleCancelers.length > 0) {
|
||||
this.loadRuleCancelers.forEach((cancel) => {
|
||||
cancel();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChoiceClick = (choiceKey: StartingEquipmentChoice): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
activeChoice: choiceKey,
|
||||
ruleSlotChoices:
|
||||
prevState.activeChoice !== choiceKey
|
||||
? this.getRuleSlotChoicesDefaultState(prevState.startingRules)
|
||||
: prevState.ruleSlotChoices,
|
||||
}));
|
||||
};
|
||||
|
||||
handleRuleSelection = (
|
||||
groupKey: RuleSlotChoiceKey,
|
||||
slotIdx: number,
|
||||
ruleSlotIdx: number,
|
||||
ruleIdx: number,
|
||||
dataId: number | null
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
return this.reduceSelectionForRuleChoiceGroup(prevState, {
|
||||
groupKey,
|
||||
slotIdx,
|
||||
ruleSlotIdx,
|
||||
ruleIdx,
|
||||
dataId,
|
||||
activeRuleSlotIdx:
|
||||
prevState.ruleSlotChoices[groupKey].activeRuleSlots[slotIdx],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
handleRuleSlotSelection = (
|
||||
groupKey: RuleSlotChoiceKey,
|
||||
slotIdx: number,
|
||||
selectedRuleData: Array<StartingEquipmentRuleSlotSelection>
|
||||
): void => {
|
||||
if (!selectedRuleData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState((prevState: State) => {
|
||||
let modifiedState = prevState;
|
||||
|
||||
selectedRuleData.forEach((ruleData) => {
|
||||
const { ruleSlotIdx, ruleIdx, dataId } = ruleData;
|
||||
let activeRuleSlotIdx =
|
||||
prevState.ruleSlotChoices[groupKey].activeRuleSlots[slotIdx] ===
|
||||
ruleSlotIdx
|
||||
? null
|
||||
: ruleSlotIdx;
|
||||
modifiedState = this.reduceSelectionForRuleChoiceGroup(modifiedState, {
|
||||
groupKey,
|
||||
slotIdx,
|
||||
ruleSlotIdx,
|
||||
ruleIdx,
|
||||
dataId,
|
||||
activeRuleSlotIdx,
|
||||
});
|
||||
});
|
||||
|
||||
return modifiedState;
|
||||
});
|
||||
};
|
||||
|
||||
handleStartingEquipmentAdd = (): void => {
|
||||
const { dispatch, onStartingEquipmentChoose } = this.props;
|
||||
|
||||
const startingEquipment = this.getCurrentStartingEquipmentSelected();
|
||||
dispatch(
|
||||
characterActions.startingEquipmentAddRequest(
|
||||
startingEquipment,
|
||||
AppNotificationUtils.handleStartingEquipmentAccepted,
|
||||
AppNotificationUtils.handleStartingEquipmentRejected
|
||||
)
|
||||
);
|
||||
|
||||
if (onStartingEquipmentChoose) {
|
||||
onStartingEquipmentChoose();
|
||||
}
|
||||
};
|
||||
|
||||
handleStartingGoldAdd = (): void => {
|
||||
const { dispatch, onStartingEquipmentChoose } = this.props;
|
||||
|
||||
dispatch(
|
||||
characterActions.startingGoldAddRequest(
|
||||
this.getStartingGoldTotal(),
|
||||
AppNotificationUtils.handleStartingGoldAccepted
|
||||
)
|
||||
);
|
||||
|
||||
if (onStartingEquipmentChoose) {
|
||||
onStartingEquipmentChoose();
|
||||
}
|
||||
};
|
||||
|
||||
handleClearStartingEquipment = (): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let modifiedState = prevState;
|
||||
|
||||
Object.keys(prevState.ruleSlotChoices).forEach(
|
||||
(groupKey: RuleSlotChoiceKey) => {
|
||||
modifiedState = this.reduceClearForRuleChoiceGroup(modifiedState, {
|
||||
groupKey,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return modifiedState;
|
||||
});
|
||||
};
|
||||
|
||||
handleRandomizeGold = (): void => {
|
||||
const { startingClass } = this.props;
|
||||
|
||||
if (startingClass) {
|
||||
const wealthDice = this.getStartingClassWealthDice();
|
||||
if (wealthDice) {
|
||||
this.setState({
|
||||
rolledGoldTotal: DiceUtils.getDiceRandomValue(wealthDice),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleGoldRolledNumberChange = (value: string): void => {
|
||||
this.setState({
|
||||
rolledGoldTotal: HelperUtils.parseInputInt(value),
|
||||
});
|
||||
};
|
||||
|
||||
getRuleSlotChoicesDefaultState = (
|
||||
startingRules: Record<RuleSlotChoiceKey, StartingEquipmentContract>
|
||||
): RuleSlotChoicesState => {
|
||||
let background = this.getSlotDefaultState(
|
||||
startingRules.background && startingRules.background.slots
|
||||
? startingRules.background.slots
|
||||
: []
|
||||
);
|
||||
let startingClass = this.getSlotDefaultState(
|
||||
startingRules.startingClass && startingRules.startingClass.slots
|
||||
? startingRules.startingClass.slots
|
||||
: []
|
||||
);
|
||||
|
||||
return {
|
||||
background,
|
||||
startingClass,
|
||||
};
|
||||
};
|
||||
|
||||
getDataKey(slotIdx: number, ruleSlotIdx: number, ruleIdx: number): string {
|
||||
return `${slotIdx}-${ruleSlotIdx}-${ruleIdx}`;
|
||||
}
|
||||
|
||||
getSlotDefaultState(
|
||||
slots: Array<StartingEquipmentSlotContract>
|
||||
): SlotChoiceInfoState {
|
||||
return slots.reduce(
|
||||
(slotAcc, slot, slotIdx): SlotChoiceInfoState => {
|
||||
let slotDataValues: Record<string, null> = {};
|
||||
if (slot.ruleSlots !== null) {
|
||||
slot.ruleSlots.reduce(
|
||||
(acc, ruleSlot, ruleSlotIdx): Record<string, null> => {
|
||||
if (ruleSlot.rules !== null) {
|
||||
ruleSlot.rules.forEach((rule, ruleIdx) => {
|
||||
if (rule.definitions !== null) {
|
||||
rule.definitions.forEach((dataItem, dataId) => {
|
||||
acc = {
|
||||
...acc,
|
||||
[this.getDataKey(slotIdx, ruleSlotIdx, ruleIdx)]: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
activeRuleSlots: [...slotAcc.activeRuleSlots, null],
|
||||
dataValues: {
|
||||
...slotAcc.dataValues,
|
||||
...slotDataValues,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ activeRuleSlots: [], dataValues: {} }
|
||||
);
|
||||
}
|
||||
|
||||
reduceSelectionForRuleChoiceGroup = (
|
||||
state: State,
|
||||
payload: RuleChoiceGroupSelectionPayload
|
||||
): State => {
|
||||
const {
|
||||
groupKey,
|
||||
slotIdx,
|
||||
ruleSlotIdx,
|
||||
ruleIdx,
|
||||
dataId,
|
||||
activeRuleSlotIdx,
|
||||
} = payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
ruleSlotChoices: {
|
||||
...state.ruleSlotChoices,
|
||||
[groupKey]: {
|
||||
activeRuleSlots: [
|
||||
...state.ruleSlotChoices[groupKey].activeRuleSlots.slice(
|
||||
0,
|
||||
slotIdx
|
||||
),
|
||||
activeRuleSlotIdx,
|
||||
...state.ruleSlotChoices[groupKey].activeRuleSlots.slice(
|
||||
slotIdx + 1
|
||||
),
|
||||
],
|
||||
dataValues: {
|
||||
...state.ruleSlotChoices[groupKey].dataValues,
|
||||
[this.getDataKey(slotIdx, ruleSlotIdx, ruleIdx)]: dataId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
processSlotGroups = (
|
||||
groupData: StartingEquipmentContract | null,
|
||||
groupChoices: SlotChoiceInfoState
|
||||
): StartingEquipmentAddRequestPayload => {
|
||||
const { characterId } = this.props;
|
||||
let items: Array<StartingEquipmentAddRequestPayloadItem> = [];
|
||||
let gold: number = 0;
|
||||
let custom: Array<string> = [];
|
||||
let slotGroups: StartingEquipmentAddRequestPayload = {
|
||||
items,
|
||||
gold,
|
||||
custom,
|
||||
};
|
||||
|
||||
if (!groupData || groupData.slots === null) {
|
||||
return slotGroups;
|
||||
}
|
||||
|
||||
groupData.slots.forEach((slot, slotIdx) => {
|
||||
let activeRuleSlotIdx = groupChoices.activeRuleSlots[slotIdx];
|
||||
if (activeRuleSlotIdx === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (slot.ruleSlots === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
slot.ruleSlots.forEach((ruleSlot, ruleSlotIdx) => {
|
||||
if (activeRuleSlotIdx !== ruleSlotIdx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ruleSlot.rules === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ruleSlot.rules.forEach((rule, ruleIdx) => {
|
||||
let dataKey = this.getDataKey(slotIdx, ruleSlotIdx, ruleIdx);
|
||||
|
||||
if (groupChoices.activeRuleSlots[slotIdx] === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const characterContainerDefinitionKey =
|
||||
ContainerUtils.getCharacterContainerDefinitionKey(characterId);
|
||||
|
||||
switch (rule.ruleType) {
|
||||
case Constants.StartingEquipmentRuleTypeEnum.ARMOR:
|
||||
case Constants.StartingEquipmentRuleTypeEnum.ARMOR_TYPE:
|
||||
case Constants.StartingEquipmentRuleTypeEnum.GEAR:
|
||||
case Constants.StartingEquipmentRuleTypeEnum.GEAR_TYPE:
|
||||
case Constants.StartingEquipmentRuleTypeEnum.WEAPON:
|
||||
case Constants.StartingEquipmentRuleTypeEnum.WEAPON_TYPE:
|
||||
let activeDataId = groupChoices.dataValues[dataKey];
|
||||
if (activeDataId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let item: BaseItemDefinitionContract | null | undefined =
|
||||
rule.definitions &&
|
||||
rule.definitions.find(
|
||||
(dataItem) => dataItem.id === activeDataId
|
||||
);
|
||||
if (item) {
|
||||
items.push({
|
||||
containerEntityId: DefinitionUtils.hack__getDefinitionKeyId(
|
||||
characterContainerDefinitionKey
|
||||
),
|
||||
containerEntityTypeId:
|
||||
DefinitionUtils.hack__getDefinitionKeyType(
|
||||
characterContainerDefinitionKey
|
||||
),
|
||||
entityId: item.id,
|
||||
entityTypeId: item.entityTypeId,
|
||||
quantity: rule.quantity ? rule.quantity : 1,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Constants.StartingEquipmentRuleTypeEnum.GOLD_VALUE:
|
||||
let ruleGold = rule.gold;
|
||||
if (ruleGold !== null) {
|
||||
gold += ruleGold;
|
||||
}
|
||||
break;
|
||||
case Constants.StartingEquipmentRuleTypeEnum.CUSTOM:
|
||||
if (rule.custom !== null) {
|
||||
custom.push(rule.custom);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...slotGroups,
|
||||
gold,
|
||||
};
|
||||
};
|
||||
|
||||
getCurrentStartingEquipmentSelected =
|
||||
(): StartingEquipmentAddRequestPayload => {
|
||||
const { startingRules, ruleSlotChoices } = this.state;
|
||||
const {
|
||||
startingClass: classStartingEquipment,
|
||||
background: backgroundStartingEquipment,
|
||||
} = startingRules;
|
||||
|
||||
let startingClassAdds = this.processSlotGroups(
|
||||
classStartingEquipment,
|
||||
ruleSlotChoices.startingClass
|
||||
);
|
||||
let backgroundAdds = this.processSlotGroups(
|
||||
backgroundStartingEquipment,
|
||||
ruleSlotChoices.background
|
||||
);
|
||||
|
||||
let items: Array<StartingEquipmentAddRequestPayloadItem> = [
|
||||
...startingClassAdds.items,
|
||||
...backgroundAdds.items,
|
||||
];
|
||||
|
||||
let gold: number = startingClassAdds.gold + backgroundAdds.gold;
|
||||
|
||||
let custom: Array<string> = [
|
||||
...startingClassAdds.custom,
|
||||
...backgroundAdds.custom,
|
||||
];
|
||||
|
||||
return {
|
||||
items,
|
||||
gold,
|
||||
custom,
|
||||
};
|
||||
};
|
||||
|
||||
isStartingEquipmentEmpty = (): boolean => {
|
||||
const startingEquipment = this.getCurrentStartingEquipmentSelected();
|
||||
return (
|
||||
!startingEquipment.items.length &&
|
||||
!startingEquipment.gold &&
|
||||
!startingEquipment.custom.length
|
||||
);
|
||||
};
|
||||
|
||||
reduceClearForRuleChoiceGroup = (
|
||||
state: State,
|
||||
payload: RuleChoiceGroupClearPayload
|
||||
): State => {
|
||||
const { groupKey } = payload;
|
||||
return {
|
||||
...state,
|
||||
ruleSlotChoices: {
|
||||
...state.ruleSlotChoices,
|
||||
[groupKey]: {
|
||||
...state.ruleSlotChoices[groupKey],
|
||||
activeRuleSlots: state.ruleSlotChoices[groupKey].activeRuleSlots.map(
|
||||
(ruleSlot) => null
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getStartingGoldTotal = (): number => {
|
||||
const { rolledGoldTotal } = this.state;
|
||||
|
||||
if (!rolledGoldTotal) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const wealthDice = this.getStartingClassWealthDice();
|
||||
let diceMultiplier: number = 1;
|
||||
if (wealthDice && wealthDice.diceMultiplier) {
|
||||
diceMultiplier = wealthDice.diceMultiplier;
|
||||
}
|
||||
|
||||
return rolledGoldTotal * diceMultiplier;
|
||||
};
|
||||
|
||||
getStartingClassName = (): string | null => {
|
||||
const { startingClass } = this.props;
|
||||
|
||||
if (!startingClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ClassUtils.getName(startingClass);
|
||||
};
|
||||
|
||||
getStartingClassWealthDice = (): DiceContract | null => {
|
||||
const { startingClass } = this.props;
|
||||
|
||||
if (!startingClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ClassUtils.getWealthDice(startingClass);
|
||||
};
|
||||
|
||||
renderGoldChoiceUi = (): React.ReactNode => {
|
||||
const { rolledGoldTotal } = this.state;
|
||||
const { startingClass } = this.props;
|
||||
|
||||
if (startingClass) {
|
||||
const wealthDice = this.getStartingClassWealthDice();
|
||||
|
||||
let diceCount: number | null = null;
|
||||
let diceMultiplier: number | null = null;
|
||||
let diceValue: number | null = null;
|
||||
let diceValues: Array<number> = [];
|
||||
if (wealthDice !== null) {
|
||||
diceCount = DiceUtils.getCount(wealthDice);
|
||||
diceMultiplier = DiceUtils.getMultiplier(wealthDice);
|
||||
diceValue = DiceUtils.getValue(wealthDice);
|
||||
diceValues = DiceUtils.getDiceValuesRange(wealthDice);
|
||||
}
|
||||
|
||||
const diceOptions = diceValues.map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
|
||||
let totalGold = this.getStartingGoldTotal();
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-gold">
|
||||
<div className="starting-equipment-gold-explanation">
|
||||
{this.getStartingClassName()} Starting Gold:
|
||||
</div>
|
||||
<div className="starting-equipment-gold-entry">
|
||||
<div className="starting-equipment-gold-entry-actions">
|
||||
<Link onClick={this.handleRandomizeGold}>Randomize</Link>
|
||||
</div>
|
||||
<div className="starting-equipment-gold-entry-input">
|
||||
<Select
|
||||
placeholder={`${diceCount}d${diceValue}`}
|
||||
options={diceOptions}
|
||||
onChange={this.handleGoldRolledNumberChange}
|
||||
value={rolledGoldTotal}
|
||||
/>
|
||||
</div>
|
||||
<div className="starting-equipment-gold-entry-multiplier">
|
||||
{diceMultiplier ? ` x ${diceMultiplier} ` : ""} gp
|
||||
</div>
|
||||
{totalGold !== null && (
|
||||
<div className="starting-equipment-gold-entry-total">
|
||||
= {totalGold} gp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="starting-equipment-gold-actions">
|
||||
<Button
|
||||
onClick={this.handleStartingGoldAdd}
|
||||
disabled={rolledGoldTotal === null}
|
||||
>
|
||||
Add Starting Gold
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-requirements-missing">
|
||||
Choose a class to view starting gold.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderEquipmentChoiceUi = (): React.ReactNode => {
|
||||
const {
|
||||
background,
|
||||
startingClass,
|
||||
globalModifiers,
|
||||
ruleData,
|
||||
valueLookupByType,
|
||||
} = this.props;
|
||||
const { startingRules, ruleSlotChoices } = this.state;
|
||||
|
||||
const classStartingEquipment = HelperUtils.lookupDataOrFallback(
|
||||
startingRules,
|
||||
"startingClass"
|
||||
);
|
||||
const backgroundStartingEquipment = HelperUtils.lookupDataOrFallback(
|
||||
startingRules,
|
||||
"background"
|
||||
);
|
||||
|
||||
let hasCustomBackground: boolean =
|
||||
background !== null && BackgroundUtils.getHasCustomBackground(background);
|
||||
let hasValidStartingClass: boolean =
|
||||
startingClass !== null && classStartingEquipment !== null;
|
||||
let hasValidBackground: boolean =
|
||||
background !== null &&
|
||||
backgroundStartingEquipment !== null &&
|
||||
!hasCustomBackground;
|
||||
|
||||
if (!hasValidStartingClass && !hasValidBackground) {
|
||||
return (
|
||||
<div className="starting-equipment-requirements-missing">
|
||||
Choose a class
|
||||
{hasCustomBackground ? " " : " or background "}
|
||||
to view starting equipment options.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-gear">
|
||||
{hasValidStartingClass &&
|
||||
classStartingEquipment !== null &&
|
||||
classStartingEquipment.slots !== null && (
|
||||
<div>
|
||||
<PageSubHeader>
|
||||
{this.getStartingClassName()} Starting Equipment
|
||||
</PageSubHeader>
|
||||
<StartingEquipmentSlots
|
||||
slots={classStartingEquipment.slots}
|
||||
onRuleSelection={this.handleRuleSelection.bind(
|
||||
this,
|
||||
"startingClass"
|
||||
)}
|
||||
onRuleSlotSelection={this.handleRuleSlotSelection.bind(
|
||||
this,
|
||||
"startingClass"
|
||||
)}
|
||||
activeRuleSlots={ruleSlotChoices.startingClass.activeRuleSlots}
|
||||
globalModifiers={globalModifiers}
|
||||
valueLookupByType={valueLookupByType}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasValidBackground &&
|
||||
backgroundStartingEquipment !== null &&
|
||||
backgroundStartingEquipment.slots !== null && (
|
||||
<div>
|
||||
<PageSubHeader>
|
||||
{background ? BackgroundUtils.getName(background) : ""} Starting
|
||||
Equipment
|
||||
</PageSubHeader>
|
||||
<StartingEquipmentSlots
|
||||
slots={backgroundStartingEquipment.slots}
|
||||
onRuleSelection={this.handleRuleSelection.bind(
|
||||
this,
|
||||
"background"
|
||||
)}
|
||||
onRuleSlotSelection={this.handleRuleSlotSelection.bind(
|
||||
this,
|
||||
"background"
|
||||
)}
|
||||
activeRuleSlots={ruleSlotChoices.background.activeRuleSlots}
|
||||
globalModifiers={globalModifiers}
|
||||
valueLookupByType={valueLookupByType}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="starting-equipment-gear-actions">
|
||||
<ThemeButton onClick={this.handleStartingEquipmentAdd}>
|
||||
{this.isStartingEquipmentEmpty() ? "Skip " : "Add "} Starting
|
||||
Equipment
|
||||
</ThemeButton>
|
||||
<Link onClick={this.handleClearStartingEquipment}>Clear All</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderUi = (): React.ReactNode => {
|
||||
const { isInitialView, startingClass } = this.props;
|
||||
const { loadingStatus, activeChoice } = this.state;
|
||||
|
||||
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wealthDice = this.getStartingClassWealthDice();
|
||||
if (startingClass && !wealthDice) {
|
||||
return this.renderEquipmentChoiceUi();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-ui">
|
||||
{isInitialView && (
|
||||
<div className="starting-equipment-choices-label">Choose</div>
|
||||
)}
|
||||
<div className="starting-equipment-choices">
|
||||
<div
|
||||
className={`starting-equipment-choice ${
|
||||
activeChoice === "equipment"
|
||||
? "starting-equipment-info-choice-active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={this.handleChoiceClick.bind(this, "equipment")}
|
||||
>
|
||||
Equipment
|
||||
</div>
|
||||
<div className="starting-equipment-choice-sep">or</div>
|
||||
<div
|
||||
className={`starting-equipment-choice ${
|
||||
activeChoice === "gold"
|
||||
? "starting-equipment-info-choice-active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={this.handleChoiceClick.bind(this, "gold")}
|
||||
>
|
||||
Gold
|
||||
</div>
|
||||
</div>
|
||||
{activeChoice === "equipment" && this.renderEquipmentChoiceUi()}
|
||||
{activeChoice === "gold" && this.renderGoldChoiceUi()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderLoading = (): React.ReactNode => {
|
||||
return <LoadingPlaceholder />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loadingStatus } = this.state;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = this.renderUi();
|
||||
} else {
|
||||
contentNode = this.renderLoading();
|
||||
}
|
||||
|
||||
return <div className="starting-equipment">{contentNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SharedAppState) {
|
||||
return {
|
||||
startingClass: rulesEngineSelectors.getStartingClass(state),
|
||||
background: rulesEngineSelectors.getBackgroundInfo(state),
|
||||
loadBackgroundStartingEquipment:
|
||||
apiCreatorSelectors.makeLoadBackgroundStartingEquipment(state),
|
||||
loadClassStartingEquipment:
|
||||
apiCreatorSelectors.makeLoadClassStartingEquipment(state),
|
||||
globalModifiers: rulesEngineSelectors.getValidGlobalModifiers(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
valueLookupByType:
|
||||
rulesEngineSelectors.getCharacterValueLookupByType(state),
|
||||
characterId: rulesEngineSelectors.getId(state),
|
||||
};
|
||||
}
|
||||
export default connect(mapStateToProps)(StartingEquipment);
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import React from "react";
|
||||
|
||||
import { Checkbox, Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
BaseItemDefinitionContract,
|
||||
Constants,
|
||||
HelperUtils,
|
||||
ItemUtils,
|
||||
RuleData,
|
||||
SourceUtils,
|
||||
StartingEquipmentRuleContract,
|
||||
StartingEquipmentUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
|
||||
import { TypeScriptUtils } from "../../../utils";
|
||||
import { StartingEquipmentRuleSlotSelection } from "../typings";
|
||||
|
||||
interface Props {
|
||||
isMultiSlot: boolean;
|
||||
rules: Array<StartingEquipmentRuleContract>;
|
||||
name: string | null;
|
||||
slotIdx: number;
|
||||
onRuleSlotSelection: (
|
||||
selections: Array<StartingEquipmentRuleSlotSelection>
|
||||
) => void;
|
||||
onRuleSelection: (
|
||||
slotIdx: number,
|
||||
ruleIdx: number,
|
||||
dataId: number | null
|
||||
) => void;
|
||||
isActive: boolean;
|
||||
isSelected: boolean;
|
||||
isDisabled: boolean;
|
||||
ruleData: RuleData;
|
||||
}
|
||||
export default class StartingEquipmentRuleSlot extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
isMultiSlot: false,
|
||||
rules: [],
|
||||
name: "",
|
||||
slotIdx: 0,
|
||||
isActive: true,
|
||||
isSelected: false,
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { isMultiSlot, rules } = this.props;
|
||||
|
||||
if (
|
||||
!isMultiSlot &&
|
||||
rules.length &&
|
||||
rules[0].ruleType !== Constants.StartingEquipmentRuleTypeEnum.INSTRUCTION
|
||||
) {
|
||||
this.handleSelection();
|
||||
}
|
||||
}
|
||||
|
||||
handleSelection = (): void => {
|
||||
const { rules, onRuleSlotSelection, slotIdx, isDisabled } = this.props;
|
||||
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onRuleSlotSelection) {
|
||||
onRuleSlotSelection(
|
||||
rules
|
||||
.filter(
|
||||
(rule) =>
|
||||
rule.ruleType !==
|
||||
Constants.StartingEquipmentRuleTypeEnum.INSTRUCTION
|
||||
)
|
||||
.map((rule, ruleIdx): StartingEquipmentRuleSlotSelection => {
|
||||
let defaultDataId: number = 0;
|
||||
|
||||
if (
|
||||
StartingEquipmentUtils.deriveIsEquipmentStartingEquipmentRuleType(
|
||||
rule.ruleType
|
||||
) &&
|
||||
rule.definitions &&
|
||||
rule.definitions.length
|
||||
) {
|
||||
let firstItem = rule.definitions[0];
|
||||
defaultDataId = firstItem.id;
|
||||
}
|
||||
|
||||
let dataId: number | null;
|
||||
if (rule.definitions && rule.definitions.length === 1) {
|
||||
dataId = defaultDataId;
|
||||
} else {
|
||||
dataId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
ruleSlotIdx: slotIdx,
|
||||
ruleIdx,
|
||||
dataId,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleRuleSelection = (ruleIdx: number, id: string): void => {
|
||||
const { onRuleSelection, slotIdx, isDisabled } = this.props;
|
||||
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onRuleSelection) {
|
||||
onRuleSelection(slotIdx, ruleIdx, HelperUtils.parseInputInt(id));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
rules,
|
||||
name,
|
||||
isMultiSlot,
|
||||
isActive,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
ruleData,
|
||||
} = this.props;
|
||||
|
||||
let classNames: Array<string> = ["starting-equipment-rule-slot"];
|
||||
if ((!isMultiSlot || (isMultiSlot && isActive)) && !isDisabled) {
|
||||
classNames.push("starting-equipment-rule-slot-active");
|
||||
} else {
|
||||
classNames.push("starting-equipment-rule-slot-inactive");
|
||||
}
|
||||
if (isSelected) {
|
||||
classNames.push("starting-equipment-rule-slot-selected");
|
||||
}
|
||||
if (isDisabled) {
|
||||
classNames.push("starting-equipment-rule-slot-disabled");
|
||||
}
|
||||
|
||||
let isInstruction: boolean = !!rules.find(
|
||||
(rule) =>
|
||||
rule.ruleType === Constants.StartingEquipmentRuleTypeEnum.INSTRUCTION
|
||||
);
|
||||
let isOnlyInstruction: boolean = false;
|
||||
if (
|
||||
isInstruction &&
|
||||
!rules.find(
|
||||
(rule) =>
|
||||
rule.ruleType !== Constants.StartingEquipmentRuleTypeEnum.INSTRUCTION
|
||||
)
|
||||
) {
|
||||
isOnlyInstruction = true;
|
||||
classNames.push("starting-equipment-rule-slot-instruction");
|
||||
}
|
||||
|
||||
let hasCustom: boolean = !!rules.find(
|
||||
(rule) => rule.ruleType === Constants.StartingEquipmentRuleTypeEnum.CUSTOM
|
||||
);
|
||||
let hasMixedCustom: boolean = false;
|
||||
if (
|
||||
hasCustom &&
|
||||
rules.find(
|
||||
(rule) =>
|
||||
rule.ruleType !== Constants.StartingEquipmentRuleTypeEnum.CUSTOM
|
||||
)
|
||||
) {
|
||||
hasMixedCustom = true;
|
||||
}
|
||||
|
||||
let itemPacks: Array<BaseItemDefinitionContract> = rules
|
||||
.filter((rule) => {
|
||||
let isPack: boolean = false;
|
||||
if (rule.definitions && rule.definitions.length) {
|
||||
let firstItem = rule.definitions[0];
|
||||
isPack = ItemUtils.isDefinitionPack(firstItem);
|
||||
}
|
||||
|
||||
return (
|
||||
isPack &&
|
||||
rule.ruleType === Constants.StartingEquipmentRuleTypeEnum.GEAR
|
||||
);
|
||||
})
|
||||
.map((rule) => rule.definitions && rule.definitions[0])
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(" ")}
|
||||
onClick={isOnlyInstruction ? undefined : this.handleSelection}
|
||||
>
|
||||
<div className="starting-equipment-rule-slot-selection">
|
||||
{!isOnlyInstruction && (
|
||||
<div className="starting-equipment-rule-slot-checkmark">
|
||||
<Checkbox
|
||||
initiallyEnabled={isSelected}
|
||||
onChange={this.handleSelection}
|
||||
stopPropagation={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="starting-equipment-rule-content">
|
||||
<div className="starting-equipment-rule-slot-name">{name}</div>
|
||||
{isDisabled && (
|
||||
<div className="starting-equipment-disabled-message">
|
||||
Missing required proficiency
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="starting-equipment-rules">
|
||||
{rules.map((rule, ruleIdx) => {
|
||||
const { definitions, ruleType } = rule;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (
|
||||
ruleType === Constants.StartingEquipmentRuleTypeEnum.ARMOR ||
|
||||
ruleType === Constants.StartingEquipmentRuleTypeEnum.GEAR ||
|
||||
ruleType === Constants.StartingEquipmentRuleTypeEnum.WEAPON ||
|
||||
ruleType ===
|
||||
Constants.StartingEquipmentRuleTypeEnum.ARMOR_TYPE ||
|
||||
ruleType ===
|
||||
Constants.StartingEquipmentRuleTypeEnum.GEAR_TYPE ||
|
||||
ruleType ===
|
||||
Constants.StartingEquipmentRuleTypeEnum.WEAPON_TYPE
|
||||
) {
|
||||
if (definitions && definitions.length > 1) {
|
||||
const groupedOptions =
|
||||
SourceUtils.getGroupedOptionsBySourceCategory(
|
||||
definitions,
|
||||
ruleData
|
||||
);
|
||||
contentNode = (
|
||||
<div className="starting-equipment-rule">
|
||||
<Select
|
||||
options={groupedOptions}
|
||||
value={null}
|
||||
preventClickPropagating={true}
|
||||
onChange={this.handleRuleSelection.bind(
|
||||
this,
|
||||
ruleIdx
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-rule" key={ruleIdx}>
|
||||
{contentNode}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{itemPacks.length > 0 && (
|
||||
<div className="starting-equipment-packs">
|
||||
{(itemPacks as any).map(
|
||||
(itemDefinition: BaseItemDefinitionContract) => (
|
||||
<div
|
||||
className="starting-equipment-pack"
|
||||
key={itemDefinition.id}
|
||||
>
|
||||
{itemPacks.length > 1 && (
|
||||
<div className="starting-equipment-pack-name">
|
||||
{itemDefinition.name}
|
||||
</div>
|
||||
)}
|
||||
{itemDefinition.description && (
|
||||
<HtmlContent
|
||||
className="starting-equipment-pack-desc"
|
||||
html={itemDefinition.description}
|
||||
withoutTooltips
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasCustom && (
|
||||
<div className="starting-equipment-custom-add">
|
||||
<div className="starting-equipment-custom-add-label">
|
||||
{hasMixedCustom ? "Some item(s) " : "Item(s) "}
|
||||
will be added to <strong>Other Possessions</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import StartingEquipmentRuleSlot from "./StartingEquipmentRuleSlot";
|
||||
|
||||
export default StartingEquipmentRuleSlot;
|
||||
export { StartingEquipmentRuleSlot };
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
BaseItemDefinitionContract,
|
||||
Modifier,
|
||||
RuleData,
|
||||
StartingEquipmentRuleSlotContract,
|
||||
TypeValueLookup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { Constants, ItemUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import StartingEquipmentRuleSlot from "../StartingEquipmentRuleSlot";
|
||||
import { StartingEquipmentRuleSlotSelection } from "../typings";
|
||||
|
||||
interface Props {
|
||||
ruleSlots: Array<StartingEquipmentRuleSlotContract>;
|
||||
onRuleSlotSelection: (
|
||||
selections: Array<StartingEquipmentRuleSlotSelection>
|
||||
) => void;
|
||||
onRuleSelection: (
|
||||
slotIdx: number,
|
||||
ruleIdx: number,
|
||||
dataId: number | null
|
||||
) => void;
|
||||
activeIdx: number | null;
|
||||
globalModifiers: Array<Modifier>;
|
||||
valueLookupByType: TypeValueLookup;
|
||||
ruleData: RuleData;
|
||||
}
|
||||
export default class StartingEquipmentRuleSlots extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
activeIdx: null,
|
||||
};
|
||||
|
||||
isRuleSlotDisabled = (
|
||||
ruleSlot: StartingEquipmentRuleSlotContract
|
||||
): boolean => {
|
||||
const { globalModifiers, valueLookupByType, ruleData } = this.props;
|
||||
|
||||
let isDisabled: boolean = false;
|
||||
let proficiencyRuleTypes: Array<Constants.StartingEquipmentRuleTypeEnum> = [
|
||||
Constants.StartingEquipmentRuleTypeEnum.WEAPON,
|
||||
Constants.StartingEquipmentRuleTypeEnum.ARMOR,
|
||||
];
|
||||
|
||||
if (ruleSlot.rules) {
|
||||
ruleSlot.rules
|
||||
.filter(
|
||||
(rule) =>
|
||||
rule.proficiencyRequired &&
|
||||
proficiencyRuleTypes.includes(rule.ruleType)
|
||||
)
|
||||
.forEach((rule) => {
|
||||
if (rule.definitions) {
|
||||
rule.definitions.forEach((item: BaseItemDefinitionContract) => {
|
||||
let simulatedItem = ItemUtils.simulateItem(
|
||||
item,
|
||||
globalModifiers,
|
||||
valueLookupByType,
|
||||
ruleData
|
||||
);
|
||||
if (!ItemUtils.hasProficiency(simulatedItem)) {
|
||||
isDisabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return isDisabled;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
ruleSlots,
|
||||
activeIdx,
|
||||
onRuleSelection,
|
||||
onRuleSlotSelection,
|
||||
ruleData,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-rule-slots">
|
||||
{ruleSlots.map((ruleSlot, ruleSlotIdx) => (
|
||||
<StartingEquipmentRuleSlot
|
||||
key={ruleSlotIdx}
|
||||
isMultiSlot={ruleSlots.length > 1}
|
||||
slotIdx={ruleSlotIdx}
|
||||
onRuleSelection={onRuleSelection}
|
||||
onRuleSlotSelection={onRuleSlotSelection}
|
||||
isActive={activeIdx === null || activeIdx === ruleSlotIdx}
|
||||
isSelected={activeIdx === ruleSlotIdx}
|
||||
isDisabled={this.isRuleSlotDisabled(ruleSlot)}
|
||||
{...ruleSlot}
|
||||
rules={ruleSlot.rules ? ruleSlot.rules : []}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import StartingEquipmentRuleSlots from "./StartingEquipmentRuleSlots";
|
||||
|
||||
export default StartingEquipmentRuleSlots;
|
||||
export { StartingEquipmentRuleSlots };
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Modifier,
|
||||
RuleData,
|
||||
StartingEquipmentSlotContract,
|
||||
TypeValueLookup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import StartingEquipmentRuleSlots from "../StartingEquipmentRuleSlots";
|
||||
import { StartingEquipmentRuleSlotSelection } from "../typings";
|
||||
|
||||
interface Props {
|
||||
slots: Array<StartingEquipmentSlotContract>;
|
||||
onRuleSlotSelection: (
|
||||
selections: Array<StartingEquipmentRuleSlotSelection>
|
||||
) => void;
|
||||
onRuleSelection: (
|
||||
slotIdx: number,
|
||||
ruleIdx: number,
|
||||
dataId: number | null
|
||||
) => void;
|
||||
activeRuleSlots: Array<number | null>;
|
||||
globalModifiers: Array<Modifier>;
|
||||
valueLookupByType: TypeValueLookup;
|
||||
ruleData: RuleData;
|
||||
}
|
||||
export default class StartingEquipmentSlots extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
slots,
|
||||
activeRuleSlots,
|
||||
globalModifiers,
|
||||
valueLookupByType,
|
||||
ruleData,
|
||||
onRuleSelection,
|
||||
onRuleSlotSelection,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="starting-equipment-slots">
|
||||
{slots.map((slot, slotIdx) => (
|
||||
<div className="starting-equipment-slot" key={slotIdx}>
|
||||
<StartingEquipmentRuleSlots
|
||||
ruleSlots={slot.ruleSlots ? slot.ruleSlots : []}
|
||||
onRuleSelection={onRuleSelection.bind(this, slotIdx)}
|
||||
onRuleSlotSelection={onRuleSlotSelection.bind(this, slotIdx)}
|
||||
activeIdx={activeRuleSlots[slotIdx]}
|
||||
globalModifiers={globalModifiers}
|
||||
valueLookupByType={valueLookupByType}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import StartingEquipmentSlots from "./StartingEquipmentSlots";
|
||||
|
||||
export default StartingEquipmentSlots;
|
||||
export { StartingEquipmentSlots };
|
||||
@@ -0,0 +1,12 @@
|
||||
import StartingEquipment from "./StartingEquipment";
|
||||
import StartingEquipmentRuleSlot from "./StartingEquipmentRuleSlot";
|
||||
import StartingEquipmentRuleSlots from "./StartingEquipmentRuleSlots";
|
||||
import StartingEquipmentSlots from "./StartingEquipmentSlots";
|
||||
|
||||
export default StartingEquipment;
|
||||
export {
|
||||
StartingEquipment,
|
||||
StartingEquipmentRuleSlot,
|
||||
StartingEquipmentRuleSlots,
|
||||
StartingEquipmentSlots,
|
||||
};
|
||||
@@ -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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user