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,358 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
EntitledEntity,
RacialTraitDefinitionContract,
DefinitionPool,
DefinitionPoolUtils,
Constants,
DefinitionUtils,
RacialTrait,
RacialTraitUtils,
OptionalOrigin,
Race,
RaceUtils,
OptionalOriginUtils,
OptionalOriginLookup,
HelperUtils,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils, TypeScriptUtils } from "../../../../../Shared/utils";
import { OptionalFeature } from "../../../../components/OptionalFeature";
import { AffectedFeatureInfo } from "../../../../components/OptionalFeature/OptionalFeature";
import { PageSubHeader } from "../../../../components/PageSubHeader";
interface OptionalOriginManagerProps {
species: Race;
definitionPool: DefinitionPool;
optionalOriginLookup: OptionalOriginLookup;
loadAvailableOptionalSpeciesTraits?: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<RacialTraitDefinitionContract>>
>;
onDefinitionsLoaded?: (
definitionData: EntitledEntity<RacialTraitDefinitionContract>
) => void;
onSelection: (
definitionKey: string,
affectedSpeciesTraitDefinitionKey: string | null
) => void;
onChangeReplacementPromise?: (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
) => void;
onRemoveSelectionPromise?: (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
ruleData: RuleData;
}
interface OptionalOriginManagerState {
loadingStatus: DataLoadingStatusEnum;
}
export class OptionalOriginManager extends React.PureComponent<
OptionalOriginManagerProps,
OptionalOriginManagerState
> {
loadOptionalOriginsCanceler: null | Canceler = null;
constructor(props: OptionalOriginManagerProps) {
super(props);
this.state = {
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
};
}
componentDidMount() {
const { loadAvailableOptionalSpeciesTraits, onDefinitionsLoaded } =
this.props;
const { loadingStatus } = this.state;
if (
loadAvailableOptionalSpeciesTraits &&
loadingStatus === DataLoadingStatusEnum.NOT_LOADED
) {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADING,
});
loadAvailableOptionalSpeciesTraits({
cancelToken: new axios.CancelToken((c) => {
this.loadOptionalOriginsCanceler = c;
}),
})
.then((response) => {
let data = ApiAdapterUtils.getResponseData(response);
if (data?.definitionData.length && onDefinitionsLoaded) {
onDefinitionsLoaded(data);
}
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
this.loadOptionalOriginsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
this.setState({
loadingStatus: DataLoadingStatusEnum.LOADED,
});
}
}
componentWillUnmount(): void {
if (this.loadOptionalOriginsCanceler !== null) {
this.loadOptionalOriginsCanceler();
}
}
getAffectedFeatureInfos = (origin: RacialTrait): AffectedFeatureInfo[] => {
const { definitionPool, species } = this.props;
return RacialTraitUtils.getAffectedFeatureDefinitionKeys(origin)
.map((definitionKey: string) =>
RacialTraitUtils.simulateRacialTrait(definitionKey, definitionPool)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.map((affectedSpeciesTrait: RacialTrait) => {
const definitionKey =
RacialTraitUtils.getDefinitionKey(affectedSpeciesTrait);
let disabled = RaceUtils.getOptionalOrigins(species).some(
(originMapping: OptionalOrigin) =>
OptionalOriginUtils.getDefinitionKey(originMapping) !==
RacialTraitUtils.getDefinitionKey(origin) &&
OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
originMapping
) === definitionKey
);
return {
name: RacialTraitUtils.getName(affectedSpeciesTrait),
definitionKey,
disabled,
};
});
};
renderOptionalOriginCta = (): React.ReactNode => {
return (
<div className="ct-optional-origin-manager__content">
<div className="ct-optional-origin-manager__content--empty">
<span>
You currently have no available custom origins for this character
</span>
<div className="ct-character-tools__marketplace-callout">
Unlock all official options in the{" "}
<Link href="/marketplace">Marketplace</Link> or create them for{" "}
<Link href="/my-creations">Homebrew</Link> Species.
</div>
</div>
</div>
);
};
render() {
const { loadingStatus } = this.state;
const {
definitionPool,
species,
optionalOriginLookup,
onChangeReplacementPromise,
onRemoveSelectionPromise,
onSelection,
} = this.props;
let contentNode: React.ReactNode;
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
contentNode = <LoadingPlaceholder />;
} else {
let availableOptionalOrigins: RacialTrait[] =
DefinitionPoolUtils.getTypedDefinitionList(
Constants.DefinitionTypeEnum.RACIAL_TRAIT,
definitionPool
)
.map((speciesTraitDef) =>
RacialTraitUtils.simulateRacialTrait(
DefinitionUtils.getDefinitionKey(speciesTraitDef),
definitionPool
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined)
.filter((speciesTrait) =>
RacialTraitUtils.isValidRaceRacialTrait(species, speciesTrait)
)
.filter(
(speciesTrait) =>
RacialTraitUtils.getFeatureType(speciesTrait) !==
Constants.FeatureTypeEnum.GRANTED
);
let availableOptionalOriginLookup = HelperUtils.generateNonNullLookup(
availableOptionalOrigins,
RacialTraitUtils.getDefinitionKey
);
const unEntitledOptionalSpeciesTraits: RacialTrait[] = [];
Object.keys(optionalOriginLookup).forEach((definitionKey) => {
if (!availableOptionalOriginLookup.hasOwnProperty(definitionKey)) {
const speciesTrait = RacialTraitUtils.simulateRacialTrait(
definitionKey,
definitionPool
);
if (
speciesTrait &&
RacialTraitUtils.isValidRaceRacialTrait(species, speciesTrait)
) {
unEntitledOptionalSpeciesTraits.push(speciesTrait);
}
}
});
const consolidatedOptionalOrigins: RacialTrait[] = [
...availableOptionalOrigins,
...unEntitledOptionalSpeciesTraits,
].filter(
(speciesTrait) =>
!RacialTraitUtils.deriveHideInContext(
speciesTrait,
Constants.AppContextTypeEnum.BUILDER
)
);
if (!consolidatedOptionalOrigins.length) {
contentNode = this.renderOptionalOriginCta();
} else {
const replacementOrigins: RacialTrait[] = [];
const additionalOrigins: RacialTrait[] = [];
consolidatedOptionalOrigins.forEach((speciesTrait) => {
switch (RacialTraitUtils.getFeatureType(speciesTrait)) {
case Constants.FeatureTypeEnum.ADDITIONAL:
additionalOrigins.push(speciesTrait);
break;
case Constants.FeatureTypeEnum.REPLACEMENT:
replacementOrigins.push(speciesTrait);
break;
default:
//not implemented
}
});
contentNode = (
<div className="ct-optional-origin-manager__content">
{replacementOrigins.length > 0 && (
<React.Fragment>
<PageSubHeader>Replacement Traits</PageSubHeader>
{RaceUtils.deriveOrderedRacialTraits(replacementOrigins).map(
(origin: RacialTrait) => {
const optionalOriginMapping =
HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
RacialTraitUtils.getDefinitionKey(origin)
);
const affectedFeatureDefinitionKey = optionalOriginMapping
? OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
optionalOriginMapping
)
: null;
return (
<OptionalFeature
key={RacialTraitUtils.getDefinitionKey(origin)}
name={`Origin ${RacialTraitUtils.getName(origin)}`}
description={RacialTraitUtils.getDescription(origin)}
featureType={RacialTraitUtils.getFeatureType(origin)}
definitionKey={RacialTraitUtils.getDefinitionKey(
origin
)}
affectedFeatureDefinitionKey={
affectedFeatureDefinitionKey
}
isSelected={!!optionalOriginMapping ?? false}
onSelection={onSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
affectedFeatures={this.getAffectedFeatureInfos(origin)}
accessType={RacialTraitUtils.getAccessType(origin)}
/>
);
}
)}
</React.Fragment>
)}
{additionalOrigins.length > 0 && (
<React.Fragment>
<PageSubHeader>Additional Traits</PageSubHeader>
{RaceUtils.deriveOrderedRacialTraits(additionalOrigins).map(
(origin: RacialTrait) => {
const optionalOriginMapping =
HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
RacialTraitUtils.getDefinitionKey(origin)
);
const affectedFeatureDefinitionKey = optionalOriginMapping
? OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
optionalOriginMapping
)
: null;
return (
<OptionalFeature
key={RacialTraitUtils.getDefinitionKey(origin)}
name={RacialTraitUtils.getName(origin)}
description={RacialTraitUtils.getDescription(origin)}
featureType={RacialTraitUtils.getFeatureType(origin)}
definitionKey={RacialTraitUtils.getDefinitionKey(
origin
)}
affectedFeatureDefinitionKey={
affectedFeatureDefinitionKey
}
isSelected={!!optionalOriginMapping ?? false}
onSelection={onSelection}
onChangeReplacementPromise={onChangeReplacementPromise}
onRemoveSelectionPromise={onRemoveSelectionPromise}
affectedFeatures={this.getAffectedFeatureInfos(origin)}
accessType={RacialTraitUtils.getAccessType(origin)}
/>
);
}
)}
</React.Fragment>
)}
</div>
);
}
}
return (
<div className="ct-optional-origin-manager">
<div className="ct-optional-origin-manager__intro">
The following options allow you to customize aspects of your character
such as ability scores, languages, and certain proficiencies to fit
the origin you have in mind.
</div>
{contentNode}
</div>
);
}
}
export default OptionalOriginManager;
@@ -0,0 +1,541 @@
import React from "react";
import { DispatchProp } from "react-redux";
import { NavigateFunction, useNavigate } from "react-router-dom";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiResponse,
characterActions,
CharacterPreferences,
ChoiceData,
ClassSpellListSpellsLookup,
Constants,
DefinitionPool,
DefinitionUtils,
EntitledEntity,
FeatDefinitionContract,
FeatLookup,
HelperUtils,
OptionalOriginLookup,
OptionalOriginUtils,
PrerequisiteData,
Race,
RaceUtils,
RacialTraitDefinitionContract,
RacialTraitUtils,
RuleData,
RuleDataUtils,
rulesEngineSelectors,
serviceDataActions,
serviceDataSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { HtmlContent } from "~/components/HtmlContent";
import { SummaryList } from "~/components/SummaryList";
import { TabList } from "~/components/TabList";
import { RouteKey } from "~/subApps/builder/constants";
import {
ModalData,
useModalManager,
} from "~/subApps/builder/contexts/ModalManager";
import { SimpleClassSpellList } from "../../../../Shared/components/SimpleClassSpellList";
import { apiCreatorSelectors } from "../../../../Shared/selectors";
import Page from "../../../components/Page";
import { PageBody } from "../../../components/PageBody";
import PageHeader from "../../../components/PageHeader";
import SpeciesTraitList from "../../../components/SpeciesTraitList";
import { navigationConfig } from "../../../config";
import ConnectedBuilderPage from "../ConnectedBuilderPage";
import { OptionalOriginManager } from "./OptionalOriginManager";
import styles from "./styles.module.css";
interface Props extends DispatchProp {
ruleData: RuleData;
preferences: CharacterPreferences;
prerequisiteData: PrerequisiteData;
choiceInfo: ChoiceData;
species: Race | null;
featLookup: FeatLookup;
definitionPool: DefinitionPool;
optionalOriginLookup: OptionalOriginLookup;
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
navigate: NavigateFunction;
characterId: number;
loadAvailableOptionalSpeciesTraits: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<
ApiResponse<EntitledEntity<RacialTraitDefinitionContract>>
>;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<FeatDefinitionContract[]>>;
createModal: (modal: ModalData) => void;
}
class SpeciesManage extends React.PureComponent<Props> {
handleChangeSpecies = (): void => {
const { navigate, characterId } = this.props;
navigate(
navigationConfig
.getRouteDefPath(RouteKey.RACE_CHOOSE)
.replace(":characterId", characterId)
);
};
handleSpeciesTraitChoiceChange = (
speciesTraitId,
choiceId,
choiceType,
optionValue
): void => {
const { dispatch } = this.props;
dispatch(
characterActions.racialTraitChoiceSetRequest(
speciesTraitId,
choiceType,
choiceId,
optionValue
)
);
};
handleDefinitionsLoaded = (
entitledData: EntitledEntity<RacialTraitDefinitionContract>
): void => {
const { dispatch } = this.props;
dispatch(
serviceDataActions.definitionPoolAdd(
entitledData.definitionData,
entitledData.accessTypes
)
);
};
handleOptionalOriginSelection = (
definitionKey: string,
affectedSpeciesTraitDefinitionKey: string | null
): void => {
const { dispatch } = this.props;
const speciesTraitId =
DefinitionUtils.hack__getDefinitionKeyId(definitionKey);
if (speciesTraitId === null) {
return;
}
const affectedSpeciesTraitId = affectedSpeciesTraitDefinitionKey
? DefinitionUtils.hack__getDefinitionKeyId(
affectedSpeciesTraitDefinitionKey
)
: null;
dispatch(
characterActions.optionalOriginCreate(
speciesTraitId,
affectedSpeciesTraitId
)
);
};
handleRemoveOptionalOriginSelectionPromise = (
definitionKey: string,
newIsEnabled: boolean,
accept: () => void,
reject: () => void
): void => {
const {
dispatch,
createModal,
optionalOriginLookup,
classSpellListSpellsLookup,
} = this.props;
const optionalOrigin = HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
definitionKey
);
if (optionalOrigin === null) {
return;
}
const optionalSpeciesTrait =
OptionalOriginUtils.getRacialTrait(optionalOrigin);
if (optionalSpeciesTrait === null) {
return;
}
const speciesTraitId = OptionalOriginUtils.getRacialTraitId(optionalOrigin);
const spellListIds =
OptionalOriginUtils.getRemoveMappingSpellListIds(optionalOrigin);
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
dispatch(characterActions.optionalOriginDestroy(speciesTraitId));
accept();
} else {
createModal({
content: (
<div>
<p>
You are about to remove{" "}
<strong>{RacialTraitUtils.getName(optionalSpeciesTrait)}</strong>{" "}
from your character.
</p>
<p>
After doing so, the following spells provided by this feature will
be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Customized Origin Warning",
variant: "remove",
size: "fit-content",
confirmButtonText: "Remove",
onConfirm: () => {
dispatch(characterActions.optionalOriginDestroy(speciesTraitId));
accept();
},
onClose: () => {
reject();
},
},
});
}
};
handleOnChangeOptionalOriginReplacementPromise = (
definitionKey: string,
newAffectedDefinitionKey: string | null,
oldAffectedDefinitionKey: string | null,
accept: () => void,
reject: () => void
): void => {
const {
dispatch,
createModal,
optionalOriginLookup,
classSpellListSpellsLookup,
} = this.props;
if (newAffectedDefinitionKey === oldAffectedDefinitionKey) {
return;
}
const optionalOrigin = HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
definitionKey
);
if (optionalOrigin === null) {
return;
}
const optionalSpeciesTrait =
OptionalOriginUtils.getRacialTrait(optionalOrigin);
if (optionalSpeciesTrait === null) {
return;
}
const speciesTraitId = OptionalOriginUtils.getRacialTraitId(optionalOrigin);
const newAffectedSpeciesTraitId: number | null = newAffectedDefinitionKey
? DefinitionUtils.hack__getDefinitionKeyId(newAffectedDefinitionKey)
: null;
const spellListIds =
OptionalOriginUtils.getUpdateMappingSpellListIdsToRemove(optionalOrigin, {
affectedRacialTraitId: newAffectedSpeciesTraitId,
});
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
dispatch(
characterActions.optionalOriginSetRequest(
speciesTraitId,
newAffectedSpeciesTraitId
)
);
accept();
} else {
createModal({
content: (
<div>
<p>
You are about to change the Species Trait to be replaced by{" "}
<strong>{RacialTraitUtils.getName(optionalSpeciesTrait)}</strong>
</p>
<p>
After doing so, the following spells provided by this feature will
be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Customized Origin Warning",
variant: "remove",
size: "fit-content",
onConfirm: () => {
dispatch(
characterActions.optionalOriginSetRequest(
speciesTraitId,
newAffectedSpeciesTraitId
)
);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
renderSpeciesTraitsGroup = (): React.ReactNode => {
const {
species,
loadAvailableFeats,
choiceInfo,
prerequisiteData,
preferences,
featLookup,
optionalOriginLookup,
definitionPool,
} = this.props;
if (!species) {
return null;
}
const speciesTraits = RaceUtils.getVisibleRacialTraits(species);
const filteredSpeciesTraits =
RacialTraitUtils.filterRacialTraitsByDisplayConfigurationType(
speciesTraits,
[
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT,
Constants.DisplayConfigurationTypeEnum.LANGUAGE,
]
);
return (
<div className="race-detail-secondary">
{filteredSpeciesTraits.length > 0 && (
<div className="race-detail-racial-traits">
<SpeciesTraitList
speciesTraits={filteredSpeciesTraits}
featLookup={featLookup}
loadAvailableFeats={loadAvailableFeats}
handleSpeciesTraitChoiceChange={
this.handleSpeciesTraitChoiceChange
}
choiceInfo={choiceInfo}
prerequisiteData={prerequisiteData}
preferences={preferences}
optionalOriginLookup={optionalOriginLookup}
definitionPool={definitionPool}
/>
</div>
)}
</div>
);
};
renderSpeciesTraitsContent = () => {
const { preferences } = this.props;
if (preferences.enableOptionalOrigins) {
return this.renderTabList();
}
return this.renderSpeciesTraitsGroup();
};
renderTabList = () => {
const {
species,
optionalOriginLookup,
loadAvailableOptionalSpeciesTraits,
definitionPool,
ruleData,
} = this.props;
if (!species) {
return null;
}
return (
<TabList
className="manage-race-tabs"
variant="collapse"
tabs={[
{
label: "Species Traits",
content: this.renderSpeciesTraitsGroup(),
id: "traits",
},
{
label: "Origin Manager",
content: (
<OptionalOriginManager
species={species}
definitionPool={definitionPool}
optionalOriginLookup={optionalOriginLookup}
onDefinitionsLoaded={this.handleDefinitionsLoaded}
loadAvailableOptionalSpeciesTraits={
loadAvailableOptionalSpeciesTraits
}
onSelection={this.handleOptionalOriginSelection}
onChangeReplacementPromise={
this.handleOnChangeOptionalOriginReplacementPromise
}
onRemoveSelectionPromise={
this.handleRemoveOptionalOriginSelectionPromise
}
ruleData={ruleData}
/>
),
id: "Origin Manager",
},
]}
/>
);
};
renderInformationCollapsible = (): React.ReactNode => {
const { ruleData, species } = this.props;
if (species === null) {
return null;
}
const definitionKey = DefinitionUtils.hack__generateDefinitionKey(
RaceUtils.getEntityRaceTypeId(species),
RaceUtils.getEntityRaceId(species)
);
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
[definitionKey],
ruleData,
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT
);
return <HelperTextAccordion builderHelperText={builderText} />;
};
render() {
const { species, ruleData } = this.props;
//TODO render something else if species doesnt exist
if (!species) {
return null;
}
const fullName = RaceUtils.getFullName(species);
const portraitAvatarUrl = RaceUtils.getPortraitAvatarUrl(species);
const description = RaceUtils.getDescription(species);
const moreDetailsUrl = RaceUtils.getMoreDetailsUrl(species);
const previewUrl: string | null =
portraitAvatarUrl ?? RuleDataUtils.getDefaultRaceImageUrl(ruleData);
const descriptionClassNames: string[] = ["race-detail-description"];
const calledOutSpeciesTraits = RaceUtils.getCalledOutRacialTraits(species);
if (calledOutSpeciesTraits.length > 0) {
descriptionClassNames.push("race-detail-description--hide-traits");
}
return (
<Page>
<PageBody>
<div className="manage-race race-detail">
<div className="race-detail-primary">
<div className="race-detail-aside">
<div className="race-detail-preview">
<img
className="race-detail-preview-img"
src={previewUrl ? previewUrl : ""}
alt=""
/>
</div>
<div
className="manage-race-chosen-action"
onClick={this.handleChangeSpecies}
>
Change Species
</div>
</div>
<PageHeader>{fullName}</PageHeader>
<HtmlContent
className={descriptionClassNames.join(" ")}
html={description ? description : ""}
withoutTooltips
/>
{calledOutSpeciesTraits.length > 0 && (
<SummaryList
list={calledOutSpeciesTraits}
title="Species Traits"
className={styles.summaryList}
/>
)}
<div className="race-detail-more">
<a
className="race-detail-more-link"
href={moreDetailsUrl ? moreDetailsUrl : ""}
>
{fullName} Details Page
</a>
</div>
{this.renderInformationCollapsible()}
</div>
{this.renderSpeciesTraitsContent()}
</div>
</PageBody>
</Page>
);
}
}
const SpeciesManageWithHooks = (props) => {
const navigate = useNavigate();
const { createModal } = useModalManager();
return (
<SpeciesManage {...props} navigate={navigate} createModal={createModal} />
);
};
export default ConnectedBuilderPage(
SpeciesManageWithHooks,
RouteKey.RACE_MANAGE,
(state) => {
return {
characterId: rulesEngineSelectors.getId(state),
species: rulesEngineSelectors.getRace(state),
choiceInfo: rulesEngineSelectors.getChoiceInfo(state),
ruleData: rulesEngineSelectors.getRuleData(state),
prerequisiteData: rulesEngineSelectors.getPrerequisiteData(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
featLookup: rulesEngineSelectors.getFeatLookup(state),
definitionPool: serviceDataSelectors.getDefinitionPool(state),
loadAvailableOptionalSpeciesTraits:
apiCreatorSelectors.makeLoadAvailableOptionalRacialTraits(state),
loadAvailableFeats: apiCreatorSelectors.makeLoadAvailableFeats(state),
optionalOriginLookup: rulesEngineSelectors.getOptionalOriginLookup(state),
classSpellListSpellsLookup:
rulesEngineSelectors.getClassSpellListSpellsLookup(state),
};
}
);
@@ -0,0 +1,346 @@
import axios, { Canceler } from "axios";
import React from "react";
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
import {
ApiAdapterPromise,
ApiAdapterRequestConfig,
ApiAdapterUtils,
ApiResponse,
CharacterPreferences,
ChoiceData,
ChoiceUtils,
Constants,
CoreUtils,
DefinitionPool,
Feat,
FeatDefinitionContract,
FeatLookup,
FeatUtils,
HelperUtils,
OptionalOriginLookup,
OptionalOriginUtils,
PrerequisiteData,
RacialTrait,
RacialTraitUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Accordion } from "~/components/Accordion";
import { FeatureChoice } from "~/components/FeatureChoice";
import { HtmlContent } from "~/components/HtmlContent";
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
import { AppLoggerUtils } from "../../../../../Shared/utils";
interface Props {
speciesTrait: RacialTrait;
choiceInfo: ChoiceData;
preferences: CharacterPreferences;
prerequisiteData: PrerequisiteData;
featLookup: FeatLookup;
onChoiceChange: (
speciesTraitId: number,
choiceId: string,
type: number,
value: any
) => void;
loadAvailableFeats: (
additionalConfig?: Partial<ApiAdapterRequestConfig>
) => ApiAdapterPromise<ApiResponse<Array<FeatDefinitionContract>>>;
optionalOriginLookup: OptionalOriginLookup;
definitionPool: DefinitionPool;
speciesName?: string | null;
}
interface State {
featData: Array<Feat>;
hasFeatChoice: boolean;
collapsibleOpened: boolean;
loadingStatus: DataLoadingStatusEnum;
}
export default class SpeciesManageSpeciesTrait extends React.PureComponent<
Props,
State
> {
constructor(props: Props) {
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
let name = RacialTraitUtils.getName(speciesTrait);
// Get the feature type for the species trait
const featureType = RacialTraitUtils.getFeatureType(speciesTrait);
// Get the display configuration for the species trait
const displayConfiguration =
RacialTraitUtils.getDisplayConfiguration(speciesTrait);
// Determine if we should add the species name to the name
const addName = displayConfiguration
? CoreUtils.getDisplayConfigurationValue(
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT,
displayConfiguration
) === Constants.DisplayConfigurationValueEnum.ON
: false;
// Add the species name to the name if necessary
if (speciesName && addName) name = `${name} (${speciesName})`;
// Add "origin" to the name if it's an optional origin replacement feature
if (featureType === Constants.FeatureTypeEnum.REPLACEMENT)
name = `Origin ${name}`;
// Return constructed name
return name;
};
getMetaItems = () => {
const { speciesTrait, optionalOriginLookup, definitionPool } = this.props;
const displayConfiguration =
RacialTraitUtils.getDisplayConfiguration(speciesTrait);
const choices = RacialTraitUtils.getChoices(speciesTrait);
let metaItems: Array<string> = [];
if (choices.length) {
metaItems.push(
`${choices.length} Choice${choices.length !== 1 ? "s" : ""}`
);
}
const featureType = RacialTraitUtils.getFeatureType(speciesTrait);
const addOrigin = displayConfiguration
? CoreUtils.getDisplayConfigurationValue(
Constants.DisplayConfigurationTypeEnum.RACIAL_TRAIT,
displayConfiguration
) === Constants.DisplayConfigurationValueEnum.OFF
: false;
if (featureType !== Constants.FeatureTypeEnum.GRANTED || addOrigin) {
metaItems.push("Origin");
}
if (featureType === Constants.FeatureTypeEnum.REPLACEMENT) {
const optionalOrigin = HelperUtils.lookupDataOrFallback(
optionalOriginLookup,
RacialTraitUtils.getDefinitionKey(speciesTrait)
);
if (optionalOrigin) {
const affectedDefinitionKey =
OptionalOriginUtils.getAffectedRacialTraitDefinitionKey(
optionalOrigin
);
if (affectedDefinitionKey) {
const replacedSpeciesTrait = RacialTraitUtils.simulateRacialTrait(
affectedDefinitionKey,
definitionPool
);
if (replacedSpeciesTrait) {
metaItems.push(
`Replaces ${RacialTraitUtils.getName(replacedSpeciesTrait)}`
);
}
}
}
}
return metaItems;
};
hasFeatChoice = (props: Props): boolean => {
const { speciesTrait } = props;
const choices = RacialTraitUtils.getChoices(speciesTrait);
return choices.some(
(choice) =>
ChoiceUtils.getType(choice) ===
Constants.BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
};
handleCollapsibleOpened = (id: string, state: boolean): void => {
this.setState({
collapsibleOpened: state,
});
};
handleChoiceChange = (choiceId: string, type: number, value: any): void => {
const { onChoiceChange, speciesTrait } = this.props;
if (onChoiceChange) {
onChoiceChange(
RacialTraitUtils.getId(speciesTrait),
choiceId,
type,
HelperUtils.parseInputInt(value)
);
}
};
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 description = RacialTraitUtils.getDescription(speciesTrait);
const choices = RacialTraitUtils.getChoices(speciesTrait);
const accordionId = RacialTraitUtils.getId(speciesTrait).toString();
const hasTodoChoices: boolean = choices.some(
(choice) => ChoiceUtils.getOptionValue(choice) === null
);
const conClsNames: Array<string> = ["race-detail-racial-trait-name"];
if (hasTodoChoices) {
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}
summary={this.getSummary()}
summaryMetaItems={this.getMetaItems()}
variant="paper"
showAlert={hasTodoChoices}
handleIsOpen={this.handleCollapsibleOpened}
>
<HtmlContent
className="race-detail-racial-trait-description"
html={description ? description : ""}
withoutTooltips
/>
{contentNode}
</Accordion>
);
}
}
@@ -0,0 +1,4 @@
import SpeciesManageSpeciesTrait from "./SpeciesManageSpeciesTrait";
export default SpeciesManageSpeciesTrait;
export { SpeciesManageSpeciesTrait };
@@ -0,0 +1,5 @@
import SpeciesManage from "./SpeciesManage";
import SpeciesManageSpeciesTrait from "./SpeciesManageSpeciesTrait";
export default SpeciesManage;
export { SpeciesManage, SpeciesManageSpeciesTrait };