Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,126 @@
import { orderBy } from "lodash";
import { FC, Fragment, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { CollapsibleContent } from "~/components/CollapsibleContent";
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
import { HtmlContent } from "~/components/HtmlContent";
import { Reference } from "~/components/Reference";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
import styles from "./styles.module.css";
export interface ConfirmClassModalProps
extends Omit<ConfirmModalProps, "onConfirm"> {}
export const ConfirmClassModal: FC<ConfirmClassModalProps> = ({
className,
onClose,
...props
}) => {
const dispatch = useDispatch();
const confirmClass = useSelector(builderSelectors.getConfirmClass);
const {
ruleData,
characterActions,
ruleDataUtils: RuleDataUtils,
formatUtils: FormatUtils,
} = useCharacterEngine();
const classFeatures = useMemo(() => {
return orderBy(confirmClass?.classFeatures, [
(classFeature) => classFeature.requiredLevel,
(classFeature) => classFeature.displayOrder,
(classFeature) => classFeature.name,
]);
}, [confirmClass]);
const onConfirm = () => {
if (confirmClass) {
dispatch(characterActions.classAddRequest(confirmClass, 1));
}
onClose();
};
return confirmClass ? (
<ConfirmModal
onClose={onClose}
heading="Confirm Add Class"
onConfirm={onConfirm}
confirmButtonText="Add Class"
useMobileFullScreen
{...props}
>
<>
<div className={styles.confirmClassHeader}>
<div>
<h3 className={styles.name}>{confirmClass.name}</h3>
{confirmClass?.sources
?.map((sourceMapping) =>
RuleDataUtils.getSourceDataInfo(
sourceMapping.sourceId,
ruleData
)
)
.filter(isNotNullOrUndefined)
.map((source, idx) => (
<Fragment key={uuidv4()}>
{idx > 0 ? " / " : " "}
<Reference name={source.description} />
</Fragment>
))}
{confirmClass.description && (
<HtmlContent html={confirmClass.description} withoutTooltips />
)}
</div>
{confirmClass.portraitAvatarUrl && (
<img
className={styles.image}
src={confirmClass.portraitAvatarUrl}
alt={confirmClass.name || "Class image"}
/>
)}
</div>
{confirmClass.moreDetailsUrl && (
<div className={styles.detailsLink}>
<Button
href={confirmClass.moreDetailsUrl}
variant="builder-text"
size="x-small"
target="_blank"
rel="noreferrer"
>
{confirmClass.name} Details Page <ArrowRightIcon />
</Button>
</div>
)}
<div className={styles.classFeatures}>
{classFeatures.map((feature) => (
<Accordion
summary={feature.name}
summaryMetaItems={
feature.requiredLevel
? [`${FormatUtils.ordinalize(feature.requiredLevel)} level`]
: undefined
}
key={uuidv4()}
variant="paper"
forceShow
>
<CollapsibleContent className={styles.description}>
{feature.description}
</CollapsibleContent>
</Accordion>
))}
</div>
</>
</ConfirmModal>
) : null;
};
@@ -0,0 +1,51 @@
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import { BuilderHelperTextInfoContract } from "@dndbeyond/character-rules-engine";
import { Accordion } from "~/components/Accordion";
import { CollapsibleContent } from "~/components/CollapsibleContent";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import styles from "./styles.module.css";
export interface ConfirmSpeciesContentProps
extends HTMLAttributes<HTMLDivElement> {
heading: string;
helpItems: BuilderHelperTextInfoContract[];
speciesTraits: { name: string; description: string | null }[];
}
export const ConfirmSpeciesContent: FC<ConfirmSpeciesContentProps> = ({
heading,
helpItems,
speciesTraits,
...props
}) => {
return (
<div {...props}>
<h3>{heading}</h3>
{helpItems.length > 0 && (
<HelperTextAccordion builderHelperText={helpItems} />
)}
{speciesTraits.length > 0 && (
<div>
{speciesTraits.map(({ name, description }) => {
return (
<Accordion
summary={name}
key={uuidv4()}
variant="paper"
forceShow
>
<CollapsibleContent className={styles.description}>
{description}
</CollapsibleContent>
</Accordion>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { HtmlContent } from "~/components/HtmlContent";
import { LegacyBadge } from "~/components/LegacyBadge";
import styles from "./styles.module.css";
export interface ConfirmSpeciesHeaderProps
extends HTMLAttributes<HTMLDivElement> {
heading: string;
content: string;
imageUrl: string;
isLegacy?: boolean;
sources?: ReactNode;
}
export const ConfirmSpeciesHeader: FC<ConfirmSpeciesHeaderProps> = ({
className,
heading,
content,
imageUrl,
isLegacy,
sources,
...props
}) => {
return (
<div className={clsx([styles.confirmSpeciesHeader, className])} {...props}>
<div>
<h3 className={styles.fullName}>
{heading}
{isLegacy && <LegacyBadge id={heading || ""} />}
</h3>
{sources}
<HtmlContent html={content} withoutTooltips />
</div>
<img className={styles.image} src={imageUrl} alt={heading} />
</div>
);
};
@@ -0,0 +1,201 @@
import clsx from "clsx";
import React, { FC } from "react";
import { useDispatch } from "react-redux";
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
import { Button } from "~/components/Button";
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
import { Reference } from "~/components/Reference";
import { SummaryList } from "~/components/SummaryList";
import { AppContextTypeEnum, DisplayConfigurationTypeEnum } from "~/constants";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Feat, RacialTraitContract as RacialTrait } from "~/types";
import { useSpeciesContext } from "../../contexts/Species";
import { ConfirmSpeciesContent } from "./ConfirmSpeciesContent";
import { ConfirmSpeciesHeader } from "./ConfirmSpeciesHeader";
import styles from "./styles.module.css";
export interface ConfirmSpeciesModalProps
extends Omit<ConfirmModalProps, "onConfirm"> {
feats?: Feat[];
}
export const ConfirmSpeciesModal: FC<ConfirmSpeciesModalProps> = ({
className,
feats,
onClose,
...props
}) => {
const dispatch = useDispatch();
const {
characterActions: { raceChoose },
definitionUtils: { hack__generateDefinitionKey: generateDefinitionKey },
featUtils: { getId: getFeatId, getName: getFeatName },
raceUtils: {
deriveCalledOutRacialTraits,
deriveOrderedRacialTraits,
deriveVisibleRacialTraits: deriveVisibleTraits,
getDefinitionRacialTraits,
getEntityRaceId,
getEntityRaceTypeId,
getFeatIds,
getMoreDetailsUrl,
},
racialTraitUtils: {
filterRacialTraitsByDisplayConfigurationType: filterRacialTraits,
getName: getRacialTraitName,
getDescription: getRacialTraitDescription,
simulateRacialTraitFromContract,
},
ruleData,
ruleDataUtils: {
getBuilderHelperTextByDefinitionKeys: getHelperText,
getSourceDataInfo,
},
race: species,
} = useCharacterEngine();
const { defaultSpeciesImageUrl: defaultImageUrl, selectedSpecies } =
useSpeciesContext();
const { description, fullName, isLegacy, portraitAvatarUrl, sources } =
selectedSpecies || {};
const previewUrl = portraitAvatarUrl || defaultImageUrl;
let missingFeatDependencies: Feat[] = [];
if (feats && species && selectedSpecies) {
let speciesFeatIds = getFeatIds(species);
let confirmSpeciesFeatIds = getFeatIds(selectedSpecies);
feats.forEach((feat) => {
if (
speciesFeatIds.includes(getFeatId(feat)) &&
!confirmSpeciesFeatIds.includes(getFeatId(feat))
) {
missingFeatDependencies.push(feat);
}
});
}
// Get listing url for species
const moreDetailsUrl = selectedSpecies
? getMoreDetailsUrl(selectedSpecies)
: "";
// Get species traits
const speciesTraits =
selectedSpecies && getDefinitionRacialTraits(selectedSpecies);
const simulatedSpeciesTraits = speciesTraits?.map((speciesTrait) =>
simulateRacialTraitFromContract(speciesTrait)
);
const calledOutSpeciesTraits =
simulatedSpeciesTraits &&
deriveCalledOutRacialTraits(simulatedSpeciesTraits, ruleData);
const onConfirm = () => {
if (selectedSpecies) dispatch(raceChoose(selectedSpecies));
onClose();
};
const definitionKey =
(selectedSpecies &&
generateDefinitionKey(
getEntityRaceTypeId(selectedSpecies),
getEntityRaceId(selectedSpecies)
)) ||
"";
const builderText = getHelperText(
[definitionKey],
ruleData,
DisplayConfigurationTypeEnum.RACIAL_TRAIT
);
const orderedSpeciesTraits: RacialTrait[] = speciesTraits
? deriveOrderedRacialTraits(speciesTraits)
: [];
let visibleSpeciesTraits: RacialTrait[] = deriveVisibleTraits(
orderedSpeciesTraits,
AppContextTypeEnum.BUILDER
);
visibleSpeciesTraits = filterRacialTraits(visibleSpeciesTraits, [
DisplayConfigurationTypeEnum.RACIAL_TRAIT,
]);
const sourceList = sources
?.map((sourceMapping) =>
getSourceDataInfo(sourceMapping.sourceId, ruleData)
)
.filter(isNotNullOrUndefined)
.map((source, idx) => (
<React.Fragment key={`${source}-${idx}`}>
{idx > 0 ? " / " : " "}
<Reference name={source.description} />
</React.Fragment>
));
return (
<ConfirmModal
className={clsx([styles.confirmSpeciesModal, className])}
onClose={onClose}
heading={`${species ? "Confirm Change" : "Confirm"} Species`}
onConfirm={onConfirm}
useMobileFullScreen
{...props}
>
{missingFeatDependencies.length > 0 && (
<div className="builder-message builder-message-warning">
By changing your species, you will have invalid feat selection(s):
{" " +
missingFeatDependencies.map((feat) => getFeatName(feat)).join(", ")}
</div>
)}
{selectedSpecies && (
<div className={styles.content}>
<ConfirmSpeciesHeader
heading={fullName || ""}
content={description || ""}
imageUrl={previewUrl}
isLegacy={isLegacy}
sources={sourceList}
/>
{calledOutSpeciesTraits && calledOutSpeciesTraits.length > 0 && (
<SummaryList
list={calledOutSpeciesTraits}
title="Species Traits"
className={styles.summaryList}
/>
)}
{moreDetailsUrl && (
<div className={styles.detailsLink}>
<Button
href={moreDetailsUrl}
variant="builder-text"
size="x-small"
target="_blank"
rel="noreferrer"
>
{fullName} Details Page <ArrowRightIcon />
</Button>
</div>
)}
<ConfirmSpeciesContent
heading={`${fullName} Traits`}
helpItems={builderText}
speciesTraits={visibleSpeciesTraits.map((trait) => ({
name: getRacialTraitName(trait),
description: getRacialTraitDescription(trait),
}))}
/>
</div>
)}
</ConfirmModal>
);
};
@@ -0,0 +1,108 @@
import clsx from "clsx";
import React, {
cloneElement,
FC,
HTMLAttributes,
ReactElement,
useRef,
useState,
} from "react";
import { v4 as uuidv4 } from "uuid";
import { Button } from "~/components/Button";
import { ConfirmModal } from "~/components/ConfirmModal";
import { Textarea } from "~/components/Textarea";
import styles from "./styles.module.css";
interface EditorWithDialogProps extends HTMLAttributes<HTMLDivElement> {
heading: ReactElement;
placeholder: string;
content: string;
editButtonLabel: string;
addButtonLabel: string;
onSave?: (content: string) => void;
extraNode?: React.ReactNode;
saveOnBlur?: boolean;
}
export const EditorWithDialog: FC<EditorWithDialogProps> = ({
addButtonLabel,
className,
editButtonLabel,
content = "",
extraNode,
heading,
onSave,
placeholder,
saveOnBlur = true,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const textareaInput = useRef<HTMLDivElement>(null);
const id = uuidv4();
const headingNode = cloneElement(heading, { className: styles.heading });
const handleInputBlur = (content: string): void => {
if (saveOnBlur) {
if (onSave) onSave(content);
}
};
const handleShowModal = () => {
setIsOpen(true);
};
const handleCloseModal = () => {
setIsOpen(false);
};
const modalHeading: string = content.trim().length
? editButtonLabel
: addButtonLabel;
const buttonLabel: string = modalHeading;
return (
<div className={clsx([styles.container, className])} {...props}>
<header className={styles.header}>
{headingNode}
<Button onClick={handleShowModal} size="xx-small" color="builder-green">
{buttonLabel}
</Button>
</header>
{content.trim().length > 0 && (
<div className={styles.preview} data-testid="textarea-editor-preview">
{content}
</div>
)}
<ConfirmModal
heading={modalHeading}
open={isOpen}
onClose={handleCloseModal}
onConfirm={handleCloseModal}
data-testid="textarea-editor-modal"
modal
variant="confirm-only"
confirmButtonText={"Save and Close"}
color="builder-green"
useMobileFullScreen
>
<div className={styles.content}>
<label className={styles.title} htmlFor={id}>
{heading}
</label>
<Textarea
className={styles.textarea}
ref={textareaInput}
id={id}
placeholder={placeholder}
value={content}
onInputKeyUp={() => {}}
onInputBlur={handleInputBlur}
/>
{extraNode && <div className={styles.extra}>{extraNode}</div>}
</div>
</ConfirmModal>
</div>
);
};
@@ -0,0 +1,54 @@
import { FC } from "react";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useEquipmentMethods } from "~/subApps/builder/hooks/useEquipmentMethods";
import { Item } from "~/types";
export interface EquipmentActionsProps {
item: Item;
equipLabel: string;
unequipLabel: string;
}
export const EquipmentActions: FC<EquipmentActionsProps> = ({
item,
equipLabel,
unequipLabel,
}) => {
const { itemUtils } = useCharacterEngine();
const { onToggleEquip } = useEquipmentMethods();
const isEquipped = itemUtils.isEquipped(item);
const isStackable = itemUtils.isStackable(item);
const canEquip = itemUtils.canEquip(item);
const quantity = itemUtils.getQuantity(item);
const handleToggleEquip = (e) => onToggleEquip(e, item);
return (
<>
{isStackable && (
<div className="equipment-list-item-callout-quantity">
<span className="equipment-list-item-callout-quantity-extra">
Qty
</span>
<span className="equipment-list-item-callout-quantity-value">
{quantity}
</span>
</div>
)}
{canEquip && (
<div className="equipment-list-item-callout-action">
<Button
onClick={handleToggleEquip}
size="xx-small"
color="builder-green"
variant={isEquipped ? "solid" : "outline"}
>
{isEquipped ? unequipLabel : equipLabel}
</Button>
</div>
)}
</>
);
};
@@ -0,0 +1,253 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useMemo, useState } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
ClassUtils,
Constants,
DiceUtils,
FormatUtils,
HelperUtils,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine";
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import {
HP_BASE_MAX_VALUE,
HP_BONUS_VALUE,
HP_OVERRIDE_MAX_VALUE,
} from "~/subApps/sheet/constants";
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
import styles from "./styles.module.css";
export interface HpManageModalProps
extends Omit<ConfirmModalProps, "onConfirm"> {}
export const HpManageModal: FC<HpManageModalProps> = ({
onClose,
...props
}) => {
const dispatch = useDispatch();
const { hpInfo, preferences, ruleData } = useCharacterEngine();
const [baseHp, setBaseHp] = useState(hpInfo.baseHp);
const [bonusHp, setBonusHp] = useState(hpInfo.bonusHp);
const [overrideHp, setOverrideHp] = useState(hpInfo.overrideHp);
const reset = () => {
setBaseHp(hpInfo.baseHp);
setBonusHp(hpInfo.bonusHp);
setOverrideHp(hpInfo.overrideHp);
};
const handleClose = () => {
onClose();
reset();
};
const onConfirm = () => {
if (baseHp !== hpInfo.baseHp) {
dispatch(characterActions.baseHitPointsSet(baseHp));
}
if (bonusHp !== hpInfo.bonusHp) {
dispatch(characterActions.bonusHitPointsSet(bonusHp));
}
if (overrideHp !== hpInfo.overrideHp) {
dispatch(characterActions.overrideHitPointsSet(overrideHp));
}
onClose();
reset();
};
const transformBaseHp = (value: string): number => {
let parsedNumber = HelperUtils.parseInputInt(
value,
RuleDataUtils.getMinimumHpTotal(ruleData)
);
return HelperUtils.clampInt(
parsedNumber,
RuleDataUtils.getMinimumHpTotal(ruleData),
HP_BASE_MAX_VALUE
);
};
const transformBonusHp = (value: string): number | null => {
let parsedNumber = HelperUtils.parseInputInt(value);
if (parsedNumber === null) {
return parsedNumber;
}
return HelperUtils.clampInt(
parsedNumber,
HP_BONUS_VALUE.MIN,
HP_BONUS_VALUE.MAX
);
};
const transformOverrideHp = (value: string): number | null => {
let parsedNumber = HelperUtils.parseInputInt(value, null);
if (parsedNumber === null) {
return parsedNumber;
}
return HelperUtils.clampInt(
parsedNumber,
RuleDataUtils.getMinimumHpTotal(ruleData)
);
};
const handleOverrideHpUpdate = (value: number | null): void => {
const newOverride =
value === null
? null
: HelperUtils.clampInt(
value,
RuleDataUtils.getMinimumHpTotal(ruleData),
HP_OVERRIDE_MAX_VALUE
);
setOverrideHp(newOverride);
};
const totalHp = useMemo(() => {
let totalHp: number =
baseHp + hpInfo.totalHitPointSources + (bonusHp === null ? 0 : bonusHp);
if (overrideHp !== null) {
totalHp = overrideHp;
}
return Math.max(RuleDataUtils.getMinimumHpTotal(ruleData), totalHp);
}, [baseHp, bonusHp, overrideHp, hpInfo, ruleData]);
return (
<ConfirmModal
onClose={handleClose}
heading="Manage Hit Points"
onConfirm={onConfirm}
confirmButtonText="Apply"
useMobileFullScreen
{...props}
>
{/* Total Max Hit Points */}
<div className={styles.hpManageModal}>
<p className={styles.total}>
<span className={styles.totalLabel}>Maximum Hit Points</span>
<span className={styles.totalValue}>{totalHp}</span>
</p>
{/* Hit Point Controls */}
<div className={styles.controls}>
{preferences.hitPointType ===
Constants.PreferenceHitPointTypeEnum.FIXED ? (
<p className={styles.baseHp}>
<span className={clsx([styles.baseHpLabel])}>Fixed HP</span>
<span className={styles.baseHpValue}>{baseHp}</span>
</p>
) : (
<FormInputField
label="Rolled HP"
initialValue={baseHp}
type="number"
onBlur={(value) => setBaseHp(value as number)}
transformValueOnBlur={transformBaseHp}
/>
)}
<FormInputField
label="HP Modifier"
initialValue={bonusHp}
type="number"
placeholder={"--"}
onBlur={(value) => setBonusHp(value as number)}
transformValueOnBlur={transformBonusHp}
/>
<FormInputField
inputAttributes={
{
min: RuleDataUtils.getMinimumHpTotal(ruleData),
max: HP_OVERRIDE_MAX_VALUE,
} as HTMLAttributes<HTMLInputElement>
}
label="Override HP"
initialValue={overrideHp}
type="number"
placeholder={"--"}
onBlur={handleOverrideHpUpdate}
transformValueOnBlur={transformOverrideHp}
/>
</div>
{/* Hit Point Bonus Sources */}
{hpInfo.hitPointSources.length > 0 && (
<div className={styles.hpSources}>
<h3>Hit Point Bonuses</h3>
{hpInfo.hitPointSources.map((hitPointSource, idx) => (
<p key={`${idx}:${hitPointSource.source}`}>
{FormatUtils.renderSignedNumber(hitPointSource.amount)} from{" "}
{hitPointSource.source}
</p>
))}
</div>
)}
{/* Hit Dice & Potential Values */}
<div className={styles.info}>
<div className={styles.infoGroup}>
<h3>Hit Dice</h3>
{hpInfo.classesHitDice.map((classHitDice) => (
<div key={ClassUtils.getId(classHitDice.charClass)}>
<span className={styles.infoLabel}>
{ClassUtils.getName(classHitDice.charClass)}:
</span>
<span>{DiceUtils.renderDie(classHitDice.dice)}</span>
</div>
))}
</div>
<div className={styles.infoGroup}>
<h3>Potential Values</h3>
<div>
<span className={styles.infoLabel}>Total Fixed Value HP:</span>
<span>{hpInfo.totalFixedValueHp}</span>
</div>
<div>
<span className={styles.infoLabel}>Total Average HP:</span>
<span>{hpInfo.totalAverageHp}</span>
</div>
<div>
<span className={styles.infoLabel}>Total Possible HP:</span>
<span>{hpInfo.possibleMaxHitPoints}</span>
</div>
</div>
</div>
{/* HP Help */}
<div className={styles.help}>
<h3>Max Hit Points</h3>
<p>
Your hit point maximum is determined by the number you roll for your
hit dice each level or a fixed value determined by your hit dice and
your Constitution modifier
</p>
<h3>Bonus Hit Points</h3>
<p>
Use this field to record any miscellaneous bonus hit points you want
to add to your normal hit point maximum. These hit points are
different from temporary hit points, which you can add on your
character sheet during play.
</p>
<h3>Override Hit Points</h3>
<p>
Use this field to override your typical hit point maximum. The
number you enter here will display as your hit point maximum on your
character sheet.
</p>
</div>
</div>
</ConfirmModal>
);
};
@@ -0,0 +1,80 @@
import { orderBy } from "lodash";
import { FC, HTMLAttributes, useMemo, useState } from "react";
import { DiceContract, DiceUtils } from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { HpManageModal } from "../HpManageModal";
import styles from "./styles.module.css";
export interface HpSummaryProps extends HTMLAttributes<HTMLDivElement> {}
export const HpSummary: FC<HpSummaryProps> = ({ className, ...props }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const { hpInfo } = useCharacterEngine();
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
const hitDiceStrings = useMemo(() => {
const hitDice = hpInfo.classesHitDice.map(
(classHitDice) => classHitDice.dice
);
const combinedHitDice: Array<DiceContract> = [];
hitDice.forEach((hitDie) => {
const existingDieIdx = combinedHitDice.findIndex(
(hitDice) => hitDice.diceValue === hitDie.diceValue
);
if (existingDieIdx > -1) {
let existingDie = combinedHitDice[existingDieIdx];
combinedHitDice[existingDieIdx] = {
...combinedHitDice[existingDieIdx],
diceCount:
(existingDie.diceCount !== null ? existingDie.diceCount : 0) +
(hitDie.diceCount !== null ? hitDie.diceCount : 0),
};
} else {
combinedHitDice.push(hitDie);
}
});
return orderBy(combinedHitDice, ["diceValue"]).map((hitDie) =>
DiceUtils.renderDie(hitDie)
);
}, [hpInfo]);
return (
<div {...props}>
<div className={styles.hpSummaryContent}>
<div>
<p>
<span className={styles.label}>Max Hit Points:</span>
<span data-testid="hit-point-max">{hpInfo.totalHp}</span>
</p>
<p>
<span className={styles.label}>Hit Dice:</span>
<span data-testid="hit-point-dice">
{hitDiceStrings.join(" + ")}
</span>
</p>
</div>
<Button
className={styles.button}
variant="builder"
size="x-small"
onClick={openModal}
>
Manage HP
</Button>
</div>
<HpManageModal open={isModalOpen} onClose={closeModal} />
</div>
);
};
@@ -0,0 +1,55 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { GroupedListingItem, ListingItem } from "../../types";
import { ListingGroup } from "./ListingGroup";
import { ListingItemButton } from "./ListingItemButton/ListingItemButton";
import styles from "./styles.module.css";
export interface ListingProps extends HTMLAttributes<HTMLDivElement> {
items: Array<ListingItem | GroupedListingItem>;
disabledIds?: Array<number | string>;
onQuickSelect?: (entity: ListingItem["entity"]) => void;
}
/**
* A generic listing component that displays a list of items. Items cannot be
* internal because it is used for both races/species and classes. Keeping the
* data fetch outside this component allows for more flexibility.
*/
export const Listing: FC<ListingProps> = ({
className,
items,
disabledIds,
onQuickSelect,
...props
}) => {
return (
<div className={clsx([styles.listing, className])} {...props}>
{items.length > 0 ? (
items.map((item) => {
const isDisabled = disabledIds && disabledIds.includes(item.id);
return item.type === "group" ? (
<ListingGroup
item={item as GroupedListingItem}
key={item.id + "group"}
disabledIds={disabledIds}
onQuickSelect={onQuickSelect}
/>
) : (
<ListingItemButton
item={item as ListingItem}
isDisabled={isDisabled}
key={item.id}
onQuickSelect={onQuickSelect}
/>
);
})
) : (
<p className={styles.notFound}>No Listings Found</p>
)}
</div>
);
};
@@ -0,0 +1,90 @@
import clsx from "clsx";
import { FC, HTMLAttributes, MouseEvent, useState } from "react";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import { LegacyBadge } from "~/components/LegacyBadge";
import { GroupedListingItem } from "~/subApps/builder/types";
import { ListingItemButton } from "../ListingItemButton";
import styles from "./styles.module.css";
export interface ListingGroupProps extends HTMLAttributes<HTMLDetailsElement> {
item: GroupedListingItem;
disabledIds?: Array<number | string>;
onQuickSelect?: (entity: GroupedListingItem["entity"]) => void;
}
export const ListingGroup: FC<ListingGroupProps> = ({
className,
item,
disabledIds,
onQuickSelect,
...props
}) => {
const {
entityTypeId,
error,
heading,
id,
image,
isLegacy,
metaItems,
text,
type,
onClick,
...itemProps
} = item;
const [isOpen, setIsOpen] = useState<boolean>(false);
const handleToggle = (e: MouseEvent) => {
e.preventDefault();
setIsOpen(!isOpen);
};
return (
<details className={styles.details} key={id} open={isOpen} {...props}>
<summary
className={clsx([styles.summary, className])}
onClick={handleToggle}
>
{image && (
<img src={image} alt={`${heading} avatar`} className={styles.image} />
)}
<div>
<header className={styles.header}>
<h3 className={styles.heading}>
{heading}{" "}
<span
className={styles.count}
>{`(${itemProps.listItems.length})`}</span>
</h3>
{isLegacy && <LegacyBadge className={styles.tag} />}
</header>
{text && <p className={styles.text}>{text}</p>}
{metaItems && metaItems.length > 0 && (
<div className={styles.metaItems}>
{metaItems.map((metaItem, idx) => (
<span className={styles[metaItem.type]} key={idx}>
{metaItem.text}
</span>
))}
</div>
)}
</div>
<ChevronDown className={styles.icon} />
</summary>
<div className={styles.content}>
{itemProps.listItems.map((item) => (
<ListingItemButton
item={item}
isDisabled={disabledIds && disabledIds.includes(item.id)}
key={item.id}
onQuickSelect={onQuickSelect}
/>
))}
</div>
</details>
);
};
@@ -0,0 +1,78 @@
import clsx from "clsx";
import { FC, HTMLAttributes, MouseEvent } from "react";
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import { LegacyBadge } from "~/components/LegacyBadge";
import { ListingItem } from "~/subApps/builder/types";
import styles from "./styles.module.css";
export interface ListingItemProps extends HTMLAttributes<HTMLButtonElement> {
item: ListingItem;
isDisabled?: boolean;
onQuickSelect?: (entity: ListingItem["entity"]) => void;
}
export const ListingItemButton: FC<ListingItemProps> = ({
className,
item,
isDisabled,
onQuickSelect,
...props
}) => {
const {
entityTypeId,
error,
heading,
id,
image,
isLegacy,
metaItems,
text,
type,
onClick,
...itemProps
} = item;
const handleClick = (e: MouseEvent) => {
e.preventDefault();
//QuickSelect is from Quick and RandomBuild modes only
if (onQuickSelect && !isDisabled) {
onQuickSelect(item.entity);
} else if (onClick && !isDisabled) {
onClick(id);
}
};
return (
<button
className={clsx([styles.button, className])}
onClick={handleClick}
aria-disabled={isDisabled}
key={id}
{...props}
>
{image && (
<img src={image} alt={`${heading} avatar`} className={styles.image} />
)}
<div>
<header className={styles.header}>
<h3 className={styles.heading}>{heading}</h3>
{isLegacy && <LegacyBadge className={styles.tag} />}
</header>
{text && <p className={styles.text}>{text}</p>}
{metaItems && metaItems.length > 0 && (
<div className={styles.metaItems}>
{metaItems.map((metaItem, idx) => (
<span className={styles[metaItem.type]} key={idx}>
{metaItem.text}
</span>
))}
</div>
)}
</div>
<ChevronRight className={styles.icon} />
</button>
);
};
@@ -0,0 +1,209 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { CharacterPortraitContract } from "@dndbeyond/character-rules-engine";
import ShuffleIcon from "@dndbeyond/fontawesome-cache/svgs/solid/shuffle.svg";
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
import { Button } from "~/components/Button";
import { ConfirmModal } from "~/components/ConfirmModal";
import {
CharacterNameLimitMsg,
InputLimits,
DefaultCharacterName,
} from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
import { PortraitManager } from "~/tools/js/Shared/containers/panes/DecoratePane";
import Tooltip from "~/tools/js/commonComponents/Tooltip";
import { CharacterAvatarPortrait } from "~/tools/js/smartComponents/CharacterAvatar";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
hidePortrait?: boolean;
handleNameUpdate?: (newValue: string) => void;
fullWidth?: boolean;
useDefaultCharacterName?: boolean;
}
export const PortraitName: FC<Props> = ({
fullWidth,
hidePortrait,
handleNameUpdate,
useDefaultCharacterName,
...props
}) => {
const dispatch = useDispatch();
const { characterActions, decorationUtils, decorationInfo, characterName } =
useCharacterEngine();
const [localValue, setLocalValue] = useState(characterName);
const suggestedNames = useSelector(builderSelectors.getSuggestedNames);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedPortrait, setSelectedPortrait] =
useState<CharacterPortraitContract | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [currentSuggestedNames, setCurrentSuggestedNames] = useState<
Array<string>
>([]);
useEffect(() => {
setCurrentSuggestedNames(suggestedNames);
}, [suggestedNames]);
const handleNameFieldSet = (value: string | null): void => {
let updatedName = value ?? "";
// If the flag useDefaultCharacterName is true and value is falsy, set the name to the DefaultCharacterName
if (useDefaultCharacterName) updatedName = value || DefaultCharacterName;
setLocalValue(updatedName);
if (handleNameUpdate) {
handleNameUpdate(updatedName);
} else {
dispatch(characterActions.nameSet(updatedName));
}
};
const handleShowSuggestions = (): void => {
if (currentSuggestedNames.length === 0) {
dispatch(builderActions.suggestedNamesRequest());
}
setShowSuggestions(!showSuggestions);
};
const handleSuggestMoreClick = (): void => {
dispatch(builderActions.suggestedNamesRequest());
};
const onConfirmPortrait = (): void => {
if (selectedPortrait?.avatarId && selectedPortrait?.avatarUrl) {
dispatch(
characterActions.portraitSet(
selectedPortrait.avatarId,
selectedPortrait.avatarUrl
)
);
}
closeModal();
};
const handleSelectPortrait = (portrait: CharacterPortraitContract): void => {
setSelectedPortrait(portrait);
};
const handlePortraitClick = (): void => {
setIsModalOpen(true);
};
const closeModal = (): void => {
setIsModalOpen(false);
setSelectedPortrait(null);
};
const avatarUrl = decorationUtils.getAvatarInfo(decorationInfo).avatarUrl;
return (
<div {...props}>
<div
className={clsx([styles.nameContainer, fullWidth && styles.fullwidth])}
>
{!hidePortrait && (
<>
<div onClick={handlePortraitClick}>
<CharacterAvatarPortrait
className={clsx([styles.avatar, !avatarUrl && styles.empty])}
avatarUrl={avatarUrl}
data-testid="character-portrait"
/>
</div>
<ConfirmModal
open={isModalOpen}
onClose={closeModal}
onConfirm={onConfirmPortrait}
heading="Manage Portrait"
className={styles.portraitManagerModal}
confirmButtonText="Apply"
useMobileFullScreen
>
<PortraitManager handleSelectPortrait={handleSelectPortrait} />
</ConfirmModal>
</>
)}
<div className={styles.inputContainer}>
<FormInputField
label="Character Name"
onBlur={handleNameFieldSet}
initialValue={localValue}
inputAttributes={
{
spellCheck: false,
autoComplete: "off",
} as HTMLAttributes<HTMLInputElement>
}
maxLength={InputLimits.characterNameMaxLength}
maxLengthErrorMsg={CharacterNameLimitMsg}
/>
<Button
className={styles.suggestionButton}
onClick={handleShowSuggestions}
size="xx-small"
variant="text"
forceThemeMode="light"
>
{showSuggestions ? "Hide" : "Show"} Suggestions
</Button>
</div>
</div>
{showSuggestions && (
<div>
<div className={styles.suggestionsContainer}>
<div className={styles.suggestionsLabel}>Suggestions:</div>
<div>
<Tooltip title="Shuffle Suggestions">
<Button
variant="text"
size="xx-small"
onClick={handleSuggestMoreClick}
className={styles.shuffleButton}
>
<ShuffleIcon className={styles.shuffleIcon} />
</Button>
</Tooltip>
</div>
<div className={styles.suggestions}>
{currentSuggestedNames.map((name: string, index: number) => (
<LabelChip
key={index}
onClick={() => handleNameFieldSet(name)}
className={styles.suggestion}
data-testid="suggestion-chip"
>
{name}
</LabelChip>
))}
</div>
</div>
<div className={styles.credit}>
Names by
<Button
className={styles.creditLink}
href="http://www.fantasynamegenerators.com/"
target="_blank"
rel="noreferrer"
variant="text"
>
<span className={styles.creditText}>Fantasy Name Generators</span>
</Button>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,32 @@
import clsx from "clsx";
import { ChangeEvent, FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface SearchProps extends HTMLAttributes<HTMLInputElement> {
placeholder?: string;
value: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}
export const Search: FC<SearchProps> = ({
className,
placeholder = "Search...",
value,
onChange,
...props
}) => (
<input
type="search"
className={clsx([styles.search, className])}
value={value}
onChange={onChange}
placeholder={placeholder}
spellCheck={false}
autoComplete={"off"}
{...props}
/>
);
{
/* {showAddAmountControls ? this.renderAmountControls() : null} */
}
@@ -0,0 +1,70 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
import { RouteKey } from "../../constants";
import styles from "./styles.module.css";
export interface SpeciesDisplayProps extends HTMLAttributes<HTMLDivElement> {
onRequestAction?: () => void;
actionText?: string;
headingText?: string;
}
export const SpeciesDisplay: FC<SpeciesDisplayProps> = ({
actionText,
className,
headingText,
...props
}) => {
const navigate = useNavigate();
const { characterId, race, ruleData, ruleDataUtils } = useCharacterEngine();
const { baseName, fullName, portraitAvatarUrl, subRaceShortName } =
race || {};
const portrait =
portraitAvatarUrl || ruleDataUtils.getDefaultRaceImageUrl(ruleData);
// Navigate back to the class manage page
const handleNavigate = (): void =>
navigate(
navigationConfig
.getRouteDefPath(RouteKey.RACE_MANAGE)
.replace(":characterId", characterId)
);
return (
<div className={clsx([styles.speciesDisplay, className])} {...props}>
<h3 className={styles.heading}>{headingText || `Selected Species`}</h3>
<div className={styles.content}>
{portrait && (
<img
className={styles.portrait}
src={portrait}
alt={fullName || baseName || ""}
width={50}
height={50}
/>
)}
<p className={styles.species}>
{subRaceShortName && (
<span className={styles.subspecies}>{subRaceShortName}</span>
)}
{baseName}
</p>
<Button
className={styles.button}
onClick={handleNavigate}
size="xx-small"
variant="builder"
>
{actionText || `Change Species`}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { AnimatedLoadingRingSvg } from "~/tools/js/smartComponents/Svg";
interface SpinnerProps extends HTMLAttributes<HTMLDivElement> {
label?: ReactNode;
}
export const Spinner: FC<SpinnerProps> = ({
label = "Loading",
className,
...props
}) => {
return (
<div className={clsx(["ddbc-loading-placeholder", className])} {...props}>
<AnimatedLoadingRingSvg className="ddbc-loading-placeholder__icon" />
<span className="ddbc-loading-placeholder__label">{label}</span>
</div>
);
};
+52
View File
@@ -0,0 +1,52 @@
export const NavigationType = {
HelpPage: "HelpPage",
Page: "StandardPage",
Section: "Section",
External: "External",
};
export const RouteKey = {
QUICK_BUILD: "ROUTE:QUICK_BUILD",
RANDOMIZE_BUILD: "ROUTE:RANDOMIZE_BUILD",
HOME_HELP: "ROUTE:HOME_HELP",
HOME_INTRODUCTION: "ROUTE:HOME_INTRODUCTION",
HOME_BASIC_INFO: "ROUTE:HOME_BASIC_INFO",
RACE_HELP: "ROUTE:RACE_HELP",
RACE_CHOOSE: "ROUTE:RACE_CHOOSE",
RACE_MANAGE: "ROUTE:RACE_MANAGE",
CLASS_HELP: "ROUTE:CLASS_HELP",
CLASS_CHOOSE: "ROUTE:CLASS_CHOOSE",
CLASS_MANAGE: "ROUTE:CLASS_MANAGE",
ABILITY_SCORES_HELP: "ROUTE:ABILITY_SCORES_HELP",
ABILITY_SCORES_MANAGE: "ROUTE:ABILITY_SCORES_MANAGE",
DESCRIPTION_HELP: "ROUTE:DESCRIPTION_HELP",
DESCRIPTION_MANAGE: "ROUTE:DESCRIPTION_MANAGE",
EQUIPMENT_HELP: "ROUTE:EQUIPMENT_HELP",
EQUIPMENT_MANAGE: "ROUTE:EQUIPMENT_MANAGE",
WHATS_NEXT: "ROUTE:WHATS_NEXT",
};
export const SectionKey = {
HOME: "SECTION:HOME",
RACE: "SECTION:RACE",
CLASS: "SECTION:CLASS",
ABILITY_SCORES: "SECTION:ABILITY_SCORES",
DESCRIPTION: "SECTION:DESCRIPTION",
EQUIPMENT: "SECTION:EQUIPMENT",
QUICK_BUILD: "SECTION:QUICK_BUILD",
RANDOMIZE: "SECTION:RANDOMIZE",
WHATS_NEXT: "SECTION:WHATS_NEXT",
};
export const BuilderMethod = {
QUICK: "QUICK",
STEP_BY_STEP: "STEP_BY_STEP",
RANDOMIZE: "RANDOMIZE",
};
@@ -0,0 +1,66 @@
import { createContext, FC, useContext, useState } from "react";
import { BuilderMethod } from "../../types";
export interface BuilderContextType {
builderMethod: BuilderMethod | null;
isCharacterLoading: boolean;
isCharacterLoaded: boolean;
pdfUrl: string | null;
setBuilderMethod: (method: BuilderMethod) => void;
suggestedNames: string[];
}
export const BuilderContext = createContext<BuilderContextType>({
builderMethod: null,
isCharacterLoading: true,
isCharacterLoaded: false,
pdfUrl: null,
setBuilderMethod: () => {},
suggestedNames: [],
});
export const BuilderProvider: FC = ({ children }) => {
const [builderMethod, setBuilderMethodState] = useState<BuilderMethod | null>(
null
);
const [isCharacterLoading, setIsCharacterLoading] = useState(true);
const [isCharacterLoaded, setIsCharacterLoaded] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [suggestedNames, setSuggestedNames] = useState<string[]>([]);
const setBuilderMethod = async (method: BuilderMethod) => {
// Handle setting the builder method via fetch
};
// const response = yield call(ApiRequests.getCharacterRuleData, {
// params: { v: appConfig.version },
// });
// const data: UnpackMakeApiResponseData<
// typeof ApiRequests.getCharacterRuleData
// > | null = ApiAdapterUtils.getResponseData(response);
// if (data !== null) {
// yield put(ruleDataActions.dataSet(data));
// }
// yield put(builderActions.builderMethodSetCommit(action.payload));
return (
<BuilderContext.Provider
value={{
builderMethod,
isCharacterLoading,
isCharacterLoaded,
pdfUrl,
setBuilderMethod,
suggestedNames,
}}
>
{children}
</BuilderContext.Provider>
);
};
export const useBuilderContext = () => {
return useContext(BuilderContext);
};
@@ -0,0 +1,90 @@
import { createContext, FC, useContext, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ApiAdapterUtils } from "@dndbeyond/character-rules-engine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { ClassDefinitionContract as ClassDef } from "~/types";
/**
* A context to provide data to the application without causing a re-render or
* unnecessary fetches and transforms. This context is used in places where
* character-specific data is not needed.
*/
export interface ClassContextType {
query: string;
setQuery: (query: string) => void;
filteredClasses: Array<ClassDef>;
allClasses: Array<ClassDef>;
handleSelectClass: (id: number) => void;
isLoading: boolean;
isModalShowing: boolean;
closeModal: () => void;
}
export const ClassContext = createContext<ClassContextType>(null!);
export const ClassProvider: FC = ({ children }) => {
const dispatch = useDispatch();
const [query, setQuery] = useState("");
const [allClasses, setAllClasses] = useState<Array<ClassDef>>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalShowing, setIsModalShowing] = useState(false);
const closeModal = () => setIsModalShowing(false);
const loadClasses = useSelector(apiCreatorSelectors.makeLoadAvailableClasses);
const handleSelectClass = (id: number) => {
const entity = allClasses.find((c) => c.id === id);
if (entity) {
dispatch(builderActions.confirmClassSet(entity));
setIsModalShowing(true);
window.scrollTo(0, 0);
}
};
// Handle searching through items
const filteredClasses = query
? allClasses.filter((c) =>
c.name?.toLowerCase().includes(query.toLowerCase())
)
: allClasses;
useEffect(() => {
const getAllData = async () => {
// Load all classes
const response = await loadClasses();
const classes = ApiAdapterUtils.getResponseData(response) || [];
setAllClasses(classes);
if (classes.length > 0) {
setIsLoading(false);
}
};
getAllData();
}, [loadClasses]);
return (
<ClassContext.Provider
value={{
query,
setQuery,
filteredClasses,
allClasses,
handleSelectClass,
isLoading,
isModalShowing,
closeModal,
}}
>
{children}
</ClassContext.Provider>
);
};
export const useClassContext = () => {
return useContext(ClassContext);
};
@@ -0,0 +1,59 @@
import { createContext, FC, useContext, useState } from "react";
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
export interface ModalData {
content: React.ReactNode;
props: Omit<ConfirmModalProps, "open">;
}
export interface ModalManagerContextType {
createModal: (modalData: ModalData) => void;
}
export const ModalManagerContext = createContext<ModalManagerContextType>(
null!
);
export const ModalManagerProvider: FC = ({ children }) => {
const [modalData, setModalData] = useState<ModalData | null>(null);
const createModal = (modalData: ModalData) => {
setModalData(modalData);
};
const removeModal = () => {
setModalData(null);
};
const handleClose = () => {
removeModal();
if (modalData) {
modalData.props.onClose();
}
};
const handleConfirm = () => {
removeModal();
if (modalData) {
modalData.props.onConfirm();
}
};
return (
<ModalManagerContext.Provider value={{ createModal }}>
{children}
<ConfirmModal
{...modalData?.props}
onConfirm={handleConfirm}
onClose={handleClose}
open={!!modalData}
>
{modalData?.content}
</ConfirmModal>
</ModalManagerContext.Provider>
);
};
export const useModalManager = () => {
return useContext(ModalManagerContext);
};
@@ -0,0 +1,302 @@
import { groupBy, keyBy, uniqBy } from "lodash";
import { createContext, FC, useContext, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
import {
HtmlSelectOption,
HtmlSelectOptionGroup,
RaceDefinitionContract as RaceDef,
RaceDefinitionContract,
SimpleSourcedDefinitionContract,
} from "~/types";
import { GroupedListingItem, ListingItem } from "../../types";
export interface SpeciesContextType {
closeModal: () => void;
defaultSpeciesImageUrl: string;
isLegacyShowing: boolean;
isModalShowing: boolean;
query: string;
selectedSpecies?: RaceDef;
setQuery: (query: string) => void;
source: string;
sourceOptions: (HtmlSelectOptionGroup | HtmlSelectOption)[];
filteredSpecies: RaceDef[];
allSpecies: RaceDef[];
toggleLegacyContent: () => void;
transformSpecies: (data: RaceDef[]) => ListingItem[];
setSource: (source: string) => void;
isLoading: boolean;
getSpeciesInGroups: (
species: ListingItem[]
) => (ListingItem | GroupedListingItem)[];
}
export const SpeciesContext = createContext<SpeciesContextType>(null!);
export const SpeciesProvider: FC = ({ children }) => {
const {
apiAdapterUtils: { getResponseData },
raceUtils: { getSubRaceShortName, getBaseName, getIsHomebrew, getSources },
ruleData,
ruleDataUtils: { getDefaultRaceImageUrl, getRaceGroups },
characterUtils: { isPrimarySource },
} = useCharacterEngine();
const { getSourceDescription, getGroupedOptionsBySourceCategory } =
useSource();
const [allSpecies, setAllSpecies] = useState<Array<RaceDef>>([]);
const [isLegacyShowing, setIsLegacyShowing] = useState(true);
const [isModalShowing, setIsModalShowing] = useState(false);
const [query, setQuery] = useState("");
const [selectedSpecies, setSelectedSpecies] = useState<RaceDef>();
const [source, setSource] = useState("");
const [sourceOptions, setSourceOptions] = useState<
(HtmlSelectOptionGroup | HtmlSelectOption)[]
>([]);
const [isLoading, setIsLoading] = useState(true);
const loadSpecies = useSelector(apiCreatorSelectors.makeLoadAvailableRaces);
const defaultSpeciesImageUrl = getDefaultRaceImageUrl(ruleData) ?? "";
const handleFilteredSpecies = () => {
let filtered = allSpecies;
//Check legacy toggle
if (!isLegacyShowing) {
filtered = filtered.filter((s) => !s.isLegacy);
}
// Check sources
if (source !== "") {
if (source === "HOMEBREW") {
filtered = filtered.filter((s) => s.isHomebrew);
} else {
filtered = filtered.filter((s) =>
s.sources?.some(({ sourceId }) => source === sourceId.toString())
);
}
}
// Check querys
if (query !== "") {
filtered = filtered.filter((s) => {
return s.fullName?.toLowerCase().includes(query.toLowerCase());
});
}
return filtered;
};
// Handle searching through items
const filteredSpecies = handleFilteredSpecies();
const onSelectSpecies = (id: string) => {
const entity = allSpecies.find(
(s) => `${s.entityRaceId}-${s.entityRaceTypeId}` === id
);
if (entity) {
setSelectedSpecies(entity);
setIsModalShowing(true);
window.scrollTo(0, 0);
}
};
const closeModal = () => setIsModalShowing(false);
const toggleLegacyContent = () => {
setIsLegacyShowing(!isLegacyShowing);
};
//Transform Species data into ListingItems
const transformSpecies = (data: RaceDef[]): ListingItem[] => {
return data.map((species) => {
const shortName = getSubRaceShortName(species);
const baseName = getBaseName(species);
const heading = shortName ? `${shortName} ${baseName}` : baseName || "";
const text = getIsHomebrew(species)
? "Homebrew"
: getSourceDescription(species?.sources?.[0]?.sourceId || 0) || "";
return {
type: "species",
id: `${species.entityRaceId}-${species.entityRaceTypeId}`,
entityTypeId: species.entityRaceTypeId,
heading,
text,
image: species.portraitAvatarUrl || defaultSpeciesImageUrl,
entity: species,
isLegacy: species.isLegacy,
onClick: onSelectSpecies,
};
});
};
// get the ListingItems for species, both grouped and individual
const getSpeciesInGroups = (
species: ListingItem[]
): (ListingItem | GroupedListingItem)[] => {
let groupedSpecies: ListingItem[] = [];
let unGroupedSpecies: ListingItem[] = [];
//species that have a groupId go into the groupedSpecies array and species that don't have a groupId go into the unGroupedSpecies array
species.forEach((s) => {
const groupIds = (s.entity as RaceDefinitionContract)?.groupIds;
if (groupIds && groupIds.length > 0) {
groupIds.forEach((groupId) => {
groupedSpecies.push({
...s,
groupId,
});
});
} else {
unGroupedSpecies.push(s);
}
});
//Create a lookup of grouped species by groupId
const groupedSpeciesLookup = groupBy(groupedSpecies, "groupId");
//Create a lookup from all Species Groups from rules data
const groupDataLookup = keyBy(getRaceGroups(ruleData), "id");
//This array will hold the final list of grouped species
const groups: GroupedListingItem[] = [];
//Iterate through the grouped species lookup to create final groupings
Object.keys(groupedSpeciesLookup).forEach((groupId) => {
const groupItems = groupedSpeciesLookup[groupId];
//If the group only has one item, it is not a group - put it in the unGroupedSpecies array, otherwise create the Grouped Listing Item for the group
if (groupItems.length === 1) {
unGroupedSpecies.push(groupItems[0]);
} else {
const groupInfo = groupDataLookup[parseInt(groupId)];
groups.push({
id: groupId,
type: "group",
image: groupInfo.avatarUrl ?? "",
heading: groupInfo.name ?? "",
groupId: parseInt(groupId),
listItems: groupItems,
onClick: undefined,
});
}
});
return [...groups, ...unGroupedSpecies];
};
const getSpecies = async () => {
const species = getResponseData(await loadSpecies()) || [];
setAllSpecies(species);
if (species.length > 0) {
setIsLoading(false);
}
};
// Set the source options when the species are loaded
useEffect(() => {
let shouldContaineHomebrewCategory = false;
let sourceShouldReset = true;
const sourcesData: Array<SimpleSourcedDefinitionContract> = allSpecies
.map((species) => {
//Flag homebrew category
if (species.isHomebrew) {
if (!shouldContaineHomebrewCategory) {
shouldContaineHomebrewCategory = true;
}
//don't reset source state if we have a homebrew category and the current source is homebrew
if (shouldContaineHomebrewCategory && source === "HOMEBREW") {
sourceShouldReset = false;
}
return null;
}
const primarySourceId =
getSources(species).filter(isPrimarySource)[0]?.sourceId;
//If there is no primary source and the above filter hasn't already returned null for isHombrew, return null
if (!primarySourceId) {
return null;
}
//don't reset source state if the current source is the sourceId of a species
if (primarySourceId.toString() === source) {
sourceShouldReset = false;
}
return {
sources: getSources(species),
name: getSourceDescription(primarySourceId) || "Unknown",
id: primarySourceId,
};
})
.filter(TypeScriptUtils.isNotNullOrUndefined);
const uniqueSources = uniqBy(sourcesData, "id");
const groupedOptions = getGroupedOptionsBySourceCategory(uniqueSources);
const homebrewOptions: HtmlSelectOption[] = [
{
label: "Homebrew",
value: "HOMEBREW",
},
{
label: "---------",
disabled: true,
value: "-----",
},
];
const sourceOptions: (HtmlSelectOptionGroup | HtmlSelectOption)[] = [];
if (shouldContaineHomebrewCategory) {
sourceOptions.push(...homebrewOptions);
}
sourceOptions.push(...groupedOptions);
setSourceOptions(sourceOptions);
//reset source state if the source isn't valid for the list of current species so that the list is updated
if (sourceShouldReset) {
setSource("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSpecies, source]);
useEffect(() => {
getSpecies();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadSpecies]);
return (
<SpeciesContext.Provider
value={{
closeModal,
defaultSpeciesImageUrl,
isLegacyShowing,
isModalShowing,
query,
selectedSpecies,
setQuery,
sourceOptions,
filteredSpecies,
allSpecies,
toggleLegacyContent,
transformSpecies,
source,
setSource,
isLoading,
getSpeciesInGroups,
}}
>
{children}
</SpeciesContext.Provider>
);
};
export const useSpeciesContext = () => {
return useContext(SpeciesContext);
};
@@ -0,0 +1,16 @@
import { PrerequisiteFailure } from "../types";
// Format a group of PrerequisiteFailures into a human-readable string
const formatReq = (group: Array<PrerequisiteFailure>) =>
group.map(({ data }) => {
const key = data.statKey?.toUpperCase() || "";
const value = data.requiredValue;
const amount = data.currentAmount === null ? "None" : data.currentAmount;
return `${key} ${value} (${amount})`;
});
// Loop through groups of PrerequisiteFailures and format them into a human-readable string
export const getMissingRequirements = (
groups: Array<Array<PrerequisiteFailure>>
) => groups.map((group) => formatReq(group).join(", ")).join(" or ");
@@ -0,0 +1,148 @@
import { useDispatch } from "react-redux";
import { ContainerTypeEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { useInventoryManager } from "~/tools/js/Shared/managers/InventoryManagerContext";
import { Item } from "~/types";
import { useModalManager } from "../contexts/ModalManager";
export const useEquipmentMethods = () => {
const dispatch = useDispatch();
const {
containerLookup,
containerUtils,
definitionUtils,
helperUtils,
itemUtils,
infusionUtils,
serviceDataActions,
} = useCharacterEngine();
const { inventoryManager } = useInventoryManager();
const { createModal } = useModalManager();
const launchContainerRemovalModal = (item: Item) =>
createModal({
content: (
<div className="equipment-manage__warning">
<p>
Removing the <strong>{itemUtils.getName(item)}</strong> will also
remove all of its contents.
</p>
</div>
),
props: {
heading: `Remove ${itemUtils.getName(item)}`,
onConfirm: () => {
inventoryManager.handleRemove({ item });
},
onClose: () => {}, //Do nothing
variant: "remove",
size: "fit-content",
confirmButtonText: "Remove",
},
});
const onRemove = (item) => {
const isContainer = itemUtils.isContainer(item);
if (isContainer) {
launchContainerRemovalModal(item);
} else {
inventoryManager.handleRemove({ item });
}
};
const onRemoveInfusion = (item) => {
const infusion = itemUtils.getInfusion(item);
if (infusion) {
const infusionId = infusionUtils.getId(infusion);
if (infusionId === null) {
return;
}
if (itemUtils.isContainer(item)) {
const container = helperUtils.lookupDataOrFallback(
containerLookup,
definitionUtils.hack__generateDefinitionKey(
ContainerTypeEnum.ITEM,
itemUtils.getMappingId(item)
)
);
if (container) {
if (containerUtils.hasInfusions(container)) {
// Pop up the infusion modal
createModal({
content: (
<div className="equipment-manage__warning">
<p>
<strong>Notice:</strong> In accordance with Artificer
guidance the Waste Management Guild of Sharn (the City of
Towers) requires the removal of all Infusions prior to
disposal of any items.
</p>
</div>
),
props: {
heading: `Remove ${itemUtils.getName(item)}: Infusions?`,
onConfirm: () => {
dispatch(
serviceDataActions.infusionMappingsDestroy(
containerUtils.getInfusedItemMappingIds(container)
)
);
launchContainerRemovalModal(item);
},
onClose: () => {}, //Do nothing
variant: "remove",
size: "fit-content",
confirmButtonText: "Remove",
},
});
} else {
// pop up the container modal
launchContainerRemovalModal(item);
}
}
} else {
dispatch(
serviceDataActions.infusionMappingDestroy(
infusionId,
infusionUtils.getInventoryMappingId(infusion)
)
);
}
}
};
const onUnequip = (item) => inventoryManager.handleUnequip({ item });
const onEquip = (item) => inventoryManager.handleEquip({ item });
const onAttune = (item) => inventoryManager.handleAttune({ item });
const onUnattune = (item) => inventoryManager.handleUnattune({ item });
const onQuantitySet = (item, quantity) =>
inventoryManager.handleQuantitySet({ item, quantity });
const onToggleEquip = useUnpropagatedClick((e, item: Item) => {
const isEquipped = itemUtils.isEquipped(item);
if (isEquipped) {
if (onUnequip) onUnequip(item);
} else {
if (onEquip) onEquip(item);
}
});
return {
onAttune,
onEquip,
onQuantitySet,
onRemove,
onRemoveInfusion,
onToggleEquip,
onUnattune,
onUnequip,
};
};
@@ -0,0 +1,169 @@
import clsx from "clsx";
import { HTMLAttributes, ReactNode, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, Link } from "react-router-dom";
import {
characterEnvSelectors,
Constants,
} from "@dndbeyond/character-rules-engine";
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
import { NavigationUtils } from "~/tools/js/CharacterBuilder/utils";
import { BuilderMethod, RouteKey } from "../../constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
isEnabled?: boolean;
}
export const BuilderTypeChoicePage: React.FC<Props> = ({
isEnabled = true,
className,
...props
}) => {
const params = new URLSearchParams(globalThis.location.search);
const isMobileApp = params.get("platform");
const [showHelpText, setShowHelpText] = useState(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const { characterId } = useCharacterEngine();
const characterIsLoading = useSelector(
characterEnvSelectors.getLoadingStatus
);
useEffect(() => {
if (characterIsLoading === Constants.CharacterLoadingStatusEnum.LOADED) {
navigate(NavigationUtils.getCharacterBuilderUrl(characterId), {
replace: true,
});
}
}, [characterIsLoading, characterId, navigate]);
const handleMethodSet = (method: string) => {
if (method === BuilderMethod.STEP_BY_STEP) {
dispatch(builderActions.stepBuildRequest(showHelpText));
} else {
dispatch(builderActions.builderMethodSet(method));
}
};
const onKeyUp = (
evt: React.KeyboardEvent<HTMLDivElement>,
method: string
): void => {
if (evt.key === "Enter") {
handleMethodSet(method);
}
};
const renderFooter = (text: string = "Start Building") => (
<div className={clsx([styles.cardFooter, styles.font])}>
<p>{text}</p>
<ChevronRight className={styles.icon} />
</div>
);
const renderCardContent = (
title: string,
description: string,
extraNode?: ReactNode
) => (
<div className={styles.cardContent}>
<h3 className={clsx([styles.cardTitle, styles.font])}>{title}</h3>
<p className={clsx([styles.cardDescription, styles.font])}>
{description}
</p>
{extraNode}
</div>
);
return (
<div className={clsx([styles.page, className])} {...props}>
<h1 className={styles.header}>Character Creation Method</h1>
<h2 className={clsx([styles.header])}>
Choose how you would like to create your character
</h2>
<section className={styles.grid}>
{/* STANDARD BUILD */}
<div
onClick={() => handleMethodSet(BuilderMethod.STEP_BY_STEP)}
onKeyUp={(event) => onKeyUp(event, BuilderMethod.STEP_BY_STEP)}
className={styles.card}
role="button"
tabIndex={0}
>
<div className={clsx([styles.img, styles.standardBuild])} />
{renderCardContent(
"Standard",
"Create a character using a step-by-step approach",
<div
onClick={(e) => {
e.stopPropagation();
if (isEnabled) setShowHelpText(!showHelpText);
}}
className={styles.helpCheckbox}
>
<Checkbox
id="q-include-help"
aria-labelledby="include-help-text"
checked={showHelpText}
disabled={!isEnabled}
darkMode
/>
<label
id="include-help-text"
htmlFor="q-include-help"
className={styles.helpText}
>
<strong>Beginner?</strong> Show help text
</label>
</div>
)}
{renderFooter()}
</div>
{/* PREMADES */}
{!isMobileApp && (
<Button href="/characters/premade" className={styles.card}>
<div className={clsx([styles.img, styles.premades])} />
{renderCardContent(
"Premade",
"Browse a selection of ready-to-play, premade characters and claim one to your account."
)}
{renderFooter("Start Browsing")}
</Button>
)}
{/* QUICK BUILD */}
<Link
className={styles.card}
to={navigationConfig.getRouteDefPath(RouteKey.QUICK_BUILD)}
onClick={() => handleMethodSet(BuilderMethod.QUICK)}
>
{renderCardContent(
"Quick Build",
"Choose a species and class to quickly create a level 1 character."
)}
{renderFooter()}
</Link>
{/* RANDOM BUILD */}
<Link
className={styles.card}
to={navigationConfig.getRouteDefPath(RouteKey.RANDOMIZE_BUILD)}
onClick={() => handleMethodSet(BuilderMethod.RANDOMIZE)}
>
{renderCardContent(
"Random",
"Roll up a randomized character! You can optionally set some parameters such as level, species, and class."
)}
{renderFooter()}
</Link>
</section>
</div>
);
};
@@ -0,0 +1,330 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
CharacterUtils,
CharClass,
ClassUtils,
Constants,
FormatUtils,
HelperUtils,
HtmlSelectOption,
} from "@dndbeyond/character-rules-engine";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
import { Select } from "~/tools/js/smartComponents/legacy";
import styles from "./styles.module.css";
export interface ClassHeaderProps extends HTMLAttributes<HTMLDivElement> {
charClass: CharClass;
isMulticlass: boolean;
levelsRemaining: number;
}
export const ClassHeader: FC<ClassHeaderProps> = ({
charClass,
isMulticlass,
levelsRemaining,
className,
...props
}) => {
const dispatch = useDispatch();
const { preferences, ruleData, classes, currentLevel, totalClassLevel } =
useCharacterEngine();
const { createModal } = useModalManager();
const portraitAvatarUrl = ClassUtils.getPortraitUrl(charClass);
const isStartingClass = ClassUtils.isStartingClass(charClass);
const level = ClassUtils.getLevel(charClass);
const name = ClassUtils.getName(charClass);
const subclassDefinition = ClassUtils.getSubclass(charClass);
const classId = ClassUtils.getActiveId(charClass);
const levelsDiff: number = currentLevel - totalClassLevel;
const isTypeMilestone: boolean =
preferences.progressionType ===
Constants.PreferenceProgressionTypeEnum.MILESTONE;
const isTypeXp: boolean =
preferences.progressionType === Constants.PreferenceProgressionTypeEnum.XP;
const hasLevelXpDiff: boolean = levelsDiff !== 0 && isTypeXp;
let levelOptions: Array<HtmlSelectOption> = [];
for (let i = 1; i <= level + levelsRemaining; i++) {
levelOptions.push({
label: "" + i,
value: i,
});
}
const handleLevelChangePromise = (
newLevel: string,
oldLevel: string,
accept: () => void,
reject: () => void
): void => {
const newLevelValue = HelperUtils.parseInputInt(newLevel, 0);
const oldLevelValue = HelperUtils.parseInputInt(oldLevel, 0);
const newTotalClassLevel: number = classes.reduce(
(acc: number, oldClass) =>
(acc += oldClass.id === charClass.id ? newLevelValue : oldClass.level),
0
);
const isLevelUp: boolean = newLevelValue > oldLevelValue;
const isLevelDown: boolean = newLevelValue < oldLevelValue;
// Modals to handle milestone
if (isLevelUp && isTypeMilestone) {
dispatch(characterActions.classLevelSetRequest(classId, newLevelValue));
accept();
}
if (isLevelDown && isTypeMilestone) {
createModal({
content: (
<div>
{renderModalIntro(currentLevel, newTotalClassLevel)}
<p>Are you sure you want to level down in the {name} class?</p>
<p>
Your hit points will be reduced by the fixed amount and class
feature choices you have made for the higher levels will be lost.
</p>
</div>
),
props: {
heading: "Level Down",
color: "secondary",
confirmButtonText: "Level Down",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.classLevelSetRequest(classId, newLevelValue)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
// Modals to handle progression
if (isLevelUp && isTypeXp) {
if (newTotalClassLevel <= currentLevel) {
dispatch(characterActions.classLevelSetRequest(classId, newLevelValue));
accept();
} else {
createModal({
content: (
<div>
{renderModalIntro(currentLevel, newTotalClassLevel)}
<p>Are you sure you want to level up in the {name} class?</p>
<p>
Your XP total will be increased to{" "}
{FormatUtils.renderLocaleNumber(
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)}{" "}
to match your new level.
</p>
</div>
),
props: {
heading: "Level Up",
confirmButtonText: "Level Up",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.classLevelSetRequest(
classId,
newLevelValue,
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
}
if (isLevelDown && isTypeXp) {
createModal({
content: (
<div>
{renderModalIntro(currentLevel, newTotalClassLevel)}
<p>Are you sure you want to level down in the {name} class?</p>
<p>
Your hit points will be reduced by the fixed amount and class
feature choices you have made for the higher levels will be lost.
</p>
<p>
Your XP total will be decreased to{" "}
{FormatUtils.renderLocaleNumber(
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)}{" "}
to match your new level.
</p>
</div>
),
props: {
heading: "Level Down",
confirmButtonText: "Level Down",
color: "secondary",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.classLevelSetRequest(
classId,
newLevelValue,
CharacterUtils.deriveCurrentLevelXp(
newTotalClassLevel,
ruleData
)
)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const handleRemoveClass = (): void => {
const newTotalClassLevel: number = classes.reduce(
(acc: number, oldClass) =>
(acc += oldClass.id === charClass.id ? 0 : oldClass.level),
0
);
createModal({
content: (
<div>
{renderModalIntro()}
<p>
Are you sure you want to remove all your levels in the {name} class?
</p>
<p>
Your hit points will be reduced by the fixed amount and class
feature choices you have made for the higher levels will be lost.
</p>
{isTypeXp && (
<p>Your XP total will be decreased to match your new level.</p>
)}
</div>
),
props: {
heading: "Remove Class",
variant: "remove",
size: "fit-content",
confirmButtonText: "Remove",
onConfirm: () => {
let newCharacterXp: number | null = isTypeXp
? CharacterUtils.deriveCurrentLevelXp(newTotalClassLevel, ruleData)
: null;
dispatch(
characterActions.classRemoveRequest(charClass.id, newCharacterXp)
);
},
onClose: () => {}, //Do nothing
},
});
};
const renderModalIntro = (
currentLevel: number | null = null,
newLevel: number | null = null
): React.ReactNode => {
return (
<div className={styles.modalIntro}>
{classPortraitName}
{currentLevel !== null && newLevel !== null && (
<div className={styles.modalLevels}>
<div className={styles.modalLevel}>
Current Level: {currentLevel}
</div>
<div className={styles.modalLevel}>New Level: {newLevel}</div>
</div>
)}
</div>
);
};
const classPortraitName: ReactNode = (
<>
<img
className={styles.classImg}
src={portraitAvatarUrl}
alt={`${name} Class avatar`}
/>
<div className={styles.nameGroup}>
{subclassDefinition && (
<div className={styles.subclassName}>{subclassDefinition.name}</div>
)}
<div className={styles.name} data-testid="class-name">
{name}
</div>
</div>
</>
);
return (
<div
className={clsx([styles.classHeader, styles.container, className])}
{...props}
>
{isMulticlass && isStartingClass && (
<div className={styles.startingClass}>Starting Class</div>
)}
{classPortraitName}
<div className={styles.container}>
<div className={styles.levelManager}>
<label
className={styles.levelLabel}
htmlFor={`class-level-${classId}`}
>
{hasLevelXpDiff && <span className={styles.todoIcon}>!</span>}
Level
</label>
<Select
id={`class-level-${classId}`}
value={level}
options={levelOptions}
initialOptionRemoved={true}
onChangePromise={handleLevelChangePromise}
/>
</div>
<Button
className={styles.removeClassButton}
size="x-small"
variant="text"
onClick={handleRemoveClass}
forceThemeMode="light"
>
<CloseIcon className={styles.icon} />
</Button>
</div>
</div>
);
};
@@ -0,0 +1,410 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Accordion } from "~/components/Accordion";
import { HtmlContent } from "~/components/HtmlContent";
import { Link } from "~/components/Link";
import { PreferenceProgressionTypeEnum as progressionType } from "~/constants";
import { orderBy } from "~/helpers/sortUtils";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
import { Button } from "../../../../components/Button";
import { ConfirmClassModal } from "../../components/ConfirmClassModal";
import { Listing } from "../../components/Listing";
import { PortraitName } from "../../components/PortraitName";
import { Search } from "../../components/Search";
import { Spinner } from "../../components/Spinner";
import { RouteKey } from "../../constants";
import { useClassContext } from "../../contexts/Class";
import { getMissingRequirements } from "../../helpers/getMissingRequirements";
import {
ClassDefinitionContract,
ClassItems,
ClassRequirements,
ListingItem,
MulticlassAvailability,
} from "../../types";
import styles from "./styles.module.css";
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
export interface ClassChooseProps extends HTMLAttributes<HTMLDivElement> {
showHeader?: boolean;
onQuickSelect?: (charClass: ClassDefinitionContract) => void;
}
export interface SourceGroupMapping {
name: string;
id: number;
items: ClassDefinitionContract[];
sortOrder: number | undefined;
isOpen: boolean;
}
/**
* The page which lists all available classes for the user to choose
* from when building or updating a character. Can be found at
* `/characters/:characterId/builder/class/choose`.
*/
export const ClassChoose: FC<ClassChooseProps> = ({
showHeader = true,
className,
onQuickSelect,
...props
}) => {
const navigate = useNavigate();
const {
classes: charClasses,
preferences,
prerequisiteData,
startingClass,
totalClassLevel,
currentLevel,
characterId,
classUtils,
helperUtils,
prerequisiteUtils: {
validatePrerequisiteGrouping: validateGrouping,
getPrerequisiteGroupingFailures: getGroupingFailures,
},
} = useCharacterEngine();
const { allSources, getSourceCategoryDescription, getSourceCategoryGroups } =
useSource();
const {
filteredClasses,
allClasses,
handleSelectClass,
query,
setQuery,
isLoading,
isModalShowing,
closeModal,
} = useClassContext();
const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState<
SourceGroupMapping[]
>([]);
useEffect(() => {
const sourceCategoryGroups = getSourceCategoryGroups(filteredClasses);
setMappedSourceCategoryGroups(
sourceCategoryGroups.map((group) => ({
...group,
isOpen: true,
}))
);
}, [filteredClasses]);
// Determine if the character's XP will be adjusted when adding a new class
const willXpBeAdjusted =
charClasses.length > 0 &&
preferences.progressionType === progressionType.XP &&
totalClassLevel + 1 > currentLevel;
// Navigate back to the class manage page
const handleNavigate = (): void =>
navigate(
navigationConfig
.getRouteDefPath(RouteKey.CLASS_MANAGE)
.replace(":characterId", characterId)
);
// Return requirements for multiclassing based on existing classes
const getMulticlassAvailability = (
classes: ClassItems
): Array<MulticlassAvailability> => {
const { enforceMulticlassRules } = preferences;
let canStartingClassMulticlass = false;
let startingClassRequirements: ClassRequirements = [];
if (enforceMulticlassRules) {
if (startingClass) {
canStartingClassMulticlass = validateGrouping(
classUtils.getPrerequisites(startingClass),
prerequisiteData
);
startingClassRequirements = getGroupingFailures(
classUtils.getPrerequisites(startingClass),
prerequisiteData
);
}
} else {
canStartingClassMulticlass = true;
}
return classes.map(
({ id: classId, prerequisites }): MulticlassAvailability => {
let canMulticlass = false;
const missingRequirements = getGroupingFailures(
prerequisites,
prerequisiteData
);
if (enforceMulticlassRules) {
canMulticlass =
canStartingClassMulticlass &&
validateGrouping(prerequisites, prerequisiteData);
} else {
canMulticlass = true;
}
return {
classId,
canStartingClassMulticlass,
startingClassRequirements,
canMulticlass,
missingRequirements,
};
}
);
};
const getDisabledIds = (classes: ClassItems): Array<number> => {
if (startingClass === null || !classes.length) {
return [];
}
// const classDefinitions = classes.map((item) => item.entity);
const multiclassAvailability = getMulticlassAvailability(classes);
// disable any current classes
let currentClassIds = charClasses.map((charClass) =>
classUtils.getId(charClass)
);
// disable any multiclass options that don't meet required prerequisites
let canMulticlassIds = multiclassAvailability
.filter((classInfo) => !classInfo.canMulticlass)
.map((classInfo) => classInfo.classId);
return [...currentClassIds, ...canMulticlassIds];
};
// Transform the class items into a format that listing can use
const transformClassItems = (data: ClassItems): Array<ListingItem> => {
let classDefinitions = [...data];
// Get the multiclass availability for each class
const availability = getMulticlassAvailability(classDefinitions);
return classDefinitions.map((classItem: ClassDefinitionContract) => {
const { id, sources, name, portraitAvatarUrl } = classItem;
// Find the class in the character's classes
const existingClass = charClasses.find(
(cls) => classUtils.getId(cls) === id
);
// Find the multiclass info for the class
const multiclassInfo = availability.find((cls) => cls.classId === id);
let metaItems: Array<{ type: string; text: string }> = [];
// Add the source descriptions to the meta items
if (sources)
sources.forEach(({ sourceId }) => {
let source = helperUtils.lookupDataOrFallback(allSources, sourceId);
// If a source has a description and is toggleable, add it to the meta items
if (
source?.description !== null &&
source?.sourceCategory?.isToggleable
)
metaItems.push({
type: "normal",
text: source.description,
});
});
// If the character is already multiclassed
if (charClasses && charClasses.length >= 1) {
if (existingClass) {
const { level, isStartingClass } = existingClass;
const currentLevel = `Level ${level}`;
const startingClass = isStartingClass ? " • Starting Class" : "";
// Add the class level to the meta items
metaItems.push({
type: "normal",
text: `${currentLevel}${startingClass}`,
});
}
// If the class is the starting class
else if (multiclassInfo) {
const {
canMulticlass,
canStartingClassMulticlass,
missingRequirements,
startingClassRequirements,
} = multiclassInfo;
// Add an error message to the meta items
if (!canMulticlass || !canStartingClassMulticlass)
metaItems.push({
type: "error",
text: !canStartingClassMulticlass
? `Starting class does not meet multiclass prerequisites: ${getMissingRequirements(
startingClassRequirements
)}`
: !canMulticlass
? `Prerequisites not met: ${getMissingRequirements(
missingRequirements
)}`
: "",
});
}
}
return {
id,
metaItems,
entityTypeId: -1,
heading: name || "",
image: portraitAvatarUrl || null,
onClick: handleSelectClass,
entity: classItem,
type: "class",
};
});
};
const handleOverrideClick = (override): void => {
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
...group,
isOpen: override,
}));
setMappedSourceCategoryGroups(updatedGroups);
};
const handleSourceCategoryClick = (id: string, state: boolean): void => {
const parsedId = parseInt(id);
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
...group,
isOpen: group.id === parsedId ? state : group.isOpen,
}));
setMappedSourceCategoryGroups(updatedGroups);
};
return (
<div className={clsx([styles.page, className])} {...props}>
{showHeader && (
<>
<PortraitName />
<hr className={styles.divider} />
<h2 className={styles.title}>
Choose a Class{startingClass && " to Multiclass"}
</h2>
{startingClass && (
<>
<p className={styles.text}>
Learn more about multiclassing{" "}
<Link
href="/sources/dnd/free-rules/creating-a-character#Multiclassing"
target="_blank"
rel="noopener noreferrer"
>
here
</Link>
.
</p>
<p className={styles.multiclassMessage}>
Classes from the 2024 and 2014 rulebooks are not designed to be
multiclassed together.
</p>
</>
)}
{charClasses.length > 0 && (
<div className={styles.buttons}>
{willXpBeAdjusted && (
<p className={styles.error}>
Your XP total will be adjusted when you add your new class.
</p>
)}
<Button
className={styles.navigateButton}
variant="builder"
onClick={handleNavigate}
>
Cancel Class Add
</Button>
</div>
)}
</>
)}
{isLoading ? (
<Spinner />
) : (
<>
<Search
className={styles.search}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className={styles.collapseExpand}>
<Button
variant="text"
size="x-small"
className={styles.collapseExpandButton}
onClick={() => handleOverrideClick(false)}
forceThemeMode="light"
>
Collapse All
</Button>
<Button
variant="text"
size="x-small"
className={styles.collapseExpandButton}
onClick={() => handleOverrideClick(true)}
forceThemeMode="light"
>
Expand All
</Button>
</div>
{mappedSourceCategoryGroups.length > 0 ? (
mappedSourceCategoryGroups.map((category) => {
const description = getSourceCategoryDescription(category.id);
const metaItems = description
? [
<HtmlContent
html={description}
className={styles.accordionText}
/>,
]
: [];
return (
<Accordion
id={category.id.toString()}
className={styles.accordion}
summary={<h3 className={styles.heading}>{category.name}</h3>}
summaryMetaItems={metaItems}
variant="text"
resetOpen={allClasses.length !== filteredClasses.length}
key={category.id}
handleIsOpen={handleSourceCategoryClick}
override={category.isOpen}
forceShow
>
<Listing
items={orderBy(
transformClassItems(category.items),
"heading"
)}
disabledIds={getDisabledIds(category.items)}
onQuickSelect={onQuickSelect}
/>
</Accordion>
);
})
) : (
<p className={styles.notFound}>No Results Found</p>
)}
</>
)}
<ConfirmClassModal open={isModalShowing} onClose={closeModal} />
</div>
);
};
@@ -0,0 +1,113 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
import { PortraitName } from "../../components/PortraitName";
import { ClassChoose } from "../ClassChoose";
import { SpeciesChoose } from "../SpeciesChoose";
import styles from "./styles.module.css";
export interface QuickBuildProps extends HTMLAttributes<HTMLDivElement> {}
export const QuickBuild: FC<QuickBuildProps> = ({ className, ...props }) => {
const dispatch = useDispatch();
const { raceUtils, ruleData } = useCharacterEngine();
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
null
);
const [name, setName] = useState("");
const [isClassesOpen, setIsClassesOpen] = useState(false);
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
const handleSubmit = (): void => {
dispatch(
builderActions.quickBuildRequest(
species ? raceUtils.getEntityRaceId(species) : null,
species ? raceUtils.getEntityRaceTypeId(species) : null,
charClass ? charClass.id : null,
name
)
);
};
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
setSpecies(species);
};
const handleRemoveSpecies = (): void => {
setSpecies(null);
setIsSpeciesOpen(true);
};
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
setCharClass(charClass);
};
const handleRemoveClass = (): void => {
setCharClass(null);
setIsClassesOpen(true);
};
return (
<div className={clsx([styles.page, className])} {...props}>
<h1 className={styles.title}>Quick Build</h1>
<p>
To create a level 1 character with recommended starting options, choose
a class and species below. You can provide a character name or leave it
blank to randomize one.
</p>
<hr className={styles.divider} />
{charClass ? (
<ClassDisplaySimple
onRequestChange={handleRemoveClass}
charClass={charClass}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
variant="text"
resetOpen={isClassesOpen}
>
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
</Accordion>
)}
{species ? (
<SpeciesDisplaySimple
onRequestAction={handleRemoveSpecies}
headingText="Selected Species"
species={species}
ruleData={ruleData}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
variant="text"
resetOpen={isSpeciesOpen}
>
<SpeciesChoose
showHeader={false}
onQuickSelect={handleSelectSpecies}
/>
</Accordion>
)}
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
<Button
className={styles.submitButton}
onClick={handleSubmit}
disabled={!species || !charClass}
variant="builder"
>
Create Character
</Button>
</div>
);
};
@@ -0,0 +1,174 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Select } from "@dndbeyond/character-components/es";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { Toggle } from "~/components/Toggle";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
import { PortraitName } from "../../components/PortraitName";
import { ClassChoose } from "../ClassChoose";
import { SpeciesChoose } from "../SpeciesChoose";
import styles from "./styles.module.css";
export interface RandomBuildProps extends HTMLAttributes<HTMLDivElement> {}
export const RandomBuild: FC<RandomBuildProps> = ({ className, ...props }) => {
const dispatch = useDispatch();
const { raceUtils, ruleData, helperUtils } = useCharacterEngine();
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
null
);
const [name, setName] = useState("");
const [level, setLevel] = useState<number | null>(null);
const [allowMulticlass, setAllowMulticlass] = useState(false);
const [allowFeats, setAllowFeats] = useState(false);
const [isClassesOpen, setIsClassesOpen] = useState(false);
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
const handleSubmit = (): void => {
dispatch(
builderActions.randomBuildRequest(
level,
species ? raceUtils.getEntityRaceId(species) : null,
species ? raceUtils.getEntityRaceTypeId(species) : null,
charClass ? charClass.id : null,
allowMulticlass,
allowFeats,
name
)
);
};
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
setSpecies(species);
};
const handleRemoveSpecies = (): void => {
setSpecies(null);
setIsSpeciesOpen(true);
};
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
setCharClass(charClass);
};
const handleRemoveClass = (): void => {
setCharClass(null);
setIsClassesOpen(true);
};
const handleLevelChange = (level: string): void => {
setLevel(helperUtils.parseInputInt(level));
};
const handleToggleAllowMulticlass = (): void => {
setAllowMulticlass(!allowMulticlass);
};
const handleToggleAllowFeats = (): void => {
setAllowFeats(!allowFeats);
};
const levels = Array.from(
new Array(ruleData.maxCharacterLevel),
(val, index) => index + 1
);
return (
<div className={clsx([styles.page, className])} {...props}>
<h1 className={styles.title}>Random Build</h1>
<p>
Create a randomized character. Make it completely random, or make
choices in some categories and randomize the rest. Once you've made your
selections, click <strong>Create Character</strong> to generate a
character sheet and start playing!
</p>
<hr className={styles.divider} />
<div className={styles.levelSelect}>
<label className={styles.label} id="choose-level">
Choose Level
</label>
<Select
className={styles.select}
placeholder={"--"}
options={levels.map((level) => ({
value: level,
label: level,
}))}
onChange={handleLevelChange}
aria-labelledby="choose-level"
value={level}
/>
</div>
{charClass ? (
<ClassDisplaySimple
onRequestChange={handleRemoveClass}
charClass={charClass}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
variant="text"
resetOpen={isClassesOpen}
>
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
</Accordion>
)}
{species ? (
<SpeciesDisplaySimple
onRequestAction={handleRemoveSpecies}
headingText="Selected Species"
species={species}
ruleData={ruleData}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
variant="text"
resetOpen={isSpeciesOpen}
>
<SpeciesChoose
showHeader={false}
onQuickSelect={handleSelectSpecies}
/>
</Accordion>
)}
<div className={styles.toggle}>
<label className={styles.label} id="allow-multiclass-label">
Allow Multiclass
</label>
<Toggle
onClick={handleToggleAllowMulticlass}
checked={allowMulticlass}
color="secondary"
aria-labelledby="allow-multiclass-label"
/>
</div>
<div className={styles.toggle}>
<label className={styles.label} id="allow-feats-label">
Allow Feats
</label>
<Toggle
onClick={handleToggleAllowFeats}
checked={allowFeats}
color="secondary"
aria-labelledby="allow-feats-label"
/>
</div>
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
<Button
className={styles.submitButton}
onClick={handleSubmit}
variant="builder"
>
Create Character
</Button>
</div>
);
};
@@ -0,0 +1,244 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import CircleInfo from "@dndbeyond/fontawesome-cache/svgs/regular/circle-info.svg";
import { Accordion } from "~/components/Accordion";
import { HtmlContent } from "~/components/HtmlContent";
import { Link } from "~/components/Link";
import { Toggle } from "~/components/Toggle";
import { Tooltip } from "~/components/Tooltip";
import { orderBy } from "~/helpers/sortUtils";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import { Select } from "~/tools/js/smartComponents/legacy";
import { RaceDefinitionContract } from "~/types";
import { Button } from "../../../../components/Button";
import { ConfirmSpeciesModal } from "../../components/ConfirmSpeciesModal";
import { Listing } from "../../components/Listing";
import { PortraitName } from "../../components/PortraitName";
import { Search } from "../../components/Search";
import { SpeciesDisplay } from "../../components/SpeciesDisplay";
import { Spinner } from "../../components/Spinner";
import { useSpeciesContext } from "../../contexts/Species";
import styles from "./styles.module.css";
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
export interface SpeciesChooseProps extends HTMLAttributes<HTMLDivElement> {
showHeader?: boolean;
onQuickSelect?: (species: RaceDefinitionContract) => void;
}
export interface SourceGroupMapping {
name: string;
id: number;
items: RaceDefinitionContract[];
sortOrder: number | undefined;
isOpen: boolean;
}
export const SpeciesChoose: FC<SpeciesChooseProps> = ({
showHeader = true,
onQuickSelect,
className,
...props
}) => {
const { race: currentSpecies } = useCharacterEngine();
const {
allSpecies,
closeModal,
filteredSpecies,
isLegacyShowing,
isModalShowing,
setSource,
source,
sourceOptions,
toggleLegacyContent,
transformSpecies,
query,
setQuery,
isLoading,
getSpeciesInGroups,
} = useSpeciesContext();
const { getSourceCategoryGroups, getSourceCategoryDescription } = useSource();
const [mappedSourceCategoryGroups, setMappedSourceCategoryGroups] = useState<
SourceGroupMapping[]
>([]);
useEffect(() => {
const sourceCategoryGroups = getSourceCategoryGroups(filteredSpecies);
setMappedSourceCategoryGroups(
sourceCategoryGroups.map((group) => ({
...group,
isOpen: true,
}))
);
}, [filteredSpecies]);
const handleOverrideClick = (override): void => {
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
...group,
isOpen: override,
}));
setMappedSourceCategoryGroups(updatedGroups);
};
const handleSourceCategoryClick = (id: string, state: boolean): void => {
const parsedId = parseInt(id);
const updatedGroups = mappedSourceCategoryGroups.map((group) => ({
...group,
isOpen: group.id === parsedId ? state : group.isOpen,
}));
setMappedSourceCategoryGroups(updatedGroups);
};
return (
<div className={clsx([styles.speciesChoose, className])} {...props}>
{showHeader && (
<>
<PortraitName />
<hr className={styles.divider} />
<h2 className={styles.title}>
{currentSpecies ? "Change Origin: " : "Choose Origin: "}
{"Species"}
</h2>
{currentSpecies && (
<>
<SpeciesDisplay
headingText="Current Species"
actionText="Keep Species"
/>
<h3 className={styles.title}>Select New Species</h3>
</>
)}
</>
)}
<div className={styles.filters}>
<div>
<div className={styles.label}>Filter Species Source(s)</div>
<Select
placeholder={"-- All Sources --"}
options={sourceOptions}
value={source}
onChange={(value) => setSource(value)}
/>
</div>
<div className={styles.legacy}>
<Toggle
className={styles.toggle}
onClick={toggleLegacyContent}
checked={isLegacyShowing}
color="secondary"
aria-labelledby="legacy-content-label"
/>
<label id="legacy-content-label">
Show Legacy Content{" "}
<>
<CircleInfo
data-tooltip-id="legacy-info"
data-tooltip-place="bottom"
className={styles.infoIcon}
/>
<Tooltip id="legacy-info" clickable>
Legacy content doesn't reflect the latest rules and lore.{" "}
<Link
href="https://dndbeyond.com/legacy"
target="_blank"
rel="noopener noreferrer"
>
Learn More
</Link>
</Tooltip>
</>
</label>
</div>
</div>
<hr className={styles.divider} />
<div className={clsx([styles.text, styles.marketplace])}>
Looking for something not in the list below? Unlock all official options
in the <Link href="/marketplace">Marketplace</Link>.
</div>
{isLoading ? (
<Spinner />
) : (
<>
<Search
className={styles.search}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className={styles.collapseExpand}>
<Button
variant="text"
size="x-small"
className={styles.collapseExpandButton}
onClick={() => handleOverrideClick(false)}
forceThemeMode="light"
>
Collapse All
</Button>
<Button
variant="text"
size="x-small"
className={styles.collapseExpandButton}
onClick={() => handleOverrideClick(true)}
forceThemeMode="light"
>
Expand All
</Button>
</div>
{mappedSourceCategoryGroups.length > 0 ? (
mappedSourceCategoryGroups.map((category) => {
const name = category.name;
const id = category.id;
const description = getSourceCategoryDescription(category.id);
const currentSpeciesId = currentSpecies
? [
`${currentSpecies.entityRaceId}-${currentSpecies.entityRaceTypeId}`,
]
: [];
const groupedSpecies = getSpeciesInGroups(
transformSpecies(category.items)
);
const metaItems = description
? [<HtmlContent html={description} className={styles.text} />]
: [];
return (
<Accordion
id={category.id.toString()}
className={styles.accordion}
summary={<h3 className={styles.heading}>{name}</h3>}
summaryMetaItems={metaItems}
variant="text"
resetOpen={allSpecies.length !== filteredSpecies.length}
key={id}
forceShow
override={category.isOpen}
handleIsOpen={handleSourceCategoryClick}
>
<Listing
items={orderBy(groupedSpecies, "heading")}
disabledIds={currentSpeciesId}
onQuickSelect={onQuickSelect}
/>
</Accordion>
);
})
) : (
<p className={styles.notFound}>No Results Found</p>
)}
</>
)}
<ConfirmSpeciesModal open={isModalShowing} onClose={closeModal} />
</div>
);
};