Grabbed dndbeyond's source code
``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export const QUICK_BUILD_REQUEST = "builder.QUICK_BUILD_REQUEST";
|
||||
export const RANDOM_BUILD_REQUEST = "builder.RANDOM_BUILD_REQUEST";
|
||||
export const STEP_BUILD_REQUEST = "builder.STEP_BUILD_REQUEST";
|
||||
export const CHARACTER_LOAD_REQUEST = "builder.CHARACTER_LOAD_REQUEST";
|
||||
export const BUILDER_METHOD_SET = "builder.BUILDER_METHOD_SET";
|
||||
export const BUILDER_METHOD_SET_COMMIT = "builder.BUILDER_METHOD_SET_COMMIT";
|
||||
export const CHARACTER_LOADING_SET_COMMIT =
|
||||
"builder.CHARACTER_LOADING_SET_COMMIT";
|
||||
export const CHARACTER_LOADED_SET_COMMIT =
|
||||
"builder.CHARACTER_LOADED_SET_COMMIT";
|
||||
export const CONFIRM_SPECIES_SET = "builder.CONFIRM_SPECIES_SET";
|
||||
export const CONFIRM_CLASS_SET = "builder.CONFIRM_CLASS_SET";
|
||||
export const SHOW_HELP_TEXT_SET = "builder.SHOW_HELP_TEXT_SET";
|
||||
export const SUGGESTED_NAMES_SET = "builder.SUGGESTED_NAMES_SET";
|
||||
export const SUGGESTED_NAMES_REQUEST = "builder.SUGGESTED_NAMES_REQUEST";
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
ClassDefinitionContract,
|
||||
RaceDefinitionContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { builderActionTypes } from "../builder";
|
||||
import {
|
||||
BuilderMethodSetAction,
|
||||
BuilderMethodSetActionPayload,
|
||||
BuilderMethodSetCommitAction,
|
||||
CharacterLoadedSetCommitAction,
|
||||
CharacterLoadingSetCommitAction,
|
||||
CharacterLoadRequestAction,
|
||||
ConfirmClassSetAction,
|
||||
ConfirmSpeciesSetAction,
|
||||
QuickBuildRequestAction,
|
||||
RandomBuildRequestAction,
|
||||
ShowHelpTextSetAction,
|
||||
StepBuildRequestAction,
|
||||
SuggestedNamesRequestAction,
|
||||
SuggestedNamesSetAction,
|
||||
} from "./typings";
|
||||
|
||||
export function characterLoadRequest(): CharacterLoadRequestAction {
|
||||
return {
|
||||
type: builderActionTypes.CHARACTER_LOAD_REQUEST,
|
||||
payload: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function quickBuildRequest(
|
||||
entitySpeciesId: number | null,
|
||||
entitySpeciesTypeId: number | null,
|
||||
classId: number | null,
|
||||
name: string | null
|
||||
): QuickBuildRequestAction {
|
||||
return {
|
||||
type: builderActionTypes.QUICK_BUILD_REQUEST,
|
||||
payload: {
|
||||
entityRaceId: entitySpeciesId,
|
||||
entityRaceTypeId: entitySpeciesTypeId,
|
||||
classId,
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function randomBuildRequest(
|
||||
level: number | null,
|
||||
entitySpeciesId: number | null,
|
||||
entitySpeciesTypeId: number | null,
|
||||
classId: number | null,
|
||||
allowMulticlass: boolean,
|
||||
allowFeats: boolean,
|
||||
name: string | null
|
||||
): RandomBuildRequestAction {
|
||||
return {
|
||||
type: builderActionTypes.RANDOM_BUILD_REQUEST,
|
||||
payload: {
|
||||
level,
|
||||
entityRaceId: entitySpeciesId,
|
||||
entityRaceTypeId: entitySpeciesTypeId,
|
||||
classId,
|
||||
allowMulticlass,
|
||||
allowFeats,
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function stepBuildRequest(
|
||||
showHelpText: boolean
|
||||
): StepBuildRequestAction {
|
||||
return {
|
||||
type: builderActionTypes.STEP_BUILD_REQUEST,
|
||||
payload: {
|
||||
showHelpText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function characterLoadingSetCommit(
|
||||
isLoading: boolean
|
||||
): CharacterLoadingSetCommitAction {
|
||||
return {
|
||||
type: builderActionTypes.CHARACTER_LOADING_SET_COMMIT,
|
||||
payload: {
|
||||
isLoading,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function characterLoadedSetCommit(
|
||||
isLoaded: boolean
|
||||
): CharacterLoadedSetCommitAction {
|
||||
return {
|
||||
type: builderActionTypes.CHARACTER_LOADED_SET_COMMIT,
|
||||
payload: {
|
||||
isLoaded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function showHelpTextSet(showHelpText: boolean): ShowHelpTextSetAction {
|
||||
return {
|
||||
type: builderActionTypes.SHOW_HELP_TEXT_SET,
|
||||
payload: {
|
||||
showHelpText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function builderMethodSet(method: string): BuilderMethodSetAction {
|
||||
return {
|
||||
type: builderActionTypes.BUILDER_METHOD_SET,
|
||||
payload: {
|
||||
method,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function builderMethodSetCommit(
|
||||
payload: BuilderMethodSetActionPayload
|
||||
): BuilderMethodSetCommitAction {
|
||||
return {
|
||||
type: builderActionTypes.BUILDER_METHOD_SET_COMMIT,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function confirmSpeciesSet(
|
||||
species: RaceDefinitionContract
|
||||
): ConfirmSpeciesSetAction {
|
||||
return {
|
||||
type: builderActionTypes.CONFIRM_SPECIES_SET,
|
||||
payload: {
|
||||
race: species,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function confirmClassSet(
|
||||
charClass: ClassDefinitionContract
|
||||
): ConfirmClassSetAction {
|
||||
return {
|
||||
type: builderActionTypes.CONFIRM_CLASS_SET,
|
||||
payload: {
|
||||
charClass,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestedNamesSet(
|
||||
suggestedNames: Array<string>
|
||||
): SuggestedNamesSetAction {
|
||||
return {
|
||||
type: builderActionTypes.SUGGESTED_NAMES_SET,
|
||||
payload: {
|
||||
suggestedNames,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestedNamesRequest(): SuggestedNamesRequestAction {
|
||||
return {
|
||||
type: builderActionTypes.SUGGESTED_NAMES_REQUEST,
|
||||
payload: {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const builderActionTypes = actionTypes;
|
||||
export const builderActions = actions;
|
||||
export const builderTypings = typings;
|
||||
|
||||
export * from "./typings";
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AbilityManager,
|
||||
HelperUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
|
||||
|
||||
|
||||
interface Props {
|
||||
abilities: Array<AbilityManager>;
|
||||
minScore?: number;
|
||||
maxScore?: number;
|
||||
}
|
||||
|
||||
export default function AbilityScoreManagerManual({
|
||||
abilities,
|
||||
minScore = 3,
|
||||
maxScore = 18,
|
||||
}: Props) {
|
||||
const handleTransformValueOnBlur = (value: string): number | null => {
|
||||
const parsedValue = HelperUtils.parseInputInt(value);
|
||||
let clampedValue: number | null = null;
|
||||
if (parsedValue !== null) {
|
||||
clampedValue = HelperUtils.clampInt(parsedValue, minScore, maxScore);
|
||||
}
|
||||
|
||||
return clampedValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=" ability-score-manager ability-score-manager-manual">
|
||||
<div className="ability-score-manager-stats">
|
||||
{abilities.map((abilityScore) => (
|
||||
<div
|
||||
className="ability-score-manager-stat"
|
||||
key={abilityScore.getId()}
|
||||
data-stat-id={abilityScore.getId()}
|
||||
>
|
||||
<FormInputField
|
||||
transformValueOnBlur={handleTransformValueOnBlur}
|
||||
onBlur={(value) => abilityScore.handleScoreChange(Number(value))}
|
||||
label={abilityScore.getLabel() ?? ""}
|
||||
initialValue={abilityScore.getBaseScore()}
|
||||
/>
|
||||
<div className="ability-score-manager-stat-total">
|
||||
Total: {abilityScore.getTotalScore() ?? "--"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import AbilityScoreManagerManual from "./AbilityScoreManagerManual";
|
||||
|
||||
export default AbilityScoreManagerManual;
|
||||
export { AbilityScoreManagerManual };
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { uniqueId } from "lodash";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityManager,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
const totalPoints: number = 27;
|
||||
const scoreChoices: Array<number> = [8, 9, 10, 11, 12, 13, 14, 15];
|
||||
const scorePoints: Record<number, number> = {
|
||||
8: 0,
|
||||
9: 1,
|
||||
10: 2,
|
||||
11: 3,
|
||||
12: 4,
|
||||
13: 5,
|
||||
14: 7,
|
||||
15: 9,
|
||||
};
|
||||
|
||||
function renderScoreLabel(score: number, currentScore: number | null): string {
|
||||
if (currentScore !== null) {
|
||||
if (score > currentScore) {
|
||||
const pointDiff = scorePoints[score] - scorePoints[currentScore];
|
||||
return `${score} (-${pointDiff} Point${pointDiff === 1 ? "" : "s"})`;
|
||||
} else if (score < currentScore) {
|
||||
const pointDiff = scorePoints[currentScore] - scorePoints[score];
|
||||
return `${score} (+${pointDiff} Point${pointDiff === 1 ? "" : "s"})`;
|
||||
}
|
||||
}
|
||||
|
||||
return "" + score;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
abilities: Array<AbilityManager>;
|
||||
}
|
||||
export default function AbilityScoreManagerPointBuy({ abilities }: Props) {
|
||||
const currentPoints = abilities.reduce((acc: number, abilityScore) => {
|
||||
const baseScore = abilityScore.getBaseScore();
|
||||
if (baseScore !== null) {
|
||||
return acc + scorePoints[baseScore];
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const remainingPoints: number = totalPoints - currentPoints;
|
||||
|
||||
return (
|
||||
<div className="ability-score-manager ability-score-manager-point">
|
||||
<div className="ability-score-manager-points">
|
||||
<div className="ability-score-manager-points-heading">
|
||||
Points Remaining
|
||||
</div>
|
||||
<div className="ability-score-manager-points-value">
|
||||
<span className="ability-score-manager-points-value-remaining">
|
||||
{remainingPoints}
|
||||
</span>
|
||||
<span className="ability-score-manager-points-value-sep">/</span>
|
||||
<span className="ability-score-manager-points-value-total">
|
||||
{totalPoints}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ability-score-manager-stats">
|
||||
{abilities.map((abilityScore) => {
|
||||
const uId = uniqueId("qry_");
|
||||
const baseScore = abilityScore.getBaseScore();
|
||||
const availableScores = scoreChoices.reduce(
|
||||
(acc: Array<number>, score) => {
|
||||
let pointDiff = scorePoints[score];
|
||||
if (baseScore !== null) {
|
||||
pointDiff -= scorePoints[baseScore];
|
||||
}
|
||||
|
||||
if (
|
||||
pointDiff < 0 ||
|
||||
(pointDiff > 0 && pointDiff <= remainingPoints) ||
|
||||
score === abilityScore.getBaseScore()
|
||||
) {
|
||||
acc.push(score);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const availableScoreOptions: Array<HtmlSelectOption> =
|
||||
availableScores.map((score) => ({
|
||||
label: renderScoreLabel(score, abilityScore.getBaseScore()),
|
||||
value: score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ability-score-manager-stat"
|
||||
key={abilityScore.getId()}
|
||||
>
|
||||
<div className="builder-field form-select-field">
|
||||
<span className="builder-field-label">
|
||||
<label
|
||||
className="builder-field-heading form-select-field-label"
|
||||
htmlFor={uId}
|
||||
>
|
||||
{abilityScore.getLabel()}
|
||||
</label>
|
||||
</span>
|
||||
<span className="builder-field-input">
|
||||
<Select
|
||||
id={uId}
|
||||
options={availableScoreOptions}
|
||||
value={abilityScore.getBaseScore()}
|
||||
onChange={(value) =>
|
||||
abilityScore.handleScoreChange(Number(value))
|
||||
}
|
||||
initialOptionRemoved={true}
|
||||
/>
|
||||
</span>
|
||||
<div className="ability-score-manager-stat-total">
|
||||
Total: {abilityScore.getTotalScore() ?? "--"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import AbilityScoreManagerPointBuy from "./AbilityScoreManagerPointBuy";
|
||||
|
||||
export default AbilityScoreManagerPointBuy;
|
||||
export { AbilityScoreManagerPointBuy };
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { uniqueId } from "lodash";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityManager,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { TypeScriptUtils } from "../../../Shared/utils";
|
||||
|
||||
const standardScores: Array<number> = [8, 10, 12, 13, 14, 15];
|
||||
|
||||
interface Props {
|
||||
abilities: Array<AbilityManager>;
|
||||
}
|
||||
export default function AbilityScoreManagerStandardArray({ abilities }: Props) {
|
||||
const usedScores = abilities
|
||||
.map((abilityScore) => abilityScore.getBaseScore())
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
return (
|
||||
<div className="ability-score-manager ability-score-manager-standard">
|
||||
<div className="ability-score-manager-stats">
|
||||
{abilities.map((abilityScore) => {
|
||||
const uId = uniqueId("qry_");
|
||||
const availableScores = standardScores.reduce(
|
||||
(acc: Array<number>, score) => {
|
||||
if (
|
||||
!usedScores.includes(score) ||
|
||||
score === abilityScore.getBaseScore()
|
||||
) {
|
||||
acc.push(score);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const availableScoreOptions: Array<HtmlSelectOption> =
|
||||
availableScores.map((score) => ({
|
||||
label: "" + score,
|
||||
value: score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ability-score-manager-stat"
|
||||
key={abilityScore.getId()}
|
||||
>
|
||||
<div className="builder-field form-select-field">
|
||||
<span className="builder-field-label">
|
||||
<label
|
||||
className="builder-field-heading form-select-field-label"
|
||||
htmlFor={uId}
|
||||
>
|
||||
{abilityScore.getLabel()}
|
||||
</label>
|
||||
</span>
|
||||
<span className="builder-field-input">
|
||||
<Select
|
||||
id={uId}
|
||||
options={availableScoreOptions}
|
||||
value={abilityScore.getBaseScore()}
|
||||
onChange={(value) => {
|
||||
const parsedValue = HelperUtils.parseInputInt(value);
|
||||
abilityScore.handleScoreChange(parsedValue);
|
||||
}}
|
||||
placeholder={"--"}
|
||||
/>
|
||||
</span>
|
||||
<div className="ability-score-manager-stat-total">
|
||||
Total: {abilityScore.getTotalScore() ?? "--"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import AbilityScoreManagerStandardArray from "./AbilityScoreManagerStandardArray";
|
||||
|
||||
export default AbilityScoreManagerStandardArray;
|
||||
export { AbilityScoreManagerStandardArray };
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@dndbeyond/character-components/es";
|
||||
|
||||
interface withBuilderProps {
|
||||
clsNames: Array<string>;
|
||||
}
|
||||
function withBuilderButton<P extends object, C>(
|
||||
WrappedComponent: React.JSXElementConstructor<P> & C
|
||||
) {
|
||||
type Props = JSX.LibraryManagedAttributes<C, P>;
|
||||
return class WithBuilderButton extends React.PureComponent<
|
||||
Props & withBuilderProps
|
||||
> {
|
||||
static defaultProps = {
|
||||
clsNames: [],
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, clsNames } = this.props;
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...(this.props as any)}
|
||||
clsNames={[...clsNames, "builder-button"]}
|
||||
>
|
||||
{children}
|
||||
</WrappedComponent>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default withBuilderButton(Button);
|
||||
@@ -0,0 +1,4 @@
|
||||
import Button from "./Button";
|
||||
|
||||
export default Button;
|
||||
export { Button };
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormLabel from "@mui/material/FormLabel";
|
||||
import Radio from "@mui/material/Radio";
|
||||
import MuiRadioGroup from "@mui/material/RadioGroup";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { ChangeEvent } from "react";
|
||||
|
||||
import { TitleH3 } from "../shared-styles";
|
||||
|
||||
type RadioGroupProps = {
|
||||
name?: string;
|
||||
defaultValue?: any;
|
||||
label: string;
|
||||
options?: any;
|
||||
subtitle?: string;
|
||||
onChange?: (event: ChangeEvent, value: any) => void;
|
||||
disabled?: boolean;
|
||||
value?: any;
|
||||
};
|
||||
|
||||
export const RadioGroup = ({
|
||||
name,
|
||||
defaultValue,
|
||||
label,
|
||||
options,
|
||||
subtitle,
|
||||
onChange,
|
||||
disabled = false,
|
||||
value,
|
||||
}: RadioGroupProps) => {
|
||||
return (
|
||||
<FormControl disabled={disabled}>
|
||||
<FormLabel
|
||||
sx={{ ...TitleH3, color: "#000" }}
|
||||
id={`radio-button-group-${label.split(" ").join("-")}`}
|
||||
>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ color: "#12181ca3", fontSize: "14px" }}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
<MuiRadioGroup
|
||||
aria-labelledby={`radio-button-group-${label.split(" ").join("-")}`}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
{...{
|
||||
...(defaultValue && { defaultValue }),
|
||||
...(value && { value }),
|
||||
}}
|
||||
>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<FormControlLabel
|
||||
value={option.value}
|
||||
control={<Radio color="secondary" />}
|
||||
label={option.label}
|
||||
key={option.value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MuiRadioGroup>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { RadioGroup } from "./RadioGroup";
|
||||
|
||||
export default RadioGroup;
|
||||
export { RadioGroup };
|
||||
@@ -0,0 +1,8 @@
|
||||
export const TitleH3 = {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
fontFamily: "Roboto Condensed",
|
||||
letterSpacing: "normal",
|
||||
lineHeight: 1.5,
|
||||
marginTop: 4,
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
import { ClassDefinitionContract } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import Button from "../Button";
|
||||
|
||||
interface Props {
|
||||
charClass: ClassDefinitionContract;
|
||||
onRequestChange: () => void;
|
||||
}
|
||||
export default class ClassDisplaySimple extends React.PureComponent<Props> {
|
||||
handleChangeClick = () => {
|
||||
const { onRequestChange } = this.props;
|
||||
|
||||
if (onRequestChange) {
|
||||
onRequestChange();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { charClass } = this.props;
|
||||
|
||||
let portraitAvatarUrl = charClass.portraitAvatarUrl;
|
||||
|
||||
return (
|
||||
<div className="builder-field builder-field-valid class-simple">
|
||||
<div className="builder-field-heading">Selected Class</div>
|
||||
<div className="class-simple-chosen">
|
||||
<div className="class-simple-chosen-preview">
|
||||
<img
|
||||
className="class-simple-chosen-preview-img"
|
||||
src={portraitAvatarUrl ? portraitAvatarUrl : ""}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="class-simple-chosen-heading">{charClass.name}</div>
|
||||
<div className="class-simple-chosen-action">
|
||||
<Button onClick={this.handleChangeClick} size="small">
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClassDisplaySimple from "./ClassDisplaySimple";
|
||||
|
||||
export default ClassDisplaySimple;
|
||||
export { ClassDisplaySimple };
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import { FeatList, FormatUtils } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { FeatDetail } from "~/subApps/sheet/components/Sidebar/panes/FeatsManagePane/FeatDetail";
|
||||
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
|
||||
|
||||
type GrantedFeatProps = {
|
||||
featList: FeatList;
|
||||
requiredLevel?: number;
|
||||
};
|
||||
|
||||
export const GrantedFeat: React.FC<GrantedFeatProps> = ({
|
||||
featList,
|
||||
requiredLevel,
|
||||
}) => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
|
||||
// Only show the Feat List section if there is something to show.
|
||||
if (featList.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let featContent: React.ReactNode | undefined;
|
||||
let selectBox: React.ReactNode | undefined;
|
||||
let infoMessage: React.ReactNode | undefined;
|
||||
let hasUnfinishedChoices = featList.hasChoiceToMake();
|
||||
let subtitleItems = ["Granted Feat"];
|
||||
|
||||
// Show a message when the character already has every feat in the feat list from somewhere else.
|
||||
if (featList.alreadyHasEveryFeat() && featList.chosenFeatId === null) {
|
||||
infoMessage = <p>This feat already exists on your character.</p>;
|
||||
} else {
|
||||
// Show the feat details if a feat has been chosen from the feat list.
|
||||
if (featList.chosenFeatId !== null) {
|
||||
const featId = featList.chosenFeatId;
|
||||
const featManager = characterFeaturesManager.getFeatById(featId);
|
||||
|
||||
if (featManager !== null) {
|
||||
const needToChooseFeatOptions =
|
||||
featManager.getUnfinishedChoices().length > 0;
|
||||
hasUnfinishedChoices = hasUnfinishedChoices || needToChooseFeatOptions;
|
||||
|
||||
featContent = <FeatDetail featManager={featManager} />;
|
||||
|
||||
if (featManager.isHiddenFeat()) {
|
||||
subtitleItems = []; // don't call it a feat if it isn't really a feat.
|
||||
}
|
||||
|
||||
const choiceCount = featManager.getChoices().length;
|
||||
if (choiceCount) {
|
||||
subtitleItems.push(
|
||||
`${choiceCount} Choice${choiceCount !== 1 ? "s" : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When there is more than one feat in the list, show the select box to choose one.
|
||||
if (!featList.isSingleFeat()) {
|
||||
selectBox = (
|
||||
<Select
|
||||
onChangePromise={(...args) => featList.handleChoiceSelected(...args)}
|
||||
options={featList.availableChoices}
|
||||
value={featList.chosenFeatId !== null ? featList.chosenFeatId : -1}
|
||||
clsNames={["description-manage-background-granted-feat-chooser"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredLevel) {
|
||||
subtitleItems.push(`${FormatUtils.ordinalize(requiredLevel)} level`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
summary={featList.definition.name}
|
||||
summaryMetaItems={subtitleItems}
|
||||
showAlert={hasUnfinishedChoices}
|
||||
variant="paper"
|
||||
key={featList.definition.id}
|
||||
>
|
||||
{infoMessage}
|
||||
{selectBox}
|
||||
{featContent}
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import React from "react";
|
||||
|
||||
import { Checkbox, Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
Constants,
|
||||
FormatUtils,
|
||||
HtmlSelectOption,
|
||||
AccessUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { CollapsibleContent } from "~/components/CollapsibleContent";
|
||||
import { Link } from "~/components/Link";
|
||||
|
||||
export interface AffectedFeatureInfo {
|
||||
name: string;
|
||||
definitionKey: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
interface OptionalFeatureProps {
|
||||
name: string;
|
||||
requiredLevel?: number;
|
||||
description: string | null;
|
||||
featureType: Constants.FeatureTypeEnum | null;
|
||||
definitionKey: string;
|
||||
affectedFeatureDefinitionKey: string | null;
|
||||
isSelected: boolean;
|
||||
onSelection: (
|
||||
definitionKey: string,
|
||||
affectedFeatureDefinitionKey: string | null
|
||||
) => void;
|
||||
onRemoveSelectionPromise?: (
|
||||
definitionKey: string,
|
||||
newIsEnabled: boolean,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onChangeReplacementPromise?: (
|
||||
definitionKey: string,
|
||||
newAffectedDefinitionKey: string | null,
|
||||
oldAffectedDefinitionKey: string | null,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
affectedFeatures: Array<AffectedFeatureInfo>;
|
||||
accessType: Constants.AccessTypeEnum;
|
||||
}
|
||||
export class OptionalFeature extends React.PureComponent<
|
||||
OptionalFeatureProps,
|
||||
{}
|
||||
> {
|
||||
handleSelection = (isEnabled: boolean): void => {
|
||||
const { featureType, onSelection, definitionKey, affectedFeatures } =
|
||||
this.props;
|
||||
|
||||
switch (featureType) {
|
||||
case Constants.FeatureTypeEnum.REPLACEMENT: {
|
||||
if (isEnabled) {
|
||||
const affectedFeatureDefinitionKey: string | null =
|
||||
affectedFeatures.length === 1
|
||||
? affectedFeatures[0].definitionKey
|
||||
: null;
|
||||
|
||||
onSelection(definitionKey, affectedFeatureDefinitionKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Constants.FeatureTypeEnum.ADDITIONAL: {
|
||||
if (isEnabled) {
|
||||
onSelection(definitionKey, null);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
//not implemented
|
||||
}
|
||||
};
|
||||
|
||||
transformDefinitionKey = (definitionKey: string): string | null => {
|
||||
return definitionKey.length ? definitionKey : null;
|
||||
};
|
||||
|
||||
handleReplacementChangePromise = (
|
||||
newAffectedDefinitionKey: string,
|
||||
oldAffectedDefinitionKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => {
|
||||
const { onChangeReplacementPromise, definitionKey } = this.props;
|
||||
|
||||
if (onChangeReplacementPromise) {
|
||||
onChangeReplacementPromise(
|
||||
definitionKey,
|
||||
this.transformDefinitionKey(newAffectedDefinitionKey),
|
||||
this.transformDefinitionKey(oldAffectedDefinitionKey),
|
||||
accept,
|
||||
reject
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleUnselectPromise = (
|
||||
newIsEnabled: boolean,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => {
|
||||
const { onRemoveSelectionPromise, definitionKey } = this.props;
|
||||
|
||||
if (onRemoveSelectionPromise) {
|
||||
onRemoveSelectionPromise(definitionKey, newIsEnabled, accept, reject);
|
||||
}
|
||||
};
|
||||
|
||||
renderReplacements = (): React.ReactNode => {
|
||||
const {
|
||||
affectedFeatures,
|
||||
isSelected,
|
||||
featureType,
|
||||
affectedFeatureDefinitionKey,
|
||||
} = this.props;
|
||||
|
||||
if (!affectedFeatures.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const introText: Array<string> = ["Replaces:"];
|
||||
const disabledText: string = "(already replaced)";
|
||||
if (affectedFeatures.length === 1) {
|
||||
introText.push(affectedFeatures[0].name);
|
||||
if (affectedFeatures[0].disabled) {
|
||||
introText.push(disabledText);
|
||||
}
|
||||
}
|
||||
|
||||
const options: Array<HtmlSelectOption> = [];
|
||||
if (affectedFeatures.length > 1) {
|
||||
affectedFeatures.forEach((feature) => {
|
||||
const label: Array<string> = [feature.name];
|
||||
if (feature.disabled) {
|
||||
label.push(disabledText);
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: label.join(" "),
|
||||
value: feature.definitionKey,
|
||||
disabled: feature.disabled,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const classNames: Array<string> = ["ct-optional-feature__select"];
|
||||
|
||||
if (
|
||||
featureType === Constants.FeatureTypeEnum.REPLACEMENT &&
|
||||
isSelected &&
|
||||
affectedFeatureDefinitionKey === null
|
||||
) {
|
||||
classNames.push("ct-optional-feature__select--todo");
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="ct-optional-feature__type">{introText.join(" ")}</div>
|
||||
{options.length > 1 && (
|
||||
<div className={classNames.join(" ")}>
|
||||
<Select
|
||||
options={options}
|
||||
value={affectedFeatureDefinitionKey}
|
||||
onChangePromise={this.handleReplacementChangePromise}
|
||||
disabled={!isSelected}
|
||||
preventClickPropagating={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderFeatureTypeContent = (): React.ReactNode => {
|
||||
const { featureType } = this.props;
|
||||
|
||||
switch (featureType) {
|
||||
case Constants.FeatureTypeEnum.ADDITIONAL:
|
||||
return (
|
||||
<div className="ct-optional-feature__type">Additional feature</div>
|
||||
);
|
||||
|
||||
case Constants.FeatureTypeEnum.REPLACEMENT:
|
||||
return this.renderReplacements();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
renderDescription = (): React.ReactNode => {
|
||||
const { description, accessType } = this.props;
|
||||
|
||||
let contentNode: React.ReactNode = null;
|
||||
if (AccessUtils.isAccessible(accessType)) {
|
||||
contentNode = description ? (
|
||||
<CollapsibleContent className="ct-optional-feature__description">
|
||||
{description}
|
||||
</CollapsibleContent>
|
||||
) : null;
|
||||
} else {
|
||||
contentNode = (
|
||||
<div className="ct-character-tools__marketplace-callout">
|
||||
To fully unlock this feature, check out the{" "}
|
||||
<Link href="/marketplace">Marketplace</Link>.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return contentNode;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSelected, name, affectedFeatures, requiredLevel } = this.props;
|
||||
|
||||
const isDisabled: boolean =
|
||||
affectedFeatures.length === 1 && affectedFeatures[0].disabled;
|
||||
|
||||
const classNames: Array<string> = ["ct-optional-feature"];
|
||||
if (isDisabled) {
|
||||
classNames.push("ct-optional-feature--is-disabled");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-optional-feature__selection">
|
||||
<div className="ct-optional-feature__checkmark">
|
||||
<Checkbox
|
||||
initiallyEnabled={isSelected}
|
||||
onChange={this.handleSelection}
|
||||
stopPropagation={true}
|
||||
onChangePromise={
|
||||
isSelected ? this.handleUnselectPromise : undefined
|
||||
}
|
||||
isInteractive={!isDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-optional-feature__primary">
|
||||
<div className="ct-optional-feature__name">{name}</div>
|
||||
{requiredLevel && (
|
||||
<div className="ct-optional-feature__level">
|
||||
{`${FormatUtils.ordinalize(requiredLevel)} level`}
|
||||
</div>
|
||||
)}
|
||||
{this.renderDescription()}
|
||||
<div className="ct-optional-feature__secondary">
|
||||
{this.renderFeatureTypeContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OptionalFeature;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
clsNames: Array<string>;
|
||||
}
|
||||
export default class Page extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
clsNames: [],
|
||||
};
|
||||
|
||||
render() {
|
||||
const { clsNames, children } = this.props;
|
||||
|
||||
const conClassNames = [...clsNames, "builder-page"];
|
||||
|
||||
return <div className={conClassNames.join(" ")}>{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import Page from "./Page";
|
||||
|
||||
export default Page;
|
||||
export { Page };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { BuilderMethod } from "~/subApps/builder/constants";
|
||||
|
||||
import { PortraitName } from "../../../../../subApps/builder/components/PortraitName";
|
||||
import { builderSelectors } from "../../selectors";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
export const PageBody: FC<Props> = ({ children, ...props }) => {
|
||||
const builderMethod = useSelector(builderSelectors.getBuilderMethod);
|
||||
|
||||
return (
|
||||
<div className="builder-page-body" {...props}>
|
||||
{builderMethod === BuilderMethod.STEP_BY_STEP && (
|
||||
<div className="builder-page-body-character-name">
|
||||
<PortraitName useDefaultCharacterName={true} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class PageHeader extends React.PureComponent {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return <div className="builder-page-header">{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import PageHeader from "./PageHeader";
|
||||
|
||||
export default PageHeader;
|
||||
export { PageHeader };
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class PageSubHeader extends React.PureComponent {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return <div className="builder-page-subheader">{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import PageSubHeader from "./PageSubHeader";
|
||||
|
||||
export default PageSubHeader;
|
||||
export { PageSubHeader };
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
RaceDefinitionContract,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import Button from "../Button";
|
||||
|
||||
interface Props {
|
||||
species: RaceDefinitionContract | null;
|
||||
onRequestAction: () => void;
|
||||
actionText: string;
|
||||
headingText: string;
|
||||
ruleData: RuleData;
|
||||
}
|
||||
export default class SpeciesDisplaySimple extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
actionText: "Change",
|
||||
headingText: "Selected ",
|
||||
};
|
||||
|
||||
render() {
|
||||
const { species, actionText, headingText, ruleData, onRequestAction } =
|
||||
this.props;
|
||||
|
||||
if (species === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { portraitAvatarUrl, baseName, subRaceShortName } = species;
|
||||
const previewUrl: string | null = portraitAvatarUrl
|
||||
? portraitAvatarUrl
|
||||
: RuleDataUtils.getDefaultRaceImageUrl(ruleData);
|
||||
|
||||
return (
|
||||
<div className="builder-field builder-field-valid race-simple">
|
||||
<div className="builder-field-heading">{headingText}</div>
|
||||
<div className="race-simple-content">
|
||||
<div className="race-simple-preview">
|
||||
<img
|
||||
className="race-simple-preview-img"
|
||||
src={previewUrl ? previewUrl : ""}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="race-simple-info">
|
||||
{subRaceShortName ? (
|
||||
<div className="race-simple-subclass">{subRaceShortName}</div>
|
||||
) : null}
|
||||
<div className="race-simple-parent">{baseName}</div>
|
||||
</div>
|
||||
<div className="race-simple-action">
|
||||
<Button onClick={onRequestAction} size="small">
|
||||
{actionText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpeciesDisplaySimple from "./SpeciesDisplaySimple";
|
||||
|
||||
export default SpeciesDisplaySimple;
|
||||
export { SpeciesDisplaySimple };
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AnySimpleDataType,
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiResponse,
|
||||
CharacterPreferences,
|
||||
ChoiceData,
|
||||
DefinitionPool,
|
||||
FeatDefinitionContract,
|
||||
FeatLookup,
|
||||
OptionalOriginLookup,
|
||||
PrerequisiteData,
|
||||
RacialTrait,
|
||||
RacialTraitUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { SpeciesManageSpeciesTrait } from "../../containers/pages/SpeciesManage";
|
||||
|
||||
interface Props {
|
||||
speciesTraits: RacialTrait[];
|
||||
featLookup: FeatLookup;
|
||||
loadAvailableFeats: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<FeatDefinitionContract[]>>;
|
||||
handleSpeciesTraitChoiceChange: (
|
||||
speciesTraitId: number,
|
||||
choiceId: string,
|
||||
choiceType: number,
|
||||
optionValue: AnySimpleDataType
|
||||
) => void;
|
||||
choiceInfo: ChoiceData;
|
||||
prerequisiteData: PrerequisiteData;
|
||||
preferences: CharacterPreferences;
|
||||
optionalOriginLookup: OptionalOriginLookup;
|
||||
definitionPool: DefinitionPool;
|
||||
speciesName?: string | null;
|
||||
}
|
||||
|
||||
const SpeciesTraitList: React.FC<Props> = ({
|
||||
speciesTraits,
|
||||
featLookup,
|
||||
loadAvailableFeats,
|
||||
handleSpeciesTraitChoiceChange,
|
||||
choiceInfo,
|
||||
prerequisiteData,
|
||||
preferences,
|
||||
optionalOriginLookup,
|
||||
definitionPool,
|
||||
speciesName,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{speciesTraits.map((speciesTrait) => (
|
||||
<SpeciesManageSpeciesTrait
|
||||
key={RacialTraitUtils.getId(speciesTrait)}
|
||||
speciesTrait={speciesTrait}
|
||||
featLookup={featLookup}
|
||||
loadAvailableFeats={loadAvailableFeats}
|
||||
onChoiceChange={handleSpeciesTraitChoiceChange}
|
||||
choiceInfo={choiceInfo}
|
||||
prerequisiteData={prerequisiteData}
|
||||
preferences={preferences}
|
||||
optionalOriginLookup={optionalOriginLookup}
|
||||
definitionPool={definitionPool}
|
||||
speciesName={speciesName}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeciesTraitList;
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpeciesTraitList from "./SpeciesTraitList";
|
||||
|
||||
export default SpeciesTraitList;
|
||||
export { SpeciesTraitList };
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as navigationConfig from "./navigation";
|
||||
|
||||
export const BUILDER_DESKTOP_COMPONENT_START_WIDTH = 768;
|
||||
export const BUILDER_MQ_IS_MOBILE_SIZE = `(max-width: ${
|
||||
BUILDER_DESKTOP_COMPONENT_START_WIDTH - 1
|
||||
}px)`;
|
||||
|
||||
export { navigationConfig };
|
||||
@@ -0,0 +1,386 @@
|
||||
import React from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
|
||||
import {
|
||||
FeatureFlagInfoState,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
// export interface RouteDefinition {
|
||||
// key: string,
|
||||
// type: string,
|
||||
// path: string,
|
||||
// Component: any,
|
||||
// checkCanAccess: (routeDef:any, state:any) => boolean,
|
||||
// }
|
||||
import config from "~/config";
|
||||
import {
|
||||
BuilderMethod,
|
||||
NavigationType,
|
||||
RouteKey,
|
||||
SectionKey,
|
||||
} from "~/subApps/builder/constants";
|
||||
import { ClassChoose } from "~/subApps/builder/routes/ClassChoose";
|
||||
import { QuickBuild } from "~/subApps/builder/routes/QuickBuild";
|
||||
import { RandomBuild } from "~/subApps/builder/routes/RandomBuild";
|
||||
import { SpeciesChoose } from "~/subApps/builder/routes/SpeciesChoose";
|
||||
|
||||
import AbilityScoresHelp from "../containers/pages/AbilityScoresHelp";
|
||||
import AbilityScoresManage from "../containers/pages/AbilityScoresManage";
|
||||
import ClassHelp from "../containers/pages/ClassHelp";
|
||||
import ClassesManage from "../containers/pages/ClassesManage";
|
||||
import DescriptionHelp from "../containers/pages/DescriptionHelp";
|
||||
import DescriptionManage from "../containers/pages/DescriptionManage";
|
||||
import EquipmentHelp from "../containers/pages/EquipmentHelp";
|
||||
import EquipmentManage from "../containers/pages/EquipmentManage";
|
||||
import HomeBasicInfo from "../containers/pages/HomeBasicInfo";
|
||||
import HomeHelp from "../containers/pages/HomeHelp";
|
||||
import SpeciesHelp from "../containers/pages/SpeciesHelp";
|
||||
import SpeciesManage from "../containers/pages/SpeciesManage";
|
||||
import WhatsNext from "../containers/pages/WhatsNext";
|
||||
import { NavigationUtils } from "../utils";
|
||||
|
||||
const BASE_PATHNAME = config.basePathname;
|
||||
|
||||
export const ROUTE_DEFINITIONS = {
|
||||
[RouteKey.QUICK_BUILD]: {
|
||||
key: RouteKey.QUICK_BUILD,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/builder/quick-build`,
|
||||
Component: QuickBuild,
|
||||
getComponent: () => <QuickBuild />,
|
||||
checkCanAccess: (routeDef, state) => {
|
||||
return NavigationUtils.checkStdBuilderPageRequirements(routeDef, state);
|
||||
},
|
||||
},
|
||||
[RouteKey.RANDOMIZE_BUILD]: {
|
||||
key: RouteKey.RANDOMIZE_BUILD,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/builder/randomize`,
|
||||
Component: RandomBuild,
|
||||
getComponent: () => <RandomBuild />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.HOME_BASIC_INFO]: {
|
||||
key: RouteKey.HOME_BASIC_INFO,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/home/basic`,
|
||||
Component: HomeBasicInfo,
|
||||
getComponent: () => <HomeBasicInfo />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.RACE_CHOOSE]: {
|
||||
key: RouteKey.RACE_CHOOSE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/species/choose`,
|
||||
Component: SpeciesChoose,
|
||||
getComponent: () => <SpeciesChoose />,
|
||||
checkCanAccess: (routeDef, state, dispatch) => {
|
||||
if (!NavigationUtils.checkStdBuilderPageRequirements(routeDef, state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rulesEngineSelectors.getRace(state) !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[RouteKey.RACE_MANAGE]: {
|
||||
key: RouteKey.RACE_MANAGE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/species/manage`,
|
||||
Component: SpeciesManage,
|
||||
getComponent: () => <SpeciesManage />,
|
||||
checkCanAccess: (routeDef, state, dispatch) => {
|
||||
if (rulesEngineSelectors.getRace(state) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[RouteKey.CLASS_CHOOSE]: {
|
||||
key: RouteKey.CLASS_CHOOSE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/class/choose`,
|
||||
Component: ClassChoose,
|
||||
getComponent: () => <ClassChoose />,
|
||||
checkCanAccess: (routeDef, state, dispatch) => {
|
||||
if (!NavigationUtils.checkStdBuilderPageRequirements(routeDef, state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rulesEngineSelectors.getClasses(state).length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[RouteKey.CLASS_MANAGE]: {
|
||||
key: RouteKey.CLASS_MANAGE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/class/manage`,
|
||||
Component: ClassesManage,
|
||||
getComponent: () => <ClassesManage />,
|
||||
checkCanAccess: (routeDef, state, dispatch) => {
|
||||
if (!NavigationUtils.checkStdBuilderPageRequirements(routeDef, state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rulesEngineSelectors.getClasses(state).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[RouteKey.ABILITY_SCORES_MANAGE]: {
|
||||
key: RouteKey.ABILITY_SCORES_MANAGE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/ability-scores/manage`,
|
||||
Component: AbilityScoresManage,
|
||||
getComponent: () => <AbilityScoresManage />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.DESCRIPTION_MANAGE]: {
|
||||
key: RouteKey.DESCRIPTION_MANAGE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/description/manage`,
|
||||
Component: DescriptionManage,
|
||||
getComponent: () => <DescriptionManage />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.EQUIPMENT_MANAGE]: {
|
||||
key: RouteKey.EQUIPMENT_MANAGE,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/equipment/manage`,
|
||||
Component: EquipmentManage,
|
||||
getComponent: () => <EquipmentManage />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
|
||||
[RouteKey.WHATS_NEXT]: {
|
||||
key: RouteKey.WHATS_NEXT,
|
||||
type: NavigationType.Page,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/whats-next`,
|
||||
Component: WhatsNext,
|
||||
getComponent: () => <WhatsNext />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
|
||||
[RouteKey.HOME_HELP]: {
|
||||
key: RouteKey.HOME_HELP,
|
||||
type: NavigationType.HelpPage,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/home/help`,
|
||||
Component: HomeHelp,
|
||||
getComponent: () => <HomeHelp />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.RACE_HELP]: {
|
||||
key: RouteKey.RACE_HELP,
|
||||
type: NavigationType.HelpPage,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/species/help`,
|
||||
Component: SpeciesHelp,
|
||||
getComponent: () => <SpeciesHelp />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.CLASS_HELP]: {
|
||||
key: RouteKey.CLASS_HELP,
|
||||
type: NavigationType.HelpPage,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/class/help`,
|
||||
Component: ClassHelp,
|
||||
getComponent: () => <ClassHelp />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.ABILITY_SCORES_HELP]: {
|
||||
key: RouteKey.ABILITY_SCORES_HELP,
|
||||
type: NavigationType.HelpPage,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/ability-scores/help`,
|
||||
Component: AbilityScoresHelp,
|
||||
getComponent: () => <AbilityScoresHelp />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.DESCRIPTION_HELP]: {
|
||||
key: RouteKey.DESCRIPTION_HELP,
|
||||
type: NavigationType.HelpPage,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/description/help`,
|
||||
Component: DescriptionHelp,
|
||||
getComponent: () => <DescriptionHelp />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
[RouteKey.EQUIPMENT_HELP]: {
|
||||
key: RouteKey.EQUIPMENT_HELP,
|
||||
type: NavigationType.HelpPage,
|
||||
path: `${BASE_PATHNAME}/:characterId/builder/equipment/help`,
|
||||
Component: EquipmentHelp,
|
||||
getComponent: () => <EquipmentHelp />,
|
||||
checkCanAccess: (routeDef, state) =>
|
||||
NavigationUtils.checkStdBuilderPageRequirements(routeDef, state),
|
||||
},
|
||||
};
|
||||
export interface RouteDef {
|
||||
key: string; // get the thing as an enum
|
||||
type: typeof NavigationType;
|
||||
path: string;
|
||||
Component: React.ComponentType<any>;
|
||||
getComponent: () => React.ComponentType<any>;
|
||||
checkCanAccess?: (routeDef: RouteDef, state: any) => boolean;
|
||||
}
|
||||
|
||||
export const getAllRouterRoutes = () => {
|
||||
return Object.values(ROUTE_DEFINITIONS).map((routeDef: RouteDef) => ({
|
||||
element: <routeDef.Component />,
|
||||
path: routeDef.path,
|
||||
routeDef,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getRouteDef = (routeKey) => ROUTE_DEFINITIONS[routeKey];
|
||||
export const getRouteDefPath = (routeKey) => getRouteDef(routeKey).path;
|
||||
export const getRouteDefType = (routeKey) => getRouteDef(routeKey).type;
|
||||
|
||||
export const NAVIGATION_DEFINITIONS = {
|
||||
[BuilderMethod.QUICK]: [
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.QUICK_BUILD,
|
||||
getLabel: () => "Quick Build",
|
||||
routes: [getRouteDef(RouteKey.QUICK_BUILD)],
|
||||
},
|
||||
],
|
||||
[BuilderMethod.RANDOMIZE]: [
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.RANDOMIZE,
|
||||
getLabel: () => "Randomize Build",
|
||||
routes: [getRouteDef(RouteKey.RANDOMIZE_BUILD)],
|
||||
},
|
||||
],
|
||||
[BuilderMethod.STEP_BY_STEP]: [
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.HOME,
|
||||
getLabel: () => "Home",
|
||||
routes: [
|
||||
getRouteDef(RouteKey.HOME_HELP),
|
||||
getRouteDef(RouteKey.HOME_BASIC_INFO),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.CLASS,
|
||||
getLabel: () => "1. Class",
|
||||
routes: [
|
||||
getRouteDef(RouteKey.CLASS_HELP),
|
||||
getRouteDef(RouteKey.CLASS_CHOOSE),
|
||||
getRouteDef(RouteKey.CLASS_MANAGE),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.DESCRIPTION,
|
||||
getLabel: () => "2. Background",
|
||||
routes: [
|
||||
getRouteDef(RouteKey.DESCRIPTION_HELP),
|
||||
getRouteDef(RouteKey.DESCRIPTION_MANAGE),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.RACE,
|
||||
getLabel: () => "3. Species",
|
||||
routes: [
|
||||
getRouteDef(RouteKey.RACE_HELP),
|
||||
getRouteDef(RouteKey.RACE_CHOOSE),
|
||||
getRouteDef(RouteKey.RACE_MANAGE),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.ABILITY_SCORES,
|
||||
getLabel: () => "4. Abilities",
|
||||
routes: [
|
||||
getRouteDef(RouteKey.ABILITY_SCORES_HELP),
|
||||
getRouteDef(RouteKey.ABILITY_SCORES_MANAGE),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.EQUIPMENT,
|
||||
getLabel: () => "5. Equipment",
|
||||
routes: [
|
||||
getRouteDef(RouteKey.EQUIPMENT_HELP),
|
||||
getRouteDef(RouteKey.EQUIPMENT_MANAGE),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: NavigationType.Section,
|
||||
key: SectionKey.WHATS_NEXT,
|
||||
getLabel: () => "What's Next",
|
||||
routes: [getRouteDef(RouteKey.WHATS_NEXT)],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function checkIsRouteInSection(testRouteDef, sectionRoutes) {
|
||||
if (testRouteDef && sectionRoutes) {
|
||||
return !!sectionRoutes.filter(
|
||||
(routeDef) => routeDef.key === testRouteDef.key
|
||||
).length;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getNavigationSections(
|
||||
builderMethod,
|
||||
currentRouteDef,
|
||||
featureFlags: FeatureFlagInfoState
|
||||
) {
|
||||
const navConfig = getNavBuilderConfig(builderMethod, featureFlags);
|
||||
|
||||
return navConfig.map((section) => ({
|
||||
...section,
|
||||
active: checkIsRouteInSection(currentRouteDef, section.routes),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getNavBuilderConfig(
|
||||
builderMethod,
|
||||
featureFlags: FeatureFlagInfoState
|
||||
) {
|
||||
if (builderMethod) {
|
||||
return NAVIGATION_DEFINITIONS[builderMethod];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentRouteDef(currentPath) {
|
||||
const routes = getAllRouterRoutes();
|
||||
|
||||
let matchedRouteDef: RouteDef | null = null;
|
||||
routes.forEach((route) => {
|
||||
const match = matchPath(route.path, currentPath);
|
||||
|
||||
if (match) {
|
||||
matchedRouteDef = route.routeDef;
|
||||
}
|
||||
});
|
||||
|
||||
return matchedRouteDef;
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
DiceRollGroupManager,
|
||||
LoadingPlaceholder,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
Ability,
|
||||
AbilityUtils,
|
||||
characterActions,
|
||||
CharacterConfiguration,
|
||||
DiceRolls,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
RollResultContract,
|
||||
rulesEngineSelectors,
|
||||
Constants,
|
||||
RollGroupContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import {
|
||||
DiceNotation,
|
||||
DiceOperation,
|
||||
DieTerm,
|
||||
RollRequest,
|
||||
RollRequestRoll,
|
||||
} from "@dndbeyond/dice";
|
||||
|
||||
import { rollResultActions } from "../../../Shared/actions/rollResult";
|
||||
import * as toastActions from "../../../Shared/actions/toastMessage/actions";
|
||||
import DataLoadingStatusEnum from "../../../Shared/constants/DataLoadingStatusEnum";
|
||||
import { rollResultSelectors } from "../../../Shared/selectors";
|
||||
import {
|
||||
RollResultUtils,
|
||||
RollResultGroupsLookup,
|
||||
RollStatusLookup,
|
||||
} from "../../../Shared/utils";
|
||||
import { BuilderAppState } from "../../typings";
|
||||
|
||||
interface AbilityScore {
|
||||
statId: number | null;
|
||||
value: number | null;
|
||||
}
|
||||
interface AbilityScoreDiceManagerProps extends DispatchProp {
|
||||
abilities: Array<Ability>;
|
||||
configuration: CharacterConfiguration;
|
||||
rollResultGroupsLookup: RollResultGroupsLookup;
|
||||
simulatedGroupsLookup: RollResultGroupsLookup;
|
||||
rollStatusLookup: RollStatusLookup;
|
||||
}
|
||||
const AbilityScoreDiceManager: React.FunctionComponent<
|
||||
AbilityScoreDiceManagerProps
|
||||
> = ({
|
||||
abilities,
|
||||
configuration,
|
||||
rollResultGroupsLookup,
|
||||
simulatedGroupsLookup,
|
||||
rollStatusLookup,
|
||||
dispatch,
|
||||
}) => {
|
||||
const SIMULATED_GROUP_COUNT: number = 1;
|
||||
const SIMULATED_ROLL_RESULT_COUNT: number = 6;
|
||||
|
||||
const [loadingStatus, setLoadingStatus] = useState(
|
||||
DataLoadingStatusEnum.NOT_INITIALIZED
|
||||
);
|
||||
|
||||
const componentKey = useMemo<string | null>(
|
||||
() =>
|
||||
configuration.abilityScoreType
|
||||
? DiceRolls.generateAbilityManagerKey(configuration.abilityScoreType)
|
||||
: null,
|
||||
[configuration]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (componentKey) {
|
||||
setLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
dispatch(
|
||||
rollResultActions.rollResultGroupsLoad(
|
||||
componentKey,
|
||||
SIMULATED_GROUP_COUNT,
|
||||
SIMULATED_ROLL_RESULT_COUNT
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [setLoadingStatus]);
|
||||
|
||||
const diceRollGroups = useMemo<Array<RollGroupContract> | null>(() => {
|
||||
if (!componentKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let groups: Array<RollGroupContract> = [];
|
||||
const simulatedGroups = RollResultUtils.getComponentOrderedGroups(
|
||||
componentKey,
|
||||
simulatedGroupsLookup
|
||||
);
|
||||
if (simulatedGroups.length) {
|
||||
groups = simulatedGroups;
|
||||
} else {
|
||||
groups = RollResultUtils.getComponentOrderedGroups(
|
||||
componentKey,
|
||||
rollResultGroupsLookup
|
||||
);
|
||||
}
|
||||
|
||||
setLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
|
||||
return groups.map((group) => {
|
||||
return {
|
||||
...group,
|
||||
rollResults: RollResultUtils.getGroupOrderedRollResults(
|
||||
group.rollResults
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [
|
||||
simulatedGroupsLookup,
|
||||
rollResultGroupsLookup,
|
||||
componentKey,
|
||||
setLoadingStatus,
|
||||
]);
|
||||
|
||||
const diceRollRequest = useMemo<RollRequest>(() => {
|
||||
const dieTerm: DieTerm = new DieTerm(4, "d6", DiceOperation.Max, 3);
|
||||
const rollRequestRoll = new RollRequestRoll(new DiceNotation([dieTerm]));
|
||||
return {
|
||||
action: "Ability Score",
|
||||
rolls: [rollRequestRoll],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const abilityStatOptions = useMemo<Array<HtmlSelectOption>>(() => {
|
||||
return abilities.map((ability) => {
|
||||
return {
|
||||
label: AbilityUtils.getName(ability),
|
||||
value: String(AbilityUtils.getId(ability)),
|
||||
};
|
||||
});
|
||||
}, [abilities]);
|
||||
|
||||
const handleRollError = useCallback((title: string): void => {
|
||||
dispatch(
|
||||
toastActions.toastError(title, "Please wait a few seconds and try again")
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAddGroup = useCallback((): void => {
|
||||
if (componentKey) {
|
||||
dispatch(
|
||||
rollResultActions.rollResultGroupCreate(
|
||||
componentKey,
|
||||
SIMULATED_ROLL_RESULT_COUNT
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [componentKey]);
|
||||
|
||||
const handleRemoveGroup = useCallback(
|
||||
(groupKey: string, nextGroupKey: string | null): void => {
|
||||
if (componentKey) {
|
||||
dispatch(
|
||||
rollResultActions.rollResultGroupDestroy(
|
||||
componentKey,
|
||||
groupKey,
|
||||
nextGroupKey
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[componentKey]
|
||||
);
|
||||
|
||||
const handleUpdateGroupRolls = useCallback(
|
||||
(groupKey: string, rollResults: Array<RollResultContract>): void => {
|
||||
if (componentKey) {
|
||||
dispatch(
|
||||
rollResultActions.rollResultGroupDiceRollsSet(
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[componentKey]
|
||||
);
|
||||
|
||||
const handleSetScores = useCallback(
|
||||
(groupKey: string, rollResults: Array<RollResultContract>): void => {
|
||||
const abilityScores: Array<AbilityScore> = rollResults
|
||||
.filter(
|
||||
(roll) => roll.assignedValue !== null && roll.rollTotal !== null
|
||||
)
|
||||
.map((roll) => ({
|
||||
statId: roll.assignedValue
|
||||
? HelperUtils.parseInputInt(roll.assignedValue)
|
||||
: null,
|
||||
value: roll.rollTotal,
|
||||
}));
|
||||
|
||||
abilityScores.forEach((score) => {
|
||||
// Flash ability score input if it was changed
|
||||
const abilityScoreElement: HTMLElement | null = document.querySelector(
|
||||
`.ability-score-manager [data-stat-id="${score.statId}"] input`
|
||||
);
|
||||
if (abilityScoreElement !== null) {
|
||||
abilityScoreElement.classList.add("builder-field-update-animation");
|
||||
setTimeout(() => {
|
||||
abilityScoreElement.classList.remove(
|
||||
"builder-field-update-animation"
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (score.statId) {
|
||||
dispatch(
|
||||
characterActions.abilityScoreSet(
|
||||
score.statId,
|
||||
Constants.AbilityScoreStatTypeEnum.BASE,
|
||||
score.value
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResetGroup = useCallback(
|
||||
(groupKey: string, rollResults: Array<RollResultContract>) => {
|
||||
rollResults = rollResults.map((rollResult: RollResultContract) => ({
|
||||
...rollResult,
|
||||
rollTotal: null,
|
||||
rollValues: [],
|
||||
assignedValue: null,
|
||||
}));
|
||||
|
||||
if (componentKey) {
|
||||
dispatch(
|
||||
rollResultActions.rollResultGroupReset(
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[componentKey]
|
||||
);
|
||||
|
||||
const handleDiceRollSet = useCallback(
|
||||
(
|
||||
rollKey: string,
|
||||
properties: Omit<Partial<RollResultContract>, "rollKey">,
|
||||
nextRollKey: string | null
|
||||
) => {
|
||||
if (componentKey) {
|
||||
dispatch(
|
||||
rollResultActions.rollResultDiceRollSet(
|
||||
rollKey,
|
||||
properties,
|
||||
nextRollKey
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[componentKey]
|
||||
);
|
||||
|
||||
const handleSetRollStatus = useCallback(
|
||||
(rollKey: string, status: DataLoadingStatusEnum) => {
|
||||
dispatch(rollResultActions.rollResultDiceRollStatusSet(rollKey, status));
|
||||
},
|
||||
[rollStatusLookup]
|
||||
);
|
||||
|
||||
if (!componentKey || !diceRollGroups?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-ability-score-dice-manager">
|
||||
{loadingStatus !== DataLoadingStatusEnum.LOADED ? (
|
||||
<LoadingPlaceholder label="Loading Dice Groups" />
|
||||
) : (
|
||||
<DiceRollGroupManager
|
||||
componentKey={componentKey}
|
||||
groups={diceRollGroups}
|
||||
showAddGroupButton={true}
|
||||
onAddGroup={handleAddGroup}
|
||||
options={abilityStatOptions}
|
||||
diceRollRequest={diceRollRequest}
|
||||
exclusiveOptions={true}
|
||||
confirmButtonText="Apply Ability Scores"
|
||||
onConfirm={handleSetScores}
|
||||
onUpdateGroup={handleUpdateGroupRolls}
|
||||
onRollError={handleRollError}
|
||||
onRemoveGroup={handleRemoveGroup}
|
||||
onResetGroup={handleResetGroup}
|
||||
onUpdateDiceRoll={handleDiceRollSet}
|
||||
onSetRollStatus={handleSetRollStatus}
|
||||
rollStatusLookup={rollStatusLookup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: BuilderAppState) {
|
||||
return {
|
||||
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
|
||||
abilities: rulesEngineSelectors.getAbilities(state),
|
||||
rollStatusLookup: rollResultSelectors.getRollStatusLookup(state),
|
||||
rollResultGroupsLookup:
|
||||
rollResultSelectors.getRollResultComponentGroupsLookup(state),
|
||||
simulatedGroupsLookup:
|
||||
rollResultSelectors.getRollResultComponentSimulatedGroupsLookup(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AbilityScoreDiceManager);
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import { Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
CharacterConfiguration,
|
||||
Constants,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { BuilderAppState } from "../../typings";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
configuration: CharacterConfiguration;
|
||||
initialOptionRemoved: boolean;
|
||||
onScoreMethodChange?: (value: number | null) => void;
|
||||
hideTilAbilityScoreTypeSet: boolean;
|
||||
}
|
||||
class AbilityScoreTypeChooser extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
initialOptionRemoved: true,
|
||||
hideTilAbilityScoreTypeSet: false,
|
||||
};
|
||||
|
||||
handleScoreMethodChange = (value: string): void => {
|
||||
const { dispatch, onScoreMethodChange } = this.props;
|
||||
|
||||
const parsedValue = HelperUtils.parseInputInt(value);
|
||||
|
||||
dispatch(characterActions.abilityScoreTypeSetRequest(parsedValue));
|
||||
|
||||
if (onScoreMethodChange) {
|
||||
onScoreMethodChange(parsedValue);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { configuration, initialOptionRemoved, hideTilAbilityScoreTypeSet } =
|
||||
this.props;
|
||||
|
||||
const scoreMethodOptions: Array<HtmlSelectOption> = [
|
||||
{
|
||||
label: "Standard Array",
|
||||
value: Constants.AbilityScoreTypeEnum.STANDARD_ARRAY,
|
||||
},
|
||||
{
|
||||
label: "Manual/Rolled",
|
||||
value: Constants.AbilityScoreTypeEnum.MANUAL,
|
||||
},
|
||||
{
|
||||
label: "Point Buy",
|
||||
value: Constants.AbilityScoreTypeEnum.POINT_BUY,
|
||||
},
|
||||
];
|
||||
|
||||
if (hideTilAbilityScoreTypeSet && configuration.abilityScoreType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ability-score-type-chooser">
|
||||
<Select
|
||||
options={scoreMethodOptions}
|
||||
onChange={this.handleScoreMethodChange}
|
||||
initialOptionRemoved={initialOptionRemoved}
|
||||
value={configuration.abilityScoreType}
|
||||
placeholder="-- Choose a Generation Method --"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: BuilderAppState) {
|
||||
return {
|
||||
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AbilityScoreTypeChooser);
|
||||
@@ -0,0 +1,4 @@
|
||||
import AbilityScoreTypeChooser from "./AbilityScoreTypeChooser";
|
||||
|
||||
export default AbilityScoreTypeChooser;
|
||||
export { AbilityScoreTypeChooser };
|
||||
@@ -0,0 +1,513 @@
|
||||
import React, { useState } from "react";
|
||||
import { connect, DispatchProp, useDispatch } from "react-redux";
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { CharacterAvatarPortrait } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterSelectors,
|
||||
CharacterStatusSlug,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
import {
|
||||
DecorationInfo,
|
||||
DecorationUtils,
|
||||
FormatUtils,
|
||||
rulesEngineSelectors,
|
||||
characterActions,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
import { NotificationSystem } from "~/components/NotificationSystem";
|
||||
import config from "~/config";
|
||||
import { useFeatureFlags } from "~/contexts/FeatureFlag";
|
||||
import { useHeadContext } from "~/contexts/Head";
|
||||
import { BuilderMethod } from "~/subApps/builder/constants";
|
||||
import { ClassProvider } from "~/subApps/builder/contexts/Class";
|
||||
import { ModalManagerProvider } from "~/subApps/builder/contexts/ModalManager";
|
||||
import { SpeciesProvider } from "~/subApps/builder/contexts/Species";
|
||||
import { BuilderTypeChoicePage } from "~/subApps/builder/routes/BuilderTypeChoicePage";
|
||||
|
||||
import { appEnvActions, toastMessageActions } from "../../../Shared/actions";
|
||||
import LoadingBlocker from "../../../Shared/components/LoadingBlocker";
|
||||
import { BuilderLinkButton } from "../../../Shared/components/common/LinkButton";
|
||||
import AppErrorTypeEnum from "../../../Shared/constants/AppErrorTypeEnum";
|
||||
import UserRoles from "../../../Shared/constants/UserRoles";
|
||||
import { DiceContainer } from "../../../Shared/containers/DiceContainer";
|
||||
import {
|
||||
appEnvSelectors,
|
||||
appInfoSelectors,
|
||||
toastMessageSelectors,
|
||||
} from "../../../Shared/selectors";
|
||||
import { AppInfoErrorState } from "../../../Shared/stores/typings";
|
||||
import { builderActions } from "../../actions/builder";
|
||||
import { BUILDER_MQ_IS_MOBILE_SIZE } from "../../config";
|
||||
import { ROUTE_DEFINITIONS } from "../../config/navigation";
|
||||
import { builderSelectors } from "../../selectors";
|
||||
import { BuilderAppState } from "../../typings";
|
||||
import { getUrlWithParams } from "../../utils/navigationUtils";
|
||||
import NavigationSections from "../NavigationSections";
|
||||
import SynchronousBlocker from "../SynchronousBlocker";
|
||||
import TodoNavigation from "../TodoNavigation";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const BASE_PATHNAME = config.basePathname;
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
appError: AppInfoErrorState | null;
|
||||
toastMessages: any;
|
||||
builderMethod: string | null;
|
||||
isCharacterLoading: boolean;
|
||||
isCharacterLoaded: boolean;
|
||||
isMobile: boolean;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
redirect: string;
|
||||
characterId: number | null;
|
||||
diceEnabled: boolean;
|
||||
decorationInfo: DecorationInfo;
|
||||
canEdit: boolean;
|
||||
isReadonly: boolean;
|
||||
userRoles: string[] | null | undefined;
|
||||
characterStatus: string | null;
|
||||
}
|
||||
|
||||
const RenderAuthError: React.FC<{ appError: Props["appError"] }> | null = ({
|
||||
appError,
|
||||
}) => {
|
||||
if (appError === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
switch (appError.type) {
|
||||
case AppErrorTypeEnum.AUTH_MISSING:
|
||||
let signInUrl: string = `/sign-in?returnUrl=${encodeURIComponent(
|
||||
window.location.pathname
|
||||
)}`;
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<p>
|
||||
You are no longer signed into D&D Beyond. Go to the{" "}
|
||||
<Link href={signInUrl}>Sign In page</Link> to continue the
|
||||
adventure!
|
||||
</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
|
||||
case AppErrorTypeEnum.API_FAIL:
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Whoops! We rolled a 1 on our API check. We're heading into town to
|
||||
visit the blacksmith for repairs.
|
||||
</p>
|
||||
<p>Try again after a Short Rest.</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
|
||||
case AppErrorTypeEnum.API_DATA_FAIL:
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Whoops! We rolled a 1 on our API Data check. We're heading into town
|
||||
to visit the blacksmith for repairs.
|
||||
</p>
|
||||
<p>Try again after a Short Rest.</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
|
||||
case AppErrorTypeEnum.AUTH_FAIL:
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Whoops! We rolled a 1 on our Authentication check. We're heading
|
||||
into town to visit the blacksmith for repairs.
|
||||
</p>
|
||||
<p>Try again after a Short Rest.</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
|
||||
case AppErrorTypeEnum.ACCESS_DENIED:
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<p>You don't have access to this page.</p>
|
||||
<p>
|
||||
If you are trying to access a character sheet, make sure its privacy
|
||||
setting is configured correctly for you to access it.
|
||||
</p>
|
||||
<p>
|
||||
It is also possible that you are trying to enter the 403rd level of
|
||||
the endless dungeon and the ancient dragon Rylzrayrth is rising up
|
||||
to block your path...
|
||||
</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
case AppErrorTypeEnum.NOT_FOUND:
|
||||
appError.errorId = null;
|
||||
contentNode = (
|
||||
<>
|
||||
<p>
|
||||
We live in a world of uncertainty. But certainly, the page you were
|
||||
looking for isn’t here. Perhaps this halfling has stolen it and
|
||||
hidden it in another place. Try searching for what you were looking
|
||||
for in another realm.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case AppErrorTypeEnum.GENERIC:
|
||||
default:
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Whoops! We rolled a 1 on our check. We're heading into town to visit
|
||||
the blacksmith for repairs.
|
||||
</p>
|
||||
<p>Try again after a Short Rest.</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-character-sheet--failed">
|
||||
<div
|
||||
className={[
|
||||
"ct-character-sheet__failed",
|
||||
`ct-character-sheet__failed--${FormatUtils.slugify(appError.type)}`,
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="ct-character-sheet__failed-content">
|
||||
{contentNode}
|
||||
{appError.errorId && (
|
||||
<React.Fragment>
|
||||
<div className="ct-character-sheet__failed-code">
|
||||
<div className="ct-character-sheet__failed-code-label">
|
||||
Error Code
|
||||
</div>
|
||||
<div className="ct-character-sheet__failed-code-value">
|
||||
{appError.errorId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-character-sheet__failed-code">
|
||||
<div className="ct-character-sheet__failed-code-label">
|
||||
Version
|
||||
</div>
|
||||
<div className="ct-character-sheet__failed-code-value">
|
||||
{config.version}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const checkForMessages = (
|
||||
toastMessages,
|
||||
dispatch,
|
||||
notificationSystem
|
||||
): void => {
|
||||
Object.keys(toastMessages).forEach((messageId) => {
|
||||
if (!notificationSystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = toastMessages[messageId];
|
||||
const { autoDismiss, dismissible, position } = message.meta;
|
||||
|
||||
if (!notificationSystem.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
notificationSystem.current.addNotification({
|
||||
title: message.payload.title,
|
||||
message: message.payload.message,
|
||||
level: message.meta.level,
|
||||
autoDismiss,
|
||||
dismissible,
|
||||
position,
|
||||
uid: messageId,
|
||||
onRemove: () => dispatch(toastMessageActions.removeToast(messageId)),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const CharacterBuilder: React.FC<Props> | null = ({
|
||||
appError,
|
||||
toastMessages,
|
||||
builderMethod,
|
||||
isCharacterLoading,
|
||||
isCharacterLoaded,
|
||||
isMobile,
|
||||
name,
|
||||
username,
|
||||
redirect,
|
||||
characterId,
|
||||
diceEnabled,
|
||||
decorationInfo,
|
||||
canEdit,
|
||||
isReadonly,
|
||||
userRoles,
|
||||
characterStatus,
|
||||
}) => {
|
||||
let mobileMql: MediaQueryList | null = null;
|
||||
// useImperativeHandle is hard to type when we us our own context this will just disapear
|
||||
const notificationSystem = React.createRef<any>();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { setTitle } = useHeadContext();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const { featureFlags } = useFeatureFlags();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (characterId !== null && !isLoaded) {
|
||||
dispatch(builderActions.characterLoadRequest());
|
||||
setIsLoaded(true);
|
||||
}
|
||||
mobileMql = window.matchMedia(BUILDER_MQ_IS_MOBILE_SIZE);
|
||||
dispatch(appEnvActions.mobileSet(mobileMql.matches));
|
||||
mobileMql.onchange = (e) => {
|
||||
dispatch(appEnvActions.mobileSet(e.matches));
|
||||
};
|
||||
}, [characterId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
isLoaded &&
|
||||
characterId !== null &&
|
||||
characterStatus === CharacterStatusSlug.PREMADE
|
||||
) {
|
||||
const isLorekeeper =
|
||||
userRoles?.includes(UserRoles.LOREKEEPER) ||
|
||||
userRoles?.includes(UserRoles.ADMIN);
|
||||
isLorekeeper && dispatch(characterActions.premadeInfoGet(characterId));
|
||||
}
|
||||
}, [characterId, characterStatus, isLoaded, userRoles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
//check for isCharacterLoaded and canEdit to set isReadonly and redirect, or is Random build to set isReadonly
|
||||
if (isCharacterLoaded) {
|
||||
dispatch(appEnvActions.dataSet({ isReadonly: !canEdit }));
|
||||
if (!canEdit) {
|
||||
navigate(getUrlWithParams(`${BASE_PATHNAME}/${characterId}`));
|
||||
}
|
||||
} else if (builderMethod && builderMethod === BuilderMethod.RANDOMIZE) {
|
||||
dispatch(appEnvActions.dataSet({ isReadonly: !canEdit }));
|
||||
}
|
||||
}, [
|
||||
isCharacterLoaded,
|
||||
canEdit,
|
||||
characterId,
|
||||
navigate,
|
||||
dispatch,
|
||||
builderMethod,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
checkForMessages(toastMessages, dispatch, notificationSystem);
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const toolsTarget = document.getElementById("character-tools-target");
|
||||
if (toolsTarget) {
|
||||
toolsTarget.style.zIndex = "0";
|
||||
}
|
||||
return () => {
|
||||
const toolsTarget = document.getElementById("character-tools-target");
|
||||
if (toolsTarget) {
|
||||
toolsTarget.style.zIndex = "auto";
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setTitle("Character Builder");
|
||||
}, [setTitle]);
|
||||
|
||||
if (appError !== null) {
|
||||
return <RenderAuthError appError={appError} />;
|
||||
}
|
||||
|
||||
let clsNames: Array<string> = ["character-builder"];
|
||||
if (builderMethod) {
|
||||
clsNames.push(`character-builder-${FormatUtils.slugify(builderMethod)}`);
|
||||
}
|
||||
|
||||
// useEffect above handles redirect but this blocks UI from showing
|
||||
if (isReadonly && isCharacterLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isCharacterLoading && builderMethod === null) {
|
||||
let anonNode: React.ReactNode;
|
||||
if (!username) {
|
||||
anonNode = (
|
||||
<div className="character-builder-anon">
|
||||
<div className="character-builder-anon-heading">
|
||||
Start Your Adventure
|
||||
</div>
|
||||
<div className="character-builder-anon-content">
|
||||
<p>
|
||||
You need to be signed in to a D&D Beyond account in order to
|
||||
save characters. You can use your character sheet anywhere, on any
|
||||
device.
|
||||
</p>
|
||||
</div>
|
||||
<div className="character-builder-anon-actions">
|
||||
<BuilderLinkButton size="oversized" url={redirect}>
|
||||
Sign in or Create Account
|
||||
</BuilderLinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<div className="character-builder">
|
||||
<SynchronousBlocker />
|
||||
<div className="character-builder-inner">
|
||||
{anonNode}
|
||||
{<BuilderTypeChoicePage isEnabled={!!username} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
></Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
clsNames.push(styles.characterBuilder);
|
||||
|
||||
if (diceEnabled) {
|
||||
clsNames.push("character-builder--dice-enabled");
|
||||
}
|
||||
|
||||
clsNames.push(styles.characterBuilder);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<div className={clsNames.join(" ")}>
|
||||
{(characterId !== null || isCharacterLoading) && (
|
||||
<LoadingBlocker isFinished={isCharacterLoaded} />
|
||||
)}
|
||||
{!isCharacterLoading && (
|
||||
<ModalManagerProvider>
|
||||
<ClassProvider>
|
||||
<SpeciesProvider>
|
||||
<SynchronousBlocker />
|
||||
<div className="character-builder-inner">
|
||||
{builderMethod === BuilderMethod.STEP_BY_STEP && (
|
||||
<React.Fragment>
|
||||
{!isMobile && (
|
||||
<div className="character-builder-page-header">
|
||||
<h1 className="character-builder-page-header-heading">
|
||||
Character Builder
|
||||
</h1>
|
||||
{name && (
|
||||
<div className="character-builder-page-header-summary">
|
||||
<CharacterAvatarPortrait
|
||||
className={
|
||||
"character-builder-page-header-avatar"
|
||||
}
|
||||
avatarUrl={
|
||||
DecorationUtils.getAvatarInfo(
|
||||
decorationInfo
|
||||
).avatarUrl
|
||||
}
|
||||
/>
|
||||
<div className="character-builder-page-header-name">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<NavigationSections
|
||||
pathname={pathname}
|
||||
characterId={characterId}
|
||||
/>
|
||||
<TodoNavigation
|
||||
pathname={pathname}
|
||||
characterId={characterId}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<Routes>
|
||||
{Object.values(ROUTE_DEFINITIONS).map(
|
||||
({ key, path, getComponent }) => {
|
||||
const Component = getComponent(featureFlags);
|
||||
// console.log('COMPONENT', Component);
|
||||
return (
|
||||
<Route
|
||||
key={key}
|
||||
path={path
|
||||
.replace(`${BASE_PATHNAME}/builder/`, "")
|
||||
.replace(
|
||||
`${BASE_PATHNAME}/:characterId/builder/`,
|
||||
""
|
||||
)}
|
||||
element={Component}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<Route
|
||||
path="/"
|
||||
element={<BuilderTypeChoicePage />}
|
||||
></Route>
|
||||
</Routes>
|
||||
</div>
|
||||
</SpeciesProvider>
|
||||
</ClassProvider>
|
||||
</ModalManagerProvider>
|
||||
)}
|
||||
<NotificationSystem ref={notificationSystem} />
|
||||
{diceEnabled && !isReadonly ? (
|
||||
<DiceContainer canShow={isCharacterLoaded} />
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
></Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: BuilderAppState) {
|
||||
return {
|
||||
toastMessages: toastMessageSelectors.getToastMessages(state),
|
||||
builderMethod: builderSelectors.getBuilderMethod(state),
|
||||
isCharacterLoading: builderSelectors.getIsCharacterLoading(state),
|
||||
isCharacterLoaded: builderSelectors.getIsCharacterLoaded(state),
|
||||
isMobile: appEnvSelectors.getIsMobile(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
name: rulesEngineSelectors.getName(state),
|
||||
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
|
||||
canEdit: rulesEngineSelectors.getCanEdit(state),
|
||||
username: appEnvSelectors.getUsername(state),
|
||||
redirect: appEnvSelectors.getRedirect(state),
|
||||
appError: appInfoSelectors.getError(state),
|
||||
characterId: appEnvSelectors.getCharacterId(state),
|
||||
diceEnabled: appEnvSelectors.getDiceEnabled(state),
|
||||
userRoles: appEnvSelectors.getUserRoles(state),
|
||||
characterStatus: characterSelectors.getStatusSlug(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(CharacterBuilder);
|
||||
@@ -0,0 +1,4 @@
|
||||
import CharacterBuilder from "./CharacterBuilder";
|
||||
|
||||
export default CharacterBuilder;
|
||||
export { CharacterBuilder };
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
import { BuilderProvider } from "~/subApps/builder/contexts/Builder";
|
||||
|
||||
import ErrorBoundary from "../../../Shared/components/ErrorBoundary";
|
||||
import CharacterBuilder from "../CharacterBuilder";
|
||||
|
||||
export function CharacterBuilderContainer(props) {
|
||||
useLayoutEffect(() => {
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.classList.add(
|
||||
"site",
|
||||
"body-rpgcharacterbuilder",
|
||||
"site-dndbeyond",
|
||||
"body-rpgcharacter"
|
||||
);
|
||||
return () => {
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.classList.remove(
|
||||
"site",
|
||||
"body-rpgcharacterbuilder",
|
||||
"site-dndbeyond",
|
||||
"body-rpgcharacter"
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<BuilderProvider>
|
||||
<ErrorBoundary>
|
||||
<CharacterBuilder />
|
||||
</ErrorBoundary>
|
||||
</BuilderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default CharacterBuilderContainer;
|
||||
@@ -0,0 +1,4 @@
|
||||
import CharacterBuilderContainer from "./CharacterBuilderContainer";
|
||||
|
||||
export default CharacterBuilderContainer;
|
||||
export { CharacterBuilderContainer };
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { connect, DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
CharacterConfiguration,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { builderActions } from "../../actions";
|
||||
import { BuilderAppState } from "../../typings";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
configuration: CharacterConfiguration;
|
||||
}
|
||||
interface State {
|
||||
enabled: boolean;
|
||||
}
|
||||
class HelpTextManager extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const { configuration } = props;
|
||||
|
||||
this.state = {
|
||||
enabled:
|
||||
configuration.showHelpText === null
|
||||
? false
|
||||
: configuration.showHelpText,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
const { configuration } = this.props;
|
||||
|
||||
this.setState({
|
||||
enabled:
|
||||
configuration.showHelpText === null
|
||||
? false
|
||||
: configuration.showHelpText,
|
||||
});
|
||||
}
|
||||
|
||||
handleHelpTextChange = (evt: React.MouseEvent): void => {
|
||||
const { dispatch } = this.props;
|
||||
const { enabled } = this.state;
|
||||
|
||||
const isEnabled = !enabled;
|
||||
this.setState({
|
||||
enabled: isEnabled,
|
||||
});
|
||||
|
||||
dispatch(builderActions.showHelpTextSet(isEnabled));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { enabled } = this.state;
|
||||
|
||||
let classNames: Array<string> = ["help-text-manager"];
|
||||
if (enabled) {
|
||||
classNames.push("help-text-manager-enabled");
|
||||
} else {
|
||||
classNames.push("help-text-manager-disabled");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")} onClick={this.handleHelpTextChange}>
|
||||
<div className="help-text-manager-icon" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: BuilderAppState) {
|
||||
return {
|
||||
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(HelpTextManager);
|
||||
@@ -0,0 +1,4 @@
|
||||
import HelpTextManager from "./HelpTextManager";
|
||||
|
||||
export default HelpTextManager;
|
||||
export { HelpTextManager };
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import SwipeableViews from "react-swipeable-views";
|
||||
|
||||
import { FeatureFlagContext } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterSelectors,
|
||||
CharacterStatusSlug,
|
||||
featureFlagInfoSelectors,
|
||||
FormatUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
import { PremadeCharacterEditStatus } from "~/components/PremadeCharacterEditStatus";
|
||||
|
||||
import { appEnvSelectors } from "../../../Shared/selectors";
|
||||
import { MobileMessengerUtils } from "../../../Shared/utils";
|
||||
import { navigationConfig } from "../../config";
|
||||
import { builderEnvSelectors, builderSelectors } from "../../selectors";
|
||||
import { BuilderAppState } from "../../typings";
|
||||
import HelpTextManager from "../HelpTextManager";
|
||||
|
||||
interface Props {
|
||||
characterId: number | null;
|
||||
builderMethod: string | null;
|
||||
sections: Array<any>;
|
||||
firstAvailableSectionRoutes: any;
|
||||
isCharacterSheetReady: boolean;
|
||||
characterSheetUrl: string;
|
||||
activeSectionIdx: number;
|
||||
isMobile: boolean;
|
||||
characterStatus: CharacterStatusSlug | null;
|
||||
isReadonly: boolean;
|
||||
}
|
||||
class NavigationSections extends React.PureComponent<Props> {
|
||||
handleSheetShowClick = () => {
|
||||
const { characterId } = this.props;
|
||||
|
||||
if (characterId !== null) {
|
||||
MobileMessengerUtils.sendMessage(
|
||||
MobileMessengerUtils.createShowCharacterSheetMessage(characterId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderNonMobileUi = (): React.ReactNode => {
|
||||
const {
|
||||
characterId,
|
||||
sections,
|
||||
firstAvailableSectionRoutes,
|
||||
isCharacterSheetReady,
|
||||
characterSheetUrl,
|
||||
} = this.props;
|
||||
|
||||
let classNames: Array<string> = ["builder-sections-sheet"];
|
||||
if (isCharacterSheetReady) {
|
||||
classNames.push("builder-sections-sheet-ready");
|
||||
} else {
|
||||
classNames.push("builder-sections-sheet-disabled");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="builder-sections builder-sections-large">
|
||||
<HelpTextManager />
|
||||
<div className={classNames.join(" ")}>
|
||||
{isCharacterSheetReady ? (
|
||||
<Link
|
||||
className="builder-sections-sheet-icon"
|
||||
href={characterSheetUrl}
|
||||
onClick={this.handleSheetShowClick}
|
||||
useRouter
|
||||
/>
|
||||
) : (
|
||||
<div className="builder-sections-sheet-icon" />
|
||||
)}
|
||||
</div>
|
||||
<div className="builder-sections-large-routes">
|
||||
{sections.map((section) => {
|
||||
const firstRoute = firstAvailableSectionRoutes[section.key];
|
||||
let clsNames: Array<string> = [
|
||||
"builder-sections-link",
|
||||
`builder-sections-${FormatUtils.slugify(section.key)}`,
|
||||
];
|
||||
if (section.active) {
|
||||
clsNames.push("builder-sections-link-active");
|
||||
}
|
||||
return (
|
||||
<div className="builder-sections-view" key={section.key}>
|
||||
<Link
|
||||
href={firstRoute.path.replace(":characterId", characterId)}
|
||||
className={clsNames.join(" ")}
|
||||
useRouter
|
||||
>
|
||||
<FeatureFlagContext.Consumer>
|
||||
{(featureFlags) => section.getLabel(featureFlags)}
|
||||
</FeatureFlagContext.Consumer>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderMobileUi = (): React.ReactNode => {
|
||||
const {
|
||||
characterId,
|
||||
sections,
|
||||
firstAvailableSectionRoutes,
|
||||
activeSectionIdx,
|
||||
isCharacterSheetReady,
|
||||
characterSheetUrl,
|
||||
} = this.props;
|
||||
|
||||
const scrollbarStyles: React.CSSProperties = {
|
||||
padding: "0 37.5%",
|
||||
};
|
||||
|
||||
let classNames = ["builder-sections-sheet"];
|
||||
if (isCharacterSheetReady) {
|
||||
classNames.push("builder-sections-sheet-ready");
|
||||
} else {
|
||||
classNames.push("builder-sections-sheet-disabled");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="builder-sections builder-sections-small">
|
||||
<HelpTextManager />
|
||||
<div className={classNames.join(" ")}>
|
||||
{isCharacterSheetReady ? (
|
||||
<Link
|
||||
className="builder-sections-sheet-icon"
|
||||
href={characterSheetUrl}
|
||||
onClick={this.handleSheetShowClick}
|
||||
useRouter
|
||||
/>
|
||||
) : (
|
||||
<div className="builder-sections-sheet-icon" />
|
||||
)}
|
||||
</div>
|
||||
<SwipeableViews
|
||||
index={activeSectionIdx}
|
||||
style={scrollbarStyles}
|
||||
className="builder-sections-views"
|
||||
ignoreNativeScroll={true}
|
||||
>
|
||||
{sections.map((section) => {
|
||||
const firstRoute = firstAvailableSectionRoutes[section.key];
|
||||
let clsNames: Array<string> = [
|
||||
"builder-sections-link",
|
||||
`builder-sections-${FormatUtils.slugify(section.key)}`,
|
||||
];
|
||||
if (section.active) {
|
||||
clsNames.push("builder-sections-link-active");
|
||||
}
|
||||
return (
|
||||
<div className="builder-sections-view" key={section.key}>
|
||||
<Link
|
||||
href={firstRoute.path.replace(":characterId", characterId)}
|
||||
className={clsNames.join(" ")}
|
||||
useRouter
|
||||
>
|
||||
<FeatureFlagContext.Consumer>
|
||||
{(featureFlags) => section.getLabel(featureFlags)}
|
||||
</FeatureFlagContext.Consumer>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SwipeableViews>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { builderMethod, isMobile, characterStatus, isReadonly } = this.props;
|
||||
|
||||
if (builderMethod === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isMobile ? (
|
||||
<>
|
||||
{this.renderMobileUi()}
|
||||
<PremadeCharacterEditStatus
|
||||
characterStatus={characterStatus}
|
||||
isReadonly={isReadonly}
|
||||
isBuilderView
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{this.renderNonMobileUi()}
|
||||
<PremadeCharacterEditStatus
|
||||
characterStatus={characterStatus}
|
||||
isReadonly={isReadonly}
|
||||
isBuilderView
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: BuilderAppState, ownProps) {
|
||||
const currentPath = ownProps.pathname;
|
||||
const sections = navigationConfig.getNavigationSections(
|
||||
builderSelectors.getBuilderMethod(state),
|
||||
navigationConfig.getCurrentRouteDef(currentPath),
|
||||
featureFlagInfoSelectors.getFeatureFlagInfo(state)
|
||||
);
|
||||
|
||||
let activeSectionIdx: number = 0;
|
||||
sections.forEach((section, idx) => {
|
||||
if (section.active) {
|
||||
activeSectionIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
sections,
|
||||
firstAvailableSectionRoutes:
|
||||
builderSelectors.getFirstAvailableSectionRoutes(state),
|
||||
isCharacterSheetReady: builderSelectors.checkIsCharacterSheetReady(state),
|
||||
characterSheetUrl: builderEnvSelectors.getCharacterSheetUrl(state),
|
||||
builderMethod: builderSelectors.getBuilderMethod(state),
|
||||
activeSectionIdx,
|
||||
isMobile: appEnvSelectors.getIsMobile(state),
|
||||
characterStatus: characterSelectors.getStatusSlug(state),
|
||||
isReadonly: appEnvSelectors.getIsReadonly(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(NavigationSections);
|
||||
@@ -0,0 +1,4 @@
|
||||
import NavigationSections from "./NavigationSections";
|
||||
|
||||
export default NavigationSections;
|
||||
export { NavigationSections };
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useMemo, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
CharacterUtils,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { ConfirmModal } from "~/components/ConfirmModal";
|
||||
import { XpManager } from "~/components/XpManager";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { toastMessageActions } from "~/tools/js/Shared/actions";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const ProgressionManager: FC<Props> = ({ ...props }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [newXpTotal, setNewXpTotal] = useState<number | null>(null);
|
||||
const [shouldReset, setShouldReset] = useState<boolean>(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { currentXp, preferences, totalClassLevel, ruleData } =
|
||||
useCharacterEngine();
|
||||
|
||||
useEffect(() => {
|
||||
setShouldReset(newXpTotal === null);
|
||||
}, [newXpTotal]);
|
||||
|
||||
const handleManageXpClick = (): void => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = (): void => {
|
||||
setNewXpTotal(null);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = (): void => {
|
||||
if (newXpTotal !== null) {
|
||||
closeModal();
|
||||
|
||||
dispatch(characterActions.xpSet(newXpTotal));
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Experience Points Updated",
|
||||
"You have successfully updated your XP"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleXpUpdates = (newXpTotal: number): void => {
|
||||
setNewXpTotal(newXpTotal);
|
||||
};
|
||||
|
||||
const levelDiff = useMemo(() => {
|
||||
const currentLevel = CharacterUtils.deriveXpLevel(currentXp, ruleData);
|
||||
return currentLevel - totalClassLevel;
|
||||
}, [currentXp, totalClassLevel]);
|
||||
|
||||
return (
|
||||
<div className="progression-manager" {...props}>
|
||||
<div className="progression-manager-info">
|
||||
<div className="progression-manager-heading">
|
||||
Character Level: {totalClassLevel}
|
||||
{preferences.progressionType ===
|
||||
Constants.PreferenceProgressionTypeEnum.XP &&
|
||||
levelDiff !== 0 && (
|
||||
<span
|
||||
className={clsx([
|
||||
"progression-manager-heading-diff",
|
||||
levelDiff > 0 && "progression-manager-heading-diff-positive",
|
||||
levelDiff < 0 && "progression-manager-heading-diff-negative",
|
||||
])}
|
||||
>
|
||||
<span className="progression-manager-heading-diff-icon" />
|
||||
<span className="progression-manager-heading-diff-amount">
|
||||
{Math.abs(levelDiff)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preferences.progressionType ===
|
||||
Constants.PreferenceProgressionTypeEnum.MILESTONE ? (
|
||||
<div className="progression-manager-meta">Milestone Advancement</div>
|
||||
) : (
|
||||
<div className="progression-manager-action">
|
||||
<Button
|
||||
color="info"
|
||||
variant="builder-text"
|
||||
size="x-small"
|
||||
onClick={handleManageXpClick}
|
||||
>
|
||||
Manage XP
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onConfirm={onConfirm}
|
||||
heading="Manage XP"
|
||||
className={styles.xpManagerModal}
|
||||
confirmButtonText="Apply"
|
||||
>
|
||||
<XpManager
|
||||
handleXpUpdates={handleXpUpdates}
|
||||
shouldReset={shouldReset}
|
||||
className={styles.xpManager}
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { syncTransactionSelectors } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { BuilderAppState } from "../../typings";
|
||||
import { SyncBlockerLoadingPlaceholder } from "./SynchronousBlockerLoadingPlaceholder";
|
||||
|
||||
let showBlockerTimerId: number;
|
||||
let hideBlockerTimerId: number;
|
||||
|
||||
interface Props {
|
||||
waitDelayToShow: number;
|
||||
waitDelayToHide: number;
|
||||
className: string;
|
||||
syncActive: boolean;
|
||||
}
|
||||
interface State {
|
||||
showBlocker: boolean;
|
||||
transitioning: boolean;
|
||||
}
|
||||
class SynchronousBlocker extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
waitDelayToShow: 300,
|
||||
waitDelayToHide: 0,
|
||||
className: "",
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showBlocker: false,
|
||||
transitioning: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { syncActive, waitDelayToShow, waitDelayToHide } = this.props;
|
||||
|
||||
if (syncActive === prevProps.syncActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevState.transitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncActive) {
|
||||
window.clearTimeout(hideBlockerTimerId);
|
||||
this.setState({
|
||||
transitioning: true,
|
||||
});
|
||||
showBlockerTimerId = window.setTimeout(() => {
|
||||
this.setState({
|
||||
showBlocker: true,
|
||||
transitioning: false,
|
||||
});
|
||||
}, waitDelayToShow);
|
||||
} else {
|
||||
window.clearTimeout(showBlockerTimerId);
|
||||
if (waitDelayToHide) {
|
||||
this.setState({
|
||||
transitioning: true,
|
||||
});
|
||||
hideBlockerTimerId = window.setTimeout(() => {
|
||||
this.setState({
|
||||
showBlocker: false,
|
||||
transitioning: false,
|
||||
});
|
||||
}, waitDelayToHide);
|
||||
} else {
|
||||
this.setState({
|
||||
showBlocker: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleOverlayClick = (evt: React.MouseEvent): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
renderContent = (): React.ReactNode => {
|
||||
const { children } = this.props;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <SyncBlockerLoadingPlaceholder />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { transitioning } = this.state;
|
||||
const { syncActive, className } = this.props;
|
||||
|
||||
const classNames: Array<string> = ["sync-blocker", className];
|
||||
if (syncActive) {
|
||||
classNames.push("sync-blocker-active");
|
||||
} else {
|
||||
classNames.push("sync-blocker-inactive");
|
||||
}
|
||||
if (transitioning) {
|
||||
classNames.push("sync-blocker-transition-in-out");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")} onClick={this.handleOverlayClick}>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: BuilderAppState) {
|
||||
return {
|
||||
syncActive: syncTransactionSelectors.getActive(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(SynchronousBlocker);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
|
||||
import { AnimatedLoadingRingSvg } from "@dndbeyond/character-components/es";
|
||||
|
||||
interface Props {}
|
||||
export default class SyncBlockerLoadingPlaceholder extends React.PureComponent<Props> {
|
||||
render() {
|
||||
return (
|
||||
<div className="sync-blocker-group">
|
||||
<div className="sync-blocker-logo" />
|
||||
<AnimatedLoadingRingSvg className="sync-blocker-anim" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SynchronousBlocker from "./SynchronousBlocker";
|
||||
import SyncBlockerLoadingPlaceholder from "./SynchronousBlockerLoadingPlaceholder";
|
||||
|
||||
export default SynchronousBlocker;
|
||||
export { SynchronousBlocker, SyncBlockerLoadingPlaceholder };
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
DisabledChevronLeftSvg,
|
||||
DisabledChevronRightSvg,
|
||||
LightChevronLeftSvg,
|
||||
LightChevronRightSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
|
||||
import { appEnvSelectors } from "../../../Shared/selectors";
|
||||
import { navigationConfig } from "../../config";
|
||||
import { builderSelectors } from "../../selectors";
|
||||
import { BuilderAppState } from "../../typings";
|
||||
import { NavigationUtils } from "../../utils";
|
||||
|
||||
interface PrevProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
class TodoNavigationPrev extends React.PureComponent<PrevProps> {
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled } = this.props;
|
||||
|
||||
let conClassNames: Array<string> = [
|
||||
"builder-navigation-large-action",
|
||||
"builder-navigation-large-action-prev",
|
||||
];
|
||||
if (disabled) {
|
||||
conClassNames.push("builder-navigation-large-action-disabled");
|
||||
conClassNames.push("builder-navigation-large-action-prev-disabled");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={conClassNames.join(" ")}>
|
||||
<div className="builder-navigation-large-action-icon">
|
||||
{disabled ? <DisabledChevronLeftSvg /> : <LightChevronLeftSvg />}
|
||||
</div>
|
||||
<div className="builder-navigation-large-action-text">Prev</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface NextProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
class TodoNavigationNext extends React.PureComponent<NextProps> {
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled } = this.props;
|
||||
|
||||
let conClassNames: Array<string> = [
|
||||
"builder-navigation-large-action",
|
||||
"builder-navigation-large-action-next",
|
||||
];
|
||||
if (disabled) {
|
||||
conClassNames.push("builder-navigation-large-action-disabled");
|
||||
conClassNames.push("builder-navigation-large-action-next-disabled");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={conClassNames.join(" ")}>
|
||||
<div className="builder-navigation-large-action-text">Next</div>
|
||||
<div className="builder-navigation-large-action-icon">
|
||||
{disabled ? <DisabledChevronRightSvg /> : <LightChevronRightSvg />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prevRouteDef: any;
|
||||
nextRouteDef: any;
|
||||
builderMethod: string | null;
|
||||
isMobile: boolean;
|
||||
characterId: number | null;
|
||||
}
|
||||
class TodoNavigation extends React.PureComponent<Props> {
|
||||
handleNavClick = (): void => {
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
renderNonMobileUi = (): React.ReactNode => {
|
||||
const { prevRouteDef, nextRouteDef, characterId } = this.props;
|
||||
|
||||
return (
|
||||
<div className="builder-navigation-large">
|
||||
{prevRouteDef !== null ? (
|
||||
<Link
|
||||
className="builder-navigation-large-link"
|
||||
href={prevRouteDef.path.replace(":characterId", characterId)}
|
||||
onClick={this.handleNavClick}
|
||||
useRouter
|
||||
>
|
||||
<TodoNavigationPrev />
|
||||
</Link>
|
||||
) : (
|
||||
<div className="builder-navigation-large-link">
|
||||
<TodoNavigationPrev disabled={true} />
|
||||
</div>
|
||||
)}
|
||||
{nextRouteDef !== null ? (
|
||||
<Link
|
||||
className="builder-navigation-large-link"
|
||||
href={nextRouteDef.path.replace(":characterId", characterId)}
|
||||
onClick={this.handleNavClick}
|
||||
useRouter
|
||||
>
|
||||
<TodoNavigationNext />
|
||||
</Link>
|
||||
) : (
|
||||
<div className="builder-navigation-large-link">
|
||||
<TodoNavigationNext disabled={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderMobileUi = (): React.ReactNode => {
|
||||
const { prevRouteDef, nextRouteDef, characterId } = this.props;
|
||||
|
||||
return (
|
||||
<div className="builder-navigation">
|
||||
{prevRouteDef !== null ? (
|
||||
<Link
|
||||
className="builder-navigation-link"
|
||||
href={prevRouteDef.path.replace(":characterId", characterId)}
|
||||
onClick={this.handleNavClick}
|
||||
useRouter
|
||||
>
|
||||
< Prev
|
||||
</Link>
|
||||
) : (
|
||||
<div className="builder-navigation-link builder-navigation-link-disabled">
|
||||
< Prev
|
||||
</div>
|
||||
)}
|
||||
{nextRouteDef !== null ? (
|
||||
<Link
|
||||
className="builder-navigation-link"
|
||||
href={nextRouteDef.path.replace(":characterId", characterId)}
|
||||
onClick={this.handleNavClick}
|
||||
useRouter
|
||||
>
|
||||
Next >
|
||||
</Link>
|
||||
) : (
|
||||
<div className="builder-navigation-link builder-navigation-link-disabled">
|
||||
Next >
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isMobile, builderMethod } = this.props;
|
||||
|
||||
if (builderMethod === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isMobile ? this.renderMobileUi() : this.renderNonMobileUi();
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: BuilderAppState, ownProps): Props {
|
||||
const currentPath = ownProps.pathname;
|
||||
const characterId = ownProps.characterId;
|
||||
const currentRouteDef = navigationConfig.getCurrentRouteDef(currentPath);
|
||||
|
||||
let prevRouteDef, nextRouteDef;
|
||||
if (currentRouteDef === null) {
|
||||
prevRouteDef = null;
|
||||
nextRouteDef = null;
|
||||
} else {
|
||||
prevRouteDef = NavigationUtils.getAvailablePrevRoute(
|
||||
currentRouteDef,
|
||||
state
|
||||
);
|
||||
nextRouteDef = NavigationUtils.getAvailableNextRoute(
|
||||
currentRouteDef,
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
prevRouteDef,
|
||||
nextRouteDef,
|
||||
builderMethod: builderSelectors.getBuilderMethod(state),
|
||||
isMobile: appEnvSelectors.getIsMobile(state),
|
||||
characterId,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(TodoNavigation);
|
||||
@@ -0,0 +1,4 @@
|
||||
import TodoNavigation from "./TodoNavigation";
|
||||
|
||||
export default TodoNavigation;
|
||||
export { TodoNavigation };
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import PageSubHeader from "../../../components/PageSubHeader";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
class AbilityScoresHelp extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Determine Ability Scores</PageHeader>
|
||||
<p>
|
||||
Much of what your character does in the game depends on their six
|
||||
abilities: <strong>Strength</strong>, <strong>Dexterity</strong>,{" "}
|
||||
<strong>Constitution</strong>, <strong>Intelligence</strong>,{" "}
|
||||
<strong>Wisdom</strong>, and <strong>Charisma</strong>. Each ability
|
||||
has a score, which is a number you record for use during play.
|
||||
</p>
|
||||
|
||||
<PageHeader>Generation Method</PageHeader>
|
||||
<p>
|
||||
You can determine ability scores in one of three ways, chosen in the
|
||||
next step:
|
||||
</p>
|
||||
|
||||
<PageSubHeader>Standard Array</PageSubHeader>
|
||||
<p>
|
||||
If you want to save time or don’t like the idea of randomly
|
||||
determining ability scores, you can choose from a fixed list (15,
|
||||
14, 13, 12, 10, 8).
|
||||
</p>
|
||||
|
||||
<PageSubHeader>Manual or Random Generation</PageSubHeader>
|
||||
<p>
|
||||
Manually enter your ability scores. If you roll to randomly
|
||||
determine scores, choose this option to record your results. The
|
||||
standard method is to roll 4d6 and drop the lowest dice roll for
|
||||
each score. This system is used when you select Roll in the Manual
|
||||
category.
|
||||
</p>
|
||||
|
||||
<PageSubHeader>Point Cost</PageSubHeader>
|
||||
<p>
|
||||
Customize your ability scores by spending points. If you are playing
|
||||
an Adventurers League character, choose this option. You have 27
|
||||
points to spend on your ability scores. The cost of each score is
|
||||
shown on the Ability Score Point Costs table. For example, a score
|
||||
of 14 costs 7 of your 27 points.
|
||||
</p>
|
||||
|
||||
<h5>Ability Score Point Costs</h5>
|
||||
<table
|
||||
className="table-compendium"
|
||||
style={
|
||||
{
|
||||
maxWidth: "420px",
|
||||
"--sb-table-row-bg-hover": "#b3d3df",
|
||||
"--sb-table-row-bg-dark": "#d7e8ee",
|
||||
"--sb-table-row-bg-light": "#f1f7f9",
|
||||
"--dark-sb-table-row-bg-light": "#5e7982",
|
||||
"--dark-sb-table-row-bg-dark": "#5c7f8c",
|
||||
"--dark-sb-table-row-bg-hover": "#78a2b0",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Score</th>
|
||||
<th>Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>8</td>
|
||||
<td>0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>9</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>10</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>11</td>
|
||||
<td>3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>12</td>
|
||||
<td>4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>13</td>
|
||||
<td>5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>14</td>
|
||||
<td>7</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>15</td>
|
||||
<td>9</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
AbilityScoresHelp,
|
||||
RouteKey.ABILITY_SCORES_HELP
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import AbilityScoresHelp from "./AbilityScoresHelp";
|
||||
|
||||
export default AbilityScoresHelp;
|
||||
export { AbilityScoresHelp };
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import React from "react";
|
||||
import { DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
CollapsibleHeading,
|
||||
LightDiceSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityManager,
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiResponse,
|
||||
characterActions,
|
||||
CharacterConfiguration,
|
||||
CharacterPreferences,
|
||||
CharClass,
|
||||
ChoiceData,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DefinitionPool,
|
||||
DefinitionUtils,
|
||||
FeatDefinitionContract,
|
||||
FeatLookup,
|
||||
OptionalOriginLookup,
|
||||
PrerequisiteData,
|
||||
Race,
|
||||
RaceUtils,
|
||||
RacialTraitUtils,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
rulesEngineSelectors,
|
||||
serviceDataSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { Dice } from "@dndbeyond/dice";
|
||||
|
||||
import { AbilityScoreManager } from "~/components/AbilityScoreManager";
|
||||
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
|
||||
import { useAbilities } from "~/hooks/useAbilities";
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import { appEnvActions } from "../../../../Shared/actions/appEnv";
|
||||
import {
|
||||
apiCreatorSelectors,
|
||||
appEnvSelectors,
|
||||
} from "../../../../Shared/selectors";
|
||||
import AbilityScoreManagerManual from "../../../components/AbilityScoreManagerManual";
|
||||
import AbilityScoreManagerPointBuy from "../../../components/AbilityScoreManagerPointBuy";
|
||||
import AbilityScoreManagerStandardArray from "../../../components/AbilityScoreManagerStandardArray";
|
||||
import { Button } from "../../../components/Button";
|
||||
import { Page } from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import { PageHeader } from "../../../components/PageHeader";
|
||||
import SpeciesTraitList from "../../../components/SpeciesTraitList";
|
||||
import { AbilityScoreDiceManager } from "../../AbilityScoreDiceManager";
|
||||
import AbilityScoreTypeChooser from "../../AbilityScoreTypeChooser";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
abilities: AbilityManager[];
|
||||
configuration: CharacterConfiguration;
|
||||
diceEnabled: boolean;
|
||||
species: Race;
|
||||
featLookup: FeatLookup;
|
||||
loadAvailableFeats: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<FeatDefinitionContract[]>>;
|
||||
choiceInfo: ChoiceData;
|
||||
prerequisiteData: PrerequisiteData;
|
||||
preferences: CharacterPreferences;
|
||||
optionalOriginLookup: OptionalOriginLookup;
|
||||
definitionPool: DefinitionPool;
|
||||
charClasses: CharClass[];
|
||||
ruleData: RuleData;
|
||||
}
|
||||
class AbilityScoresManage extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
diceEnabled: false,
|
||||
};
|
||||
|
||||
handleDiceEnable = (): void => {
|
||||
const { dispatch, diceEnabled } = this.props;
|
||||
|
||||
if (diceEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDiceEnabledSetting: boolean = !diceEnabled;
|
||||
|
||||
try {
|
||||
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
|
||||
Dice.setEnabled(newDiceEnabledSetting);
|
||||
} catch (e) {}
|
||||
|
||||
dispatch(
|
||||
appEnvActions.dataSet({
|
||||
diceEnabled: newDiceEnabledSetting,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
renderAbilityScoreManager = (): React.ReactNode => {
|
||||
const { abilities, configuration } = this.props;
|
||||
|
||||
switch (configuration.abilityScoreType) {
|
||||
case Constants.AbilityScoreTypeEnum.MANUAL:
|
||||
return <AbilityScoreManagerManual abilities={abilities} />;
|
||||
|
||||
case Constants.AbilityScoreTypeEnum.STANDARD_ARRAY:
|
||||
return <AbilityScoreManagerStandardArray abilities={abilities} />;
|
||||
|
||||
case Constants.AbilityScoreTypeEnum.POINT_BUY:
|
||||
return <AbilityScoreManagerPointBuy abilities={abilities} />;
|
||||
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderAbilityDice = (): React.ReactNode => {
|
||||
const { diceEnabled } = this.props;
|
||||
|
||||
if (!diceEnabled) {
|
||||
return (
|
||||
<div className="ability-score-dice-control">
|
||||
<span className="ability-score-dice-control-text">
|
||||
Enable digital dice to assist with rolling your ability scores:
|
||||
</span>
|
||||
<Button size="medium" onClick={this.handleDiceEnable}>
|
||||
<LightDiceSvg />
|
||||
<span className="ability-score-dice-control-button-text">
|
||||
Use Dice
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let headingNode: React.ReactNode = (
|
||||
<CollapsibleHeading>Dice Roll Groups</CollapsibleHeading>
|
||||
);
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={headingNode} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={headerNode}
|
||||
className="ability-score-dice"
|
||||
initiallyCollapsed={false}
|
||||
>
|
||||
<AbilityScoreDiceManager />
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
handleSpeciesTraitChoiceChange = (
|
||||
speciesTraitId,
|
||||
choiceId,
|
||||
choiceType,
|
||||
optionValue
|
||||
): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(
|
||||
characterActions.racialTraitChoiceSetRequest(
|
||||
speciesTraitId,
|
||||
choiceType,
|
||||
choiceId,
|
||||
optionValue
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
renderSpeciesTraits() {
|
||||
const {
|
||||
species,
|
||||
featLookup,
|
||||
loadAvailableFeats,
|
||||
choiceInfo,
|
||||
prerequisiteData,
|
||||
preferences,
|
||||
optionalOriginLookup,
|
||||
definitionPool,
|
||||
} = this.props;
|
||||
|
||||
if (!species) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const speciesTraits = RaceUtils.getVisibleRacialTraits(species);
|
||||
const filteredSpeciesTraits =
|
||||
RacialTraitUtils.filterRacialTraitsByDisplayConfigurationType(
|
||||
speciesTraits,
|
||||
[Constants.DisplayConfigurationTypeEnum.ABILITY_SCORE]
|
||||
);
|
||||
|
||||
return (
|
||||
filteredSpeciesTraits.length > 0 && (
|
||||
<SpeciesTraitList
|
||||
speciesTraits={filteredSpeciesTraits}
|
||||
featLookup={featLookup}
|
||||
loadAvailableFeats={loadAvailableFeats}
|
||||
handleSpeciesTraitChoiceChange={this.handleSpeciesTraitChoiceChange}
|
||||
choiceInfo={choiceInfo}
|
||||
prerequisiteData={prerequisiteData}
|
||||
preferences={preferences}
|
||||
optionalOriginLookup={optionalOriginLookup}
|
||||
definitionPool={definitionPool}
|
||||
speciesName={RaceUtils.getFullName(species)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
renderInformationCollapsible = (): React.ReactNode => {
|
||||
const { ruleData, charClasses, species } = this.props;
|
||||
if (charClasses.length === 0 || species === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let definitionKeys: string[] = [];
|
||||
if (species) {
|
||||
definitionKeys.push(
|
||||
DefinitionUtils.hack__generateDefinitionKey(
|
||||
RaceUtils.getEntityRaceTypeId(species),
|
||||
RaceUtils.getEntityRaceId(species)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (charClasses.length > 0) {
|
||||
definitionKeys = definitionKeys.concat(
|
||||
charClasses.map((charClass) =>
|
||||
DefinitionUtils.hack__generateDefinitionKey(
|
||||
ClassUtils.getMappingEntityTypeId(charClass),
|
||||
ClassUtils.getId(charClass)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
|
||||
definitionKeys,
|
||||
ruleData,
|
||||
Constants.DisplayConfigurationTypeEnum.ABILITY_SCORE
|
||||
);
|
||||
|
||||
return <HelperTextAccordion builderHelperText={builderText} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { configuration, abilities } = this.props;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Ability Scores</PageHeader>
|
||||
<AbilityScoreTypeChooser initialOptionRemoved={false} />
|
||||
{this.renderInformationCollapsible()}
|
||||
{this.renderAbilityScoreManager()}
|
||||
{configuration.abilityScoreType ===
|
||||
Constants.AbilityScoreTypeEnum.MANUAL && this.renderAbilityDice()}
|
||||
{this.renderSpeciesTraits()}
|
||||
<PageHeader>Score Calculations</PageHeader>
|
||||
<p>
|
||||
Calculations, including the base scores you set above and any
|
||||
modifiers, are found below. You can also override any automatic
|
||||
calculations or modify them under each ability summary.
|
||||
</p>
|
||||
<div className="ability-score-calcs">
|
||||
{abilities.map((ability) => (
|
||||
<AbilityScoreManager
|
||||
ability={ability}
|
||||
showHeader={true}
|
||||
isReadonly={false}
|
||||
isBuilder={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AbilityScoresManageContainer(props) {
|
||||
const abilities = useAbilities();
|
||||
return <AbilityScoresManage {...props} abilities={abilities} />;
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
AbilityScoresManageContainer,
|
||||
RouteKey.ABILITY_SCORES_MANAGE,
|
||||
(state) => {
|
||||
return {
|
||||
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
|
||||
diceEnabled: appEnvSelectors.getDiceEnabled(state),
|
||||
species: rulesEngineSelectors.getRace(state),
|
||||
featLookup: rulesEngineSelectors.getFeatLookup(state),
|
||||
loadAvailableFeats: apiCreatorSelectors.makeLoadAvailableFeats(state),
|
||||
choiceInfo: rulesEngineSelectors.getChoiceInfo(state),
|
||||
prerequisiteData: rulesEngineSelectors.getPrerequisiteData(state),
|
||||
preferences: rulesEngineSelectors.getCharacterPreferences(state),
|
||||
optionalOriginLookup: rulesEngineSelectors.getOptionalOriginLookup(state),
|
||||
definitionPool: serviceDataSelectors.getDefinitionPool(state),
|
||||
charClasses: rulesEngineSelectors.getClasses(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import AbilityScoresManage from "./AbilityScoresManage";
|
||||
|
||||
export default AbilityScoresManage;
|
||||
export { AbilityScoresManage };
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import { CollapsibleContent } from "../../../../../../components/CollapsibleContent";
|
||||
import { appEnvSelectors } from "../../../../Shared/selectors";
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props {
|
||||
isMobile: boolean;
|
||||
}
|
||||
class ClassHelp extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { isMobile } = this.props;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Choose your Class</PageHeader>
|
||||
|
||||
<CollapsibleContent
|
||||
forceShow={!isMobile}
|
||||
heading={
|
||||
<p>
|
||||
In the first step, choose a class for your character. Every
|
||||
adventurer is a member of a class. Class broadly describes a
|
||||
character's vocation, what special talents they possess, and the
|
||||
tactics they are most likely to employ when exploring a dungeon,
|
||||
fighting monsters, or engaging in a tense negotiation.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Your character receives a number of benefits from your chosen
|
||||
class. These features are capabilities (such as Spellcasting) that
|
||||
set your character apart from members of other classes. You also
|
||||
gain a number of proficiencies: weapons, skills, saving throws,
|
||||
and sometimes tools. Your proficiencies define many of the things
|
||||
your character can do particularly well, from using certain
|
||||
weapons to telling a convincing lie.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
|
||||
<PageHeader>Multiclassing</PageHeader>
|
||||
|
||||
<p>
|
||||
Adventurers sometimes advance in more than one class. A Rogue might
|
||||
switch direction in life and swear the oath of a Paladin, A
|
||||
Barbarian might discover latent magical ability and dabble in the
|
||||
Sorcerer class while continuing to advance as a Barbarian.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The optional rules for combining classes in this way is called
|
||||
Multiclassing.
|
||||
</p>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
ClassHelp,
|
||||
RouteKey.CLASS_HELP,
|
||||
(state: BuilderAppState) => {
|
||||
return {
|
||||
isMobile: appEnvSelectors.getIsMobile(state),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClassHelp from "./ClassHelp";
|
||||
|
||||
export default ClassHelp;
|
||||
export { ClassHelp };
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiResponse,
|
||||
BaseSpellContract,
|
||||
CharacterPreferences,
|
||||
CharacterTheme,
|
||||
CharClass,
|
||||
ChoiceData,
|
||||
ClassDefinitionContract,
|
||||
ClassFeatureDefinitionContract,
|
||||
ClassFeatureUtils,
|
||||
ClassSpellInfo,
|
||||
ClassSpellListSpellsLookup,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DataOriginRefData,
|
||||
DefinitionPool,
|
||||
DefinitionUtils,
|
||||
EntitledEntity,
|
||||
FeatDefinitionContract,
|
||||
FeatLookup,
|
||||
InfusionChoice,
|
||||
InfusionDefinitionContract,
|
||||
InventoryManager,
|
||||
Modifier,
|
||||
OptionalClassFeatureLookup,
|
||||
OverallSpellInfo,
|
||||
PrerequisiteData,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
TypeValueLookup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
|
||||
import { TabList } from "~/components/TabList";
|
||||
import { ClassHeader } from "~/subApps/builder/routes/Class/ClassHeader";
|
||||
|
||||
import ClassSpellListManager from "../../../../../Shared/components/legacy/ClassSpellListManager";
|
||||
import {
|
||||
ApiClassFeaturesRequest,
|
||||
ApiSpellsRequest,
|
||||
} from "../../../../../Shared/selectors/composite/apiCreator";
|
||||
import ClassManagerFeature from "../ClassManagerFeature";
|
||||
import { OptionalFeatureManager } from "../OptionalFeatureManager";
|
||||
|
||||
interface Props {
|
||||
charClass: CharClass;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
overallSpellInfo: OverallSpellInfo;
|
||||
ruleData: RuleData;
|
||||
prerequisiteData: PrerequisiteData;
|
||||
choiceInfo: ChoiceData;
|
||||
preferences: CharacterPreferences;
|
||||
globalModifiers: Array<Modifier>;
|
||||
typeValueLookup: TypeValueLookup;
|
||||
definitionPool: DefinitionPool;
|
||||
knownInfusionLookup: Record<string, InfusionChoice>;
|
||||
knownReplicatedItems: Array<string>;
|
||||
activeSourceCategories: Array<number>;
|
||||
onClassFeatureChoiceChange: (
|
||||
classId: number,
|
||||
classFeatureId: number,
|
||||
choiceType: any,
|
||||
choiceId: string,
|
||||
optionValue: number | null,
|
||||
parentChoiceId: string | null
|
||||
) => void;
|
||||
onSpellPrepare: (spell: Spell, classMappingId: number) => void;
|
||||
onSpellUnprepare: (spell: Spell, classMappingId: number) => void;
|
||||
onSpellRemove: (spell: Spell, classMappingId: number) => void;
|
||||
onSpellAdd: (spell: Spell, classMappingId: number) => void;
|
||||
onAlwaysKnownLoad: (
|
||||
spells: Array<BaseSpellContract>,
|
||||
classId: number
|
||||
) => void;
|
||||
onInfusionChoiceItemChangePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
itemDefinitionKey: string,
|
||||
itemName: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceItemDestroyPromise?: (
|
||||
infusionChoiceKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceChangePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceDestroyPromise?: (
|
||||
infusionChoiceKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceCreatePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onOptionalFeatureSelection: (
|
||||
definitionKey: string,
|
||||
affectedClassFeatureDefinitionKey: 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;
|
||||
classSpellList: ClassSpellInfo | null;
|
||||
levelsRemaining: number;
|
||||
isMulticlass: boolean;
|
||||
loadAvailableSubclasses: (
|
||||
baseClassId: number,
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<Array<ClassDefinitionContract>>>;
|
||||
loadAvailableFeats: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<Array<FeatDefinitionContract>>>;
|
||||
loadRemainingSpellList: ApiSpellsRequest | null;
|
||||
loadAlwaysKnownSpells?: ApiSpellsRequest | null;
|
||||
loadAvailableOptionalClassFeatures: ApiClassFeaturesRequest | null;
|
||||
loadAvailableInfusions: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<
|
||||
ApiResponse<EntitledEntity<InfusionDefinitionContract>>
|
||||
>;
|
||||
onDefinitionsLoaded?: (
|
||||
definitions: Array<InfusionDefinitionContract>,
|
||||
accessTypes: Record<string, number>
|
||||
) => void;
|
||||
onFeatureDefinitionsLoaded?: (
|
||||
definitionData: EntitledEntity<ClassFeatureDefinitionContract>
|
||||
) => void;
|
||||
featLookup: FeatLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
|
||||
optionalClassFeatureLookup: OptionalClassFeatureLookup;
|
||||
theme: CharacterTheme;
|
||||
inventoryManager: InventoryManager;
|
||||
}
|
||||
interface State {
|
||||
showClassFeatures: boolean;
|
||||
showAtHigherLevelsClassFeatures: boolean;
|
||||
}
|
||||
export default class ClassManager extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showClassFeatures: true,
|
||||
showAtHigherLevelsClassFeatures: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleAtHigherLevelsTriggerClick = (evt: React.MouseEvent): void => {
|
||||
this.setState((prevState) => ({
|
||||
showAtHigherLevelsClassFeatures:
|
||||
!prevState.showAtHigherLevelsClassFeatures,
|
||||
}));
|
||||
};
|
||||
|
||||
handleFeatureChoiceChange = (
|
||||
classFeatureId: number,
|
||||
choiceId: string,
|
||||
choiceType: any,
|
||||
optionValue: number | null,
|
||||
parentChoiceId: string | null
|
||||
): void => {
|
||||
const { onClassFeatureChoiceChange, charClass } = this.props;
|
||||
|
||||
if (onClassFeatureChoiceChange) {
|
||||
onClassFeatureChoiceChange(
|
||||
ClassUtils.getActiveId(charClass),
|
||||
classFeatureId,
|
||||
choiceType,
|
||||
choiceId,
|
||||
optionValue,
|
||||
parentChoiceId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderInformationCollapsible = (): React.ReactNode => {
|
||||
const { ruleData, charClass } = this.props;
|
||||
if (charClass === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definitionKey = DefinitionUtils.hack__generateDefinitionKey(
|
||||
ClassUtils.getMappingEntityTypeId(charClass),
|
||||
ClassUtils.getId(charClass)
|
||||
);
|
||||
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
|
||||
[definitionKey],
|
||||
ruleData,
|
||||
Constants.DisplayConfigurationTypeEnum.CLASS_FEATURE
|
||||
);
|
||||
|
||||
return <HelperTextAccordion builderHelperText={builderText} />;
|
||||
};
|
||||
|
||||
renderClassFeatureGroups = (): React.ReactNode => {
|
||||
const { showClassFeatures, showAtHigherLevelsClassFeatures } = this.state;
|
||||
const {
|
||||
charClass,
|
||||
choiceInfo,
|
||||
prerequisiteData,
|
||||
preferences,
|
||||
loadAvailableSubclasses,
|
||||
loadAvailableFeats,
|
||||
typeValueLookup,
|
||||
globalModifiers,
|
||||
ruleData,
|
||||
definitionPool,
|
||||
loadAvailableInfusions,
|
||||
onDefinitionsLoaded,
|
||||
onInfusionChoiceItemChangePromise,
|
||||
onInfusionChoiceItemDestroyPromise,
|
||||
onInfusionChoiceChangePromise,
|
||||
onInfusionChoiceDestroyPromise,
|
||||
onInfusionChoiceCreatePromise,
|
||||
featLookup,
|
||||
knownInfusionLookup,
|
||||
knownReplicatedItems,
|
||||
optionalClassFeatureLookup,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
|
||||
if (!showClassFeatures) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const level = ClassUtils.getLevel(charClass);
|
||||
const isStartingClass = ClassUtils.isStartingClass(charClass);
|
||||
|
||||
const visibleClassFeatures = ClassUtils.getVisibileClassFeatures(charClass);
|
||||
const orderedVisibleFeatures = ClassUtils.deriveOrderedClassFeatures(
|
||||
visibleClassFeatures
|
||||
).filter((feature) => ClassFeatureUtils.getRequiredLevel(feature) <= level);
|
||||
|
||||
const classFeatures = ClassUtils.getClassFeatures(charClass);
|
||||
const orderedClassFeatures =
|
||||
ClassUtils.deriveOrderedClassFeatures(classFeatures);
|
||||
const atHigherLevelsClassFeatures = orderedClassFeatures.filter(
|
||||
(feature) => ClassFeatureUtils.getRequiredLevel(feature) > level
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="class-manager-features-groups">
|
||||
{this.renderInformationCollapsible()}
|
||||
{orderedVisibleFeatures.length > 0 && (
|
||||
<div className="class-manager-features-group">
|
||||
<div className="class-manager-features-group-items">
|
||||
{orderedVisibleFeatures.map((feature) => (
|
||||
<ClassManagerFeature
|
||||
key={ClassFeatureUtils.getId(feature)}
|
||||
charClass={charClass}
|
||||
feature={feature}
|
||||
isStartingClass={isStartingClass}
|
||||
onChoiceChange={this.handleFeatureChoiceChange}
|
||||
onInfusionChoiceItemChangePromise={
|
||||
onInfusionChoiceItemChangePromise
|
||||
}
|
||||
onInfusionChoiceItemDestroyPromise={
|
||||
onInfusionChoiceItemDestroyPromise
|
||||
}
|
||||
onInfusionChoiceChangePromise={onInfusionChoiceChangePromise}
|
||||
onInfusionChoiceDestroyPromise={
|
||||
onInfusionChoiceDestroyPromise
|
||||
}
|
||||
onInfusionChoiceCreatePromise={onInfusionChoiceCreatePromise}
|
||||
onDefinitionsLoaded={onDefinitionsLoaded}
|
||||
choiceInfo={choiceInfo}
|
||||
prerequisiteData={prerequisiteData}
|
||||
preferences={preferences}
|
||||
loadAvailableInfusions={loadAvailableInfusions}
|
||||
loadAvailableSubclasses={loadAvailableSubclasses}
|
||||
loadAvailableFeats={loadAvailableFeats}
|
||||
typeValueLookup={typeValueLookup}
|
||||
globalModifiers={globalModifiers}
|
||||
ruleData={ruleData}
|
||||
definitionPool={definitionPool}
|
||||
featLookup={featLookup}
|
||||
knownReplicatedItems={knownReplicatedItems}
|
||||
knownInfusionLookup={knownInfusionLookup}
|
||||
optionalClassFeatureLookup={optionalClassFeatureLookup}
|
||||
inventoryManager={inventoryManager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{atHigherLevelsClassFeatures.length > 0 && (
|
||||
<div
|
||||
className={`class-manager-features-group ${
|
||||
showAtHigherLevelsClassFeatures
|
||||
? "class-manager-features-group-opened"
|
||||
: "class-manager-features-group-collapsed"
|
||||
}`}
|
||||
>
|
||||
<div className="class-manager-features-group-header">
|
||||
<div
|
||||
className="class-manager-features-group-heading"
|
||||
onClick={this.handleAtHigherLevelsTriggerClick}
|
||||
>
|
||||
Available at Higher Levels ({atHigherLevelsClassFeatures.length}
|
||||
)
|
||||
</div>
|
||||
<div
|
||||
className="class-manager-features-group-trigger"
|
||||
onClick={this.handleAtHigherLevelsTriggerClick}
|
||||
/>
|
||||
</div>
|
||||
{showAtHigherLevelsClassFeatures && (
|
||||
<div className="class-manager-features-group-items">
|
||||
{atHigherLevelsClassFeatures.map((feature) => (
|
||||
<ClassManagerFeature
|
||||
key={ClassFeatureUtils.getId(feature)}
|
||||
charClass={charClass}
|
||||
choiceInfo={choiceInfo}
|
||||
feature={feature}
|
||||
isStartingClass={isStartingClass}
|
||||
isActive={false}
|
||||
prerequisiteData={prerequisiteData}
|
||||
preferences={preferences}
|
||||
loadAvailableInfusions={loadAvailableInfusions}
|
||||
loadAvailableSubclasses={loadAvailableSubclasses}
|
||||
loadAvailableFeats={loadAvailableFeats}
|
||||
typeValueLookup={typeValueLookup}
|
||||
globalModifiers={globalModifiers}
|
||||
ruleData={ruleData}
|
||||
definitionPool={definitionPool}
|
||||
knownReplicatedItems={knownReplicatedItems}
|
||||
knownInfusionLookup={knownInfusionLookup}
|
||||
optionalClassFeatureLookup={optionalClassFeatureLookup}
|
||||
inventoryManager={inventoryManager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderClassFeatures = (): React.ReactNode => {
|
||||
const { showClassFeatures } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`class-manager-features ${
|
||||
showClassFeatures
|
||||
? "class-manager-features-opened"
|
||||
: "class-manager-features-collapsed"
|
||||
}`}
|
||||
>
|
||||
{/*<div className="class-manager-features-header">*/}
|
||||
{/*<div className="class-manager-features-heading" onClick={this.handleFeaturesTriggerClick}>Class Features</div>*/}
|
||||
{/*<div className="class-manager-features-trigger" onClick={this.handleFeaturesTriggerClick} />*/}
|
||||
{/*</div>*/}
|
||||
{this.renderClassFeatureGroups()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
charClass,
|
||||
definitionPool,
|
||||
levelsRemaining,
|
||||
isMulticlass,
|
||||
loadRemainingSpellList,
|
||||
loadAlwaysKnownSpells,
|
||||
loadAvailableOptionalClassFeatures,
|
||||
preferences,
|
||||
classSpellList,
|
||||
spellCasterInfo,
|
||||
ruleData,
|
||||
overallSpellInfo,
|
||||
onSpellAdd,
|
||||
onSpellRemove,
|
||||
onSpellPrepare,
|
||||
onSpellUnprepare,
|
||||
onAlwaysKnownLoad,
|
||||
dataOriginRefData,
|
||||
onOptionalFeatureSelection,
|
||||
onRemoveSelectionPromise,
|
||||
onChangeReplacementPromise,
|
||||
onFeatureDefinitionsLoaded,
|
||||
optionalClassFeatureLookup,
|
||||
theme,
|
||||
activeSourceCategories,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="class-manager">
|
||||
<ClassHeader
|
||||
charClass={charClass}
|
||||
isMulticlass={isMulticlass}
|
||||
levelsRemaining={levelsRemaining}
|
||||
/>
|
||||
<TabList
|
||||
variant="toggle"
|
||||
defaultActiveId={isMulticlass ? "none" : "features"}
|
||||
tabs={[
|
||||
{
|
||||
label: "Class Features",
|
||||
content: this.renderClassFeatures(),
|
||||
id: "features",
|
||||
},
|
||||
preferences.enableOptionalClassFeatures
|
||||
? {
|
||||
label: "Optional Feature Manager",
|
||||
content: (
|
||||
<OptionalFeatureManager
|
||||
charClass={charClass}
|
||||
definitionPool={definitionPool}
|
||||
optionalClassFeatureLookup={optionalClassFeatureLookup}
|
||||
onDefinitionsLoaded={onFeatureDefinitionsLoaded}
|
||||
loadAvailableOptionalClassFeatures={
|
||||
loadAvailableOptionalClassFeatures
|
||||
}
|
||||
onSelection={onOptionalFeatureSelection}
|
||||
onChangeReplacementPromise={onChangeReplacementPromise}
|
||||
onRemoveSelectionPromise={onRemoveSelectionPromise}
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
),
|
||||
id: "optional-features",
|
||||
}
|
||||
: null,
|
||||
classSpellList
|
||||
? {
|
||||
label: "Spells",
|
||||
content: (
|
||||
<ClassSpellListManager
|
||||
{...(classSpellList as any)}
|
||||
loadRemainingSpellList={loadRemainingSpellList}
|
||||
loadAlwaysKnownSpells={loadAlwaysKnownSpells}
|
||||
onSpellPrepare={onSpellPrepare}
|
||||
onSpellUnprepare={onSpellUnprepare}
|
||||
onSpellRemove={onSpellRemove}
|
||||
onSpellAdd={onSpellAdd}
|
||||
onAlwaysKnownLoad={onAlwaysKnownLoad}
|
||||
showHeader={false}
|
||||
spellCasterInfo={spellCasterInfo}
|
||||
enableSpellcasting={false}
|
||||
enableCustomization={false}
|
||||
ruleData={ruleData}
|
||||
overallSpellInfo={overallSpellInfo}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
theme={theme}
|
||||
activeSourceCategories={activeSourceCategories}
|
||||
/>
|
||||
),
|
||||
id: "spells",
|
||||
}
|
||||
: null,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ClassManager from "./ClassManager";
|
||||
|
||||
export default ClassManager;
|
||||
export { ClassManager };
|
||||
+673
@@ -0,0 +1,673 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import React from "react";
|
||||
|
||||
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiAdapterUtils,
|
||||
ApiResponse,
|
||||
CharacterPreferences,
|
||||
CharClass,
|
||||
ChoiceData,
|
||||
ChoiceUtils,
|
||||
ClassDefinitionContract,
|
||||
ClassFeature,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DefinitionPool,
|
||||
EntitledEntity,
|
||||
Feat,
|
||||
FeatDefinitionContract,
|
||||
FeatLookup,
|
||||
FeatUtils,
|
||||
FormatUtils,
|
||||
HelperUtils,
|
||||
InfusionChoice,
|
||||
InfusionChoiceUtils,
|
||||
InfusionDefinitionContract,
|
||||
InfusionUtils,
|
||||
InventoryManager,
|
||||
KnownInfusionUtils,
|
||||
Modifier,
|
||||
OptionalClassFeatureLookup,
|
||||
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 { 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;
|
||||
charClass: CharClass;
|
||||
choiceInfo: ChoiceData;
|
||||
feature: ClassFeature;
|
||||
prerequisiteData: PrerequisiteData;
|
||||
isStartingClass: boolean;
|
||||
preferences: CharacterPreferences;
|
||||
globalModifiers: Array<Modifier>;
|
||||
typeValueLookup: TypeValueLookup;
|
||||
ruleData: RuleData;
|
||||
definitionPool: DefinitionPool;
|
||||
knownInfusionLookup: Record<string, InfusionChoice>;
|
||||
knownReplicatedItems: Array<string>;
|
||||
onChoiceChange?: (
|
||||
classFeatureId: number,
|
||||
choiceId: string,
|
||||
type: any,
|
||||
value: number | null,
|
||||
parentChoiceId: string | null
|
||||
) => void;
|
||||
onInfusionChoiceItemChangePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
itemDefinitionKey: string,
|
||||
itemName: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceItemDestroyPromise?: (
|
||||
infusionChoiceKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceChangePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceDestroyPromise?: (
|
||||
infusionChoiceKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceCreatePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
loadAvailableSubclasses: (
|
||||
baseClassId: number,
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<Array<ClassDefinitionContract>>>;
|
||||
loadAvailableFeats: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<ApiResponse<Array<FeatDefinitionContract>>>;
|
||||
loadAvailableInfusions: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<
|
||||
ApiResponse<EntitledEntity<InfusionDefinitionContract>>
|
||||
>;
|
||||
onDefinitionsLoaded?: (
|
||||
definitions: Array<InfusionDefinitionContract>,
|
||||
accessTypes: Record<string, number>
|
||||
) => void;
|
||||
featLookup: FeatLookup;
|
||||
optionalClassFeatureLookup: OptionalClassFeatureLookup;
|
||||
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,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
isActive: true,
|
||||
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 {
|
||||
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(
|
||||
(choice) =>
|
||||
ChoiceUtils.getType(choice) ===
|
||||
Constants.BuilderChoiceTypeEnum.SUB_CLASS_OPTION
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
handleChoiceChange = (
|
||||
id: string,
|
||||
type: number,
|
||||
value: any,
|
||||
parentChoiceId: string | null
|
||||
): void => {
|
||||
const { onChoiceChange, feature } = this.props;
|
||||
|
||||
if (onChoiceChange) {
|
||||
onChoiceChange(
|
||||
ClassFeatureUtils.getId(feature),
|
||||
id,
|
||||
type,
|
||||
HelperUtils.parseInputInt(value),
|
||||
parentChoiceId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
getAvailableInfusionChoices = (): Array<InfusionChoice> => {
|
||||
const { feature } = this.props;
|
||||
|
||||
return ClassFeatureUtils.getInfusionChoices(feature).filter(
|
||||
InfusionChoiceUtils.validateIsAvailable
|
||||
);
|
||||
};
|
||||
|
||||
getInfusionChoiceCount = (): number => {
|
||||
return this.getAvailableInfusionChoices().length;
|
||||
};
|
||||
|
||||
getTodoInfusionChoiceCount = (): number => {
|
||||
return this.getAvailableInfusionChoices().filter(
|
||||
(infusionChoice) =>
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice) === null
|
||||
).length;
|
||||
};
|
||||
|
||||
getInfusionItemChoiceCount = (): number => {
|
||||
return this.getAvailableInfusionChoices().filter((infusionChoice) => {
|
||||
const knownInfusion =
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
if (knownInfusion === null) {
|
||||
return false;
|
||||
}
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (simulatedInfusion === null) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
InfusionUtils.getType(simulatedInfusion) ===
|
||||
Constants.InfusionTypeEnum.REPLICATE
|
||||
);
|
||||
}).length;
|
||||
};
|
||||
|
||||
getTodoInfusionItemChoiceCount = (): number => {
|
||||
return this.getAvailableInfusionChoices().filter((infusionChoice) => {
|
||||
const knownInfusion =
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
if (knownInfusion === null) {
|
||||
return false;
|
||||
}
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (simulatedInfusion === null) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
InfusionUtils.getType(simulatedInfusion) !==
|
||||
Constants.InfusionTypeEnum.REPLICATE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (KnownInfusionUtils.getItemId(knownInfusion) !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).length;
|
||||
};
|
||||
|
||||
getChoiceCount = (): number => {
|
||||
const { feature } = this.props;
|
||||
return ClassFeatureUtils.getChoices(feature).length;
|
||||
};
|
||||
|
||||
getTodoChoiceCount = (): number => {
|
||||
const { feature } = this.props;
|
||||
return ClassFeatureUtils.getChoices(feature).filter(ChoiceUtils.isTodo)
|
||||
.length;
|
||||
};
|
||||
|
||||
getTotalChoiceCount = (): number => {
|
||||
return (
|
||||
this.getChoiceCount() +
|
||||
this.getInfusionChoiceCount() +
|
||||
this.getInfusionItemChoiceCount()
|
||||
);
|
||||
};
|
||||
|
||||
getTotalTodoCount = (): number => {
|
||||
return (
|
||||
this.getTodoChoiceCount() +
|
||||
this.getTodoInfusionChoiceCount() +
|
||||
this.getTodoInfusionItemChoiceCount()
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const multiClassDescription =
|
||||
ClassFeatureUtils.getMultiClassDescription(feature);
|
||||
if (
|
||||
!isStartingClass &&
|
||||
multiClassDescription &&
|
||||
multiClassDescription.length > 0
|
||||
) {
|
||||
return multiClassDescription;
|
||||
}
|
||||
|
||||
const description = ClassFeatureUtils.getDescription(feature);
|
||||
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();
|
||||
const featureType = ClassFeatureUtils.getFeatureType(feature);
|
||||
let metaItems: Array<string> = [];
|
||||
// Display the number of choices if there are any
|
||||
if (choiceCount) {
|
||||
metaItems.push(`${choiceCount} Choice${choiceCount !== 1 ? "s" : ""}`);
|
||||
}
|
||||
// Display the required level
|
||||
metaItems.push(
|
||||
`${FormatUtils.ordinalize(
|
||||
ClassFeatureUtils.getRequiredLevel(feature)
|
||||
)} level`
|
||||
);
|
||||
// Display that this is an optional class feature if it is
|
||||
if (featureType !== Constants.FeatureTypeEnum.GRANTED) {
|
||||
metaItems.push("Optional Class Feature");
|
||||
}
|
||||
// Display the feature that this feature replaces if it is a replacement
|
||||
if (featureType === Constants.FeatureTypeEnum.REPLACEMENT) {
|
||||
const optionalFeature = HelperUtils.lookupDataOrFallback(
|
||||
optionalClassFeatureLookup,
|
||||
ClassFeatureUtils.getDefinitionKey(feature)
|
||||
);
|
||||
|
||||
if (optionalFeature) {
|
||||
const affectedDefinitionKey =
|
||||
OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
|
||||
optionalFeature
|
||||
);
|
||||
|
||||
if (affectedDefinitionKey) {
|
||||
const replacedFeature = ClassFeatureUtils.simulateClassFeature(
|
||||
affectedDefinitionKey,
|
||||
definitionPool
|
||||
);
|
||||
|
||||
if (replacedFeature) {
|
||||
metaItems.push(
|
||||
`Replaces ${ClassFeatureUtils.getName(replacedFeature)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metaItems;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isActive,
|
||||
charClass,
|
||||
definitionPool,
|
||||
ruleData,
|
||||
globalModifiers,
|
||||
typeValueLookup,
|
||||
loadAvailableInfusions,
|
||||
onInfusionChoiceItemChangePromise,
|
||||
onInfusionChoiceItemDestroyPromise,
|
||||
onInfusionChoiceCreatePromise,
|
||||
onInfusionChoiceDestroyPromise,
|
||||
onInfusionChoiceChangePromise,
|
||||
onDefinitionsLoaded,
|
||||
knownReplicatedItems,
|
||||
knownInfusionLookup,
|
||||
feature,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
const { hasSubclassChoice, hasFeatChoice } = this.state;
|
||||
|
||||
// If the class feature grants feats, render those instead of the class feature.
|
||||
if (feature.featLists.length > 0) {
|
||||
return feature.featLists.map((fl) => (
|
||||
<GrantedFeat
|
||||
featList={fl}
|
||||
key={fl.definition.id}
|
||||
requiredLevel={ClassFeatureUtils.getRequiredLevel(feature)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
id={accordionId}
|
||||
summary={ClassFeatureUtils.getName(feature)}
|
||||
summaryMetaItems={this.getMetaItems()}
|
||||
variant="paper"
|
||||
showAlert={hasChoices}
|
||||
handleIsOpen={this.handleCollapsibleOpened}
|
||||
>
|
||||
<CollapsibleContent className="class-manager-feature-description">
|
||||
{this.getClassFeatureDescription()}
|
||||
</CollapsibleContent>
|
||||
{isActive && 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 && (
|
||||
<InfusionChoiceManager
|
||||
infusionChoices={this.getAvailableInfusionChoices()}
|
||||
contextLevel={ClassUtils.getLevel(charClass)}
|
||||
definitionPool={definitionPool}
|
||||
ruleData={ruleData}
|
||||
globalModifiers={globalModifiers}
|
||||
typeValueLookup={typeValueLookup}
|
||||
knownInfusionLookup={knownInfusionLookup}
|
||||
knownReplicatedItems={knownReplicatedItems}
|
||||
loadAvailableInfusions={loadAvailableInfusions}
|
||||
onInfusionChoiceItemChangePromise={
|
||||
onInfusionChoiceItemChangePromise
|
||||
}
|
||||
onInfusionChoiceItemDestroyPromise={
|
||||
onInfusionChoiceItemDestroyPromise
|
||||
}
|
||||
onInfusionChoiceChangePromise={onInfusionChoiceChangePromise}
|
||||
onInfusionChoiceDestroyPromise={onInfusionChoiceDestroyPromise}
|
||||
onInfusionChoiceCreatePromise={onInfusionChoiceCreatePromise}
|
||||
onDefinitionsLoaded={onDefinitionsLoaded}
|
||||
inventoryManager={inventoryManager}
|
||||
/>
|
||||
)}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ClassManagerFeature from "./ClassManagerFeature";
|
||||
|
||||
export default ClassManagerFeature;
|
||||
export { ClassManagerFeature };
|
||||
File diff suppressed because it is too large
Load Diff
+374
@@ -0,0 +1,374 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
MarketplaceCta,
|
||||
LoadingPlaceholder,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterUtils,
|
||||
CharClass,
|
||||
ClassFeature,
|
||||
ClassFeatureUtils,
|
||||
Constants,
|
||||
DefinitionPool,
|
||||
DefinitionPoolUtils,
|
||||
DefinitionUtils,
|
||||
OptionalClassFeature,
|
||||
OptionalClassFeatureLookup,
|
||||
OptionalClassFeatureUtils,
|
||||
ClassUtils,
|
||||
HelperUtils,
|
||||
RuleData,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
|
||||
import DataLoadingStatusEnum from "../../../../../Shared/constants/DataLoadingStatusEnum";
|
||||
import {
|
||||
ApiClassFeatureResponseData,
|
||||
ApiClassFeaturesRequest,
|
||||
} from "../../../../../Shared/selectors/composite/apiCreator";
|
||||
import { AppLoggerUtils, TypeScriptUtils } from "../../../../../Shared/utils";
|
||||
import { OptionalFeature } from "../../../../components/OptionalFeature";
|
||||
import { AffectedFeatureInfo } from "../../../../components/OptionalFeature/OptionalFeature";
|
||||
import { PageSubHeader } from "../../../../components/PageSubHeader";
|
||||
|
||||
interface OptionalFeatureManagerProps {
|
||||
charClass: CharClass;
|
||||
definitionPool: DefinitionPool;
|
||||
optionalClassFeatureLookup: OptionalClassFeatureLookup;
|
||||
loadAvailableOptionalClassFeatures?: ApiClassFeaturesRequest | null;
|
||||
onDefinitionsLoaded?: (definitionData: ApiClassFeatureResponseData) => void;
|
||||
onSelection: (
|
||||
definitionKey: string,
|
||||
affectedClassFeatureDefinitionKey: 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 OptionalFeatureManagerState {
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
}
|
||||
export class OptionalFeatureManager extends React.PureComponent<
|
||||
OptionalFeatureManagerProps,
|
||||
OptionalFeatureManagerState
|
||||
> {
|
||||
loadOptionalOriginsCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props: OptionalFeatureManagerProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { loadAvailableOptionalClassFeatures, onDefinitionsLoaded } =
|
||||
this.props;
|
||||
|
||||
const { loadingStatus } = this.state;
|
||||
|
||||
if (
|
||||
loadAvailableOptionalClassFeatures &&
|
||||
loadingStatus === DataLoadingStatusEnum.NOT_LOADED
|
||||
) {
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
});
|
||||
|
||||
loadAvailableOptionalClassFeatures({
|
||||
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 = (
|
||||
optionalClassFeature: ClassFeature
|
||||
): Array<AffectedFeatureInfo> => {
|
||||
const { definitionPool, charClass } = this.props;
|
||||
|
||||
return ClassFeatureUtils.getAffectedFeatureDefinitionKeys(
|
||||
optionalClassFeature
|
||||
)
|
||||
.map((definitionKey: string) =>
|
||||
ClassFeatureUtils.simulateClassFeature(definitionKey, definitionPool)
|
||||
)
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined)
|
||||
.map((affectedClassFeature: ClassFeature) => {
|
||||
const definitionKey =
|
||||
ClassFeatureUtils.getDefinitionKey(affectedClassFeature);
|
||||
let disabled = ClassUtils.getOptionalClassFeatures(charClass).some(
|
||||
(optionalFeatureMapping: OptionalClassFeature) =>
|
||||
OptionalClassFeatureUtils.getDefinitionKey(
|
||||
optionalFeatureMapping
|
||||
) !== ClassFeatureUtils.getDefinitionKey(optionalClassFeature) &&
|
||||
OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
|
||||
optionalFeatureMapping
|
||||
) === definitionKey
|
||||
);
|
||||
|
||||
return {
|
||||
name: ClassFeatureUtils.getName(affectedClassFeature),
|
||||
definitionKey,
|
||||
disabled,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
renderOptionalFeatureCta = (): React.ReactNode => {
|
||||
return (
|
||||
<div className="ct-optional-feature-manager__content">
|
||||
<div className="ct-optional-feature-manager__content--empty">
|
||||
<span>
|
||||
You currently have no available optional class features for this
|
||||
class
|
||||
</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> Subclasses.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loadingStatus } = this.state;
|
||||
const {
|
||||
definitionPool,
|
||||
charClass,
|
||||
optionalClassFeatureLookup,
|
||||
onChangeReplacementPromise,
|
||||
onRemoveSelectionPromise,
|
||||
onSelection,
|
||||
} = this.props;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
} else {
|
||||
let availableOptionalFeatures: Array<ClassFeature> =
|
||||
DefinitionPoolUtils.getTypedDefinitionList(
|
||||
Constants.DefinitionTypeEnum.CLASS_FEATURE,
|
||||
definitionPool
|
||||
)
|
||||
.map((featureDefinition) =>
|
||||
ClassFeatureUtils.simulateClassFeature(
|
||||
DefinitionUtils.getDefinitionKey(featureDefinition),
|
||||
definitionPool
|
||||
)
|
||||
)
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined)
|
||||
.filter((classFeature) =>
|
||||
ClassFeatureUtils.isValidClassClassFeature(charClass, classFeature)
|
||||
)
|
||||
.filter(
|
||||
(classFeature) =>
|
||||
ClassFeatureUtils.getFeatureType(classFeature) !==
|
||||
Constants.FeatureTypeEnum.GRANTED
|
||||
);
|
||||
|
||||
let availableOptionalClassFeatureLookup =
|
||||
HelperUtils.generateNonNullLookup(
|
||||
availableOptionalFeatures,
|
||||
ClassFeatureUtils.getDefinitionKey
|
||||
);
|
||||
|
||||
const unEntitledOptionalClassFeatures: Array<ClassFeature> = [];
|
||||
Object.keys(optionalClassFeatureLookup).forEach((definitionKey) => {
|
||||
if (
|
||||
!availableOptionalClassFeatureLookup.hasOwnProperty(definitionKey)
|
||||
) {
|
||||
const classFeature = ClassFeatureUtils.simulateClassFeature(
|
||||
definitionKey,
|
||||
definitionPool
|
||||
);
|
||||
if (
|
||||
classFeature &&
|
||||
ClassFeatureUtils.isValidClassClassFeature(charClass, classFeature)
|
||||
) {
|
||||
unEntitledOptionalClassFeatures.push(classFeature);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const consolidatedOptionalOrigins = [
|
||||
...availableOptionalFeatures,
|
||||
...unEntitledOptionalClassFeatures,
|
||||
].filter(
|
||||
(feature) =>
|
||||
!ClassFeatureUtils.getHideInContext(
|
||||
feature,
|
||||
Constants.AppContextTypeEnum.BUILDER
|
||||
)
|
||||
);
|
||||
|
||||
if (!consolidatedOptionalOrigins.length) {
|
||||
contentNode = this.renderOptionalFeatureCta();
|
||||
} else {
|
||||
const replacementFeatures: Array<ClassFeature> = [];
|
||||
const additionalFeatures: Array<ClassFeature> = [];
|
||||
consolidatedOptionalOrigins.forEach((feature) => {
|
||||
switch (ClassFeatureUtils.getFeatureType(feature)) {
|
||||
case Constants.FeatureTypeEnum.ADDITIONAL:
|
||||
additionalFeatures.push(feature);
|
||||
break;
|
||||
case Constants.FeatureTypeEnum.REPLACEMENT:
|
||||
replacementFeatures.push(feature);
|
||||
break;
|
||||
default:
|
||||
//not implemented
|
||||
}
|
||||
});
|
||||
|
||||
contentNode = (
|
||||
<div className="ct-optional-feature-manager__content">
|
||||
{replacementFeatures.length > 0 && (
|
||||
<React.Fragment>
|
||||
<PageSubHeader>Replacement Features</PageSubHeader>
|
||||
{ClassUtils.deriveOrderedClassFeatures(replacementFeatures).map(
|
||||
(feature) => {
|
||||
const optionalFeatureMapping =
|
||||
HelperUtils.lookupDataOrFallback(
|
||||
optionalClassFeatureLookup,
|
||||
ClassFeatureUtils.getDefinitionKey(feature)
|
||||
);
|
||||
const affectedFeatureDefinitionKey: string | null =
|
||||
optionalFeatureMapping
|
||||
? OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
|
||||
optionalFeatureMapping
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<OptionalFeature
|
||||
key={ClassFeatureUtils.getDefinitionKey(feature)}
|
||||
name={ClassFeatureUtils.getName(feature)}
|
||||
description={ClassFeatureUtils.getDescription(feature)}
|
||||
requiredLevel={ClassFeatureUtils.getRequiredLevel(
|
||||
feature
|
||||
)}
|
||||
featureType={ClassFeatureUtils.getFeatureType(feature)}
|
||||
definitionKey={ClassFeatureUtils.getDefinitionKey(
|
||||
feature
|
||||
)}
|
||||
affectedFeatureDefinitionKey={
|
||||
affectedFeatureDefinitionKey
|
||||
}
|
||||
isSelected={!!optionalFeatureMapping ?? false}
|
||||
onSelection={onSelection}
|
||||
onChangeReplacementPromise={onChangeReplacementPromise}
|
||||
onRemoveSelectionPromise={onRemoveSelectionPromise}
|
||||
affectedFeatures={this.getAffectedFeatureInfos(feature)}
|
||||
accessType={ClassFeatureUtils.getAccessType(feature)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{additionalFeatures.length > 0 && (
|
||||
<React.Fragment>
|
||||
<PageSubHeader>Additional Features</PageSubHeader>
|
||||
{ClassUtils.deriveOrderedClassFeatures(additionalFeatures).map(
|
||||
(feature) => {
|
||||
const optionalFeatureMapping =
|
||||
HelperUtils.lookupDataOrFallback(
|
||||
optionalClassFeatureLookup,
|
||||
ClassFeatureUtils.getDefinitionKey(feature)
|
||||
);
|
||||
const affectedFeatureDefinitionKey: string | null =
|
||||
optionalFeatureMapping
|
||||
? OptionalClassFeatureUtils.getAffectedClassFeatureDefinitionKey(
|
||||
optionalFeatureMapping
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<OptionalFeature
|
||||
key={ClassFeatureUtils.getDefinitionKey(feature)}
|
||||
name={ClassFeatureUtils.getName(feature)}
|
||||
description={ClassFeatureUtils.getDescription(feature)}
|
||||
requiredLevel={ClassFeatureUtils.getRequiredLevel(
|
||||
feature
|
||||
)}
|
||||
featureType={ClassFeatureUtils.getFeatureType(feature)}
|
||||
definitionKey={ClassFeatureUtils.getDefinitionKey(
|
||||
feature
|
||||
)}
|
||||
affectedFeatureDefinitionKey={
|
||||
affectedFeatureDefinitionKey
|
||||
}
|
||||
isSelected={!!optionalFeatureMapping ?? false}
|
||||
onSelection={onSelection}
|
||||
onChangeReplacementPromise={onChangeReplacementPromise}
|
||||
onRemoveSelectionPromise={onRemoveSelectionPromise}
|
||||
affectedFeatures={this.getAffectedFeatureInfos(feature)}
|
||||
accessType={ClassFeatureUtils.getAccessType(feature)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-optional-feature-manager">
|
||||
<div className="ct-optional-feature-manager__intro">
|
||||
Unlike the features in the Player’s Handbook, you don’t gain the
|
||||
features here automatically. Consulting with your DM, you decide
|
||||
whether to gain a feature in this section if you meet the level
|
||||
requirement noted in the feature’s description. These features can be
|
||||
selected separately from one another; you can use some, all, or none
|
||||
of them.
|
||||
</div>
|
||||
{contentNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OptionalFeatureManager;
|
||||
@@ -0,0 +1,5 @@
|
||||
import ClassManagerFeature from "./ClassManagerFeature";
|
||||
import ClassesManage from "./ClassesManage";
|
||||
|
||||
export default ClassesManage;
|
||||
export { ClassesManage, ClassManagerFeature };
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { navigationConfig } from "../../../config";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import { NavigationUtils } from "../../../utils";
|
||||
|
||||
interface Props {
|
||||
isPageAccessible: boolean;
|
||||
redirectRoute?: string | null;
|
||||
}
|
||||
const BuilderPage = (Wrapped) =>
|
||||
class extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { isPageAccessible, redirectRoute } = this.props;
|
||||
|
||||
if (!isPageAccessible && redirectRoute) {
|
||||
return <Navigate to={redirectRoute} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Wrapped {...this.props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ConnectedBuilderPage = (
|
||||
Wrapped,
|
||||
routeKey,
|
||||
wrappedMapStateToProps = (state: BuilderAppState): any => ({})
|
||||
) => {
|
||||
function mapStateToProps(state: BuilderAppState) {
|
||||
// const isPageAccessible = checkIsRouteAccessible(routeKey, state);
|
||||
|
||||
const isPageAccessible = NavigationUtils.checkStdBuilderPageRequirements(
|
||||
navigationConfig.getRouteDef(routeKey),
|
||||
state
|
||||
);
|
||||
|
||||
return {
|
||||
...wrappedMapStateToProps(state),
|
||||
isPageAccessible,
|
||||
redirectRoute: isPageAccessible
|
||||
? null
|
||||
: NavigationUtils.getAvailableRouteRedirect(routeKey, state),
|
||||
};
|
||||
}
|
||||
|
||||
return connect(mapStateToProps)(BuilderPage(Wrapped));
|
||||
};
|
||||
|
||||
export default ConnectedBuilderPage;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ConnectedBuilderPage from "./ConnectedBuilderPage";
|
||||
|
||||
export default ConnectedBuilderPage;
|
||||
export { ConnectedBuilderPage };
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props {}
|
||||
class DescriptionHelp extends React.PureComponent<Props> {
|
||||
render() {
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Background</PageHeader>
|
||||
<p>
|
||||
Your character’s background describes where they came from, their
|
||||
original occupation, and their place in the D&D world.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Backgrounds from the Core D&D source category give your
|
||||
character ability score increases, an Origin feat, and proficiencies
|
||||
in specific skills and tools.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Backgrounds from other sources give your character a background
|
||||
feature (a general benefit) and proficiency in two skills, and it
|
||||
might also give you additional languages or proficiency with certain
|
||||
kinds of tools.
|
||||
</p>
|
||||
|
||||
<PageHeader>Describe Your Character</PageHeader>
|
||||
<p>
|
||||
In this step, you will flesh your character out as a person. Your
|
||||
character needs a name.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You’ll need to decide your character’s appearance and personality.
|
||||
Choose your character’s alignment (the moral compass that guides his
|
||||
or her decisions) and ideals. Identify the things your character
|
||||
holds most dear, called bonds, and the flaws that could one day
|
||||
undermine them.
|
||||
</p>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(DescriptionHelp, RouteKey.DESCRIPTION_HELP);
|
||||
@@ -0,0 +1,4 @@
|
||||
import DescriptionHelp from "./DescriptionHelp";
|
||||
|
||||
export default DescriptionHelp;
|
||||
export { DescriptionHelp };
|
||||
+1635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
import DescriptionManage from "./DescriptionManage";
|
||||
|
||||
export default DescriptionManage;
|
||||
export { DescriptionManage };
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import PageSubHeader from "../../../components/PageSubHeader";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
class EquipmentHelp extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Choose Equipment</PageHeader>
|
||||
<p>
|
||||
In this step, you will select weapons, armor, and other adventuring
|
||||
gear for your character.
|
||||
</p>
|
||||
|
||||
<PageSubHeader>Starting Equipment</PageSubHeader>
|
||||
<p>
|
||||
Your starting equipment options will be displayed on the next
|
||||
screen. Your class and background determine your character’s
|
||||
starting equipment. Review the options and add the appropriate items
|
||||
using Manage Equipment.
|
||||
</p>
|
||||
|
||||
<PageSubHeader>Starting Gold</PageSubHeader>
|
||||
<p>
|
||||
Instead of taking the gear given to you by your class and
|
||||
background, you can purchase starting equipment. You have a number
|
||||
of gold pieces (GP) to spend based on your class, as shown on the
|
||||
next screen if you choose this option.
|
||||
</p>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(EquipmentHelp, RouteKey.EQUIPMENT_HELP);
|
||||
@@ -0,0 +1,4 @@
|
||||
import EquipmentHelp from "./EquipmentHelp";
|
||||
|
||||
export default EquipmentHelp;
|
||||
export { EquipmentHelp };
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
import { DispatchProp } from "react-redux";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
CollapsibleHeading,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
CharacterConfiguration,
|
||||
CharacterCurrencyContract,
|
||||
CharacterNotes,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
ContainerLookup,
|
||||
ContainerUtils,
|
||||
InventoryManager,
|
||||
FormatUtils,
|
||||
Item,
|
||||
ItemUtils,
|
||||
Modifier,
|
||||
RuleData,
|
||||
rulesEngineSelectors,
|
||||
TypeValueLookup,
|
||||
CoinManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { EditorWithDialog } from "~/subApps/builder/components/EditorWithDialog";
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
import {
|
||||
ModalData,
|
||||
useModalManager,
|
||||
} from "~/subApps/builder/contexts/ModalManager";
|
||||
|
||||
import * as toastActions from "../../../../Shared/actions/toastMessage/actions";
|
||||
import { ArmorListItem } from "../../../../Shared/components/legacy/ArmorList";
|
||||
import { CurrencyList } from "../../../../Shared/components/legacy/CurrencyList";
|
||||
import { EquipmentManagerShop } from "../../../../Shared/components/legacy/EquipmentManagerShop";
|
||||
import { GearListItem } from "../../../../Shared/components/legacy/GearList";
|
||||
import { WeaponListItem } from "../../../../Shared/components/legacy/WeaponList";
|
||||
import { CURRENCY_VALUE } from "../../../../Shared/constants/App";
|
||||
import StartingEquipment from "../../../../Shared/containers/StartingEquipment";
|
||||
import { CurrencyErrorTypeEnum } from "../../../../Shared/containers/panes/CurrencyPane/CurrencyPaneConstants";
|
||||
import { CoinManagerContext } from "../../../../Shared/managers/CoinManagerContext";
|
||||
import { InventoryManagerContext } from "../../../../Shared/managers/InventoryManagerContext";
|
||||
import { AppNotificationUtils } from "../../../../Shared/utils";
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import { PageHeader } from "../../../components/PageHeader";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
configuration: CharacterConfiguration;
|
||||
inventory: Array<Item>;
|
||||
containerLookup: ContainerLookup;
|
||||
totalWeight: number;
|
||||
notes: CharacterNotes;
|
||||
ruleData: RuleData;
|
||||
hasMaxAttunedItems: boolean;
|
||||
characterId: number;
|
||||
theme: CharacterTheme;
|
||||
globalModifiers: Array<Modifier>;
|
||||
valueLookupByType: TypeValueLookup;
|
||||
proficiencyBonus: number;
|
||||
inventoryManager: InventoryManager;
|
||||
coinManager: CoinManager;
|
||||
activeSourceCategories: Array<number>;
|
||||
createModal: (modalData: ModalData) => void;
|
||||
}
|
||||
interface State {
|
||||
showStartingEquipment: boolean;
|
||||
}
|
||||
class EquipmentManage extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showStartingEquipment: props.configuration.startingEquipmentType === null,
|
||||
};
|
||||
}
|
||||
|
||||
textareaInput = React.createRef<HTMLDivElement>();
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { configuration } = this.props;
|
||||
|
||||
if (configuration !== prevProps.configuration) {
|
||||
this.setState({
|
||||
showStartingEquipment: configuration.startingEquipmentType === null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleItemAdd = (
|
||||
item: Item,
|
||||
amount: number,
|
||||
containerDefinitionKey: string
|
||||
): void => {
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
inventoryManager.handleAdd(
|
||||
{ item, amount, containerDefinitionKey },
|
||||
AppNotificationUtils.handleItemAddAccepted.bind(this, item, amount)
|
||||
);
|
||||
};
|
||||
|
||||
handleCurrencyChange = (coin: CharacterCurrencyContract): void => {
|
||||
const { coinManager } = this.props;
|
||||
|
||||
coinManager.handleCoinSet({
|
||||
coin,
|
||||
containerDefinitionKey: coinManager.getCharacterContainerDefinitionKey(),
|
||||
});
|
||||
};
|
||||
|
||||
handleCurrencyError = (
|
||||
currencyName: string,
|
||||
errorType: CurrencyErrorTypeEnum
|
||||
): void => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
let message: string = "";
|
||||
if (errorType === CurrencyErrorTypeEnum.MIN) {
|
||||
message =
|
||||
"Cannot set currency to a negative value, the previous amount has been set instead.";
|
||||
}
|
||||
|
||||
if (errorType === CurrencyErrorTypeEnum.MAX) {
|
||||
message = `The max amount allowed for each currency type is ${FormatUtils.renderLocaleNumber(
|
||||
CURRENCY_VALUE.MAX
|
||||
)}, the previous value has been set instead.`;
|
||||
}
|
||||
|
||||
if (errorType !== null) {
|
||||
dispatch(
|
||||
toastActions.toastError(
|
||||
`Unable to Set Currency: ${currencyName}`,
|
||||
message
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderStartingEquipment = (): React.ReactNode => {
|
||||
const { showStartingEquipment } = this.state;
|
||||
|
||||
let headingNode: React.ReactNode = (
|
||||
<CollapsibleHeading>Starting Equipment</CollapsibleHeading>
|
||||
);
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={headingNode} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={headerNode}
|
||||
className="equipment-manage__starting"
|
||||
useBuilderStyles={true}
|
||||
initiallyCollapsed={!showStartingEquipment}
|
||||
collapsed={!showStartingEquipment}
|
||||
onChangeHandler={(isCollapsed) => {
|
||||
this.setState({
|
||||
showStartingEquipment: !isCollapsed,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<StartingEquipment
|
||||
onStartingEquipmentChoose={() => {
|
||||
this.setState({
|
||||
showStartingEquipment: false,
|
||||
});
|
||||
}}
|
||||
isInitialView={true}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
renderItem = (
|
||||
item: Item,
|
||||
itemParams,
|
||||
weaponLabels,
|
||||
armorLabels,
|
||||
gearLabels
|
||||
): React.ReactNode => {
|
||||
if (ItemUtils.isWeaponContract(item)) {
|
||||
let finalWeaponLabels = weaponLabels;
|
||||
if (ItemUtils.isAmmunition(item)) {
|
||||
//TODO fix this to be cleaner
|
||||
finalWeaponLabels = {
|
||||
...weaponLabels,
|
||||
equipLabel: "Use",
|
||||
unequipLabel:
|
||||
weaponLabels.unequipLabel === "Stow"
|
||||
? weaponLabels.unequipLabel
|
||||
: "In Use",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<WeaponListItem
|
||||
key={uuidv4()}
|
||||
item={item}
|
||||
{...finalWeaponLabels}
|
||||
{...itemParams}
|
||||
/>
|
||||
);
|
||||
} else if (ItemUtils.isArmorContract(item)) {
|
||||
return (
|
||||
<ArmorListItem
|
||||
key={uuidv4()}
|
||||
item={item}
|
||||
{...armorLabels}
|
||||
{...itemParams}
|
||||
/>
|
||||
);
|
||||
} else if (ItemUtils.isGearContract(item)) {
|
||||
return (
|
||||
<GearListItem
|
||||
key={uuidv4()}
|
||||
item={item}
|
||||
{...gearLabels}
|
||||
{...itemParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderItemList = (): React.ReactNode => {
|
||||
const { inventory } = this.props;
|
||||
|
||||
const weaponLabels = {
|
||||
equipLabel: "Wield",
|
||||
unequipLabel: "Wielding",
|
||||
};
|
||||
const armorLabels = {
|
||||
equipLabel: "Wear",
|
||||
unequipLabel: "Wearing",
|
||||
};
|
||||
const gearLabels = {
|
||||
equipLabel: "Use",
|
||||
unequipLabel: "In Use",
|
||||
};
|
||||
const itemParams = {
|
||||
showRemove: true,
|
||||
showEquip: true,
|
||||
showUnequip: true,
|
||||
showHeaderAction: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{ItemUtils.sortInventoryItems(inventory).map((item) =>
|
||||
this.renderItem(
|
||||
item,
|
||||
itemParams,
|
||||
weaponLabels,
|
||||
armorLabels,
|
||||
gearLabels
|
||||
)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderInventory = (): React.ReactNode => {
|
||||
const { inventory, totalWeight } = this.props;
|
||||
|
||||
const itemTotal: number = inventory.length;
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent
|
||||
heading={
|
||||
<CollapsibleHeading>
|
||||
Current Inventory ({itemTotal})
|
||||
</CollapsibleHeading>
|
||||
}
|
||||
callout={
|
||||
<div className="equipment-manage__callout">
|
||||
Total Weight: {FormatUtils.renderWeight(totalWeight)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={headerNode}
|
||||
className="equipment-manage__inventory"
|
||||
useBuilderStyles={true}
|
||||
initiallyCollapsed={itemTotal === 0}
|
||||
collapsed={itemTotal === 0}
|
||||
>
|
||||
{itemTotal === 0 ? (
|
||||
<div className="equipment-manager__inventory-empty">
|
||||
You currently have no items in your inventory. Add Starting
|
||||
Equipment above or Add Items from the list of available items below.
|
||||
</div>
|
||||
) : (
|
||||
this.renderItemList()
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
renderOtherPossessions = (): React.ReactNode => {
|
||||
const { notes, dispatch } = this.props;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={"Other Possessions"}
|
||||
className="equipment-manage__possessions"
|
||||
useBuilderStyles={true}
|
||||
>
|
||||
<EditorWithDialog
|
||||
heading={<h3>Personal Possessions</h3>}
|
||||
editButtonLabel="Edit Possessions"
|
||||
addButtonLabel="Add Possessions"
|
||||
placeholder="Add personal possessions here..."
|
||||
content={notes[Constants.NoteKeyEnum.PERSONAL_POSSESSIONS] ?? ""}
|
||||
onSave={(content) => {
|
||||
dispatch(
|
||||
characterActions.noteSet(
|
||||
Constants.NoteKeyEnum.PERSONAL_POSSESSIONS,
|
||||
content
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
renderAddItems = (): React.ReactNode => {
|
||||
const {
|
||||
ruleData,
|
||||
characterId,
|
||||
theme,
|
||||
globalModifiers,
|
||||
valueLookupByType,
|
||||
proficiencyBonus,
|
||||
activeSourceCategories,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
|
||||
const characterContainerKey =
|
||||
ContainerUtils.getCharacterContainerDefinitionKey(characterId);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={"Add Items"}
|
||||
className="equipment-manage__add-items"
|
||||
useBuilderStyles={true}
|
||||
>
|
||||
<EquipmentManagerShop
|
||||
theme={theme}
|
||||
globalModifiers={globalModifiers}
|
||||
valueLookupByType={valueLookupByType}
|
||||
onItemAdd={this.handleItemAdd}
|
||||
ruleData={ruleData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
containerDefinitionKey={characterContainerKey}
|
||||
activeSourceCategories={activeSourceCategories}
|
||||
inventoryManager={inventoryManager}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
renderCurrency = (): React.ReactNode => {
|
||||
const { coinManager } = this.props;
|
||||
|
||||
const coin = coinManager.getContainerCoin(
|
||||
coinManager.getCharacterContainerDefinitionKey()
|
||||
);
|
||||
|
||||
if (!coin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent
|
||||
heading={<CollapsibleHeading>Currency</CollapsibleHeading>}
|
||||
callout={
|
||||
<div className="equipment-manage__callout">
|
||||
Total in GP:{" "}
|
||||
{FormatUtils.renderLocaleNumber(
|
||||
coinManager.getTotalContainerCoinInGold(
|
||||
coinManager.getCharacterContainerDefinitionKey()
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
header={headerNode}
|
||||
className="equipment-manage__currency"
|
||||
useBuilderStyles={true}
|
||||
>
|
||||
<CurrencyList
|
||||
{...coin}
|
||||
onChange={this.handleCurrencyChange}
|
||||
onError={this.handleCurrencyError}
|
||||
totalGp={coinManager.getTotalContainerCoinInGold(
|
||||
coinManager.getCharacterContainerDefinitionKey()
|
||||
)}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Choose Equipment</PageHeader>
|
||||
<div className="equipment-manage">
|
||||
{this.renderStartingEquipment()}
|
||||
{this.renderInventory()}
|
||||
{this.renderOtherPossessions()}
|
||||
{this.renderAddItems()}
|
||||
{this.renderCurrency()}
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function EquipmentManageContainer(props) {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
const { createModal } = useModalManager();
|
||||
|
||||
return (
|
||||
<EquipmentManage
|
||||
coinManager={coinManager}
|
||||
inventoryManager={inventoryManager}
|
||||
createModal={createModal}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
EquipmentManageContainer,
|
||||
RouteKey.EQUIPMENT_MANAGE,
|
||||
(state: BuilderAppState) => {
|
||||
return {
|
||||
configuration: rulesEngineSelectors.getCharacterConfiguration(state),
|
||||
inventory: rulesEngineSelectors.getInventory(state),
|
||||
containerLookup: rulesEngineSelectors.getContainerLookup(state),
|
||||
totalWeight: rulesEngineSelectors.getTotalCarriedWeight(state),
|
||||
notes: rulesEngineSelectors.getCharacterNotes(state),
|
||||
currencies: rulesEngineSelectors.getCurrencies(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
hasMaxAttunedItems: rulesEngineSelectors.hasMaxAttunedItems(state),
|
||||
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
|
||||
characterId: rulesEngineSelectors.getId(state),
|
||||
globalModifiers: rulesEngineSelectors.getValidGlobalModifiers(state),
|
||||
valueLookupByType:
|
||||
rulesEngineSelectors.getCharacterValueLookupByType(state),
|
||||
theme: rulesEngineSelectors.getCharacterTheme(state),
|
||||
activeSourceCategories:
|
||||
rulesEngineSelectors.getActiveSourceCategories(state),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import EquipmentManage from "./EquipmentManage";
|
||||
|
||||
export default EquipmentManage;
|
||||
export { EquipmentManage };
|
||||
@@ -0,0 +1,846 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import React, { ChangeEvent, HTMLAttributes, ReactNode } from "react";
|
||||
import { DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
CharacterPreferences,
|
||||
Constants,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
rulesEngineSelectors,
|
||||
CharacterUtils,
|
||||
ClassSpellListSpellsLookup,
|
||||
ClassUtils,
|
||||
CharClass,
|
||||
Race,
|
||||
RaceUtils,
|
||||
characterSelectors,
|
||||
PremadeInfo,
|
||||
PremadeInfoStatus,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { Dice } from "@dndbeyond/dice";
|
||||
|
||||
import { SourceCategoryDescription } from "~/constants";
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
import {
|
||||
ModalData,
|
||||
useModalManager,
|
||||
} from "~/subApps/builder/contexts/ModalManager";
|
||||
import { FormInputField } from "~/tools/js/Shared/components/common/FormInputField";
|
||||
import UserRoles from "~/tools/js/Shared/constants/UserRoles";
|
||||
import PrivacyTypeRadio from "~/tools/js/smartComponents/PrivacyTypeRadio";
|
||||
|
||||
import { appEnvActions } from "../../../../Shared/actions";
|
||||
import { SimpleClassSpellList } from "../../../../Shared/components/SimpleClassSpellList";
|
||||
import {
|
||||
FormCheckBoxesField,
|
||||
CheckboxInfo,
|
||||
} from "../../../../Shared/components/common/FormCheckBoxesField";
|
||||
import FormSelectField from "../../../../Shared/components/common/FormSelectField";
|
||||
import FormToggleField from "../../../../Shared/components/common/FormToggleField";
|
||||
import { appEnvSelectors } from "../../../../Shared/selectors";
|
||||
import config from "../../../../config";
|
||||
import RadioGroup from "../../../components/CharacterSheetOptions/RadioGroup";
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
preferences: CharacterPreferences;
|
||||
ruleData: RuleData;
|
||||
activeSourceCategories: Array<number>;
|
||||
diceEnabled: boolean;
|
||||
userRoles: string[] | null | undefined;
|
||||
classes: Array<CharClass>;
|
||||
species: Race | null;
|
||||
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
|
||||
premadeInfo: PremadeInfo | null;
|
||||
characterId: number;
|
||||
createModal: (modalData: ModalData) => void;
|
||||
}
|
||||
|
||||
class HomeBasicInfo extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
diceEnabled: false,
|
||||
};
|
||||
|
||||
handlePreferenceChange = (prefKey: string, value: boolean): void => {
|
||||
const { dispatch } = this.props;
|
||||
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
|
||||
if (typedPrefKey !== null) {
|
||||
dispatch(characterActions.preferenceChoose(typedPrefKey, value));
|
||||
}
|
||||
};
|
||||
|
||||
handleIntPreferenceChange = (prefKey: string, value: string): void => {
|
||||
const { dispatch } = this.props;
|
||||
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
|
||||
if (typedPrefKey !== null) {
|
||||
dispatch(
|
||||
characterActions.preferenceChoose(
|
||||
typedPrefKey,
|
||||
HelperUtils.parseInputInt(value)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleSourceCategoryChange = (sourceId: number, isActive: boolean): void => {
|
||||
const { dispatch, activeSourceCategories } = this.props;
|
||||
|
||||
let newSourceCats: Array<any> = [];
|
||||
if (isActive) {
|
||||
newSourceCats = [...activeSourceCategories, sourceId];
|
||||
} else {
|
||||
newSourceCats = activeSourceCategories.filter((id) => id !== sourceId);
|
||||
}
|
||||
|
||||
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
|
||||
};
|
||||
|
||||
handlePartneredSourceChangeAll = (
|
||||
sourceIds: number[],
|
||||
isActive: boolean
|
||||
): void => {
|
||||
const { dispatch, activeSourceCategories } = this.props;
|
||||
|
||||
let newSourceCats: Array<any> = [];
|
||||
if (isActive) {
|
||||
newSourceCats = [...activeSourceCategories, ...sourceIds];
|
||||
} else {
|
||||
newSourceCats = activeSourceCategories.filter(
|
||||
(id) => !sourceIds.includes(id)
|
||||
);
|
||||
}
|
||||
|
||||
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
|
||||
};
|
||||
|
||||
handleOptionalClassFeaturesPreferenceChangePromise = (
|
||||
newIsEnabled: boolean,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const { classes, classSpellListSpellsLookup, createModal } = this.props;
|
||||
const spellListIds: Array<number> =
|
||||
ClassUtils.getUpdateEnableOptionalClassFeaturesSpellListIdsToRemove(
|
||||
classes,
|
||||
newIsEnabled
|
||||
);
|
||||
|
||||
const hasSpellsToRemove = spellListIds.some((id) =>
|
||||
classSpellListSpellsLookup.hasOwnProperty(id)
|
||||
);
|
||||
|
||||
if (!hasSpellsToRemove) {
|
||||
this.handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled);
|
||||
accept();
|
||||
} else {
|
||||
createModal({
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to disable{" "}
|
||||
<strong>Optional Class Features</strong> for this character?
|
||||
</p>
|
||||
<p>
|
||||
After doing so, the following spells provided by these features
|
||||
will be removed from your character:
|
||||
</p>
|
||||
<SimpleClassSpellList
|
||||
spellListIds={spellListIds}
|
||||
classSpellListSpellsLookup={classSpellListSpellsLookup}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: "Optional Class Features",
|
||||
size: "fit-content",
|
||||
variant: "remove",
|
||||
onConfirm: () => {
|
||||
this.handlePreferenceChange(
|
||||
"enableOptionalClassFeatures",
|
||||
newIsEnabled
|
||||
);
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleOptionalOriginsPreferenceChangePromise = (
|
||||
newIsEnabled: boolean,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const { createModal, species, classSpellListSpellsLookup } = this.props;
|
||||
|
||||
if (!species) {
|
||||
this.handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
|
||||
accept();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const spellListIds: Array<number> =
|
||||
RaceUtils.getUpdateEnableOptionalOriginsSpellListIdsToRemove(
|
||||
species,
|
||||
newIsEnabled
|
||||
);
|
||||
|
||||
const hasSpellsToRemove = spellListIds.some((id) =>
|
||||
classSpellListSpellsLookup.hasOwnProperty(id)
|
||||
);
|
||||
|
||||
if (!hasSpellsToRemove) {
|
||||
this.handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
|
||||
accept();
|
||||
} else {
|
||||
createModal({
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to disable{" "}
|
||||
<strong>Customized Origins</strong> for this character?
|
||||
</p>
|
||||
<p>
|
||||
After doing so, the following spells provided by these features
|
||||
will be removed from your character:
|
||||
</p>
|
||||
<SimpleClassSpellList
|
||||
spellListIds={spellListIds}
|
||||
classSpellListSpellsLookup={classSpellListSpellsLookup}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
heading: "Customized Origin Features",
|
||||
size: "fit-content",
|
||||
variant: "remove",
|
||||
onConfirm: () => {
|
||||
this.handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleProgressionPreferenceChangePromise = (
|
||||
newValue: string,
|
||||
oldValue: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const { dispatch, createModal } = this.props;
|
||||
|
||||
const prefKey = CharacterUtils.getPreferenceKey("progressionType");
|
||||
const newIdValue = HelperUtils.parseInputInt(newValue);
|
||||
|
||||
let content: ReactNode;
|
||||
let heading: string | null = null;
|
||||
if (newIdValue === Constants.PreferenceProgressionTypeEnum.XP) {
|
||||
heading = "XP Advancement";
|
||||
content = (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to change your advancement method to XP
|
||||
progression?
|
||||
</p>
|
||||
<p>You will begin with the base XP value for your current level.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
newIdValue === Constants.PreferenceProgressionTypeEnum.MILESTONE
|
||||
) {
|
||||
heading = "Milestone Advancement";
|
||||
content = (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to change your advancement method to Milestone
|
||||
progression?
|
||||
</p>
|
||||
<p>Your current XP values will be lost.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (prefKey !== null && heading) {
|
||||
createModal({
|
||||
content,
|
||||
props: {
|
||||
heading,
|
||||
size: "fit-content",
|
||||
onConfirm: () => {
|
||||
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleHitPointPreferenceChangePromise = (
|
||||
newValue: string,
|
||||
oldValue: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const { dispatch, createModal } = this.props;
|
||||
|
||||
const prefKey = CharacterUtils.getPreferenceKey("hitPointType");
|
||||
const newIdValue = HelperUtils.parseInputInt(newValue);
|
||||
|
||||
let content: ReactNode;
|
||||
let heading: string | null = null;
|
||||
if (newIdValue === Constants.PreferenceHitPointTypeEnum.FIXED) {
|
||||
heading = "Fixed Hit Points";
|
||||
content = (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to change your hit points to the fixed value?
|
||||
</p>
|
||||
<p>Any rolled hit point totals will be lost.</p>
|
||||
</div>
|
||||
);
|
||||
} else if (newIdValue === Constants.PreferenceHitPointTypeEnum.MANUAL) {
|
||||
heading = "Manual Hit Points";
|
||||
content = (
|
||||
<div>
|
||||
<p>Are you sure you want to change your hit points manual entry?</p>
|
||||
<p>
|
||||
After doing so, use Manage HP in the Class section to enter your
|
||||
rolled values.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (prefKey !== null && heading) {
|
||||
createModal({
|
||||
content,
|
||||
props: {
|
||||
heading,
|
||||
size: "fit-content",
|
||||
onConfirm: () => {
|
||||
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleDiceToggle = (): void => {
|
||||
const { dispatch, diceEnabled } = this.props;
|
||||
|
||||
const newDiceEnabledSetting: boolean = !diceEnabled;
|
||||
|
||||
try {
|
||||
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
|
||||
Dice.setEnabled(newDiceEnabledSetting);
|
||||
} catch (e) {}
|
||||
|
||||
dispatch(
|
||||
appEnvActions.dataSet({
|
||||
diceEnabled: newDiceEnabledSetting,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
handleChangePrivacy = (value: number | null): void => {
|
||||
const typedPrefKey = CharacterUtils.getPreferenceKey("privacyType");
|
||||
if (typedPrefKey !== null) {
|
||||
this.props.dispatch(
|
||||
characterActions.preferenceChoose(typedPrefKey, value)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderPreferences = (): React.ReactNode => {
|
||||
const { preferences, ruleData, activeSourceCategories, diceEnabled } =
|
||||
this.props;
|
||||
|
||||
const {
|
||||
useHomebrewContent,
|
||||
encumbranceType,
|
||||
hitPointType,
|
||||
progressionType,
|
||||
abilityScoreDisplayType,
|
||||
privacyType,
|
||||
ignoreCoinWeight,
|
||||
enforceFeatRules,
|
||||
enforceMulticlassRules,
|
||||
showScaledSpells,
|
||||
enableOptionalOrigins,
|
||||
enableOptionalClassFeatures,
|
||||
} = preferences;
|
||||
|
||||
const encumbranceOptions: Array<HtmlSelectOption> = [
|
||||
{
|
||||
label: "Use Encumbrance",
|
||||
value: Constants.PreferenceEncumbranceTypeEnum.ENCUMBRANCE,
|
||||
},
|
||||
{
|
||||
label: "No Encumbrance",
|
||||
value: Constants.PreferenceEncumbranceTypeEnum.NONE,
|
||||
},
|
||||
{
|
||||
label: "Variant Encumbrance",
|
||||
value: Constants.PreferenceEncumbranceTypeEnum.VARIANT,
|
||||
},
|
||||
];
|
||||
|
||||
const hpOptions: Array<HtmlSelectOption> = [
|
||||
{ label: "Fixed", value: Constants.PreferenceHitPointTypeEnum.FIXED },
|
||||
{ label: "Manual", value: Constants.PreferenceHitPointTypeEnum.MANUAL },
|
||||
];
|
||||
|
||||
const advancementOptions: Array<HtmlSelectOption> = [
|
||||
{
|
||||
label: "Milestone",
|
||||
value: Constants.PreferenceProgressionTypeEnum.MILESTONE,
|
||||
},
|
||||
{ label: "XP", value: Constants.PreferenceProgressionTypeEnum.XP },
|
||||
];
|
||||
|
||||
const abilityDisplayOptions: Array<HtmlSelectOption> = [
|
||||
{
|
||||
label: "Modifiers Top",
|
||||
value: Constants.PreferenceAbilityScoreDisplayTypeEnum.MODIFIERS_TOP,
|
||||
},
|
||||
{
|
||||
label: "Scores Top",
|
||||
value: Constants.PreferenceAbilityScoreDisplayTypeEnum.SCORES_TOP,
|
||||
},
|
||||
];
|
||||
|
||||
let sourceToggles: Array<CheckboxInfo> = [];
|
||||
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
|
||||
let allPartneredSources: Array<number> = [];
|
||||
|
||||
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
|
||||
if (!sourceCategory.isToggleable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkbox: CheckboxInfo = {
|
||||
label: `${sourceCategory.name}`,
|
||||
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
|
||||
onChange: this.handleSourceCategoryChange.bind(this, sourceCategory.id),
|
||||
sortOrder: sourceCategory.sortOrder,
|
||||
description: sourceCategory.description ?? "",
|
||||
};
|
||||
|
||||
if (sourceCategory.isPartneredContent) {
|
||||
delete checkbox.description; // remove description from partnered content
|
||||
partneredSourceCheckboxes.push(checkbox);
|
||||
allPartneredSources.push(sourceCategory.id);
|
||||
} else {
|
||||
sourceToggles.push(checkbox);
|
||||
}
|
||||
});
|
||||
|
||||
const checkboxAllPartneredContent: CheckboxInfo = {
|
||||
label: "All Partnered Content",
|
||||
initiallyEnabled: allPartneredSources.every((id) =>
|
||||
activeSourceCategories.includes(id)
|
||||
),
|
||||
onChange: this.handlePartneredSourceChangeAll.bind(
|
||||
this,
|
||||
allPartneredSources
|
||||
),
|
||||
};
|
||||
return (
|
||||
<div className="home-manage-preferences">
|
||||
<FormCheckBoxesField
|
||||
heading="Sources"
|
||||
description={SourceCategoryDescription.official}
|
||||
checkboxes={[
|
||||
...sourceToggles,
|
||||
{
|
||||
label: "Homebrew",
|
||||
description: SourceCategoryDescription.homebrew,
|
||||
initiallyEnabled: useHomebrewContent,
|
||||
onChange: this.handlePreferenceChange.bind(
|
||||
this,
|
||||
"useHomebrewContent"
|
||||
),
|
||||
sortOrder: 0,
|
||||
},
|
||||
]}
|
||||
showAccordion={false}
|
||||
variant="builder"
|
||||
/>
|
||||
|
||||
<FormCheckBoxesField
|
||||
heading="Partnered Content"
|
||||
description={SourceCategoryDescription.partnered}
|
||||
checkboxes={partneredSourceCheckboxes}
|
||||
checkUncheckAllEnabled={true}
|
||||
onCheckUncheckAll={checkboxAllPartneredContent}
|
||||
showAccordion={true}
|
||||
accordionHeading="Choose Partners"
|
||||
variant="builder"
|
||||
allText="All"
|
||||
/>
|
||||
|
||||
<FormCheckBoxesField
|
||||
heading="Dice Rolling"
|
||||
description="Enables digital dice rolling for this character"
|
||||
checkboxes={[
|
||||
{
|
||||
initiallyEnabled: diceEnabled,
|
||||
label: "Enable Dice Rolling",
|
||||
onChange: this.handleDiceToggle,
|
||||
},
|
||||
]}
|
||||
variant="builder"
|
||||
/>
|
||||
|
||||
<FormCheckBoxesField
|
||||
heading="Optional Features"
|
||||
description="Allow or restrict optional features for this character."
|
||||
checkboxes={[
|
||||
{
|
||||
label: "Optional Class Features",
|
||||
initiallyEnabled: enableOptionalClassFeatures,
|
||||
onChangePromise:
|
||||
this.handleOptionalClassFeaturesPreferenceChangePromise,
|
||||
},
|
||||
{
|
||||
label: "Customize Your Origin",
|
||||
initiallyEnabled: enableOptionalOrigins,
|
||||
onChangePromise:
|
||||
this.handleOptionalOriginsPreferenceChangePromise,
|
||||
},
|
||||
]}
|
||||
variant="builder"
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
heading="Advancement Type"
|
||||
description="Story-based character progression / XP-based character progression"
|
||||
onChangePromise={this.handleProgressionPreferenceChangePromise}
|
||||
initialOptionRemoved={true}
|
||||
options={advancementOptions}
|
||||
initialValue={"" + progressionType}
|
||||
block={true}
|
||||
/>
|
||||
<FormSelectField
|
||||
heading="Hit Point Type"
|
||||
description="When leveling up, increase hit points by the fixed value for your chosen class or manually enter a rolled value"
|
||||
onChangePromise={this.handleHitPointPreferenceChangePromise}
|
||||
initialOptionRemoved={true}
|
||||
options={hpOptions}
|
||||
initialValue={"" + hitPointType}
|
||||
block={true}
|
||||
/>
|
||||
|
||||
<FormCheckBoxesField
|
||||
heading="Use Prerequisites"
|
||||
description="Allow or restrict choices based on rule prerequisites for the following for this character"
|
||||
checkboxes={[
|
||||
{
|
||||
label: "Feats",
|
||||
initiallyEnabled: enforceFeatRules,
|
||||
onChange: this.handlePreferenceChange.bind(
|
||||
this,
|
||||
"enforceFeatRules"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Multiclass Requirements",
|
||||
initiallyEnabled: enforceMulticlassRules,
|
||||
onChange: this.handlePreferenceChange.bind(
|
||||
this,
|
||||
"enforceMulticlassRules"
|
||||
),
|
||||
},
|
||||
]}
|
||||
variant="builder"
|
||||
/>
|
||||
|
||||
<FormCheckBoxesField
|
||||
heading="Show Level-Scaled Spells"
|
||||
description="Display and highlight available spells to cast with higher level spell slots"
|
||||
checkboxes={[
|
||||
{
|
||||
label: "Show Level-Scaled Spells",
|
||||
initiallyEnabled: showScaledSpells,
|
||||
onChange: this.handlePreferenceChange.bind(
|
||||
this,
|
||||
"showScaledSpells"
|
||||
),
|
||||
},
|
||||
]}
|
||||
variant="builder"
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
heading="Encumbrance Type"
|
||||
description="Use the standard encumbrance rules / Disable the encumbrance display / Use the more detailed rules for encumbrance"
|
||||
onChange={this.handleIntPreferenceChange.bind(
|
||||
this,
|
||||
"encumbranceType"
|
||||
)}
|
||||
initialOptionRemoved={true}
|
||||
options={encumbranceOptions}
|
||||
initialValue={"" + encumbranceType}
|
||||
block={true}
|
||||
/>
|
||||
|
||||
<FormCheckBoxesField
|
||||
heading="Ignore Coin Weight"
|
||||
description="Coins do not count against your total weight carried (50 coins weigh 1 lb.)"
|
||||
checkboxes={[
|
||||
{
|
||||
label: "Ignore Coin Weight",
|
||||
initiallyEnabled: ignoreCoinWeight,
|
||||
onChange: this.handlePreferenceChange.bind(
|
||||
this,
|
||||
"ignoreCoinWeight"
|
||||
),
|
||||
},
|
||||
]}
|
||||
variant="builder"
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
heading="Ability Score/Modifier Display"
|
||||
description="Reverse the arrangement of ability modifiers and scores"
|
||||
onChange={this.handleIntPreferenceChange.bind(
|
||||
this,
|
||||
"abilityScoreDisplayType"
|
||||
)}
|
||||
initialOptionRemoved={true}
|
||||
options={abilityDisplayOptions}
|
||||
initialValue={"" + abilityScoreDisplayType}
|
||||
block={true}
|
||||
/>
|
||||
|
||||
<PrivacyTypeRadio
|
||||
initialValue={privacyType}
|
||||
handleChange={(e) =>
|
||||
this.handleChangePrivacy(parseInt(e.target.value))
|
||||
}
|
||||
variant="builder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
handleAddPremadeInfo = (): void => {
|
||||
const { dispatch, characterId } = this.props;
|
||||
|
||||
const data = {
|
||||
characterId: characterId,
|
||||
publishStatus: PremadeInfoStatus.DRAFT,
|
||||
definition: {
|
||||
longDescription: null,
|
||||
shortDescription: null,
|
||||
imageUrl: null,
|
||||
imageAltText: null,
|
||||
mobileImageUrl: null,
|
||||
mobileImageAccessibility: null,
|
||||
themeColor: null,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(characterActions.premadeInfoAdd(data));
|
||||
};
|
||||
|
||||
handleDeletePremadeInfo = (): void => {
|
||||
const { dispatch, characterId } = this.props;
|
||||
dispatch(characterActions.premadeInfoDelete(characterId));
|
||||
};
|
||||
|
||||
handlePremadeInfoChanged = (premadeInfo: PremadeInfo): void => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(characterActions.premadeInfoUpdate(premadeInfo));
|
||||
};
|
||||
|
||||
renderPremadePreferences = (): React.ReactNode => {
|
||||
const { premadeInfo } = this.props;
|
||||
|
||||
const statusOptions: Array<HtmlSelectOption> = [
|
||||
{ label: "Draft", value: PremadeInfoStatus.DRAFT },
|
||||
{ label: "Published", value: PremadeInfoStatus.PUBLISHED },
|
||||
{ label: "Archived", value: PremadeInfoStatus.ARCHIVED },
|
||||
];
|
||||
|
||||
const inputAttributes = {
|
||||
disabled: !premadeInfo,
|
||||
} as HTMLAttributes<HTMLInputElement>;
|
||||
|
||||
return (
|
||||
<div className="home-manage-preferences">
|
||||
<FormToggleField
|
||||
heading="Premade Character"
|
||||
toggleLabel="Enabled official character"
|
||||
description="Toggle on to make this a premade character. You cannot disable this if the premade status is Published."
|
||||
initiallyEnabled={!!premadeInfo}
|
||||
onChange={(toggledOn) => {
|
||||
toggledOn
|
||||
? this.handleAddPremadeInfo()
|
||||
: this.handleDeletePremadeInfo();
|
||||
}}
|
||||
isReadOnly={
|
||||
premadeInfo?.publishStatus === PremadeInfoStatus.PUBLISHED
|
||||
}
|
||||
/>
|
||||
{premadeInfo && (
|
||||
<>
|
||||
<RadioGroup
|
||||
name="premadeStatus"
|
||||
label="Publish Status"
|
||||
subtitle="Mark this character as published when you are ready for it to be publically visible."
|
||||
value={premadeInfo.publishStatus}
|
||||
options={statusOptions}
|
||||
disabled={!premadeInfo}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
premadeInfo.publishStatus = event.currentTarget
|
||||
.value as PremadeInfoStatus;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
this.forceUpdate();
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Long Description"
|
||||
initialValue={premadeInfo.definition.longDescription}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.longDescription = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Short Description"
|
||||
initialValue={premadeInfo.definition.shortDescription}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.shortDescription = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Image Url"
|
||||
initialValue={premadeInfo.definition.imageUrl}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.imageUrl = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Image Alt Text"
|
||||
initialValue={premadeInfo.definition.imageAltText}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.imageAltText = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Mobile Image Url"
|
||||
initialValue={premadeInfo.definition.mobileImageUrl}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.mobileImageUrl = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Mobile Image Accessibility"
|
||||
initialValue={premadeInfo.definition.mobileImageAccessibility}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.mobileImageAccessibility = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
<FormInputField
|
||||
label="Theme Color Hex Code"
|
||||
initialValue={premadeInfo.definition.themeColor}
|
||||
inputAttributes={inputAttributes}
|
||||
onBlur={(value: string) => {
|
||||
premadeInfo.definition.themeColor = value;
|
||||
this.handlePremadeInfoChanged(premadeInfo);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { userRoles } = this.props;
|
||||
const isLorekeeper =
|
||||
userRoles?.includes(UserRoles.LOREKEEPER) ||
|
||||
userRoles?.includes(UserRoles.ADMIN);
|
||||
|
||||
return (
|
||||
<Page clsNames={["home-manage"]}>
|
||||
<PageBody>
|
||||
{isLorekeeper && (
|
||||
<div style={{ border: "5px solid hotpink" }}>
|
||||
<PageHeader>
|
||||
Premade Character Preferences - Lorekeepers Only
|
||||
</PageHeader>
|
||||
{this.renderPremadePreferences()}
|
||||
</div>
|
||||
)}
|
||||
<Typography fontSize={24}>Character Preferences</Typography>
|
||||
{this.renderPreferences()}
|
||||
<div className="home-manage__version">
|
||||
<div className="home-manage__version-label">Version:</div>
|
||||
<div className="home-manage__version-value">{config.version}</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const HomeBasicInfoContainer = (props: Props) => {
|
||||
const { createModal } = useModalManager();
|
||||
return <HomeBasicInfo {...props} createModal={createModal} />;
|
||||
};
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
HomeBasicInfoContainer,
|
||||
RouteKey.HOME_BASIC_INFO,
|
||||
(state: BuilderAppState) => ({
|
||||
preferences: rulesEngineSelectors.getCharacterPreferences(state),
|
||||
ruleData: rulesEngineSelectors.getRuleData(state),
|
||||
activeSourceCategories:
|
||||
rulesEngineSelectors.getActiveSourceCategories(state),
|
||||
diceEnabled: appEnvSelectors.getDiceEnabled(state),
|
||||
classes: rulesEngineSelectors.getClasses(state),
|
||||
species: rulesEngineSelectors.getRace(state),
|
||||
classSpellListSpellsLookup:
|
||||
rulesEngineSelectors.getClassSpellListSpellsLookup(state),
|
||||
userRoles: appEnvSelectors.getUserRoles(state),
|
||||
premadeInfo: characterSelectors.getPremadeInfo(state),
|
||||
characterId: rulesEngineSelectors.getId(state),
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import HomeBasicInfo from "./HomeBasicInfo";
|
||||
|
||||
export default HomeBasicInfo;
|
||||
export { HomeBasicInfo };
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import { CollapsibleContent } from "../../../../../../components/CollapsibleContent";
|
||||
import { appEnvSelectors } from "../../../../Shared/selectors";
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props {
|
||||
isMobile: boolean;
|
||||
lowerCase: { singular: string; plural: string; desc: string };
|
||||
}
|
||||
class HomeHelp extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { isMobile } = this.props;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<CollapsibleContent
|
||||
forceShow={!isMobile}
|
||||
heading={
|
||||
<>
|
||||
<PageHeader>Creating a Character</PageHeader>
|
||||
<p>
|
||||
Your first step in playing an adventure in the Dungeons &
|
||||
Dragons game is to imagine and create a character of your own.
|
||||
Your character is a combination of game statistics,
|
||||
roleplaying hooks, and your imagination.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
You choose a class (such as Fighter or Wizard), a background (such
|
||||
as Artisan or Soldier), and a species (such as Human or Halfling).
|
||||
You also invent the personality, appearance, and backstory of your
|
||||
character. Once completed, your character serves as your
|
||||
representative in the game, your avatar in the Dungeons &
|
||||
Dragons multiverse.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Before you dive into the character builder, think about the kind
|
||||
of adventurer you want to play. You might be a courageous Fighter,
|
||||
a skulking Rogue, a fervent Cleric, or a flamboyant Wizard. Or you
|
||||
might be more interested in an unconventional character, such as a
|
||||
brawny Rogue who likes hand-to-hand combat, or a sharpshooter who
|
||||
picks off enemies from afar. Do you like fantasy fiction featuring
|
||||
Dwarves or Elves? Try building a character of one of those
|
||||
species. Do you want your character to be the toughest adventurer
|
||||
at the table? Consider a class like Barbarian or Paladin. Once you
|
||||
have a character in mind, follow the steps in this builder in
|
||||
order, making decisions that reflect the character you want. Your
|
||||
conception of your character might evolve with each choice you
|
||||
make. What's important is that you come to the table with a
|
||||
character you're excited to play.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
<CollapsibleContent
|
||||
forceShow={!isMobile}
|
||||
heading={
|
||||
<>
|
||||
<PageHeader>Character Level</PageHeader>
|
||||
<p>
|
||||
Typically, your character begins play at 1st level. In the
|
||||
next step, adjust your level if you’re playing in a
|
||||
higher-powered campaign.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
As your character goes on adventures and overcomes challenges,
|
||||
they gain experience, represented by Experience Points (XP). Once
|
||||
you reach a specified XP total, you gain a new level. Adjusting
|
||||
your character level will change your XP total and vice versa.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
<PageHeader>Preferences</PageHeader>
|
||||
<p>
|
||||
The next step also includes various preferences for your character.
|
||||
You can proceed with the default options or make changes if desired.
|
||||
</p>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
HomeHelp,
|
||||
RouteKey.HOME_HELP,
|
||||
(state: BuilderAppState) => {
|
||||
return {
|
||||
isMobile: appEnvSelectors.getIsMobile(state),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import HomeHelp from "./HomeHelp";
|
||||
|
||||
export default HomeHelp;
|
||||
export { HomeHelp };
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import { CollapsibleContent } from "../../../../../../components/CollapsibleContent";
|
||||
import { appEnvSelectors } from "../../../../Shared/selectors";
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const SpeciesHelp: React.FC<Props> = ({ isMobile }) => {
|
||||
return (
|
||||
<Page>
|
||||
<PageBody>
|
||||
<PageHeader>Choose your Species</PageHeader>
|
||||
|
||||
<CollapsibleContent
|
||||
forceShow={!isMobile}
|
||||
heading={
|
||||
<p>
|
||||
Choose a species in the next step. Your choice of species affects
|
||||
many different aspects of your character. It establishes
|
||||
fundamental qualities that exist throughout your character's
|
||||
adventuring career. When making this decision, keep in mind the
|
||||
kind of character you want to play.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
For example, a halfling could be a good choice for a sneaky rogue, a
|
||||
dwarf makes a tough warrior, and an elf can be a master of arcane
|
||||
magic.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Your character species not only affects your ability scores and
|
||||
traits but also provides the cues for building your character's
|
||||
story. Each species' description includes information to help you
|
||||
roleplay a character of that species, including personality,
|
||||
physical appearance, features of society, and alignment tendencies.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
These details are suggestions to help you think about your
|
||||
character; adventurers can deviate widely from the norm for their
|
||||
species. It's worthwhile to consider why your character is
|
||||
different, as a helpful way to think about your character's
|
||||
background and personality.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
|
||||
<PageHeader>Traits</PageHeader>
|
||||
|
||||
<CollapsibleContent
|
||||
forceShow={!isMobile}
|
||||
heading={
|
||||
<p>
|
||||
The description of each species includes traits that are common to
|
||||
members of that species. The following entries appear among the
|
||||
traits of most species.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<strong>AGE</strong>
|
||||
<br />
|
||||
The age entry notes the age when a member of the species is
|
||||
considered an adult, as well as the species' expected lifespan. This
|
||||
information can help you decide how old your character is at the
|
||||
start of the game. You can choose any age for your character, which
|
||||
could provide an explanation for some of your ability scores.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For example, if you play a young or very old character, your age
|
||||
could explain a particularly low Strength or Constitution score,
|
||||
while advanced age could account for a high Intelligence or Wisdom.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>SIZE</strong>
|
||||
<br />
|
||||
Characters of most species are Medium, a size category including
|
||||
creatures that are roughly 4 to 8 feet tall. Members of a few
|
||||
species are Small (between 2 and 4 feet tall), which means that
|
||||
certain rules of the game affect them differently.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>SPEED</strong>
|
||||
<br />
|
||||
Your speed determines how far you can move when traveling and
|
||||
fighting.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>LANGUAGES</strong>
|
||||
<br />
|
||||
By virtue of your species, your character can speak, read, and write
|
||||
certain languages.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
SpeciesHelp,
|
||||
RouteKey.RACE_HELP,
|
||||
(state: BuilderAppState) => {
|
||||
return {
|
||||
isMobile: appEnvSelectors.getIsMobile(state),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpeciesHelp from "./SpeciesHelp";
|
||||
|
||||
export default SpeciesHelp;
|
||||
export { SpeciesHelp };
|
||||
+358
@@ -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),
|
||||
};
|
||||
}
|
||||
);
|
||||
+346
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -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 };
|
||||
@@ -0,0 +1,293 @@
|
||||
import React from "react";
|
||||
import { DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
LightExportSvg,
|
||||
DisabledExportSvg,
|
||||
DarkExportSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
import { ExportPdfData, usePdfExport } from "~/hooks/usePdfExport";
|
||||
import { RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import { BuilderLinkButton } from "../../../../Shared/components/common/LinkButton";
|
||||
import { appEnvSelectors } from "../../../../Shared/selectors";
|
||||
import { ClipboardUtils, MobileMessengerUtils } from "../../../../Shared/utils";
|
||||
import Page from "../../../components/Page";
|
||||
import { PageBody } from "../../../components/PageBody";
|
||||
import PageHeader from "../../../components/PageHeader";
|
||||
import { builderEnvSelectors, builderSelectors } from "../../../selectors";
|
||||
import { BuilderAppState } from "../../../typings";
|
||||
import ConnectedBuilderPage from "../ConnectedBuilderPage";
|
||||
|
||||
interface Props extends DispatchProp {
|
||||
characterId: number | null;
|
||||
characterListingUrl: string;
|
||||
characterSheetUrl: string;
|
||||
isCharacterSheetReady: boolean;
|
||||
exportPdfData: ExportPdfData;
|
||||
}
|
||||
interface State {
|
||||
hasCopied: boolean;
|
||||
}
|
||||
class WhatsNext extends React.PureComponent<Props, State> {
|
||||
urlInput = React.createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasCopied: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const {
|
||||
exportPdfData: { pdfUrl },
|
||||
} = this.props;
|
||||
|
||||
if (pdfUrl !== null) {
|
||||
this.selectUrlInput();
|
||||
}
|
||||
}
|
||||
|
||||
handleExportPdf = (evt: React.MouseEvent): void => {
|
||||
const {
|
||||
isCharacterSheetReady,
|
||||
exportPdfData: { exportPdf, isLoading, isFinished },
|
||||
} = this.props;
|
||||
|
||||
if (isCharacterSheetReady && !isLoading && !isFinished) {
|
||||
exportPdf();
|
||||
}
|
||||
};
|
||||
|
||||
selectUrlInput = (): void => {
|
||||
if (this.urlInput.current) {
|
||||
this.urlInput.current.focus();
|
||||
this.urlInput.current.setSelectionRange(
|
||||
0,
|
||||
this.urlInput.current.value.length
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const {
|
||||
exportPdfData: { pdfUrl },
|
||||
} = this.props;
|
||||
|
||||
ClipboardUtils.copyTextToClipboard(pdfUrl === null ? "" : pdfUrl);
|
||||
this.setState({
|
||||
hasCopied: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleSheetShowClick = (isDisabled: boolean) => {
|
||||
const { characterId } = this.props;
|
||||
|
||||
if (characterId !== null && !isDisabled) {
|
||||
MobileMessengerUtils.sendMessage(
|
||||
MobileMessengerUtils.createShowCharacterSheetMessage(characterId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderPdfButton = (): React.ReactNode => {
|
||||
const {
|
||||
isCharacterSheetReady,
|
||||
exportPdfData: { isLoading, isFinished },
|
||||
} = this.props;
|
||||
// const { pdfLoadingStatus } = this.state;
|
||||
|
||||
let pdfButtonClasses: Array<string> = [
|
||||
"ct-button",
|
||||
"builder-button",
|
||||
"whats-next-action-pdf",
|
||||
"character-button-oversized",
|
||||
"character-tools-export-pdf-button",
|
||||
];
|
||||
let iconNode: React.ReactNode;
|
||||
if (isCharacterSheetReady) {
|
||||
iconNode = <LightExportSvg />;
|
||||
} else {
|
||||
pdfButtonClasses.push("character-button-disabled");
|
||||
pdfButtonClasses.push("character-button-oversized-disabled");
|
||||
|
||||
iconNode = <DisabledExportSvg />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={pdfButtonClasses.join(" ")}
|
||||
onClick={this.handleExportPdf}
|
||||
>
|
||||
<span className="whats-next-action-icon">{iconNode}</span>
|
||||
<span className="whats-next-action-text">Exporting PDF...</span>
|
||||
</div>
|
||||
);
|
||||
} else if (isFinished) {
|
||||
pdfButtonClasses.push("whats-next-action-confirmed");
|
||||
return (
|
||||
<div
|
||||
className={pdfButtonClasses.join(" ")}
|
||||
onClick={this.handleExportPdf}
|
||||
>
|
||||
<span className="whats-next-action-icon">✓</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
pdfButtonClasses.push("whats-next-action-clickable");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={pdfButtonClasses.join(" ")}
|
||||
onClick={this.handleExportPdf}
|
||||
>
|
||||
<span className="whats-next-action-icon">{iconNode}</span>
|
||||
<span className="whats-next-action-text">Export to PDF</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderPdfData = (): React.ReactNode => {
|
||||
const {
|
||||
exportPdfData: { pdfUrl, isFinished },
|
||||
} = this.props;
|
||||
const { hasCopied } = this.state;
|
||||
|
||||
if (!isFinished || pdfUrl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="whats-next-pdf-data">
|
||||
<div className="whats-next-pdf-data-splash-icon">
|
||||
<DarkExportSvg />
|
||||
</div>
|
||||
<div className="whats-next-pdf-data-header">PDF Generated</div>
|
||||
<div className="whats-next-pdf-data-url">
|
||||
<input
|
||||
type="text"
|
||||
value={pdfUrl ? pdfUrl.replace(/https*:\/\//, "") : ""}
|
||||
className="whats-next-pdf-data-input"
|
||||
ref={this.urlInput}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="whats-next-pdf-data-clipboard"
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<React.Fragment>Copied! ✔</React.Fragment>
|
||||
) : (
|
||||
"Click to Copy"
|
||||
)}
|
||||
</div>
|
||||
{pdfUrl !== null && (
|
||||
<div className="whats-next-pdf-data-download">
|
||||
<BuilderLinkButton
|
||||
url={pdfUrl}
|
||||
size={"large"}
|
||||
download={true}
|
||||
className="whats-next-pdf-data-download-link"
|
||||
>
|
||||
Click to Download
|
||||
</BuilderLinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { characterListingUrl, characterSheetUrl, isCharacterSheetReady } =
|
||||
this.props;
|
||||
|
||||
return (
|
||||
<Page clsNames={["whats-next"]}>
|
||||
<PageBody>
|
||||
<p>
|
||||
Once you have completed creating your character, you can view your
|
||||
statistics on the digital character sheet or export it for printing.
|
||||
</p>
|
||||
|
||||
<div className="whats-next-actions">
|
||||
<div className="whats-next-action">
|
||||
<BuilderLinkButton
|
||||
url={characterSheetUrl}
|
||||
className="whats-next-action-sheet-link"
|
||||
size="oversized"
|
||||
disabled={!isCharacterSheetReady}
|
||||
onClick={this.handleSheetShowClick}
|
||||
>
|
||||
<div className="whats-next-action-text">
|
||||
<div className="whats-next-action-text">
|
||||
View Character Sheet
|
||||
{!isCharacterSheetReady && (
|
||||
<span className="whats-next-action-subtext">
|
||||
Unavailable - Character Incomplete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BuilderLinkButton>
|
||||
</div>
|
||||
<div className="whats-next-action">{this.renderPdfButton()}</div>
|
||||
</div>
|
||||
{this.renderPdfData()}
|
||||
<div className="whats-next-characters">
|
||||
<Link href={characterListingUrl}>View all my characters</Link>
|
||||
</div>
|
||||
|
||||
<PageHeader>Come Together</PageHeader>
|
||||
|
||||
<p>
|
||||
Most D&D characters don't work alone. Each character plays a
|
||||
role within a party, a group of adventurers working together for a
|
||||
common purpose. Talk to your fellow players and your DM to decide
|
||||
whether your characters know one another, how they met, and what
|
||||
sorts of quests the group might undertake.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
A campaign is a series of adventures undertaken by your group's
|
||||
characters. If your DM has a campaign, you can join it by visiting
|
||||
the invite link for the campaign. You can also start your own.
|
||||
</p>
|
||||
|
||||
<div className="whats-next-campaign">
|
||||
<Link href="/campaigns/create" className="whats-next-campaign-link">
|
||||
Start a new campaign
|
||||
</Link>
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function WhatsNextContainer(props) {
|
||||
const exportPdfData = usePdfExport();
|
||||
return <WhatsNext {...props} exportPdfData={exportPdfData} />;
|
||||
}
|
||||
|
||||
export default ConnectedBuilderPage(
|
||||
WhatsNextContainer,
|
||||
RouteKey.WHATS_NEXT,
|
||||
(state: BuilderAppState) => {
|
||||
return {
|
||||
characterId: appEnvSelectors.getCharacterId(state),
|
||||
characterListingUrl: builderEnvSelectors.getProfileCharacterListingUrl(),
|
||||
characterSheetUrl: builderEnvSelectors.getCharacterSheetUrl(state),
|
||||
isCharacterSheetReady: builderSelectors.checkIsCharacterSheetReady(state),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import WhatsNext from "./WhatsNext";
|
||||
|
||||
export default WhatsNext;
|
||||
export { WhatsNext };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BuilderAction, builderActionTypes } from "../actions/builder";
|
||||
import { BuilderState } from "../typings";
|
||||
|
||||
const initialState: BuilderState = {
|
||||
method: null,
|
||||
confirmSpecies: null,
|
||||
confirmClass: null,
|
||||
isCharacterLoading: false,
|
||||
isCharacterLoaded: false,
|
||||
suggestedNames: [],
|
||||
};
|
||||
function builder(
|
||||
state: BuilderState = initialState,
|
||||
action: BuilderAction
|
||||
): BuilderState {
|
||||
switch (action.type) {
|
||||
case builderActionTypes.BUILDER_METHOD_SET_COMMIT:
|
||||
return {
|
||||
...state,
|
||||
method: action.payload.method,
|
||||
};
|
||||
|
||||
case builderActionTypes.CONFIRM_SPECIES_SET:
|
||||
return {
|
||||
...state,
|
||||
confirmSpecies: action.payload.race,
|
||||
};
|
||||
|
||||
case builderActionTypes.CONFIRM_CLASS_SET:
|
||||
return {
|
||||
...state,
|
||||
confirmClass: action.payload.charClass,
|
||||
};
|
||||
|
||||
case builderActionTypes.CHARACTER_LOADING_SET_COMMIT:
|
||||
return {
|
||||
...state,
|
||||
isCharacterLoading: action.payload.isLoading,
|
||||
};
|
||||
|
||||
case builderActionTypes.CHARACTER_LOADED_SET_COMMIT:
|
||||
return {
|
||||
...state,
|
||||
isCharacterLoaded: action.payload.isLoaded,
|
||||
};
|
||||
|
||||
case builderActionTypes.SUGGESTED_NAMES_SET:
|
||||
return {
|
||||
...state,
|
||||
suggestedNames: action.payload.suggestedNames,
|
||||
};
|
||||
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default builder;
|
||||
@@ -0,0 +1,381 @@
|
||||
import { uniqueId } from "lodash";
|
||||
import { call, put, select, takeEvery } from "redux-saga/effects";
|
||||
|
||||
import {
|
||||
rulesEngineSelectors,
|
||||
characterActionTypes,
|
||||
characterActions,
|
||||
syncTransactionActions,
|
||||
syncTransactionSelectors,
|
||||
ApiAdapterUtils,
|
||||
ApiRequests,
|
||||
UnpackMakeApiResponseData,
|
||||
ruleDataActions,
|
||||
SagaHelpers,
|
||||
characterEnvSelectors,
|
||||
Constants,
|
||||
RaceUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { BuilderMethod, RouteKey } from "~/subApps/builder/constants";
|
||||
|
||||
import { appEnvActions } from "../../Shared/actions";
|
||||
import { appEnvSelectors } from "../../Shared/selectors";
|
||||
import appConfig from "../../config";
|
||||
import {
|
||||
builderActions,
|
||||
builderActionTypes,
|
||||
BuilderAction,
|
||||
BuilderMethodSetAction,
|
||||
QuickBuildRequestAction,
|
||||
RandomBuildRequestAction,
|
||||
StepBuildRequestAction,
|
||||
SuggestedNamesRequestAction,
|
||||
} from "../actions";
|
||||
import * as navConfig from "../config/navigation";
|
||||
import { getRouteDefPath } from "../config/navigation";
|
||||
import { builderSelectors } from "../selectors";
|
||||
import { NavigationUtils } from "../utils";
|
||||
|
||||
const syncActions = [
|
||||
builderActionTypes.CHARACTER_LOAD_REQUEST,
|
||||
builderActionTypes.QUICK_BUILD_REQUEST,
|
||||
builderActionTypes.RANDOM_BUILD_REQUEST,
|
||||
builderActionTypes.STEP_BUILD_REQUEST,
|
||||
builderActionTypes.BUILDER_METHOD_SET,
|
||||
builderActionTypes.SUGGESTED_NAMES_REQUEST,
|
||||
];
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(syncActions, syncFilter);
|
||||
yield takeEvery(
|
||||
Object.keys(characterActionTypes).map((key) => characterActionTypes[key]),
|
||||
characterTypesFilter
|
||||
);
|
||||
yield takeEvery(
|
||||
Object.keys(builderActionTypes).map((key) => builderActionTypes[key]),
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
function* characterTypesFilter(action: any) {
|
||||
switch (action.type) {
|
||||
case characterActionTypes.RACE_CHOOSE_POST_ACTION: {
|
||||
// yield put(push(getRouteDefPath(RouteKey.RACE_MANAGE)));
|
||||
const characterId = yield select((state: any) =>
|
||||
rulesEngineSelectors.getId(state)
|
||||
);
|
||||
const navigate = yield select(
|
||||
(state: any) => state.routeControls.navigate
|
||||
);
|
||||
navigate(
|
||||
getRouteDefPath(RouteKey.RACE_MANAGE).replace(
|
||||
":characterId",
|
||||
characterId
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case characterActionTypes.CLASS_ADD_POST_ACTION: {
|
||||
// yield put(push(getRouteDefPath(RouteKey.CLASS_MANAGE)));
|
||||
const characterId = yield select((state: any) =>
|
||||
rulesEngineSelectors.getId(state)
|
||||
);
|
||||
const navigate = yield select(
|
||||
(state: any) => state.routeControls.navigate
|
||||
);
|
||||
navigate(
|
||||
getRouteDefPath(RouteKey.CLASS_MANAGE).replace(
|
||||
":characterId",
|
||||
characterId
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case characterActionTypes.CLASS_REMOVE_POST_ACTION: {
|
||||
let classes = yield select(rulesEngineSelectors.getClasses);
|
||||
if (classes.length === 0) {
|
||||
// yield put(push(getRouteDefPath(RouteKey.CLASS_CHOOSE)));
|
||||
const characterId = yield select((state: any) =>
|
||||
rulesEngineSelectors.getId(state)
|
||||
);
|
||||
const navigate = yield select(
|
||||
(state: any) => state.routeControls.navigate
|
||||
);
|
||||
navigate(
|
||||
getRouteDefPath(RouteKey.CLASS_CHOOSE).replace(
|
||||
":characterId",
|
||||
characterId
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case characterActionTypes.CHARACTER_LOAD_POST_ACTION: {
|
||||
yield put(builderActions.characterLoadingSetCommit(false));
|
||||
yield put(builderActions.characterLoadedSetCommit(true));
|
||||
|
||||
const builderMethod: ReturnType<
|
||||
typeof builderSelectors.getBuilderMethod
|
||||
> = yield select(builderSelectors.getBuilderMethod);
|
||||
const context: ReturnType<typeof characterEnvSelectors.getContext> =
|
||||
yield select(characterEnvSelectors.getContext);
|
||||
|
||||
if (
|
||||
builderMethod === BuilderMethod.STEP_BY_STEP &&
|
||||
context === Constants.AppContextTypeEnum.BUILDER
|
||||
) {
|
||||
const configuration: ReturnType<
|
||||
typeof rulesEngineSelectors.getCharacterConfiguration
|
||||
> = yield select(rulesEngineSelectors.getCharacterConfiguration);
|
||||
const stepByStepInitialPage = configuration.showHelpText
|
||||
? RouteKey.HOME_HELP
|
||||
: RouteKey.HOME_BASIC_INFO;
|
||||
|
||||
const isCurrentRouteInRoutePathing = yield select(
|
||||
NavigationUtils.checkIsCurrentRouteInRoutePathing
|
||||
);
|
||||
|
||||
// TODO routes this needs fixed
|
||||
// yield put(push(navConfig.getRouteDefPath(stepByStepInitialPage)));
|
||||
if (!isCurrentRouteInRoutePathing) {
|
||||
const navigate = yield select(
|
||||
(state: any) => state.routeControls.navigate
|
||||
);
|
||||
navigate(
|
||||
navConfig
|
||||
.getRouteDefPath(stepByStepInitialPage)
|
||||
.replace(":characterId", action.payload.characterId)
|
||||
);
|
||||
}
|
||||
// window.location = navConfig.getRouteDefPath(stepByStepInitialPage).replace(':characterId', action.payload.characterId);
|
||||
// }
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* filter(action: BuilderAction) {
|
||||
switch (action.type) {
|
||||
case builderActionTypes.SHOW_HELP_TEXT_SET:
|
||||
yield call(ApiRequests.putCharacterHelpText, action.payload);
|
||||
yield put(characterActions.showHelpTextSet(action.payload.showHelpText));
|
||||
|
||||
if (action.payload.showHelpText) {
|
||||
const currentSectionHelpRoutePath = yield select(
|
||||
NavigationUtils.getCurrentSectionHelpRoutePath
|
||||
);
|
||||
if (currentSectionHelpRoutePath !== null) {
|
||||
const characterId = yield select((state: any) =>
|
||||
rulesEngineSelectors.getId(state)
|
||||
);
|
||||
const navigate = yield select(
|
||||
(state: any) => state.routeControls.navigate
|
||||
);
|
||||
navigate(
|
||||
currentSectionHelpRoutePath.replace(":characterId", characterId)
|
||||
);
|
||||
// yield put(push(currentSectionHelpRoutePath));
|
||||
}
|
||||
} else {
|
||||
const availablePathRedirect = yield select(
|
||||
NavigationUtils.getAvailablePathRedirect
|
||||
);
|
||||
if (availablePathRedirect !== null) {
|
||||
const characterId = yield select((state: any) =>
|
||||
rulesEngineSelectors.getId(state)
|
||||
);
|
||||
const navigate = yield select(
|
||||
(state: any) => state.routeControls.navigate
|
||||
);
|
||||
navigate(availablePathRedirect.replace(":characterId", characterId));
|
||||
// yield put(push(availablePathRedirect));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function* syncFilter(action: BuilderAction) {
|
||||
const isSyncTransactionActive = yield select(
|
||||
syncTransactionSelectors.getActive
|
||||
);
|
||||
let transactionInitiatorId;
|
||||
|
||||
if (!isSyncTransactionActive) {
|
||||
transactionInitiatorId = uniqueId();
|
||||
yield put(syncTransactionActions.activate(transactionInitiatorId));
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case builderActionTypes.QUICK_BUILD_REQUEST:
|
||||
yield call(handleQuickBuildRequest, action);
|
||||
break;
|
||||
|
||||
case builderActionTypes.RANDOM_BUILD_REQUEST:
|
||||
yield call(handleRandomBuildRequest, action);
|
||||
break;
|
||||
|
||||
case builderActionTypes.STEP_BUILD_REQUEST:
|
||||
yield call(handleStepBuildRequest, action);
|
||||
break;
|
||||
|
||||
case builderActionTypes.CHARACTER_LOAD_REQUEST:
|
||||
yield call(handleLoadCharacterRequest, action);
|
||||
break;
|
||||
|
||||
case builderActionTypes.BUILDER_METHOD_SET:
|
||||
yield call(handleBuilderMethodSet, action);
|
||||
break;
|
||||
|
||||
case builderActionTypes.SUGGESTED_NAMES_REQUEST:
|
||||
yield call(handleSuggestedNamesRequest, action);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case builderActionTypes.QUICK_BUILD_REQUEST:
|
||||
case builderActionTypes.RANDOM_BUILD_REQUEST:
|
||||
break;
|
||||
default:
|
||||
let syncTransactionInitiator = yield select(
|
||||
syncTransactionSelectors.getInitiator
|
||||
);
|
||||
if (syncTransactionInitiator === transactionInitiatorId) {
|
||||
yield put(syncTransactionActions.deactivate());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function* handleBuilderMethodSet(action: BuilderMethodSetAction) {
|
||||
const response = yield call(ApiRequests.getCharacterRuleData, {
|
||||
params: { v: appConfig.version },
|
||||
});
|
||||
const data: UnpackMakeApiResponseData<
|
||||
typeof ApiRequests.getCharacterRuleData
|
||||
> | null = ApiAdapterUtils.getResponseData(response);
|
||||
|
||||
if (data !== null) {
|
||||
yield put(ruleDataActions.dataSet(data));
|
||||
}
|
||||
yield put(builderActions.builderMethodSetCommit(action.payload));
|
||||
}
|
||||
|
||||
function* handleQuickBuildRequest(action: QuickBuildRequestAction) {
|
||||
const characterId: UnpackMakeApiResponseData<
|
||||
typeof ApiRequests.postCharacterBuilderQuickBuild
|
||||
> = yield call(
|
||||
SagaHelpers.getApiRequestData,
|
||||
ApiRequests.postCharacterBuilderQuickBuild,
|
||||
action.payload
|
||||
);
|
||||
|
||||
// if (data.success) {
|
||||
const characterUrl = NavigationUtils.getCharacterBuilderUrl(characterId);
|
||||
const initialPath = navConfig.getRouteDefPath(RouteKey.WHATS_NEXT);
|
||||
// todo just a soft navigation
|
||||
window.location = `${characterUrl}${initialPath}` as any;
|
||||
// } else {
|
||||
//TODO do something with error
|
||||
// }
|
||||
}
|
||||
|
||||
function* handleRandomBuildRequest(action: RandomBuildRequestAction) {
|
||||
const characterId: UnpackMakeApiResponseData<
|
||||
typeof ApiRequests.postCharacterBuilderRandomBuild
|
||||
> = yield call(
|
||||
SagaHelpers.getApiRequestData,
|
||||
ApiRequests.postCharacterBuilderRandomBuild,
|
||||
action.payload
|
||||
);
|
||||
|
||||
// if (data.success) {
|
||||
const characterUrl = NavigationUtils.getCharacterBuilderUrl(characterId);
|
||||
const initialPath = navConfig.getRouteDefPath(RouteKey.WHATS_NEXT);
|
||||
window.location = `${characterUrl}#${initialPath}` as any;
|
||||
// } else {
|
||||
//TODO do something with error
|
||||
// }
|
||||
}
|
||||
|
||||
function* handleStepBuildRequest(action: StepBuildRequestAction) {
|
||||
yield put(builderActions.characterLoadingSetCommit(true));
|
||||
|
||||
//TODO fix with common refactor for populating character
|
||||
const characterId: UnpackMakeApiResponseData<
|
||||
typeof ApiRequests.postCharacterBuilderStandardBuild
|
||||
> = yield call(
|
||||
SagaHelpers.getApiRequestData,
|
||||
ApiRequests.postCharacterBuilderStandardBuild,
|
||||
action.payload
|
||||
);
|
||||
|
||||
yield put(appEnvActions.dataSet({ characterId }));
|
||||
yield put(
|
||||
builderActions.builderMethodSetCommit({
|
||||
method: BuilderMethod.STEP_BY_STEP,
|
||||
})
|
||||
);
|
||||
yield select(appEnvSelectors.getUsername);
|
||||
/* eslint-disable-next-line */
|
||||
yield put(
|
||||
characterActions.characterLoad(characterId, { v: appConfig.version })
|
||||
);
|
||||
}
|
||||
|
||||
function* handleLoadCharacterRequest(action: BuilderAction) {
|
||||
yield put(builderActions.characterLoadingSetCommit(true));
|
||||
const characterId: ReturnType<typeof appEnvSelectors.getCharacterId> =
|
||||
yield select(appEnvSelectors.getCharacterId);
|
||||
|
||||
if (characterId === null) {
|
||||
// TODO do something
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
yield put(
|
||||
builderActions.builderMethodSetCommit({
|
||||
method: BuilderMethod.STEP_BY_STEP,
|
||||
})
|
||||
);
|
||||
yield put(
|
||||
characterActions.characterLoad(characterId, { v: appConfig.version })
|
||||
);
|
||||
} catch (e) {
|
||||
// TODO handle failure in builder init process
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param action
|
||||
*/
|
||||
export function* handleSuggestedNamesRequest(
|
||||
action: SuggestedNamesRequestAction
|
||||
) {
|
||||
const species: ReturnType<typeof rulesEngineSelectors.getRace> = yield select(
|
||||
rulesEngineSelectors.getRace
|
||||
);
|
||||
let reqParams: ApiRequests.GetSuggestedNames_RequestData = {
|
||||
count: 5,
|
||||
};
|
||||
|
||||
if (species !== null) {
|
||||
reqParams = {
|
||||
...reqParams,
|
||||
raceDefinitionKey: RaceUtils.getDefinitionKey(species),
|
||||
};
|
||||
}
|
||||
|
||||
const names: UnpackMakeApiResponseData<typeof ApiRequests.getSuggestedNames> =
|
||||
yield call(SagaHelpers.getApiRequestData, ApiRequests.getSuggestedNames, {
|
||||
params: reqParams,
|
||||
});
|
||||
yield put(builderActions.suggestedNamesSet(names));
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createSelector } from "reselect";
|
||||
|
||||
import {
|
||||
featureFlagInfoSelectors,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { NavigationType } from "~/subApps/builder/constants";
|
||||
|
||||
import { getNavBuilderConfig } from "../config/navigation";
|
||||
import { BuilderAppState } from "../typings";
|
||||
|
||||
export const getCurrentPath = (state) => window.location.pathname;
|
||||
|
||||
export const getBuilderMethod = (state: BuilderAppState) =>
|
||||
state.builder.method;
|
||||
export const getConfirmSpecies = (state: BuilderAppState) =>
|
||||
state.builder.confirmSpecies;
|
||||
export const getConfirmClass = (state: BuilderAppState) =>
|
||||
state.builder.confirmClass;
|
||||
export const getIsCharacterLoading = (state: BuilderAppState) =>
|
||||
state.builder.isCharacterLoading;
|
||||
export const getIsCharacterLoaded = (state: BuilderAppState) =>
|
||||
state.builder.isCharacterLoaded;
|
||||
export const getSuggestedNames = (state: BuilderAppState) =>
|
||||
state.builder.suggestedNames;
|
||||
|
||||
export const getRoutePathing = createSelector(
|
||||
[
|
||||
(state) => state,
|
||||
getBuilderMethod,
|
||||
rulesEngineSelectors.getCharacterConfiguration,
|
||||
],
|
||||
(state, builderMethod, configuration) => {
|
||||
const navConfig = getNavBuilderConfig(
|
||||
builderMethod,
|
||||
featureFlagInfoSelectors.getFeatureFlagInfo(state)
|
||||
);
|
||||
|
||||
let exclusionNavTypes: Array<any> = [];
|
||||
if (!configuration.showHelpText) {
|
||||
exclusionNavTypes.push(NavigationType.HelpPage);
|
||||
}
|
||||
|
||||
let routePath: Array<any> = [];
|
||||
navConfig.forEach((section) => {
|
||||
const { routes } = section;
|
||||
routes.forEach((routeDef) => {
|
||||
let available = true;
|
||||
if (exclusionNavTypes.indexOf(routeDef.type) > -1) {
|
||||
available = false;
|
||||
}
|
||||
if (!routeDef.checkCanAccess(routeDef, state)) {
|
||||
available = false;
|
||||
}
|
||||
routePath.push({
|
||||
...routeDef,
|
||||
available,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return routePath;
|
||||
}
|
||||
);
|
||||
|
||||
export const getAvailableRoutePathing = createSelector(
|
||||
[
|
||||
(state) => state,
|
||||
getBuilderMethod,
|
||||
rulesEngineSelectors.getCharacterConfiguration,
|
||||
],
|
||||
(state, builderMethod, configuration) => {
|
||||
const navConfig = getNavBuilderConfig(
|
||||
builderMethod,
|
||||
featureFlagInfoSelectors.getFeatureFlagInfo(state)
|
||||
);
|
||||
|
||||
let exclusionNavTypes: Array<any> = [];
|
||||
if (!configuration.showHelpText) {
|
||||
exclusionNavTypes.push(NavigationType.HelpPage);
|
||||
}
|
||||
|
||||
let routePath: Array<any> = [];
|
||||
navConfig.forEach((section) => {
|
||||
const { routes } = section;
|
||||
routes.forEach((routeDef) => {
|
||||
if (exclusionNavTypes.indexOf(routeDef.type) > -1) {
|
||||
return;
|
||||
}
|
||||
if (!routeDef.checkCanAccess(routeDef, state)) {
|
||||
return;
|
||||
}
|
||||
routePath.push({
|
||||
...routeDef,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return routePath;
|
||||
}
|
||||
);
|
||||
|
||||
export const getFirstAvailableSectionRoutes = createSelector(
|
||||
[
|
||||
(state) => state,
|
||||
getBuilderMethod,
|
||||
rulesEngineSelectors.getCharacterConfiguration,
|
||||
],
|
||||
(state, builderMethod, configuration) => {
|
||||
const navConfig = getNavBuilderConfig(
|
||||
builderMethod,
|
||||
featureFlagInfoSelectors.getFeatureFlagInfo(state)
|
||||
);
|
||||
|
||||
let exclusionNavTypes: Array<any> = [];
|
||||
if (!configuration.showHelpText) {
|
||||
exclusionNavTypes.push(NavigationType.HelpPage);
|
||||
}
|
||||
|
||||
let sections = {};
|
||||
navConfig.forEach((section) => {
|
||||
const { routes } = section;
|
||||
let found = false;
|
||||
routes.forEach((routeDef) => {
|
||||
if (found) {
|
||||
return;
|
||||
}
|
||||
if (exclusionNavTypes.indexOf(routeDef.type) > -1) {
|
||||
return;
|
||||
}
|
||||
if (!routeDef.checkCanAccess(routeDef, state)) {
|
||||
return;
|
||||
}
|
||||
sections[section.key] = routeDef;
|
||||
found = true;
|
||||
});
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
);
|
||||
|
||||
export const checkIsCharacterSheetReady = createSelector(
|
||||
[
|
||||
rulesEngineSelectors.getRace,
|
||||
rulesEngineSelectors.getClasses,
|
||||
rulesEngineSelectors.getAbilities,
|
||||
rulesEngineSelectors.getCharacterConfiguration,
|
||||
],
|
||||
(species, classes, stats, configuration) => {
|
||||
if (!species) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!classes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasAllStats = true;
|
||||
if (configuration.abilityScoreType === null) {
|
||||
hasAllStats = false;
|
||||
}
|
||||
stats.forEach((stat) => {
|
||||
if (stat.baseScore === null) {
|
||||
hasAllStats = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAllStats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export const checkHasCharRequiredData = createSelector(
|
||||
[rulesEngineSelectors.getRace, rulesEngineSelectors.getClasses],
|
||||
(species, classes) => species !== null && !!classes.length
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createSelector } from "reselect";
|
||||
|
||||
import * as appEnvSelectors from "../../Shared/selectors/appEnv";
|
||||
import * as NavigationUtils from "../utils/navigationUtils";
|
||||
|
||||
export const getProfileCharacterListingUrl = () =>
|
||||
NavigationUtils.getCharacterListingUrl();
|
||||
|
||||
export const getCharacterSheetUrl = createSelector(
|
||||
[appEnvSelectors.getCharacterId],
|
||||
(characterId) => {
|
||||
return NavigationUtils.getCharacterSheetUrl(characterId);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,163 @@
|
||||
import { flatten } from "lodash";
|
||||
|
||||
import {
|
||||
featureFlagInfoSelectors,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import config from "~/config";
|
||||
import { NavigationType } from "~/subApps/builder/constants";
|
||||
|
||||
import * as navConfig from "../config/navigation";
|
||||
import { builderSelectors } from "../selectors";
|
||||
|
||||
const BASE_PATHNAME = config.basePathname;
|
||||
|
||||
function checkIsRouteInNavConfig(targetRouteDef, navConfig) {
|
||||
const routeDefs: Array<any> = flatten(
|
||||
navConfig.map((section) => section.routes)
|
||||
);
|
||||
return routeDefs.filter((routeDef) => routeDef.key === targetRouteDef.key)
|
||||
.length;
|
||||
}
|
||||
|
||||
export function checkStdBuilderPageRequirements(routeDef, state) {
|
||||
return (
|
||||
builderSelectors.getBuilderMethod(state) !== null &&
|
||||
checkIsRouteInNavConfig(
|
||||
routeDef,
|
||||
navConfig.getNavBuilderConfig(
|
||||
builderSelectors.getBuilderMethod(state),
|
||||
featureFlagInfoSelectors.getFeatureFlagInfo(state)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function checkIsCurrentRouteInRoutePathing(state) {
|
||||
const currentPath = state.routeControls.currentPath;
|
||||
const characterId = rulesEngineSelectors.getId(state);
|
||||
const routePathing = builderSelectors.getRoutePathing(state);
|
||||
const currentRoutePathIdx = getRoutePathIdxByPath(
|
||||
currentPath,
|
||||
routePathing,
|
||||
characterId
|
||||
);
|
||||
return currentRoutePathIdx > 0;
|
||||
}
|
||||
|
||||
// TODO: This function seems to jsut return '/' so investigate it and remove it if it's not needed
|
||||
export const getAvailableRouteRedirect = (routeKey, state) => {
|
||||
const routePath = navConfig.getRouteDefPath(routeKey);
|
||||
const routePathing = builderSelectors.getRoutePathing(state);
|
||||
const currentRoutePathIdx = getRoutePathIdxByPath(routePath, routePathing);
|
||||
|
||||
const currentRoutePathingNode = routePathing[currentRoutePathIdx];
|
||||
|
||||
if (currentRoutePathIdx === -1 || !currentRoutePathingNode.available) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return "/";
|
||||
};
|
||||
|
||||
export const getRoutePathIdx = (routeKey, routePathDef) => {
|
||||
const path = navConfig.getRouteDefPath(routeKey);
|
||||
return routePathDef.findIndex((routeDef) => path === routeDef.path);
|
||||
};
|
||||
|
||||
export function getRoutePathIdxByPath(path, routePathDef, characterId = 0) {
|
||||
return routePathDef.findIndex(
|
||||
(routeDef) => path === routeDef.path.replace(":characterId", characterId)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCurrentSectionHelpRoutePath(state) {
|
||||
const currentPath = builderSelectors.getCurrentPath(state);
|
||||
const sections = navConfig.getNavigationSections(
|
||||
builderSelectors.getBuilderMethod(state),
|
||||
navConfig.getCurrentRouteDef(currentPath),
|
||||
featureFlagInfoSelectors.getFeatureFlagInfo(state)
|
||||
);
|
||||
const currentSection = sections.find((section) => section.active);
|
||||
const currentSectionHelpRoute = currentSection.routes.find(
|
||||
(route) => route.type === NavigationType.HelpPage
|
||||
);
|
||||
//TODO change this to getRouteDefPath once it takes a routeDef instead of routeKey
|
||||
if (currentSectionHelpRoute) {
|
||||
return currentSectionHelpRoute.path;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvailablePathRedirect(state) {
|
||||
const currentPath = builderSelectors.getCurrentPath(state);
|
||||
const routePathing = builderSelectors.getRoutePathing(state);
|
||||
const characterId = rulesEngineSelectors.getId(state);
|
||||
const currentRoutePathIdx = getRoutePathIdxByPath(
|
||||
currentPath,
|
||||
routePathing,
|
||||
characterId
|
||||
);
|
||||
|
||||
const currentRoutePathingNode = routePathing[currentRoutePathIdx];
|
||||
if (!currentRoutePathingNode.available) {
|
||||
for (let i = currentRoutePathIdx; i < routePathing.length; i++) {
|
||||
if (routePathing[i].available) {
|
||||
return routePathing[i].path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getAvailablePrevRoute = (routeDef, state) => {
|
||||
const availableRoutePath = builderSelectors.getAvailableRoutePathing(state);
|
||||
const currentRoutePathIdx = getRoutePathIdx(routeDef.key, availableRoutePath);
|
||||
|
||||
// -1 === can't find in route path
|
||||
// 0 === first page in path and there isn't a previous
|
||||
if (currentRoutePathIdx <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return availableRoutePath[currentRoutePathIdx - 1];
|
||||
};
|
||||
|
||||
export const getAvailableNextRoute = (routeDef, state) => {
|
||||
const availableRoutePath = builderSelectors.getAvailableRoutePathing(state);
|
||||
const currentRoutePathIdx = getRoutePathIdx(routeDef.key, availableRoutePath);
|
||||
const lastAvailableRoutePathIdx = availableRoutePath.length - 1;
|
||||
|
||||
// -1 === can't find in route path
|
||||
// current = last === can't go any next because at last
|
||||
if (
|
||||
currentRoutePathIdx < 0 ||
|
||||
currentRoutePathIdx >= lastAvailableRoutePathIdx
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return availableRoutePath[currentRoutePathIdx + 1];
|
||||
};
|
||||
|
||||
export function getCharacterBuilderUrl(characterId): string {
|
||||
return `${BASE_PATHNAME}/${characterId}/builder`;
|
||||
}
|
||||
|
||||
export function getCharacterSheetUrl(characterId): string {
|
||||
return `${BASE_PATHNAME}/${characterId}`;
|
||||
}
|
||||
|
||||
export function getCharacterListingUrl(): string {
|
||||
return `${BASE_PATHNAME}`;
|
||||
}
|
||||
|
||||
export function getUrlWithParams(url?: string): string {
|
||||
const baseUrl = url ?? BASE_PATHNAME;
|
||||
return window.location.search
|
||||
? `${baseUrl}${window.location.search}`
|
||||
: baseUrl;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const CHARACTER_INIT = "sheet.CHARACTER_INIT";
|
||||
export const CHARACTER_RECEIVE_FAIL = "sheet.CHARACTER_RECEIVE_FAIL";
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
CharacterInitAction,
|
||||
CharacterReceiveFailAction,
|
||||
sheetActionTypes,
|
||||
} from "../sheet";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function characterInit(): CharacterInitAction {
|
||||
return {
|
||||
type: sheetActionTypes.CHARACTER_INIT,
|
||||
payload: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param error
|
||||
*/
|
||||
export function characterReceiveFail(error: Error): CharacterReceiveFailAction {
|
||||
return {
|
||||
type: sheetActionTypes.CHARACTER_RECEIVE_FAIL,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const sheetActionTypes = actionTypes;
|
||||
export const sheetActions = actions;
|
||||
export const sheetTypings = typings;
|
||||
|
||||
export * from "./typings";
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from "react";
|
||||
|
||||
import { Snippet } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
ActionUtils,
|
||||
ActivationContract,
|
||||
ActivationUtils,
|
||||
BaseInventoryContract,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
ClassFeatureContract,
|
||||
InfusionUtils,
|
||||
ItemUtils,
|
||||
LevelScaleContract,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
ClassFeatureUtils,
|
||||
InventoryLookup,
|
||||
HelperUtils,
|
||||
Hack__BaseCharClass,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
|
||||
import { FeatureSnippetLimitedUse } from "../FeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
action: Action;
|
||||
activation: ActivationContract;
|
||||
showActivationInfo: boolean;
|
||||
|
||||
onActionClick?: (action: Action) => void;
|
||||
onActionUseSet?: (action: Action, used: number) => void;
|
||||
|
||||
abilityLookup: AbilityLookup;
|
||||
inventoryLookup: InventoryLookup;
|
||||
ruleData: RuleData;
|
||||
snippetData: SnippetData;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ActionSnippet extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
showActivationInfo: false,
|
||||
};
|
||||
|
||||
handleActionUseSet = (action: Action, uses: number): void => {
|
||||
const { onActionUseSet } = this.props;
|
||||
|
||||
if (onActionUseSet) {
|
||||
onActionUseSet(action, uses);
|
||||
}
|
||||
};
|
||||
|
||||
handleActionClick = (evt: React.MouseEvent): void => {
|
||||
const { action, onActionClick } = this.props;
|
||||
|
||||
if (onActionClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onActionClick(action);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
action,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
snippetData,
|
||||
isInteractive,
|
||||
showActivationInfo,
|
||||
inventoryLookup,
|
||||
activation,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
let limitedUse = ActionUtils.getLimitedUse(action);
|
||||
let name: React.ReactNode = ActionUtils.getName(action);
|
||||
let dataOrigin = ActionUtils.getDataOrigin(action);
|
||||
let dataOriginType = ActionUtils.getDataOriginType(action);
|
||||
|
||||
let classLevel: number | null = null;
|
||||
let scaleValue: LevelScaleContract | null = null;
|
||||
|
||||
switch (dataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
|
||||
classLevel = ClassUtils.getLevel(
|
||||
dataOrigin.parent as Hack__BaseCharClass
|
||||
);
|
||||
scaleValue = ClassFeatureUtils.getLevelScale(
|
||||
dataOrigin.primary as ClassFeatureContract
|
||||
);
|
||||
break;
|
||||
case Constants.DataOriginTypeEnum.RACE:
|
||||
//scaleValue = RacialTraitUtils.getLevelScale(dataOrigin.primary); //TODO this doesn't exist.. is it still needed?
|
||||
break;
|
||||
case Constants.DataOriginTypeEnum.ITEM: {
|
||||
if (!name) {
|
||||
const itemContract = dataOrigin.primary as BaseInventoryContract;
|
||||
const itemMappingId = ItemUtils.getMappingId(itemContract);
|
||||
const item = HelperUtils.lookupDataOrFallback(
|
||||
inventoryLookup,
|
||||
itemMappingId
|
||||
);
|
||||
|
||||
if (item !== null) {
|
||||
const infusion = ItemUtils.getInfusion(item);
|
||||
name = (
|
||||
<React.Fragment>
|
||||
<ItemName item={item} />
|
||||
{infusion !== null && (
|
||||
<span className="ct-feature-snippet__heading-extra">
|
||||
(Infusion: {InfusionUtils.getName(infusion)})
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let activationDisplay: React.ReactNode;
|
||||
if (showActivationInfo && activation) {
|
||||
activationDisplay = (
|
||||
<span
|
||||
className={`ct-feature-snippet__heading-activation ${
|
||||
theme.isDarkMode
|
||||
? "ct-feature-snippet__heading-activation--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
({ActivationUtils.renderActivation(activation, ruleData)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-feature-snippet" onClick={this.handleActionClick}>
|
||||
<div
|
||||
className={`ct-feature-snippet__heading ${
|
||||
theme.isDarkMode ? "ct-feature-snippet__heading--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{name} {activationDisplay}
|
||||
</div>
|
||||
<div className="ct-feature-snippet__content">
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
theme={theme}
|
||||
classLevel={classLevel}
|
||||
levelScale={scaleValue}
|
||||
limitedUse={limitedUse}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
>
|
||||
{ActionUtils.getSnippet(action)}
|
||||
</Snippet>
|
||||
</div>
|
||||
<FeatureSnippetLimitedUse
|
||||
component={action}
|
||||
theme={theme}
|
||||
limitedUse={limitedUse}
|
||||
onUseSet={this.handleActionUseSet}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ActionSnippet from "./ActionSnippet";
|
||||
|
||||
export default ActionSnippet;
|
||||
export { ActionSnippet };
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CharacterTheme } from "@dndbeyond/character-rules-engine";
|
||||
import { HTMLAttributes } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
||||
interface ActionListSectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
headingText: string;
|
||||
theme: CharacterTheme;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const ActionListSection = ({
|
||||
// Component Props
|
||||
theme,
|
||||
headingText,
|
||||
testId = "",
|
||||
// From HTMLAttributes
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ActionListSectionProps) => {
|
||||
const headingTestId = `actions-list-${testId}-heading`;
|
||||
const listTestId = `actions-list-${testId}-list`;
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.section, className])} {...props}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.sectionHeading,
|
||||
theme?.isDarkMode && styles.darkMode,
|
||||
])}
|
||||
data-testid={headingTestId}
|
||||
>
|
||||
{headingText}
|
||||
</div>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.list,
|
||||
theme?.isDarkMode && styles.darkMode,
|
||||
])}
|
||||
data-testid={listTestId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React, { HTMLAttributes, useMemo } from "react";
|
||||
import { AttackTable } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
Activatable,
|
||||
Attack,
|
||||
BasicActionContract,
|
||||
Constants,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellUtils,
|
||||
WeaponSpellDamageGroup,
|
||||
InventoryLookup,
|
||||
DataOriginRefData,
|
||||
HelperUtils,
|
||||
ActionUtils,
|
||||
BaseSpell,
|
||||
CharacterTheme,
|
||||
Action,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { IRollContext } from "@dndbeyond/dice";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import ActionSnippet from "../ActionSnippet";
|
||||
import BasicActions from "../BasicActions";
|
||||
import { FeatureSnippetSpells } from "../FeatureSnippet";
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
import { ActionListSection } from "./ActionListSection";
|
||||
|
||||
|
||||
interface ActionsListProps extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: React.ReactNode;
|
||||
theme: CharacterTheme;
|
||||
showNotes: boolean;
|
||||
diceEnabled?: boolean;
|
||||
isInteractive: boolean;
|
||||
showActivationInfo?: boolean;
|
||||
|
||||
actions?: Array<Activatable>;
|
||||
basicActions?: Array<BasicActionContract>;
|
||||
ritualSpells?: Array<Spell>;
|
||||
attacks?: Array<Attack>;
|
||||
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
|
||||
ruleData: RuleData;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
proficiencyBonus: number;
|
||||
rollContext: IRollContext;
|
||||
inventoryLookup: InventoryLookup;
|
||||
snippetData: SnippetData;
|
||||
|
||||
// ActionHandlers
|
||||
onAttackClick: (attack: Attack) => void;
|
||||
onActionClick: (action: Action) => void;
|
||||
onBasicActionClick: (basicAction: BasicActionContract) => void;
|
||||
onActionUseSet: (action: Action, used: number) => void;
|
||||
onSpellClick: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, used: number) => void;
|
||||
};
|
||||
|
||||
export const ActionsList = ({
|
||||
heading,
|
||||
theme,
|
||||
showNotes,
|
||||
isInteractive,
|
||||
diceEnabled = false,
|
||||
showActivationInfo = false,
|
||||
|
||||
actions = [],
|
||||
basicActions = [],
|
||||
ritualSpells = [],
|
||||
attacks = [],
|
||||
weaponSpellDamageGroups,
|
||||
ruleData,
|
||||
spellCasterInfo,
|
||||
abilityLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
rollContext,
|
||||
inventoryLookup,
|
||||
snippetData,
|
||||
// From ActionHandlers
|
||||
onAttackClick,
|
||||
onBasicActionClick,
|
||||
onActionClick,
|
||||
onActionUseSet,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
// From HTMLAttributes
|
||||
className,
|
||||
...props
|
||||
}: ActionsListProps) => {
|
||||
const { actionLookup } = useCharacterEngine();
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => !!basicActions.length
|
||||
|| !!actions.length
|
||||
|| !!ritualSpells.length
|
||||
|| !!attacks.length,
|
||||
[basicActions, actions, ritualSpells, attacks]
|
||||
);
|
||||
|
||||
const attackTableProps = {
|
||||
attacks,
|
||||
weaponSpellDamageGroups,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
spellCasterInfo,
|
||||
onAttackClick,
|
||||
showNotes,
|
||||
diceEnabled,
|
||||
theme,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
rollContext,
|
||||
};
|
||||
|
||||
const spellSnippetProps = {
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
ruleData,
|
||||
theme,
|
||||
abilityLookup,
|
||||
isInteractive,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
};
|
||||
|
||||
const spells = useMemo(() => {
|
||||
let spells: Array<BaseSpell> = [];
|
||||
|
||||
actions.forEach((action) => {
|
||||
switch (action.type) {
|
||||
case Constants.ActivatableTypeEnum.CLASS_SPELL:
|
||||
case Constants.ActivatableTypeEnum.CHARACTER_SPELL:
|
||||
spells.push(action.entity);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return spells;
|
||||
}, [actions]);
|
||||
|
||||
const orderedRitualSpells = useMemo(
|
||||
() => orderBy(
|
||||
ritualSpells,
|
||||
(spell) => SpellUtils.getName(spell)
|
||||
),
|
||||
[ritualSpells]
|
||||
);
|
||||
|
||||
const features = useMemo(() => actions.filter(
|
||||
a => a.type === Constants.ActivatableTypeEnum.ACTION)
|
||||
.map(feature => {
|
||||
const action = HelperUtils.lookupDataOrFallback(
|
||||
actionLookup,
|
||||
ActionUtils.getUniqueKey(feature.entity)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.activatable}
|
||||
key={feature.key}
|
||||
data-testid="actions-list-activatable-feature"
|
||||
>
|
||||
<ActionSnippet
|
||||
theme={theme}
|
||||
action={action ?? feature.entity}
|
||||
onActionClick={onActionClick}
|
||||
onActionUseSet={onActionUseSet}
|
||||
abilityLookup={abilityLookup}
|
||||
inventoryLookup={inventoryLookup}
|
||||
ruleData={ruleData}
|
||||
snippetData={snippetData}
|
||||
isInteractive={isInteractive}
|
||||
showActivationInfo={showActivationInfo}
|
||||
activation={feature.activation}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
[
|
||||
actions,
|
||||
actionLookup,
|
||||
theme,
|
||||
onActionClick,
|
||||
onActionUseSet,
|
||||
abilityLookup,
|
||||
inventoryLookup,
|
||||
ruleData,
|
||||
snippetData,
|
||||
isInteractive,
|
||||
showActivationInfo,
|
||||
proficiencyBonus,
|
||||
]);
|
||||
|
||||
return isVisible ? (
|
||||
<div className={clsx([styles.actionsList, className])} {...props}>
|
||||
<div
|
||||
className={clsx([styles.heading, theme.isDarkMode && styles.darkMode])}
|
||||
data-testid="actions-list-header"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
<div>
|
||||
{!!attacks.length && <AttackTable className={styles.attackTable} {...attackTableProps} />}
|
||||
|
||||
{!!basicActions.length && (
|
||||
<ActionListSection headingText="Actions in Combat" testId="basic" theme={theme}>
|
||||
<BasicActions
|
||||
onActionClick={onBasicActionClick}
|
||||
basicActions={basicActions}
|
||||
theme={theme}
|
||||
/>
|
||||
</ActionListSection>
|
||||
)}
|
||||
|
||||
{!!spells.length && (
|
||||
<ActionListSection headingText="Spells" theme={theme}>
|
||||
<FeatureSnippetSpells layoutType={"compact"} spells={spells} {...spellSnippetProps} />
|
||||
</ActionListSection>
|
||||
)}
|
||||
|
||||
{!!ritualSpells.length && (
|
||||
<ActionListSection headingText="Ritual Spells" theme={theme}>
|
||||
<FeatureSnippetSpells layoutType={"compact"} spells={orderedRitualSpells} {...spellSnippetProps} />
|
||||
</ActionListSection>
|
||||
)}
|
||||
|
||||
{features}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
Background,
|
||||
BackgroundUtils,
|
||||
CharacterFeaturesManager,
|
||||
CharacterTheme,
|
||||
DataOriginRefData,
|
||||
FeatManager,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { DataOriginTypeEnum } from "~/constants";
|
||||
|
||||
import { FeatFeatureSnippet } from "../FeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
background: Background | null;
|
||||
onClick: () => void;
|
||||
onFeatClick?: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager;
|
||||
onActionUseSet: (action: Action, uses: number) => void;
|
||||
onActionClick?: (action: Action) => void;
|
||||
onSpellClick?: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class BackgroundDetail extends React.PureComponent<Props> {
|
||||
handleBackgroundClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
renderDefault = (): React.ReactNode => {
|
||||
return (
|
||||
<div className="ct-background__default">
|
||||
Your character's background feature will display in this section once
|
||||
chosen.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { background, featuresManager, onFeatClick, ...restProps } =
|
||||
this.props;
|
||||
|
||||
if (!background) {
|
||||
return this.renderDefault();
|
||||
}
|
||||
|
||||
const name = BackgroundUtils.getName(background);
|
||||
const featureName = BackgroundUtils.getFeatureName(background);
|
||||
const featureDescription =
|
||||
BackgroundUtils.getFeatureDescription(background);
|
||||
|
||||
const feats = featuresManager.getDataOriginOnlyFeatsByParent(
|
||||
DataOriginTypeEnum.BACKGROUND,
|
||||
`${BackgroundUtils.getId(background)}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-background" onClick={this.handleBackgroundClick}>
|
||||
<div className="ct-background__name">{name}</div>
|
||||
<>
|
||||
{featureDescription && (
|
||||
<div className="ct-background__feature">
|
||||
<div className="ct-background__feature-name">
|
||||
{featureName ? `Feature: ${featureName}` : "Feature"}
|
||||
</div>
|
||||
<HtmlContent
|
||||
className="ct-background__feature-description"
|
||||
html={featureDescription}
|
||||
withoutTooltips
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{feats.map((feat) => (
|
||||
<FeatFeatureSnippet key={feat.getId()} feat={feat} {...restProps} />
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import BackgroundDetail from "./BackgroundDetail";
|
||||
|
||||
export default BackgroundDetail;
|
||||
export { BackgroundDetail };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
BasicActionContract,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
basicActions: Array<BasicActionContract>;
|
||||
onActionClick?: (basicAction: BasicActionContract) => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class BasicActions extends React.PureComponent<Props> {
|
||||
handleActionClick = (
|
||||
basicAction: BasicActionContract,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
const { onActionClick } = this.props;
|
||||
|
||||
if (onActionClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onActionClick(basicAction);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { basicActions, theme } = this.props;
|
||||
|
||||
if (!basicActions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let orderedActions = orderBy(
|
||||
basicActions,
|
||||
[(basicAction) => basicAction.name],
|
||||
["asc"]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-basic-actions ${
|
||||
theme.isDarkMode ? "ct-basic-actions--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{orderedActions.map((basicAction, idx) => (
|
||||
<span
|
||||
className="ct-basic-actions__action"
|
||||
key={basicAction.id}
|
||||
onClick={this.handleActionClick.bind(this, basicAction)}
|
||||
>
|
||||
{basicAction.name}
|
||||
{idx + 1 < orderedActions.length && (
|
||||
<span className="ct-basic-actions__action-sep">,</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import BasicActions from "./BasicActions";
|
||||
|
||||
export default BasicActions;
|
||||
export { BasicActions };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user