New source found from dndbeyond.com

This commit is contained in:
2025-07-16 01:00:16 -07:00
parent 151590af7a
commit f9f8dd0e7a
25 changed files with 886 additions and 1299 deletions
@@ -1,306 +0,0 @@
import { FC, HTMLAttributes } from "react";
import { BuilderChoiceTypeEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { useSource } from "~/hooks/useSource";
import {
DetailChoice,
DetailChoiceFeat,
} from "~/tools/js/Shared/containers/DetailChoice";
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
import {
CharClass,
Choice,
ClassDefinitionContract,
ClassFeature,
Feat,
FeatLookup,
FeatureChoiceOption,
HtmlSelectOptionGroup,
SourceData,
} from "~/types";
export interface FeatureChoiceProps extends HTMLAttributes<HTMLDivElement> {
choice: Choice;
charClass?: CharClass;
feature?: ClassFeature;
featsData: Feat[];
subclassData?: ClassDefinitionContract[];
onChoiceChange: (
choiceId: string,
type: number,
value: any,
parentChoiceId: string | null
) => void;
collapseDescription?: boolean;
}
/**
* Component for rendering a features choice(s) - for species traits and class features - using DetailChoiceFeat for a selected feat's choices and DetailChoice for all others.
It is used in both the builder and character sheet sidebar panes.
*/
export const FeatureChoice: FC<FeatureChoiceProps> = ({
charClass,
choice,
feature,
featsData,
subclassData,
onChoiceChange,
className,
collapseDescription,
...props
}) => {
const {
choiceUtils,
featUtils,
helperUtils,
prerequisiteUtils,
classUtils,
featLookup,
preferences,
prerequisiteData,
choiceInfo,
ruleData,
entityRestrictionData,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const {
getGroupedOptionsBySourceCategory,
getSimpleSourcedDefinitionContracts,
} = useSource();
const optionValue = choiceUtils.getOptionValue(choice);
const options = choiceUtils.getOptions(choice);
const type = choiceUtils.getType(choice);
const tagConstraints = choiceUtils.getTagConstraints(choice);
let availableOptions: Array<FeatureChoiceOption> = [];
let availableGroupedOptions: HtmlSelectOptionGroup[] = [];
let detailChoiceDesc: string | null = null;
let subchoicesNode: React.ReactNode;
const handleChoiceChange = (
id: string,
type: number,
subType: number | null,
value: any,
parentChoiceId: string | null
): void => {
onChoiceChange(id, type, value, parentChoiceId);
};
const getSubclassData = (): ClassDefinitionContract[] => {
if (!subclassData || !charClass) {
return [];
}
let data: ClassDefinitionContract[] = [...subclassData];
let existingSubclass = classUtils.getSubclass(charClass);
if (
existingSubclass !== null &&
!data.some(
(classDefinition) =>
existingSubclass !== null &&
classDefinition.id === existingSubclass.id
)
) {
data.push(existingSubclass);
}
return data;
};
const getAvailableFeatChoices = (
existingFeatId: number | null,
featData: Feat[],
featLookup: FeatLookup
): Feat[] => {
let data: Feat[] = [...featData];
if (existingFeatId !== null) {
let existingFeat = helperUtils.lookupDataOrFallback(
featLookup,
existingFeatId
);
if (
existingFeat !== null &&
!data.some((feat) => featUtils.getId(feat) === existingFeatId)
) {
data.push(existingFeat);
}
}
return data;
};
const getSubclassSources = (
subclass: ClassDefinitionContract
): SourceData[] => {
if (subclass.sources === null) {
return [];
}
return subclass.sources
.map((sourceMapping) =>
helperUtils.lookupDataOrFallback(
ruleDataUtils.getSourceDataLookup(ruleData),
sourceMapping.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
};
switch (type) {
case BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION:
const availableFeats = getAvailableFeatChoices(
optionValue,
featsData,
featLookup
);
const repeatableFeatTracker = new Set();
// Add selected feat to repeatable tracker if repeatable
const selectedFeat = optionValue ? featLookup[optionValue] : null;
if (selectedFeat && featUtils.isRepeatable(selectedFeat)) {
const parentId = featUtils.getRepeatableGroupId(selectedFeat);
if (parentId) {
repeatableFeatTracker.add(parentId);
}
}
const filteredFeats = availableFeats.filter((feat) => {
const featId = featUtils.getId(feat);
const isRepeatable = featUtils.isRepeatable(feat);
// If the feat is the currently selected, always include it
if (featId === optionValue) {
return true;
}
// Always exclude all previous selected feats
if (featLookup[featId]) {
return false;
}
// If the Feat does not meet the tag constraints, should they exist, exclude it
const tagCategories = featUtils.getCategories(feat);
if (
tagConstraints &&
!featUtils.doesSatisfyTagConstraints(tagCategories, tagConstraints)
) {
return false;
}
// Handle prerequisites when enforcing feat rules
if (
preferences.enforceFeatRules &&
!prerequisiteUtils.validatePrerequisiteGrouping(
featUtils.getPrerequisites(feat),
prerequisiteData
)
) {
return false;
}
// Special handling for repeatable feats, there can be only one
if (isRepeatable) {
const parentId = featUtils.getRepeatableGroupId(feat);
// If a feat from this repeatable group exist exclude all others
if (repeatableFeatTracker.has(parentId)) {
return false;
}
repeatableFeatTracker.add(parentId);
}
// If none of the exclusions above are met, include the feat
return true;
});
//Group available feats by source category
availableGroupedOptions = getGroupedOptionsBySourceCategory(
filteredFeats
.map((feat) => featUtils.getDefinition(feat))
.filter(TypeScriptUtils.isNotNullOrUndefined)
);
if (selectedFeat && optionValue !== null) {
detailChoiceDesc = featUtils.getDescription(selectedFeat);
subchoicesNode = <DetailChoiceFeat featId={optionValue} />;
}
break;
case BuilderChoiceTypeEnum.SUB_CLASS_OPTION:
const subclassData = getSubclassData();
//Group available subclasses by source category
availableGroupedOptions = getGroupedOptionsBySourceCategory(subclassData);
const chosenSubclass = subclassData.find(
(subclass) => subclass.id === optionValue
);
if (chosenSubclass) {
detailChoiceDesc = "";
let sources = getSubclassSources(chosenSubclass);
sources.forEach((source) => {
if (source.sourceCategory && source.sourceCategory.isToggleable) {
detailChoiceDesc += source.sourceCategory.description
? source.sourceCategory.description
: "";
}
});
}
break;
case BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION:
// Map over the options to mock parts of a SpellDefinitionContract.
const spellOptions = getSimpleSourcedDefinitionContracts(options);
availableGroupedOptions = getGroupedOptionsBySourceCategory(
spellOptions,
optionValue,
entityRestrictionData
);
// If there is a chosen spell, set detailChoiceDesc to its description.
const chosenSpell = spellOptions.find(
(spell) => spell.id === optionValue
);
if (chosenSpell) {
detailChoiceDesc = chosenSpell.description ?? "";
}
break;
default:
availableOptions = options.map((option) => ({
...option,
value: option.id,
}));
}
return (
<div className={className} {...props}>
<DetailChoice
{...choice}
choice={choice}
options={
availableGroupedOptions.length > 0
? availableGroupedOptions
: availableOptions
}
onChange={handleChoiceChange}
description={detailChoiceDesc || ""}
choiceInfo={choiceInfo}
classId={charClass && classUtils.getId(charClass)}
showBackgroundProficiencyOptions={true}
collapseDescription={collapseDescription}
/>
{subchoicesNode}
</div>
);
};
@@ -0,0 +1,119 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
ChoiceUtils,
} from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
import { CharClass, Choice, ClassDefinitionContract, Feat } from "~/types";
export interface FeatureChoiceProps extends HTMLAttributes<HTMLDivElement> {
choice: Choice;
charClass?: CharClass;
featsData: Feat[];
subclassData?: ClassDefinitionContract[];
onChoiceChange: (
choiceId: string,
type: number,
value: any,
parentChoiceId: string | null
) => void;
collapseDescription?: boolean;
}
/**
* Component for rendering a features choice(s) - for species traits and class features - using DetailChoice for the choices and mapping over any feat sub choices if the choice from a species trait or class feature is a feat.
* It is used in both the builder and character sheet sidebar panes.
*/
export const FeatureChoice: FC<FeatureChoiceProps> = ({
charClass,
choice,
featsData,
subclassData,
onChoiceChange,
className,
collapseDescription,
...props
}) => {
const dispatch = useDispatch();
const {
classUtils,
featLookup,
preferences,
prerequisiteData,
choiceInfo,
ruleData,
entityRestrictionData,
} = useCharacterEngine();
const handleChoiceChange = (
id: string,
type: number,
value: any,
parentChoiceId: string | null
): void => {
onChoiceChange(id, type, value, parentChoiceId);
};
const handleFeatChoiceChange = (
featId: number,
id: string | null,
type: number,
value: any
): void => {
if (id !== null) {
dispatch(characterActions.featChoiceSetRequest(featId, type, id, value));
}
};
const { description, options, featSubChoices } =
ChoiceUtils.getSortedChoiceOptionsForFeaturesInfo(
choice,
featsData,
featLookup,
charClass || null,
subclassData || [],
preferences,
prerequisiteData,
ruleData,
entityRestrictionData
);
return (
<div className={className} {...props}>
<DetailChoice
{...choice}
choice={choice}
options={options}
onChange={handleChoiceChange}
description={description || ""}
choiceInfo={choiceInfo}
classId={charClass && classUtils.getId(charClass)}
showBackgroundProficiencyOptions={true}
collapseDescription={collapseDescription}
/>
{featSubChoices &&
featSubChoices.map((subChoice) => {
const id = ChoiceUtils.getId(subChoice.choice);
return (
<DetailChoice
{...subChoice.choice}
label={subChoice.choice.label}
choice={subChoice.choice}
key={id !== null ? id : ""}
options={subChoice.options}
onChange={(id, type, value) =>
handleFeatChoiceChange(subChoice.featId, id, type, value)
}
choiceInfo={choiceInfo}
showBackgroundProficiencyOptions={true}
description={subChoice.description}
/>
);
})}
</div>
);
};
@@ -0,0 +1,179 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import {
ApiAdapterUtils,
apiCreatorSelectors,
CharClass,
Choice,
ChoiceUtils,
ClassDefinitionContract,
ClassUtils,
Constants,
Feat,
FeatUtils,
} from "@dndbeyond/character-rules-engine";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import { DataLoadingStatusEnum } from "~/tools/js/smartComponents/componentConstants";
import { FeatureChoice } from "./FeatureChoice";
import styles from "./styles.module.css";
export interface FeatureChoicesProps extends HTMLAttributes<HTMLDivElement> {
choices: Array<Choice>;
showHeading?: boolean;
onChoiceChange: (
choiceId: string,
type: number,
value: any,
parentChoiceId: string | null
) => void;
collapseDescription?: boolean;
charClass?: CharClass;
shouldFetch?: boolean; // Optional prop to control fetching behavior
}
export const FeatureChoices: FC<FeatureChoicesProps> = ({
className,
choices,
showHeading,
onChoiceChange,
collapseDescription,
charClass,
shouldFetch = true, // Default to true if not provided
...props
}) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const loadAvailableFeats = useSelector(
apiCreatorSelectors.makeLoadAvailableFeats
);
const loadAvailableSubclasses = useSelector(
apiCreatorSelectors.makeLoadAvailableSubclasses
);
const [subclassData, setSubclassData] = useState<
Array<ClassDefinitionContract>
>([]);
const [featsData, setFeatsData] = useState<Array<Feat>>([]);
const [featLoadingStatus, setFeatLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const [subclassLoadingStatus, setSubclassLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const hasFeatChoice = choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
const hasSubclassChoice = choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.SUB_CLASS_OPTION
);
useEffect(() => {
if (
hasFeatChoice &&
shouldFetch &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
setFeatLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableFeats()
.then((response) => {
let featsData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featsData = data.map((definition) =>
FeatUtils.simulateFeat(definition)
);
}
setFeatsData(featsData);
setFeatLoadingStatus(DataLoadingStatusEnum.LOADED);
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
charClass &&
hasSubclassChoice &&
shouldFetch &&
subclassLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableSubclasses(ClassUtils.getId(charClass))
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
setSubclassData(subclassData);
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADED);
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
}, [
hasFeatChoice,
hasSubclassChoice,
subclassLoadingStatus,
featLoadingStatus,
charClass,
loadAvailableFeats,
loadAvailableSubclasses,
shouldFetch,
]);
const isDataLoaded = (): boolean => {
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
return !isReadonly && choices.length > 0 ? (
<div className={clsx([styles.featureChoices, className])} {...props}>
<>
{showHeading && (
<Heading className={styles.heading}>{`Option${
choices.length > 1 ? "s" : ""
}`}</Heading>
)}
{isDataLoaded() ? (
choices.map((choice) => (
<FeatureChoice
charClass={charClass}
choice={choice}
featsData={featsData}
subclassData={subclassData}
className={styles.choice}
key={ChoiceUtils.getId(choice)}
onChoiceChange={onChoiceChange}
collapseDescription={collapseDescription}
/>
))
) : (
<LoadingPlaceholder />
)}
</>
</div>
) : null;
};
@@ -1,11 +1,15 @@
import { orderBy } from 'lodash';
import { TypeScriptUtils } from "../../utils";
import { ChoiceAccessors } from '.';
import { ClassAccessors } from '../Class';
import { AbilityStatEnum, BuilderChoiceSubtypeEnum } from '../Core';
import { AbilityStatEnum, BuilderChoiceSubtypeEnum, BuilderChoiceTypeEnum, } from '../Core';
import { DataOriginTypeEnum } from '../DataOrigin';
import { EntityUtils } from '../Entity';
import { FeatAccessors, FeatUtils } from '../Feat';
import { HelperUtils } from '../Helper';
import { ModifierAccessors } from '../Modifier';
import { PrerequisiteValidators } from '../Prerequisite';
import { RuleDataAccessors } from '../RuleData';
import { SourceUtils } from '../Source';
import { SpellUtils } from '../Spell';
import { getDefaultSubtypes, getOptions, getOptionValue, isOptional } from './accessors';
@@ -33,8 +37,9 @@ export function isTodo(choice) {
return !isOptional(choice) && getOptionValue(choice) === null;
}
// Given a Choice, return options for a select element that are potentially grouped by sourceCategory
//used in DetailChoiceFeat.tsx for feat choices
export function getSortedChoiceOptionsInfo(choice, ruleData, entityRestrictionData) {
//used in FeatPane and FeatDetail for feat choices
// and in getSortedChoiceOptionsForFeaturesInfo for feature choices that are feats and have subchoices
export function getSortedChoiceOptionsForFeatsInfo(featId, choice, ruleData, entityRestrictionData) {
var _a;
const options = getOptions(choice);
let description;
@@ -56,10 +61,126 @@ export function getSortedChoiceOptionsInfo(choice, ruleData, entityRestrictionDa
featChoiceOptions = orderBy(featChoiceOptions, 'label');
}
return {
featId,
choice,
options: featChoiceOptions,
description,
};
}
// Given a Choice, return options for a select element that are potentially grouped by sourceCategory
//used in FeatureChoice for all choices
// extracted from FeatureChoice.tsx
// TODO needs to be broken down into smaller utils and simplified
export function getSortedChoiceOptionsForFeaturesInfo(choice, fetchedFeats, featLookup, charClass, fetchedSubclasses, characterPreferences, prerequisiteData, ruleData, entityRestrictionData) {
var _a, _b;
let description;
let availableOptions;
let featSubChoices;
const optionValue = ChoiceAccessors.getOptionValue(choice);
const options = ChoiceAccessors.getOptions(choice);
const type = ChoiceAccessors.getType(choice);
const tagConstraints = ChoiceAccessors.getTagConstraints(choice);
switch (type) {
case BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION:
const availableFeats = getAvailableFeatChoices(optionValue, fetchedFeats, featLookup);
const repeatableFeatTracker = new Set();
// Add selected feat to repeatable tracker if repeatable
const selectedFeat = optionValue ? featLookup[optionValue] : null;
if (selectedFeat && FeatAccessors.isRepeatable(selectedFeat)) {
const parentId = FeatUtils.getRepeatableGroupId(selectedFeat);
if (parentId) {
repeatableFeatTracker.add(parentId);
}
}
const filteredFeats = availableFeats.filter((feat) => {
const featId = FeatAccessors.getId(feat);
const isRepeatable = FeatAccessors.isRepeatable(feat);
// If the feat is the currently selected, always include it
if (featId === optionValue) {
return true;
}
// Always exclude all previous selected feats
if (featLookup[featId]) {
return false;
}
// If the Feat does not meet the tag constraints, should they exist, exclude it
const tagCategories = FeatAccessors.getCategories(feat);
if (tagConstraints && !FeatUtils.doesSatisfyTagConstraints(tagCategories, tagConstraints)) {
return false;
}
// Handle prerequisites when enforcing feat rules
if (characterPreferences.enforceFeatRules &&
!PrerequisiteValidators.validatePrerequisiteGrouping(FeatAccessors.getPrerequisites(feat), prerequisiteData)) {
return false;
}
// Special handling for repeatable feats, there can be only one
if (isRepeatable) {
const parentId = FeatUtils.getRepeatableGroupId(feat);
// If a feat from this repeatable group exist exclude all others
if (repeatableFeatTracker.has(parentId)) {
return false;
}
repeatableFeatTracker.add(parentId);
}
// If none of the exclusions above are met, include the feat
return true;
});
//Group available feats by source category
availableOptions = SourceUtils.getGroupedOptionsBySourceCategory(filteredFeats
.map((feat) => FeatAccessors.getDefinition(feat))
.filter(TypeScriptUtils.isNotNullOrUndefined), ruleData);
if (selectedFeat && optionValue !== null) {
description = (_a = FeatAccessors.getDescription(selectedFeat)) !== null && _a !== void 0 ? _a : '';
const featChoices = FeatAccessors.getChoices(selectedFeat);
if (featChoices.length > 0) {
featSubChoices = featChoices.map((featChoice) => {
return getSortedChoiceOptionsForFeatsInfo(optionValue, featChoice, ruleData, entityRestrictionData);
});
}
// subchoicesNode = <DetailChoiceFeat featId={optionValue} />;
}
break;
case BuilderChoiceTypeEnum.SUB_CLASS_OPTION:
const subclassData = charClass ? getAvailableSubclassChoices(charClass, fetchedSubclasses) : [];
//Group available subclasses by source category
availableOptions = SourceUtils.getGroupedOptionsBySourceCategory(subclassData, ruleData);
const chosenSubclass = subclassData.find((subclass) => subclass.id === optionValue);
if (chosenSubclass) {
description = '';
const sources = chosenSubclass.sources
? chosenSubclass.sources
.map((sourceMapping) => HelperUtils.lookupDataOrFallback(RuleDataAccessors.getSourceDataLookup(ruleData), sourceMapping.sourceId))
.filter(TypeScriptUtils.isNotNullOrUndefined)
: [];
sources.forEach((source) => {
var _a, _b;
if ((_a = source.sourceCategory) === null || _a === void 0 ? void 0 : _a.isToggleable) {
description += source.sourceCategory.description ? source.sourceCategory.description : '';
}
description += (_b = chosenSubclass.description) !== null && _b !== void 0 ? _b : '';
});
}
break;
case BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION:
// Map over the options to mock parts of a SpellDefinitionContract.
const spellOptions = SourceUtils.getSimpleSourcedDefinitionContracts(options);
availableOptions = SourceUtils.getGroupedOptionsBySourceCategory(spellOptions, ruleData, optionValue, entityRestrictionData);
// If there is a chosen spell, set detailChoiceDesc to its description.
const chosenSpell = spellOptions.find((spell) => spell.id === optionValue);
if (chosenSpell) {
description = (_b = chosenSpell.description) !== null && _b !== void 0 ? _b : '';
}
break;
default:
availableOptions = options.map((option) => (Object.assign(Object.assign({}, option), { value: option.id })));
}
return {
choice,
options: availableOptions,
featSubChoices,
description,
};
}
// filter Choice options based on modifiers and default subtypes that identify when a user has made a choice from another source. We filter those options out or we append the source data origin name next to the option label
export function getRemainingOptions(options, modifiers, defaultSubtypes, showBackgroundProficiencyOptions, optionValue) {
let remainingSubtypes = [];
@@ -209,3 +330,21 @@ export function getSortedRenderOptions(options, classId, subType, entityRestrict
? orderBy(renderOptions, 'sortOrder')
: orderBy(renderOptions, 'label');
}
//Taken from FeatureChoice.tsx
function getAvailableSubclassChoices(charClass, fetchedSubclasses) {
const existingSubclass = ClassAccessors.getSubclass(charClass);
//if there is an existing subclass that isn't already in the fetched subclasses, append it to the list
if (existingSubclass && !fetchedSubclasses.some((subclass) => subclass.id === existingSubclass.id)) {
return [...fetchedSubclasses, existingSubclass];
}
return fetchedSubclasses;
}
//Taken from FeatureChoice.tsx
function getAvailableFeatChoices(existingFeatId, fetchedFeats, featLookup) {
const existingFeat = existingFeatId ? HelperUtils.lookupDataOrFallback(featLookup, existingFeatId) : null;
// If there is an existing feat that isn't already in the fetched feats, append it to the list
if (existingFeat !== null && !fetchedFeats.some((feat) => FeatAccessors.getId(feat) === existingFeatId)) {
return [...fetchedFeats, existingFeat];
}
return fetchedFeats;
}
@@ -171,8 +171,9 @@ export function deriveReplacementWeaponAbilityStats(classModifiers) {
classModifiers.forEach((modifier) => {
if (ModifierValidators.isEnablesAbilityStatModifier(modifier)) {
const statId = ModifierAccessors.getEntityId(modifier) || 0;
if (statId && !replacementWeaponAbilityStats.includes(statId)) {
replacementWeaponAbilityStats.push(statId);
const dataOrigin = ModifierAccessors.getDataOrigin(modifier);
if (statId) {
replacementWeaponAbilityStats.push({ statId, dataOrigin });
}
}
});
@@ -343,6 +343,11 @@ export function* handleAdhocFeatCreate(action) {
* @param action
*/
export function* handleAdhocFeatRemove(action) {
const featLookup = yield select(rulesEngineSelectors.getFeatLookup);
const currentFeat = HelperUtils.lookupDataOrFallback(featLookup, action.payload.id, null);
if (currentFeat !== null) {
yield call(apiRemoveSpellsBySpellListIds, FeatAccessors.getSpellListIds(currentFeat));
}
const data = yield call(SagaHelpers.getApiRequestData, ApiRequests.deleteCharacterFeatAdHoc, action.payload);
yield call(handleDataUpdates, data);
}
@@ -457,7 +462,13 @@ export function* handleRaceChoose(action) {
const { race } = action.payload;
const existingRace = yield select(rulesEngineSelectors.getRace);
if (existingRace !== null) {
yield call(apiRemoveSpellsBySpellListIds, RaceAccessors.getSpellListIds(existingRace));
const spellListIdsToRemove = RaceAccessors.getSpellListIds(existingRace);
const racialTraitsToRemove = RaceAccessors.getRacialTraits(existingRace);
const featsFromRacialTraits = racialTraitsToRemove.map((trait) => RacialTraitAccessors.getFeats(trait)).flat();
featsFromRacialTraits.forEach((feat) => {
spellListIdsToRemove.push(...FeatAccessors.getSpellListIds(feat));
});
yield call(apiRemoveSpellsBySpellListIds, spellListIdsToRemove);
const existingOptionalOrigins = yield select(rulesEngineSelectors.getOptionalOrigins);
const optionalOriginsIdsToRemove = existingOptionalOrigins.map(OptionalOriginAccessors.getRacialTraitId);
yield call(apiRemoveOptionalOriginsCollection, {
@@ -1309,17 +1320,23 @@ export function* handleCurrencyTransactionSet(action) {
*/
export function* handleRacialTraitChoiceSetRequest(action) {
const { racialTraitId, choiceId } = action.payload;
yield call(apiRacialTraitChoiceSet, action);
const race = yield select(rulesEngineSelectors.getRace);
if (race) {
const racialTrait = RaceAccessors.getRacialTraits(race).find((racialTrait) => RacialTraitAccessors.getId(racialTrait) === racialTraitId);
const racialTrait = race
? RaceAccessors.getRacialTraits(race).find((racialTrait) => RacialTraitAccessors.getId(racialTrait) === racialTraitId)
: null;
// Remove any spells that are associated with the feat from the racial trait choices
const feats = racialTrait ? RacialTraitAccessors.getFeats(racialTrait) : [];
const featSpellListIds = feats
.map(FeatAccessors.getSpellListIds)
.reduce((acc, ids) => acc.concat(ids), []);
yield call(apiRemoveSpellsBySpellListIds, featSpellListIds);
yield call(apiRacialTraitChoiceSet, action);
if (racialTrait) {
const racialTraitChoice = RacialTraitAccessors.getChoices(racialTrait).find((choice) => ChoiceAccessors.getId(choice) === choiceId);
if (racialTraitChoice) {
yield call(autoUpdateChoices, racialTraitChoice);
}
}
}
}
/**
*
@@ -1343,6 +1360,7 @@ export function* handleClassFeatureChoiceSetRequest(action) {
const { classId, classFeatureId, choiceId, choiceType, optionValue } = action.payload;
let oldClasses = yield select(rulesEngineSelectors.getClasses);
let oldCharClass = oldClasses.find((charClass) => ClassAccessors.getActiveId(charClass) === classId);
const spellListIdsToRemove = [];
if (choiceType === BuilderChoiceTypeEnum.SUB_CLASS_OPTION && oldCharClass) {
const subclass = ClassAccessors.getSubclass(oldCharClass);
if (subclass !== null) {
@@ -1350,9 +1368,19 @@ export function* handleClassFeatureChoiceSetRequest(action) {
.filter((classFeature) => ClassFeatureAccessors.getClassId(classFeature) === subclass.id)
.map(ClassFeatureAccessors.getSpellListIds)
.reduce((acc, ids) => acc.concat(ids), []);
yield call(apiRemoveSpellsBySpellListIds, spellListIds);
spellListIdsToRemove.push(...spellListIds);
}
}
if (choiceType === BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION && oldCharClass) {
const classFeature = ClassAccessors.getClassFeatures(oldCharClass).find((classFeature) => ClassFeatureAccessors.getId(classFeature) === classFeatureId);
const featsFromClassFeature = classFeature ? ClassFeatureAccessors.getFeats(classFeature) : [];
featsFromClassFeature.forEach((feat) => {
const spellListIds = FeatAccessors.getSpellListIds(feat);
spellListIdsToRemove.push(...spellListIds);
});
}
//remove any spells that are associated with the spell lists from the subclass or feat choices
yield call(apiRemoveSpellsBySpellListIds, spellListIdsToRemove);
yield call(apiClassFeatureChoiceSet, action);
const classes = yield select(rulesEngineSelectors.getClasses);
const charClass = classes.find((charClass) => !!oldCharClass && ClassAccessors.getId(charClass) === ClassAccessors.getId(oldCharClass));
@@ -1453,12 +1481,21 @@ export function* handleClassLevelSetRequest(action) {
const oldClassId = ClassAccessors.getId(oldCharClass);
const removedClassFeatures = ClassAccessors.getActiveClassFeatures(oldCharClass).filter((classFeature) => ClassFeatureAccessors.getRequiredLevel(classFeature) > level);
const isRemovingSubclass = removedClassFeatures.some((feature) => ClassFeatureAccessors.getChoices(feature).some((choice) => ChoiceAccessors.getType(choice) === BuilderChoiceTypeEnum.SUB_CLASS_OPTION));
const spellListIdsFromFeatsToRemove = [];
removedClassFeatures.forEach((classFeature) => {
const feats = ClassFeatureAccessors.getFeats(classFeature);
feats.forEach((feat) => {
const spellListIds = FeatAccessors.getSpellListIds(feat);
spellListIdsFromFeatsToRemove.push(...spellListIds);
});
});
const removedSpellListIds = ClassAccessors.getActiveClassFeatures(oldCharClass)
.filter((classFeature) => ClassFeatureAccessors.getRequiredLevel(classFeature) > level ||
(isRemovingSubclass && ClassFeatureAccessors.getClassId(classFeature) !== oldClassId))
.map(ClassFeatureAccessors.getSpellListIds)
.reduce((acc, spellListIds) => acc.concat(spellListIds), []);
yield call(apiRemoveSpellsBySpellListIds, removedSpellListIds);
// Remove spells associated with the spell lists from the removed class features
yield call(apiRemoveSpellsBySpellListIds, [...removedSpellListIds, ...spellListIdsFromFeatsToRemove]);
}
}
yield call(apiClassLevelSet, action);
@@ -1582,7 +1619,21 @@ function* updateBackgroundResult(action, data) {
export function* handleBackgroundSetRequest(action) {
const existingBackground = yield select(rulesEngineSelectors.getBackgroundInfo);
if (existingBackground !== null) {
yield call(apiRemoveSpellsBySpellListIds, BackgroundAccessors.getSpellListIds(existingBackground));
//start an array of spellListIds to remove with any spellListIds from the existing background
const spellListIdsToRemove = BackgroundAccessors.getSpellListIds(existingBackground);
//check for any granted or chosen feats on the background and get their spellListIds
const featLookup = yield select(rulesEngineSelectors.getFeatLookup);
const chosenFeatIds = BackgroundAccessors.getFeatLists(existingBackground)
.map((featList) => featList.chosenFeatId)
.filter(TypeScriptUtils.isNotNullOrUndefined);
chosenFeatIds.forEach((featId) => {
const feat = HelperUtils.lookupDataOrFallback(featLookup, featId);
if (feat) {
spellListIdsToRemove.push(...FeatAccessors.getSpellListIds(feat));
}
});
//remove any spellListIds that were collected
yield call(apiRemoveSpellsBySpellListIds, spellListIdsToRemove);
}
yield put(callCommitAction(characterActions.backgroundHasCustomSet, false));
const data = yield call(SagaHelpers.getApiRequestData, ApiRequests.putCharacterBackground, action.payload);
@@ -1,5 +1,6 @@
import { CreaturePane } from "~/subApps/sheet/components/Sidebar/panes/CreaturePane";
import { GameLogPane } from "~/subApps/sheet/components/Sidebar/panes/GameLogPane";
import { SpeciesTraitPane } from "~/subApps/sheet/components/Sidebar/panes/SpeciesTraitPane";
import BlessingPane from "~/tools/js/Shared/containers/panes/BlessingPane";
import CharacterSpellPane from "~/tools/js/Shared/containers/panes/CharacterSpellPane";
import ClassSpellPane from "~/tools/js/Shared/containers/panes/ClassSpellPane";
@@ -34,7 +35,6 @@ import { ShareUrlPane } from "~/tools/js/Shared/containers/panes/ShareUrlPane";
import ShortRestPane from "~/tools/js/Shared/containers/panes/ShortRestPane";
import SkillPane from "~/tools/js/Shared/containers/panes/SkillPane";
import SkillsPane from "~/tools/js/Shared/containers/panes/SkillsPane";
import SpeciesTraitPane from "~/tools/js/Shared/containers/panes/SpeciesTraitPane";
import SpeedManagePane from "~/tools/js/Shared/containers/panes/SpeedManagePane";
import SpellManagePane from "~/tools/js/Shared/containers/panes/SpellManagePane";
import StartingEquipmentPane from "~/tools/js/Shared/containers/panes/StartingEquipmentPane";
@@ -1,45 +1,27 @@
import {
FC,
HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
ApiAdapterUtils,
FeatManager,
} from "@dndbeyond/character-rules-engine";
import { FeatManager } from "@dndbeyond/character-rules-engine";
import { FeatureChoice } from "~/components/FeatureChoice";
import { BuilderChoiceTypeEnum } from "~/constants";
import { FeatureChoices } from "~/components/FeatureChoices";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { ClassFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { AppLoggerUtils, PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import {
Action,
CharClass,
ClassDefinitionContract,
ClassFeature,
Feat,
InfusionChoice,
Spell,
} from "~/types";
import { ClassFeatureSnippet } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { Heading } from "../../components/Heading";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneComponentEnum, PaneIdentifiersClassFeature } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersClassFeature | null;
@@ -61,8 +43,6 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
choiceUtils,
featUtils,
helperUtils,
} = useCharacterEngine();
@@ -76,23 +56,6 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const loadAvailableFeats = useSelector(
apiCreatorSelectors.makeLoadAvailableFeats
);
const loadAvailableSubclasses = useSelector(
apiCreatorSelectors.makeLoadAvailableSubclasses
);
const [subclassData, setSubclassData] = useState<
Array<ClassDefinitionContract>
>([]);
const [featsData, setFeatsData] = useState<Array<Feat>>([]);
const [featLoadingStatus, setFeatLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const [subclassLoadingStatus, setSubclassLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const currentCharClass = useRef<CharClass | null>(null);
const charClass = identifiers?.classMappingId
? classes.find(
(charClass) =>
@@ -105,98 +68,6 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
(feature) => identifiers?.id === classFeatureUtils.getId(feature)
) ?? null;
const featureChoices = classFeature
? classFeatureUtils.getChoices(classFeature)
: [];
const hasFeatChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
const hasSubclassChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.SUB_CLASS_OPTION
);
useEffect(() => {
const isSameClass = (
c1: CharClass | null,
c2: CharClass | null
): boolean => {
if (!c1 || !c2) return false;
return classUtils.getId(c1) === classUtils.getId(c2);
};
if (
hasFeatChoice &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
setFeatLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableFeats({
// cancelToken: new axios.CancelToken((c) => {
// this.loadFeatsCanceler = c;
// }),
})
.then((response) => {
let featsData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featsData = data.map((definition) =>
featUtils.simulateFeat(definition)
);
}
setFeatsData(featsData);
setFeatLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
charClass &&
!isSameClass(charClass, currentCharClass.current) && // Only pull data if the class has changed from previous fetch
hasSubclassChoice &&
subclassLoadingStatus !== DataLoadingStatusEnum.LOADING // Check if its not already fetching data
) {
currentCharClass.current = charClass; //Update ref of fetched class
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableSubclasses(classUtils.getId(charClass), {
// cancelToken: new axios.CancelToken((c) => {
// this.loadSubclassesCanceler = c;
// }),
})
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
setSubclassData(subclassData);
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadSubclassesCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
}, [
hasFeatChoice,
hasSubclassChoice,
subclassLoadingStatus,
featLoadingStatus,
charClass,
classUtils,
featUtils,
loadAvailableFeats,
loadAvailableSubclasses,
currentCharClass,
]);
const handleInfusionChoiceShow = (infusionChoice: InfusionChoice): void => {
const choiceKey = infusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
@@ -302,21 +173,6 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
);
};
const isDataLoaded = (): boolean => {
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
if (!charClass || !classFeature) {
return <PaneInitFailureContent />;
}
@@ -356,30 +212,13 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
onFeatClick={handleFeatShow}
featuresManager={characterFeaturesManager}
/>
{!isReadonly && featureChoices.length > 0 && (
<>
<Heading className={styles.heading}>{`Option${
featureChoices.length > 1 ? "s" : ""
}`}</Heading>
{isDataLoaded() ? (
featureChoices.map((choice) => (
<FeatureChoice
<FeatureChoices
choices={classFeatureUtils.getChoices(classFeature)}
charClass={charClass}
choice={choice}
feature={classFeature}
featsData={featsData}
subclassData={subclassData}
className={styles.choice}
key={choiceUtils.getId(choice)}
onChoiceChange={handleChoiceChange}
collapseDescription={true}
showHeading={true}
/>
))
) : (
<LoadingPlaceholder />
)}
</>
)}
</div>
);
};
@@ -1,18 +1,17 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Accordion } from "~/components/Accordion";
import { ChoiceUtils } from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { HtmlContent } from "~/components/HtmlContent";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { FeatFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg";
import { Action, Spell } from "~/types";
import { Header } from "../../components/Header";
@@ -38,6 +37,8 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
originRef: dataOriginRefData,
characterTheme: theme,
entityUtils,
entityRestrictionData,
choiceInfo,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
@@ -115,6 +116,18 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
}
};
const handleChoiceChange = (
id: string | null,
type: number,
value: any
): void => {
if (feat && id !== null) {
dispatch(
characterActions.featChoiceSetRequest(feat.getId(), type, id, value)
);
}
};
if (feat === null) {
return <PaneInitFailureContent />;
}
@@ -163,7 +176,31 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
/>
{!isReadonly && feat.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={feat.getId()} />
{feat.getChoices().map((choice) => {
const id = ChoiceUtils.getId(choice);
const { description, options: featChoiceOptions } =
ChoiceUtils.getSortedChoiceOptionsForFeatsInfo(
feat.getId(),
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>
)}
{!feat.isHiddenFeat() &&
@@ -1,9 +1,15 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch } from "react-redux";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import {
characterActions,
ChoiceUtils,
FeatManager,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
import styles from "./styles.module.css";
@@ -12,9 +18,28 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
}
export const FeatDetail: FC<Props> = ({ featManager, className, ...props }) => {
const dispatch = useDispatch();
const { ruleData, entityRestrictionData, choiceInfo } = useCharacterEngine();
const prerequisiteDescription = featManager.getPrerequisiteDescription();
const featDescription = featManager.getDescription();
const handleChoiceChange = (
id: string | null,
type: number,
value: any
): void => {
if (featManager && id !== null) {
dispatch(
characterActions.featChoiceSetRequest(
featManager.getId(),
type,
id,
value
)
);
}
};
return (
<div className={className} {...props}>
{prerequisiteDescription && (
@@ -27,7 +52,31 @@ export const FeatDetail: FC<Props> = ({ featManager, className, ...props }) => {
)}
{featManager.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={featManager.getId()} />
{featManager.getChoices().map((choice) => {
const id = ChoiceUtils.getId(choice);
const { description, options: featChoiceOptions } =
ChoiceUtils.getSortedChoiceOptionsForFeatsInfo(
featManager.getId(),
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>
)}
</div>
@@ -0,0 +1,193 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
Action,
ActionUtils,
characterActions,
FeatManager,
HelperUtils,
RaceUtils,
RacialTrait,
RacialTraitUtils,
Spell,
SpellUtils,
} from "@dndbeyond/character-rules-engine";
import { FeatureChoices } from "~/components/FeatureChoices";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import {
PaneComponentEnum,
PaneIdentifiersRacialTrait,
} from "~/subApps/sheet/components/Sidebar/types";
import { SpeciesTraitFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersRacialTrait | null;
}
export const SpeciesTraitPane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
race: species,
feats,
snippetData,
ruleData,
abilityLookup,
originRef: dataOriginRefData,
proficiencyBonus,
characterTheme: theme,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const {
pane: { paneHistoryPush, paneHistoryStart },
} = useSidebar();
const speciesTrait = species
? RaceUtils.getVisibleRacialTraits(species).find(
(trait) => identifiers?.id === RacialTraitUtils.getId(trait)
)
: null;
const handleActionUseSet = (action: Action, uses: number): void => {
const id = ActionUtils.getId(action);
const entityTypeId = ActionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
ActionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = SpellUtils.getMappingId(spell);
const mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
if (mappingId !== null && mappingEntityTypeId !== null) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
SpellUtils.getDataOriginType(spell)
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleSpeciesTraitShow = (speciesTrait: RacialTrait): void => {
paneHistoryStart(
PaneComponentEnum.SPECIES_TRAIT_DETAIL,
PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(speciesTrait)
)
);
};
const handleActionShow = (action: Action): void => {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleFeatShow = (feat: FeatManager): void => {
paneHistoryPush(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const handleChoiceChange = (
choiceId: string,
type: number,
value: any
): void => {
if (!species || !speciesTrait) {
return;
}
dispatch(
characterActions.racialTraitChoiceSetRequest(
RacialTraitUtils.getId(speciesTrait),
type,
choiceId,
HelperUtils.parseInputInt(value)
)
);
};
if (species === null || !speciesTrait) {
return <PaneInitFailureContent />;
}
const portrait = RaceUtils.getPortraitAvatarUrl(species);
return (
<div key={RacialTraitUtils.getId(speciesTrait)} {...props}>
<Header
parent={RaceUtils.getFullName(species)}
preview={portrait && <Preview imageUrl={portrait} />}
>
{RacialTraitUtils.getName(speciesTrait)}
</Header>
<SpeciesTraitFeatureSnippet
speciesTrait={speciesTrait}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellUseSet={handleSpellUseSet}
onSpellClick={handleSpellDetailShow}
onFeatureClick={handleSpeciesTraitShow}
showHeader={false}
showDescription={true}
feats={feats}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
onFeatClick={handleFeatShow}
featuresManager={characterFeaturesManager}
/>
<FeatureChoices
choices={RacialTraitUtils.getChoices(speciesTrait)}
onChoiceChange={handleChoiceChange}
collapseDescription={true}
showHeading={true}
/>
</div>
);
};
@@ -1,11 +1,8 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
CharacterPreferences,
CharClass,
@@ -18,10 +15,8 @@ import {
Constants,
DefinitionPool,
EntitledEntity,
Feat,
FeatDefinitionContract,
FeatLookup,
FeatUtils,
FormatUtils,
HelperUtils,
InfusionChoice,
@@ -35,20 +30,16 @@ import {
OptionalClassFeatureUtils,
PrerequisiteData,
RuleData,
RuleDataUtils,
SourceData,
TypeValueLookup,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { CollapsibleContent } from "~/components/CollapsibleContent";
import { FeatureChoice } from "~/components/FeatureChoice";
import { FeatureChoices } from "~/components/FeatureChoices";
import { Link } from "~/components/Link";
import { GrantedFeat } from "~/tools/js/CharacterBuilder/components/GrantedFeat/GrantedFeat";
import InfusionChoiceManager from "../../../../../Shared/components/InfusionChoiceManager";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils, TypeScriptUtils } from "../../../../../Shared/utils";
interface Props {
isActive: boolean;
@@ -122,13 +113,7 @@ interface Props {
inventoryManager: InventoryManager;
}
interface State {
featData: Array<Feat>;
subclassData: Array<ClassDefinitionContract>;
hasFeatChoice: boolean;
hasSubclassChoice: boolean;
collapsibleOpened: boolean;
featLoadingStatus: DataLoadingStatusEnum;
subclassLoadingStatus: DataLoadingStatusEnum;
}
export default class ClassManagerFeature extends React.PureComponent<
Props,
@@ -139,157 +124,16 @@ export default class ClassManagerFeature extends React.PureComponent<
featLookup: {},
};
loadFeatsCanceler: null | Canceler = null;
loadSubclassesCanceler: null | Canceler = null;
constructor(props: Props) {
super(props);
this.state = {
featData: [],
subclassData: [],
hasFeatChoice: false,
hasSubclassChoice: false,
collapsibleOpened: false,
featLoadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
subclassLoadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
};
}
componentDidMount(): void {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
hasSubclassChoice: this.hasSubclassChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
hasSubclassChoice = (): boolean => {
const { feature } = this.props;
const { collapsibleOpened } = this.state;
if (feature !== prevProps.feature) {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
hasSubclassChoice: this.hasSubclassChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
if (collapsibleOpened && !prevState.collapsibleOpened) {
this.conditionallyLoadFeatData();
}
}
conditionallyLoadFeatData = (): void => {
const { loadAvailableFeats, loadAvailableSubclasses, charClass } =
this.props;
const {
hasFeatChoice,
hasSubclassChoice,
featLoadingStatus,
subclassLoadingStatus,
collapsibleOpened,
} = this.state;
if (
hasFeatChoice &&
collapsibleOpened &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
this.setState({
featLoadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableFeats({
cancelToken: new axios.CancelToken((c) => {
this.loadFeatsCanceler = c;
}),
})
.then((response) => {
let featData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featData = data.map((definition) =>
FeatUtils.simulateFeat(definition)
);
}
this.setState({
featData,
featLoadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
hasSubclassChoice &&
collapsibleOpened &&
subclassLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
this.setState({
subclassLoadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableSubclasses(ClassUtils.getId(charClass), {
cancelToken: new axios.CancelToken((c) => {
this.loadSubclassesCanceler = c;
}),
})
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
this.setState({
subclassData,
subclassLoadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadSubclassesCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
};
componentWillUnmount(): void {
if (this.loadFeatsCanceler !== null) {
this.loadFeatsCanceler();
}
if (this.loadSubclassesCanceler !== null) {
this.loadSubclassesCanceler();
}
}
hasFeatChoice = (props: Props): boolean => {
const { feature } = props;
const choices = ClassFeatureUtils.getChoices(feature);
return choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
};
hasSubclassChoice = (props: Props): boolean => {
const { feature } = props;
const choices = ClassFeatureUtils.getChoices(feature);
return choices.some(
@@ -299,28 +143,6 @@ export default class ClassManagerFeature extends React.PureComponent<
);
};
isDataLoaded = (): boolean => {
const {
hasSubclassChoice,
hasFeatChoice,
subclassLoadingStatus,
featLoadingStatus,
} = this.state;
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
handleCollapsibleOpened = (id: string, state: boolean): void => {
this.setState({
collapsibleOpened: state,
@@ -436,46 +258,6 @@ export default class ClassManagerFeature extends React.PureComponent<
);
};
getSubclassSources = (
subclass: ClassDefinitionContract
): Array<SourceData> => {
const { ruleData } = this.props;
if (subclass.sources === null) {
return [];
}
return subclass.sources
.map((sourceMapping) =>
HelperUtils.lookupDataOrFallback(
RuleDataUtils.getSourceDataLookup(ruleData),
sourceMapping.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
};
getSubclassData = (): Array<ClassDefinitionContract> => {
const { subclassData } = this.state;
const { charClass } = this.props;
let data: Array<ClassDefinitionContract> = [...subclassData];
let existingSubclass = ClassUtils.getSubclass(charClass);
if (
existingSubclass !== null &&
!data.some(
(classDefinition) =>
existingSubclass !== null &&
classDefinition.id === existingSubclass.id
)
) {
data.push(existingSubclass);
}
return data;
};
getClassFeatureDescription = (): string => {
const { feature, isStartingClass } = this.props;
@@ -493,33 +275,6 @@ export default class ClassManagerFeature extends React.PureComponent<
return description === null ? "" : description;
};
renderChoices = (): React.ReactNode => {
const { isActive, feature, charClass } = this.props;
const { featData } = this.state;
if (!isActive) {
return null;
}
const choices = ClassFeatureUtils.getChoices(feature);
if (choices === null) {
return null;
}
return choices.map((choice) => (
<FeatureChoice
choice={choice}
charClass={charClass}
feature={feature}
featsData={featData}
subclassData={this.getSubclassData()}
onChoiceChange={this.handleChoiceChange}
key={ChoiceUtils.getId(choice)}
/>
));
};
getMetaItems = () => {
const { feature, optionalClassFeatureLookup, definitionPool } = this.props;
const choiceCount = this.getTotalChoiceCount();
@@ -590,7 +345,7 @@ export default class ClassManagerFeature extends React.PureComponent<
feature,
inventoryManager,
} = this.props;
const { hasSubclassChoice, hasFeatChoice } = this.state;
const { collapsibleOpened } = this.state;
// If the class feature grants feats, render those instead of the class feature.
if (feature.featLists.length > 0) {
@@ -604,22 +359,11 @@ export default class ClassManagerFeature extends React.PureComponent<
}
const conClsNames: Array<string> = ["class-manager-feature-name"];
let contentNode: React.ReactNode;
if (isActive) {
if (this.getTotalTodoCount() > 0) {
conClsNames.push("collapsible-todo");
}
if (hasFeatChoice || hasSubclassChoice) {
if (this.isDataLoaded()) {
contentNode = this.renderChoices();
} else {
contentNode = <LoadingPlaceholder />;
}
} else {
contentNode = this.renderChoices();
}
}
const hasChoices = this.getTotalTodoCount() > 0;
const accordionId = ClassFeatureUtils.getId(feature).toString();
@@ -636,13 +380,20 @@ export default class ClassManagerFeature extends React.PureComponent<
<CollapsibleContent className="class-manager-feature-description">
{this.getClassFeatureDescription()}
</CollapsibleContent>
{isActive && hasSubclassChoice && (
{isActive && this.hasSubclassChoice() && (
<div className="ct-character-tools__marketplace-callout">
Looking for something not in the list below? Unlock all official
options in the <Link href="/marketplace">Marketplace</Link>.
</div>
)}
{contentNode}
{isActive && (
<FeatureChoices
choices={ClassFeatureUtils.getChoices(feature)}
charClass={charClass}
onChoiceChange={this.handleChoiceChange}
shouldFetch={collapsibleOpened}
/>
)}
{isActive && (
<InfusionChoiceManager
infusionChoices={this.getAvailableInfusionChoices()}
@@ -585,7 +585,6 @@ class DescriptionManage extends React.PureComponent<Props, State> {
handleChoiceChange = (
choiceId: string,
choiceType: number,
choiceSubType: number | null,
optionValue: any
): void => {
const { dispatch } = this.props;
@@ -1,11 +1,8 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
CharacterPreferences,
ChoiceData,
@@ -13,10 +10,8 @@ import {
Constants,
CoreUtils,
DefinitionPool,
Feat,
FeatDefinitionContract,
FeatLookup,
FeatUtils,
HelperUtils,
OptionalOriginLookup,
OptionalOriginUtils,
@@ -26,12 +21,9 @@ import {
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { FeatureChoice } from "~/components/FeatureChoice";
import { FeatureChoices } from "~/components/FeatureChoices";
import { HtmlContent } from "~/components/HtmlContent";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils } from "../../../../../Shared/utils";
interface Props {
speciesTrait: RacialTrait;
choiceInfo: ChoiceData;
@@ -52,10 +44,7 @@ interface Props {
speciesName?: string | null;
}
interface State {
featData: Array<Feat>;
hasFeatChoice: boolean;
collapsibleOpened: boolean;
loadingStatus: DataLoadingStatusEnum;
}
export default class SpeciesManageSpeciesTrait extends React.PureComponent<
Props,
@@ -65,115 +54,10 @@ export default class SpeciesManageSpeciesTrait extends React.PureComponent<
super(props);
this.state = {
featData: [],
hasFeatChoice: false,
collapsibleOpened: false,
loadingStatus: DataLoadingStatusEnum.NOT_INITIALIZED,
};
}
loadFeatsCanceler: null | Canceler = null;
componentDidMount(): void {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { speciesTrait } = this.props;
const { collapsibleOpened } = this.state;
if (speciesTrait !== prevProps.speciesTrait) {
this.setState(
{
hasFeatChoice: this.hasFeatChoice(this.props),
},
() => {
this.conditionallyLoadFeatData();
}
);
}
if (collapsibleOpened && !prevState.collapsibleOpened) {
this.conditionallyLoadFeatData();
}
}
componentWillUnmount(): void {
if (this.loadFeatsCanceler !== null) {
this.loadFeatsCanceler();
}
}
conditionallyLoadFeatData = (): void => {
const { loadAvailableFeats } = this.props;
const { hasFeatChoice, loadingStatus, collapsibleOpened } = this.state;
if (
hasFeatChoice &&
collapsibleOpened &&
loadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableFeats({
cancelToken: new axios.CancelToken((c) => {
this.loadFeatsCanceler = c;
}),
})
.then((response) => {
let featData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featData = data.map((definition) =>
FeatUtils.simulateFeat(definition)
);
}
this.setState({
featData,
loadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
};
getFeatData = (existingFeatId: number | null): Array<Feat> => {
const { featData } = this.state;
const { featLookup } = this.props;
let data: Array<Feat> = [...featData];
if (existingFeatId !== null) {
let existingFeat = HelperUtils.lookupDataOrFallback(
featLookup,
existingFeatId
);
if (
existingFeat !== null &&
!data.some((feat) => FeatUtils.getId(feat) === existingFeatId)
) {
data.push(existingFeat);
}
}
return data;
};
getSummary = () => {
const { speciesTrait, speciesName } = this.props;
// Get base name for the species trait
@@ -277,29 +161,9 @@ export default class SpeciesManageSpeciesTrait extends React.PureComponent<
}
};
renderChoices = (): React.ReactNode => {
const { speciesTrait } = this.props;
const { featData } = this.state;
const choices = RacialTraitUtils.getChoices(speciesTrait);
if (choices === null) {
return null;
}
return choices.map((choice) => (
<FeatureChoice
choice={choice}
featsData={featData}
onChoiceChange={this.handleChoiceChange}
key={ChoiceUtils.getId(choice)}
/>
));
};
render() {
const { speciesTrait } = this.props;
const { hasFeatChoice, loadingStatus } = this.state;
const { collapsibleOpened } = this.state;
const description = RacialTraitUtils.getDescription(speciesTrait);
const choices = RacialTraitUtils.getChoices(speciesTrait);
@@ -314,17 +178,6 @@ export default class SpeciesManageSpeciesTrait extends React.PureComponent<
conClsNames.push("collapsible-todo");
}
let contentNode: React.ReactNode;
if (hasFeatChoice) {
if (loadingStatus === DataLoadingStatusEnum.LOADED) {
contentNode = this.renderChoices();
} else {
contentNode = <LoadingPlaceholder />;
}
} else {
contentNode = this.renderChoices();
}
return (
<Accordion
id={accordionId}
@@ -339,7 +192,11 @@ export default class SpeciesManageSpeciesTrait extends React.PureComponent<
html={description ? description : ""}
withoutTooltips
/>
{contentNode}
<FeatureChoices
choices={RacialTraitUtils.getChoices(speciesTrait)}
onChoiceChange={this.handleChoiceChange}
shouldFetch={collapsibleOpened}
/>
</Accordion>
);
}
@@ -64,12 +64,19 @@ export default class ClassFeatureSnippet extends React.PureComponent<Props> {
};
renderDescription = (): React.ReactNode => {
const { feature, showDescription } = this.props;
const { feature, showDescription, charClass } = this.props;
const isStartingClass = ClassUtils.isStartingClass(charClass);
const multiClassDescription =
ClassFeatureUtils.getMultiClassDescription(feature);
// If the class feature is accessible, show the description or multi-class description, otherwise show a "Check out the Marketplace" message.
const description: string | null = AccessUtils.isAccessible(
ClassFeatureUtils.getAccessType(feature)
)
? ClassFeatureUtils.getDescription(feature)
? !isStartingClass && multiClassDescription
? multiClassDescription
: ClassFeatureUtils.getDescription(feature)
: "Check out the Marketplace to unlock this Class Feature.";
return showDescription
@@ -1,5 +1,5 @@
import React from "react";
import { RacialTraitAccessors } from "@dndbeyond/character-rules-engine/es/engine/RacialTrait";
import {
AbilityLookup,
BaseFeat,
@@ -16,7 +16,6 @@ import {
CharacterFeaturesManager,
} from "@dndbeyond/character-rules-engine/es";
import { DataOriginTypeEnum } from "~/constants";
import { FeatureSnippet } from "~/subApps/sheet/components/FeatureSnippet";
@@ -83,7 +82,7 @@ export default class SpeciesTraitFeatureSnippet extends React.PureComponent<Prop
const feats = featuresManager.getDataOriginOnlyFeatsByPrimary(
DataOriginTypeEnum.RACE,
`${RacialTraitAccessors.getId(speciesTrait)}`
`${RacialTraitUtils.getId(speciesTrait)}`
);
return (
@@ -383,18 +383,38 @@ export class ItemDetail extends React.PureComponent<Props> {
/>
</div>
)}
{replacementWeaponStats.map((statId) => (
<div className="ct-item-detail__class-customize-item" key={statId}>
{replacementWeaponStats.map((replacementInfo) => (
<div
className="ct-item-detail__class-customize-item"
key={`${replacementInfo.statId}-${replacementInfo.dataOrigin.primary.definition.id}`}
>
<Checkbox
enabled={appliedWeaponReplacementStats.includes(statId)}
enabled={appliedWeaponReplacementStats.includes(
replacementInfo.statId
)}
onChange={(enabled) =>
this.handleReplaceAbilityStatChange(enabled, statId)
this.handleReplaceAbilityStatChange(
enabled,
replacementInfo.statId
)
}
label={`Use ${RuleDataUtils.getStatNameById(
statId,
label={
<>
Use{" "}
{RuleDataUtils.getStatNameById(
replacementInfo.statId,
ruleData,
true
)}`}
)}{" "}
<span className={styles.origin}>
(<span className={styles.originLabel}>From</span>
<span className={styles.originName}>
{replacementInfo.dataOrigin.primary.definition.name}
</span>
)
</span>
</>
}
/>
</div>
))}
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { FC, ReactNode } from "react";
import {
@@ -18,6 +19,7 @@ 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
// TODO scss to styles.module.css
interface Props extends Omit<BuilderChoiceContract, "options"> {
choice: Choice;
description?: string;
@@ -31,7 +33,6 @@ interface Props extends Omit<BuilderChoiceContract, "options"> {
onChange?: (
id: string,
type: number,
subType: number | null,
value: any,
parentChoiceId: string | null
) => void;
@@ -61,12 +62,17 @@ export const DetailChoice: FC<Props> = ({
const { entityRestrictionData, ruleData } = useCharacterEngine();
const { languages } = useRuleData();
const hasTodo =
(ChoiceUtils.isInfinite(choice) && !ChoiceUtils.isOptionSelected(choice)) ||
ChoiceUtils.isTodo(choice);
const handleChoiceChange = (value: string): void => {
if (onChange && id !== null) {
onChange(id, type, subType, value, parentChoiceId);
onChange(id, type, value, parentChoiceId);
}
};
//Don't render the choice if hideWhenOnlyDefaultSelected is true and the choice is only default selected.
if (
hideWhenOnlyDefaultSelected &&
ChoiceUtils.isOnlyDefaultSelected(choice)
@@ -74,14 +80,6 @@ export const DetailChoice: FC<Props> = ({
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;
@@ -94,10 +92,6 @@ export const DetailChoice: FC<Props> = ({
choiceDescription = description || chosenOption["description"];
}
if (parentChoiceId !== null) {
classNames.push("detail-choice--child");
}
let choiceNode: ReactNode;
if (choiceDescription) {
switch (type) {
@@ -142,7 +136,14 @@ export const DetailChoice: FC<Props> = ({
}
return (
<div className={classNames.join(" ")}>
<div
className={clsx([
"detail-choice",
hasTodo && "detail-choice--todo",
parentChoiceId !== null && "detail-choice--child",
className,
])}
>
<Select
className="detail-choice-input"
options={ChoiceUtils.getSortedRenderOptions(
@@ -1,77 +0,0 @@
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>
);
};
@@ -1,267 +0,0 @@
import React, { useContext } from "react";
import { connect, DispatchProp } from "react-redux";
import {
AbilityLookup,
Action,
ActionUtils,
BaseFeat,
characterActions,
CharacterFeaturesManager,
CharacterTheme,
DataOriginRefData,
FeatManager,
Race,
RaceUtils,
RacialTrait,
RacialTraitUtils,
RuleData,
rulesEngineSelectors,
SnippetData,
Spell,
SpellUtils,
} 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 { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import {
PaneComponentEnum,
PaneIdentifiersRacialTrait,
} from "~/subApps/sheet/components/Sidebar/types";
import { PaneInitFailureContent } from "../../../../../../subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { SpeciesTraitFeatureSnippet } from "../../../../CharacterSheet/components/FeatureSnippet";
import { CharacterFeaturesManagerContext } from "../../../managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "../../../selectors";
import { SharedAppState } from "../../../stores/typings";
import { PaneIdentifierUtils } from "../../../utils";
interface Props extends DispatchProp {
species: Race | null;
identifiers: PaneIdentifiersRacialTrait | null;
feats: Array<BaseFeat>;
snippetData: SnippetData;
ruleData: RuleData;
abilityLookup: AbilityLookup;
dataOriginRefData: DataOriginRefData;
isReadonly: boolean;
proficiencyBonus: number;
theme: CharacterTheme;
characterFeaturesManager: CharacterFeaturesManager;
paneContext: PaneInfo;
}
interface State {
speciesTrait: RacialTrait | null;
}
class SpeciesTraitPane extends React.PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = this.generateStateData(props);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { species, identifiers } = this.props;
if (
species !== prevProps.species ||
identifiers !== prevProps.identifiers
) {
this.setState(this.generateStateData(this.props));
}
}
generateStateData = (props: Props): State => {
const { species, identifiers } = props;
let foundSpeciesTrait: RacialTrait | null | undefined = null;
if (species !== null && identifiers !== null) {
foundSpeciesTrait = RaceUtils.getVisibleRacialTraits(species).find(
(speciesTrait) =>
identifiers.id === RacialTraitUtils.getId(speciesTrait)
);
}
return {
speciesTrait: foundSpeciesTrait ? foundSpeciesTrait : null,
};
};
handleActionUseSet = (action: Action, uses: number): void => {
const { dispatch } = this.props;
const id = ActionUtils.getId(action);
const entityTypeId = ActionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
ActionUtils.getDataOriginType(action)
)
);
}
};
handleSpellUseSet = (spell: Spell, uses: number): void => {
const { dispatch } = this.props;
const mappingId = SpellUtils.getMappingId(spell);
const mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
if (mappingId !== null && mappingEntityTypeId !== null) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
SpellUtils.getDataOriginType(spell)
)
);
}
};
handleSpellDetailShow = (spell: Spell): void => {
const {
paneContext: { paneHistoryPush },
} = this.props;
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
handleSpeciesTraitShow = (speciesTrait: RacialTrait): void => {
const {
paneContext: { paneHistoryStart },
} = this.props;
paneHistoryStart(
PaneComponentEnum.SPECIES_TRAIT_DETAIL,
PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(speciesTrait)
)
);
};
handleActionShow = (action: Action): void => {
const {
paneContext: { paneHistoryPush },
} = this.props;
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
handleFeatShow = (feat: FeatManager): void => {
const {
paneContext: { paneHistoryPush },
} = this.props;
paneHistoryPush(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
render() {
const { speciesTrait } = this.state;
const {
species,
feats,
abilityLookup,
snippetData,
ruleData,
dataOriginRefData,
isReadonly,
proficiencyBonus,
theme,
characterFeaturesManager,
} = this.props;
if (species === null || speciesTrait === null) {
return <PaneInitFailureContent />;
}
let fullName = RaceUtils.getFullName(species);
let portrait = RaceUtils.getPortraitAvatarUrl(species);
return (
<div
className="ct-racial-trait-pane"
key={RacialTraitUtils.getId(speciesTrait)}
>
<Header
parent={fullName}
preview={portrait && <Preview imageUrl={portrait} />}
>
{RacialTraitUtils.getName(speciesTrait)}
</Header>
<SpeciesTraitFeatureSnippet
speciesTrait={speciesTrait}
onActionUseSet={this.handleActionUseSet}
onActionClick={this.handleActionShow}
onSpellUseSet={this.handleSpellUseSet}
onSpellClick={this.handleSpellDetailShow}
onFeatureClick={this.handleSpeciesTraitShow}
showHeader={false}
showDescription={true}
feats={feats}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
onFeatClick={this.handleFeatShow}
featuresManager={characterFeaturesManager}
/>
</div>
);
}
}
function mapStateToProps(state: SharedAppState) {
return {
species: rulesEngineSelectors.getRace(state),
feats: rulesEngineSelectors.getBaseFeats(state),
snippetData: rulesEngineSelectors.getSnippetData(state),
ruleData: rulesEngineSelectors.getRuleData(state),
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
};
}
const SpeciesTraitPaneWrapper = (props) => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const { pane } = useSidebar();
return (
<SpeciesTraitPane
characterFeaturesManager={characterFeaturesManager}
paneContext={pane}
{...props}
/>
);
};
export default connect(mapStateToProps)(SpeciesTraitPaneWrapper);
@@ -1,4 +0,0 @@
import SpeciesTraitPane from "./SpeciesTraitPane";
export default SpeciesTraitPane;
export { SpeciesTraitPane };
@@ -15,7 +15,7 @@ interface Props {
accept: () => void,
reject: () => void
) => void;
label?: string;
label?: React.ReactNode;
preventDefault: boolean;
stopPropagation: boolean;
isInteractive?: boolean;
@@ -0,0 +1,2 @@
// extracted by mini-css-extract-plugin
export default {"choice":"styles_choice__qE4aQ","heading":"styles_heading__qlmfe"};
@@ -1,2 +0,0 @@
// extracted by mini-css-extract-plugin
export default {"choice":"styles_choice__eGzfJ","heading":"styles_heading__IAIav"};
@@ -1,2 +1,2 @@
// extracted by mini-css-extract-plugin
export default {"action":"styles_action__Q1OAQ","label":"styles_label__-pkXo"};
export default {"action":"styles_action__Q1OAQ","label":"styles_label__-pkXo","origin":"styles_origin__9Ke5E","originLabel":"styles_originLabel__Zwjnz","originName":"styles_originName__F2eRI"};