From 41f37371f202a210dcd24feb5424ca74ccc61539 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 30 May 2025 13:29:56 -0400 Subject: [PATCH 01/31] First-take with ChatGTP --- src/components/GoalTimeline/GoalList.tsx | 23 +++-- src/components/GoalTimeline/index.tsx | 106 +++++++++++++---------- 2 files changed, 71 insertions(+), 58 deletions(-) diff --git a/src/components/GoalTimeline/GoalList.tsx b/src/components/GoalTimeline/GoalList.tsx index 6108a53bb2..9dd73dc3cb 100644 --- a/src/components/GoalTimeline/GoalList.tsx +++ b/src/components/GoalTimeline/GoalList.tsx @@ -48,6 +48,7 @@ interface GoalListProps { data: Goal[]; size: number; numPanes: number; + recommendFirst?: boolean; scrollable?: boolean; scrollToEnd?: boolean; handleChange: (goal: Goal) => void; @@ -68,12 +69,13 @@ export default function GoalList(props: GoalListProps): ReactElement { onMouseLeave={() => setScrollVisible(false)} > {props.data.length > 0 ? ( - props.data.map((g) => ( + props.data.map((g, i) => ( props.handleChange(g) }} goal={g} key={g.guid || g.name} orientation={props.orientation} + recommended={props.recommendFirst && i === 0} size={tileSize} /> )) @@ -104,6 +106,7 @@ interface GoalTileProps { buttonProps?: ButtonProps & { "data-testid"?: string }; goal?: Goal; orientation: Orientation; + recommended?: boolean; size: number; } @@ -113,19 +116,13 @@ function GoalTile(props: GoalTileProps): ReactElement { diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 3bf7bd9aa0..c58944818d 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -1,4 +1,10 @@ -import { Button, ImageList, ImageListItem, Typography } from "@mui/material"; +import { + Box, + Button, + Typography, + useTheme, + useMediaQuery, +} from "@mui/material"; import { CSSProperties, ReactElement, @@ -15,7 +21,24 @@ import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; import { Goal, GoalType } from "types/goals"; import { requiredPermission, goalTypeToGoal } from "utilities/goalUtilities"; -import { useWindowSize } from "utilities/useWindowSize"; + +// Collapse history items with same name + same changes +function collapseHistory(goals: Goal[]): Goal[] { + const seen: Record = {}; + const collapsed: Goal[] = []; + + for (const goal of goals) { + const key = `${goal.name}-${JSON.stringify(goal.changes)}`; + if (!seen[key]) { + seen[key] = 1; + collapsed.push(goal); + } else { + seen[key] += 1; + } + } + + return collapsed; +} const timelineStyle: { [key: string]: CSSProperties } = { centerButton: { @@ -37,7 +60,8 @@ const timelineStyle: { [key: string]: CSSProperties } = { // Extracted for testing purposes. export function createSuggestionData( availableGoalTypes: GoalType[], - goalTypeSuggestions: GoalType[] + goalTypeSuggestions: GoalType[], + keepFirst = false ): Goal[] { const suggestions = goalTypeSuggestions.filter((t) => availableGoalTypes.includes(t) @@ -45,7 +69,7 @@ export function createSuggestionData( if (!suggestions.length) { return availableGoalTypes.map(goalTypeToGoal); } - const secondarySuggestions = suggestions.slice(1); + const secondarySuggestions = suggestions.slice(keepFirst ? 0 : 1); const nonSuggestions = availableGoalTypes.filter( (t) => !suggestions.includes(t) ); @@ -69,28 +93,23 @@ export default function GoalTimeline(): ReactElement { const [availableGoalTypes, setAvailableGoalTypes] = useState([]); const [suggestedGoalTypes, setSuggestedGoalTypes] = useState([]); - + const [toolGoals, setToolGoals] = useState([]); const [hasGraylist, setHasGraylist] = useState(false); const [loaded, setLoaded] = useState(false); - const [portrait, setPortrait] = useState(true); const { t } = useTranslation(); - const { windowHeight, windowWidth } = useWindowSize(); + const theme = useTheme(); + const isPortrait = useMediaQuery(theme.breakpoints.down("sm")); useEffect(() => { if (!loaded) { dispatch(asyncGetUserEdits()); setLoaded(true); } - const updateHasGraylist = async (): Promise => - setHasGraylist(await hasGraylistEntries()); - updateHasGraylist(); - }, [dispatch, loaded]); - useEffect(() => { - setPortrait(windowWidth - 40 < windowHeight); - }, [windowHeight, windowWidth]); + hasGraylistEntries().then(setHasGraylist); + }, [dispatch, loaded]); const getGoalTypes = useCallback(async (): Promise => { const permissions = await getCurrentPermissions(); @@ -99,7 +118,9 @@ export default function GoalTimeline(): ReactElement { ? allGoalTypes.concat([GoalType.ReviewDeferredDups]) : allGoalTypes ).filter((t) => permissions.includes(requiredPermission(t))); + setAvailableGoalTypes(goalTypes); + setToolGoals(createSuggestionData(goalTypes, goalTypeSuggestions, true)); setSuggestedGoalTypes( goalTypes.filter((t) => goalTypeSuggestions.includes(t)) ); @@ -169,44 +190,39 @@ export default function GoalTimeline(): ReactElement { function renderLandscape(): ReactElement { return ( - - {/* Alternatives */} - - {t("goal.selector.other")} - - - - {/* Recommendation */} - - {t("goal.selector.present")} - {goalButton()} - - - {/* History */} - - {t("goal.selector.past")} + + {/* TOOL SELECTION ROW */} + + {t("goal.selector.present")} + + 0} + scrollable={toolGoals.length > 4} + size={100} + /> + + {/* HISTORY ROW */} + + + {t("goal.selector.past")} + - - + + ); } - return portrait ? renderPortrait() : renderLandscape(); + return isPortrait ? renderPortrait() : renderLandscape(); } From a2a3d08d28eb8e1f65d605f60cd9c3dd4c03a6e4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Jun 2025 15:17:24 -0400 Subject: [PATCH 02/31] Finish first draft --- public/locales/en/translation.json | 21 +- .../GoalTimeline/GoalHistoryButton.tsx | 66 ++++++ src/components/GoalTimeline/GoalList.tsx | 169 --------------- src/components/GoalTimeline/GoalNameGrid.tsx | 34 +++ src/components/GoalTimeline/index.tsx | 201 +++++------------- .../GoalTimeline/tests/index.test.tsx | 57 ++--- src/goals/Redux/GoalReduxTypes.ts | 2 +- src/utilities/goalUtilities.ts | 52 ++++- 8 files changed, 242 insertions(+), 360 deletions(-) create mode 100644 src/components/GoalTimeline/GoalHistoryButton.tsx delete mode 100644 src/components/GoalTimeline/GoalList.tsx create mode 100644 src/components/GoalTimeline/GoalNameGrid.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 20850a277f..67cb85ab6f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -318,25 +318,32 @@ "progressMerge": "Merge Set {{ val1 }} of {{ val2 }}" }, "createStrWordInv": { - "title": "Create Structural Word Inventory" + "title": "Create Structural Word Inventory", + "description": "NOT IMPLEMENTED" }, "handleFlags": { - "title": "Handle Flags" + "title": "Handle Flags", + "description": "NOT IMPLEMENTED" }, "reviewDeferredDups": { - "title": "Review Deferred Duplicates" + "title": "Review Deferred Duplicates", + "description": "Reconsider deferred sets of potential duplicates." }, "spellCheckGloss": { - "title": "Spell Check Gloss" + "title": "Spell Check Gloss", + "description": "NOT IMPLEMENTED" }, "validateChars": { - "title": "Validate Characters" + "title": "Validate Characters", + "description": "NOT IMPLEMENTED" }, "validateStrWords": { - "title": "Validate Structural Words" + "title": "Validate Structural Words", + "description": "NOT IMPLEMENTED" }, "reviewEntries": { "title": "Review Entries", + "description": "Review all entries in the project.", "allEntriesPerPageOption": "All", "editSense": "Edit sense", "discardChanges": "Discard changes?", @@ -376,6 +383,7 @@ }, "charInventory": { "title": "Create Character Inventory", + "description": "Review all vernacular characters.", "characters": "characters", "examples": "Examples", "occurrences": "occurrences", @@ -418,6 +426,7 @@ }, "mergeDups": { "title": "Merge Duplicates", + "description": "Consider potential duplicates.", "helpText": { "dragCard": "Drag a card here to merge", "saveAndContinue": "Save changes and load a new set of words", diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx new file mode 100644 index 0000000000..27d1ee0a7a --- /dev/null +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -0,0 +1,66 @@ +import { Button, Typography } from "@mui/material"; +import { Fragment, ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { CharInvChangesGoalList } from "goals/CharacterInventory/CharInvCompleted"; +import { CharInvChanges } from "goals/CharacterInventory/CharacterInventoryTypes"; +import { MergesCount } from "goals/MergeDuplicates/MergeDupsCompleted"; +import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; +import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; +import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; +import { Goal, GoalType } from "types/goals"; + +interface GoalHistoryButtonProps { + goal?: Goal; + onClick: () => void; +} + +export default function GoalHistoryButton( + props: GoalHistoryButtonProps +): ReactElement { + const { onClick, goal } = props; + return ( + + ); +} + +interface GoalInfoProps { + goal?: Goal; +} + +function GoalInfo(props: GoalInfoProps): ReactElement { + const { t } = useTranslation(); + + const goal = props.goal; + if (!goal) { + return {t("goal.selector.noHistory")}; + } + + return ( + + {t(goal.name + ".title")} + {goal.changes ? getCompletedGoalInfo(goal) : null} + + ); +} + +function getCompletedGoalInfo(goal: Goal): ReactElement { + switch (goal.goalType) { + case GoalType.CreateCharInv: + return CharInvChangesGoalList(goal.changes as CharInvChanges); + case GoalType.MergeDups: + case GoalType.ReviewDeferredDups: + return MergesCount(goal.changes as MergesCompleted); + case GoalType.ReviewEntries: + return EditsCount(goal.changes as EntriesEdited); + default: + return ; + } +} diff --git a/src/components/GoalTimeline/GoalList.tsx b/src/components/GoalTimeline/GoalList.tsx deleted file mode 100644 index 9dd73dc3cb..0000000000 --- a/src/components/GoalTimeline/GoalList.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { - Button, - ButtonProps, - ImageList, - ImageListItem, - Typography, -} from "@mui/material"; -import { CSSProperties, Fragment, ReactElement, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { CharInvChangesGoalList } from "goals/CharacterInventory/CharInvCompleted"; -import { CharInvChanges } from "goals/CharacterInventory/CharacterInventoryTypes"; -import { MergesCount } from "goals/MergeDuplicates/MergeDupsCompleted"; -import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; -import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; -import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; -import { Goal, GoalStatus, GoalType } from "types/goals"; - -type Orientation = "horizontal" | "vertical"; - -function gridStyle( - orientation: Orientation, - size: number, - scrollVisible?: boolean -): CSSProperties { - switch (orientation) { - case "horizontal": - return { - flexWrap: "nowrap", - width: size + "vw", - overflowX: scrollVisible ? "scroll" : "hidden", - overflowY: "hidden", - }; - case "vertical": - return { - flexWrap: "wrap", - height: size + "vw", - overflowX: "auto", - overflowY: scrollVisible ? "scroll" : "hidden", - padding: "50px", - }; - } -} - -interface GoalListProps { - completed?: boolean; - orientation: Orientation; - data: Goal[]; - size: number; - numPanes: number; - recommendFirst?: boolean; - scrollable?: boolean; - scrollToEnd?: boolean; - handleChange: (goal: Goal) => void; -} - -export default function GoalList(props: GoalListProps): ReactElement { - const [scrollVisible, setScrollVisible] = useState(); - const tileSize = props.size / 3 - 1.25; - - const id = (g: Goal): string => - props.completed ? `completed-goal-${g.guid}` : `new-goal-${g.name}`; - - return ( - setScrollVisible(props.scrollable)} - onMouseLeave={() => setScrollVisible(false)} - > - {props.data.length > 0 ? ( - props.data.map((g, i) => ( - props.handleChange(g) }} - goal={g} - key={g.guid || g.name} - orientation={props.orientation} - recommended={props.recommendFirst && i === 0} - size={tileSize} - /> - )) - ) : ( - - )} -
{ - if (props.scrollToEnd && element) { - element.scrollIntoView(true); - } - }} - /> - - ); -} - -function buttonStyle(orientation: Orientation, size: number): CSSProperties { - switch (orientation) { - case "horizontal": - return { height: "95%", padding: "1vw", width: size + "vw" }; - case "vertical": - return { height: "95%", padding: "1vw", width: "100%" }; - } -} - -interface GoalTileProps { - buttonProps?: ButtonProps & { "data-testid"?: string }; - goal?: Goal; - orientation: Orientation; - recommended?: boolean; - size: number; -} - -function GoalTile(props: GoalTileProps): ReactElement { - const goal = props.goal; - return ( - - - - ); -} - -interface GoalInfoProps { - goal?: Goal; -} - -function GoalInfo(props: GoalInfoProps): ReactElement { - const { t } = useTranslation(); - - const goal = props.goal; - if (!goal) { - return {t("goal.selector.noHistory")}; - } - - if (goal.status === GoalStatus.Completed) { - return ( - - {t(goal.name + ".title")} - {getCompletedGoalInfo(goal)} - - ); - } - - return {t(goal.name + ".title")}; -} - -function getCompletedGoalInfo(goal: Goal): ReactElement { - switch (goal.goalType) { - case GoalType.CreateCharInv: - return CharInvChangesGoalList(goal.changes as CharInvChanges); - case GoalType.MergeDups: - case GoalType.ReviewDeferredDups: - return MergesCount(goal.changes as MergesCompleted); - case GoalType.ReviewEntries: - return EditsCount(goal.changes as EntriesEdited); - default: - return ; - } -} diff --git a/src/components/GoalTimeline/GoalNameGrid.tsx b/src/components/GoalTimeline/GoalNameGrid.tsx new file mode 100644 index 0000000000..20a3a9f6dc --- /dev/null +++ b/src/components/GoalTimeline/GoalNameGrid.tsx @@ -0,0 +1,34 @@ +import { Button, Grid2, Stack, Typography } from "@mui/material"; +import { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { GoalName } from "types/goals"; + +interface GoalNameGridProps { + goalName: GoalName; + onClick: () => void; + recommended?: boolean; +} + +export default function GoalNameGrid(props: GoalNameGridProps): ReactElement { + const { t } = useTranslation(); + const { onClick, goalName, recommended } = props; + return ( + + + + ); +} diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index c58944818d..efb5e4adae 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -1,28 +1,21 @@ -import { - Box, - Button, - Typography, - useTheme, - useMediaQuery, -} from "@mui/material"; -import { - CSSProperties, - ReactElement, - useCallback, - useEffect, - useState, -} from "react"; +import { Box, Grid2, Stack, Typography } from "@mui/material"; +import { ReactElement, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getCurrentPermissions, hasGraylistEntries } from "backend"; -import GoalList from "components/GoalTimeline/GoalList"; +import GoalHistoryButton from "components/GoalTimeline/GoalHistoryButton"; +import GoalNameGrid from "components/GoalTimeline/GoalNameGrid"; import { asyncAddGoal, asyncGetUserEdits } from "goals/Redux/GoalActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; -import { Goal, GoalType } from "types/goals"; -import { requiredPermission, goalTypeToGoal } from "utilities/goalUtilities"; +import { Goal, GoalName, GoalType } from "types/goals"; +import { + goalNameToGoal, + goalTypeToName, + requiredPermission, +} from "utilities/goalUtilities"; -// Collapse history items with same name + same changes +// Collapse history items with same name & changes function collapseHistory(goals: Goal[]): Goal[] { const seen: Record = {}; const collapsed: Goal[] = []; @@ -40,41 +33,24 @@ function collapseHistory(goals: Goal[]): Goal[] { return collapsed; } -const timelineStyle: { [key: string]: CSSProperties } = { - centerButton: { - padding: "70px 0", - textAlign: "center", - width: "100%", - height: "80%", - }, - paneStyling: { - display: "block", - justifyContent: "center", - marginInline: "auto", - textAlign: "center", - }, -}; - // Creates a list of suggestions, with non-suggested goals at the end and // our main suggestion absent (to be displayed on the suggestions button). // Extracted for testing purposes. export function createSuggestionData( availableGoalTypes: GoalType[], - goalTypeSuggestions: GoalType[], - keepFirst = false -): Goal[] { + goalTypeSuggestions: GoalType[] +): GoalName[] { const suggestions = goalTypeSuggestions.filter((t) => availableGoalTypes.includes(t) ); if (!suggestions.length) { - return availableGoalTypes.map(goalTypeToGoal); + return availableGoalTypes.map(goalTypeToName); } - const secondarySuggestions = suggestions.slice(keepFirst ? 0 : 1); const nonSuggestions = availableGoalTypes.filter( (t) => !suggestions.includes(t) ); - secondarySuggestions.push(...nonSuggestions); - return secondarySuggestions.map(goalTypeToGoal); + suggestions.push(...nonSuggestions); + return suggestions.map(goalTypeToName); } /** @@ -88,28 +64,16 @@ export default function GoalTimeline(): ReactElement { (state: StoreState) => state.goalsState ); - const chooseGoal = (goal: Goal): Promise => - dispatch(asyncAddGoal(goal)); - - const [availableGoalTypes, setAvailableGoalTypes] = useState([]); const [suggestedGoalTypes, setSuggestedGoalTypes] = useState([]); - const [toolGoals, setToolGoals] = useState([]); + const [toolGoals, setToolGoals] = useState([]); const [hasGraylist, setHasGraylist] = useState(false); - const [loaded, setLoaded] = useState(false); const { t } = useTranslation(); - const theme = useTheme(); - const isPortrait = useMediaQuery(theme.breakpoints.down("sm")); - useEffect(() => { - if (!loaded) { - dispatch(asyncGetUserEdits()); - setLoaded(true); - } - + dispatch(asyncGetUserEdits()); hasGraylistEntries().then(setHasGraylist); - }, [dispatch, loaded]); + }, [dispatch]); const getGoalTypes = useCallback(async (): Promise => { const permissions = await getCurrentPermissions(); @@ -119,8 +83,7 @@ export default function GoalTimeline(): ReactElement { : allGoalTypes ).filter((t) => permissions.includes(requiredPermission(t))); - setAvailableGoalTypes(goalTypes); - setToolGoals(createSuggestionData(goalTypes, goalTypeSuggestions, true)); + setToolGoals(createSuggestionData(goalTypes, goalTypeSuggestions)); setSuggestedGoalTypes( goalTypes.filter((t) => goalTypeSuggestions.includes(t)) ); @@ -130,99 +93,41 @@ export default function GoalTimeline(): ReactElement { getGoalTypes(); }, [getGoalTypes]); - // Creates a button for our recommended goal - function goalButton(): ReactElement { - const done = suggestedGoalTypes.length === 0; - const goal = goalTypeToGoal(suggestedGoalTypes[0]); - return ( - - ); - } - - function renderPortrait(): ReactElement { - return ( - <> - {/* Alternatives */} -
- -
- - {/* Recommendation */} -
- {t("goal.selector.present")} - {goalButton()} -
- - {/* History */} -
- {t("goal.selector.past")} - + {/* Tools */} + + {toolGoals.map((g, i) => ( + dispatch(asyncAddGoal(goalNameToGoal(g)))} + recommended={i === 0 && suggestedGoalTypes.length > 0} /> -
- - ); - } + ))} + - function renderLandscape(): ReactElement { - return ( - - {/* TOOL SELECTION ROW */} - - {t("goal.selector.present")} + {/* History */} + + + {t("goal.selector.past")} - 0} - scrollable={toolGoals.length > 4} - size={100} - /> - - {/* HISTORY ROW */} - - - {t("goal.selector.past")} - - - + + {goalHistory.length ? ( + goalHistory.map((g, i) => ( + dispatch(asyncAddGoal(g))} + /> + )) + ) : ( + {}} /> + )} + - ); - } - - return isPortrait ? renderPortrait() : renderLandscape(); + + ); } diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 3b334783e8..93371898f0 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -5,8 +5,9 @@ import createMockStore from "redux-mock-store"; import { Permission } from "api/models"; import GoalTimeline, { createSuggestionData } from "components/GoalTimeline"; -import { type GoalsState, defaultState } from "goals/Redux/GoalReduxTypes"; -import { Goal, GoalType } from "types/goals"; +import { implementedTypes, type GoalsState } from "goals/Redux/GoalReduxTypes"; +import { defaultState } from "rootRedux/types"; +import { Goal } from "types/goals"; import { goalTypeToGoal } from "utilities/goalUtilities"; jest.mock("backend", () => ({ @@ -30,7 +31,7 @@ const mockChooseGoal = jest.fn(); const mockGetCurrentPermissions = jest.fn(); const mockHasGraylistEntries = jest.fn(); -const allGoals = defaultState.allGoalTypes.map((t) => goalTypeToGoal(t)); +const allGoals = implementedTypes.map((t) => goalTypeToGoal(t)); const goalWithAnyGuid = (g: Goal): Goal => ({ ...g, guid: expect.any(String) }); const allGoalsWithAnyGuids = allGoals.map(goalWithAnyGuid); @@ -45,20 +46,16 @@ beforeEach(() => { describe("GoalTimeline", () => { it("has the expected number of buttons", async () => { - await renderTimeline(defaultState.allGoalTypes, allGoals); + await renderTimeline(implementedTypes, allGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength( - defaultState.allGoalTypes.length + allGoals.length - ); + expect(buttons).toHaveLength(implementedTypes.length + allGoals.length); }); it("has one more button if there's a graylist entry", async () => { mockHasGraylistEntries.mockResolvedValue(true); - await renderTimeline(defaultState.allGoalTypes, allGoals); + await renderTimeline(implementedTypes, allGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength( - defaultState.allGoalTypes.length + allGoals.length + 1 - ); + expect(buttons).toHaveLength(implementedTypes.length + allGoals.length + 1); }); it("selects a goal from suggestions", async () => { @@ -67,58 +64,48 @@ describe("GoalTimeline", () => { await userEvent.click(screen.getByText(`${allGoals[goalNum].name}.title`)); expect(mockChooseGoal).toHaveBeenCalledTimes(1); expect(mockChooseGoal.mock.calls[0][0].goalType).toEqual( - defaultState.allGoalTypes[goalNum] + implementedTypes[goalNum] ); }); describe("createSuggestionData", () => { it("don't suggests goal types that aren't available", () => { - const suggestions = createSuggestionData([], defaultState.allGoalTypes); + const suggestions = createSuggestionData([], implementedTypes); expect(suggestions).toEqual([]); }); - it("suggests all but the first of the available suggestions", () => { - const suggestions = createSuggestionData( - defaultState.allGoalTypes, - defaultState.allGoalTypes - ); - expect(suggestions).toEqual(allGoalsWithAnyGuids.slice(1)); - }); - it("appends non-suggested available goal types to the end", () => { const sliceIndex = 2; const suggestions = createSuggestionData( - defaultState.allGoalTypes, - defaultState.allGoalTypes.slice(sliceIndex) + implementedTypes, + implementedTypes.slice(sliceIndex) ); const expectedGoals = [ - ...allGoalsWithAnyGuids.slice(sliceIndex + 1), + ...allGoalsWithAnyGuids.slice(sliceIndex), ...allGoalsWithAnyGuids.slice(0, sliceIndex), ]; - expect(suggestions).toEqual(expectedGoals); + expect(suggestions).toEqual(expectedGoals.map((g) => g.name)); }); it("has a fallback for empty suggestion data", () => { - const suggestions = createSuggestionData(defaultState.allGoalTypes, []); - expect(suggestions).toEqual(allGoalsWithAnyGuids); + const suggestions = createSuggestionData(implementedTypes, []); + expect(suggestions).toEqual(allGoalsWithAnyGuids.map((g) => g.name)); }); }); }); async function renderTimeline( - goalTypeSuggestions?: GoalType[], - history?: Goal[] + goalTypeSuggestions = [...implementedTypes], + history: Goal[] = [] ): Promise { - const currentProjectState = { project: { id: "mockProjId" } }; const goalsState: GoalsState = { - ...defaultState, - goalTypeSuggestions: goalTypeSuggestions ?? defaultState.allGoalTypes, - history: history ?? [], - previousGoalType: GoalType.Default, + ...defaultState.goalsState, + goalTypeSuggestions, + history, }; await act(async () => { render( - + ); diff --git a/src/goals/Redux/GoalReduxTypes.ts b/src/goals/Redux/GoalReduxTypes.ts index 24147ec68a..4a65434b7a 100644 --- a/src/goals/Redux/GoalReduxTypes.ts +++ b/src/goals/Redux/GoalReduxTypes.ts @@ -19,7 +19,7 @@ export interface GoalsState { // GoalType.ReviewDeferredDups is also implemented, // but is conditionally available -const implementedTypes: GoalType[] = [ +export const implementedTypes: GoalType[] = [ GoalType.CreateCharInv, GoalType.MergeDups, GoalType.ReviewEntries, diff --git a/src/utilities/goalUtilities.ts b/src/utilities/goalUtilities.ts index b2cc8d4552..ff158bb7e9 100644 --- a/src/utilities/goalUtilities.ts +++ b/src/utilities/goalUtilities.ts @@ -10,7 +10,7 @@ import { ReviewEntries } from "goals/ReviewEntries/ReviewEntriesTypes"; import { SpellCheckGloss } from "goals/SpellCheckGloss/SpellCheckGloss"; import { ValidateChars } from "goals/ValidateChars/ValidateChars"; import { ValidateStrWords } from "goals/ValidateStrWords/ValidateStrWords"; -import { Goal, GoalStatus, GoalType } from "types/goals"; +import { Goal, GoalName, GoalStatus, GoalType } from "types/goals"; export function maxNumSteps(type: GoalType): number { switch (type) { @@ -37,6 +37,31 @@ export function requiredPermission(type: GoalType): Permission { } } +export function goalNameToGoal(type: GoalName): Goal { + switch (type) { + case GoalName.CreateCharInv: + return new CreateCharInv(); + case GoalName.CreateStrWordInv: + return new CreateStrWordInv(); + case GoalName.HandleFlags: + return new HandleFlags(); + case GoalName.MergeDups: + return new MergeDups(); + case GoalName.ReviewDeferredDups: + return new ReviewDeferredDups(); + case GoalName.ReviewEntries: + return new ReviewEntries(); + case GoalName.SpellCheckGloss: + return new SpellCheckGloss(); + case GoalName.ValidateChars: + return new ValidateChars(); + case GoalName.ValidateStrWords: + return new ValidateStrWords(); + default: + return new Goal(); + } +} + export function goalTypeToGoal(type: GoalType): Goal { switch (type) { case GoalType.CreateCharInv: @@ -62,6 +87,31 @@ export function goalTypeToGoal(type: GoalType): Goal { } } +export function goalTypeToName(type: GoalType): GoalName { + switch (type) { + case GoalType.CreateCharInv: + return GoalName.CreateCharInv; + case GoalType.CreateStrWordInv: + return GoalName.CreateStrWordInv; + case GoalType.HandleFlags: + return GoalName.HandleFlags; + case GoalType.MergeDups: + return GoalName.MergeDups; + case GoalType.ReviewDeferredDups: + return GoalName.ReviewDeferredDups; + case GoalType.ReviewEntries: + return GoalName.ReviewEntries; + case GoalType.SpellCheckGloss: + return GoalName.SpellCheckGloss; + case GoalType.ValidateChars: + return GoalName.ValidateChars; + case GoalType.ValidateStrWords: + return GoalName.ValidateStrWords; + default: + return GoalName.Default; + } +} + export function convertGoalToEdit(goal: Goal): Edit { const guid = goal.guid; const goalType = goal.goalType as number; From 8b830c89188b2561ac9fad4b68eed56311198a9a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Jun 2025 16:06:44 -0400 Subject: [PATCH 03/31] Tidy --- .../GoalTimeline/GoalHistoryButton.tsx | 4 +-- .../GoalTimeline/GoalNameButton.tsx | 34 ++++++++++++++++++ src/components/GoalTimeline/GoalNameGrid.tsx | 34 ------------------ src/components/GoalTimeline/index.tsx | 35 +++++++------------ 4 files changed, 48 insertions(+), 59 deletions(-) create mode 100644 src/components/GoalTimeline/GoalNameButton.tsx delete mode 100644 src/components/GoalTimeline/GoalNameGrid.tsx diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 27d1ee0a7a..ed35c2b055 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -12,7 +12,7 @@ import { Goal, GoalType } from "types/goals"; interface GoalHistoryButtonProps { goal?: Goal; - onClick: () => void; + onClick?: () => void; } export default function GoalHistoryButton( @@ -22,7 +22,7 @@ export default function GoalHistoryButton( return ( + ); +} diff --git a/src/components/GoalTimeline/GoalNameGrid.tsx b/src/components/GoalTimeline/GoalNameGrid.tsx deleted file mode 100644 index 20a3a9f6dc..0000000000 --- a/src/components/GoalTimeline/GoalNameGrid.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, Grid2, Stack, Typography } from "@mui/material"; -import { ReactElement } from "react"; -import { useTranslation } from "react-i18next"; - -import { GoalName } from "types/goals"; - -interface GoalNameGridProps { - goalName: GoalName; - onClick: () => void; - recommended?: boolean; -} - -export default function GoalNameGrid(props: GoalNameGridProps): ReactElement { - const { t } = useTranslation(); - const { onClick, goalName, recommended } = props; - return ( - - - - ); -} diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index efb5e4adae..130f6f7d09 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { getCurrentPermissions, hasGraylistEntries } from "backend"; import GoalHistoryButton from "components/GoalTimeline/GoalHistoryButton"; -import GoalNameGrid from "components/GoalTimeline/GoalNameGrid"; +import GoalNameButton from "components/GoalTimeline/GoalNameButton"; import { asyncAddGoal, asyncGetUserEdits } from "goals/Redux/GoalActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; @@ -15,7 +15,7 @@ import { requiredPermission, } from "utilities/goalUtilities"; -// Collapse history items with same name & changes +/** Collapse history items with same name & changes. */ function collapseHistory(goals: Goal[]): Goal[] { const seen: Record = {}; const collapsed: Goal[] = []; @@ -33,9 +33,8 @@ function collapseHistory(goals: Goal[]): Goal[] { return collapsed; } -// Creates a list of suggestions, with non-suggested goals at the end and -// our main suggestion absent (to be displayed on the suggestions button). -// Extracted for testing purposes. +/** Creates a list of suggestions, with non-suggested goals at the end. + * Extracted for testing purposes. */ export function createSuggestionData( availableGoalTypes: GoalType[], goalTypeSuggestions: GoalType[] @@ -43,9 +42,6 @@ export function createSuggestionData( const suggestions = goalTypeSuggestions.filter((t) => availableGoalTypes.includes(t) ); - if (!suggestions.length) { - return availableGoalTypes.map(goalTypeToName); - } const nonSuggestions = availableGoalTypes.filter( (t) => !suggestions.includes(t) ); @@ -53,10 +49,7 @@ export function createSuggestionData( return suggestions.map(goalTypeToName); } -/** - * Displays the list of goals the user has decided they will work on, their choices - * for the next goal, and suggestions for which goals they should choose to work on. - */ +/** List of goals, followed by goal history. */ export default function GoalTimeline(): ReactElement { const dispatch = useAppDispatch(); @@ -64,8 +57,7 @@ export default function GoalTimeline(): ReactElement { (state: StoreState) => state.goalsState ); - const [suggestedGoalTypes, setSuggestedGoalTypes] = useState([]); - const [toolGoals, setToolGoals] = useState([]); + const [goalOptions, setGoalOptions] = useState([]); const [hasGraylist, setHasGraylist] = useState(false); const { t } = useTranslation(); @@ -83,10 +75,7 @@ export default function GoalTimeline(): ReactElement { : allGoalTypes ).filter((t) => permissions.includes(requiredPermission(t))); - setToolGoals(createSuggestionData(goalTypes, goalTypeSuggestions)); - setSuggestedGoalTypes( - goalTypes.filter((t) => goalTypeSuggestions.includes(t)) - ); + setGoalOptions(createSuggestionData(goalTypes, goalTypeSuggestions)); }, [allGoalTypes, goalTypeSuggestions, hasGraylist]); useEffect(() => { @@ -97,14 +86,14 @@ export default function GoalTimeline(): ReactElement { return ( <> - {/* Tools */} + {/* Goals */} - {toolGoals.map((g, i) => ( - ( + dispatch(asyncAddGoal(goalNameToGoal(g)))} - recommended={i === 0 && suggestedGoalTypes.length > 0} + recommended={i === 0 && goalTypeSuggestions.length > 0} /> ))} @@ -124,7 +113,7 @@ export default function GoalTimeline(): ReactElement { /> )) ) : ( - {}} /> + )} From ade9783a931fc701a6226222cd880683c95cd4b5 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Jun 2025 16:42:25 -0400 Subject: [PATCH 04/31] Add icons --- .../GoalTimeline/GoalHistoryButton.tsx | 21 ++++++++++++----- .../GoalTimeline/GoalNameButton.tsx | 7 +++--- src/components/GoalTimeline/index.tsx | 4 ++-- .../{goalUtilities.ts => goalUtilities.tsx} | 23 +++++++++++++++++-- 4 files changed, 42 insertions(+), 13 deletions(-) rename src/utilities/{goalUtilities.ts => goalUtilities.tsx} (87%) diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index ed35c2b055..b4745cb70b 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -1,4 +1,4 @@ -import { Button, Typography } from "@mui/material"; +import { Button, Grid2, Stack, Typography } from "@mui/material"; import { Fragment, ReactElement } from "react"; import { useTranslation } from "react-i18next"; @@ -9,6 +9,7 @@ import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; import { Goal, GoalType } from "types/goals"; +import { goalNameToIcon } from "utilities/goalUtilities"; interface GoalHistoryButtonProps { goal?: Goal; @@ -23,7 +24,7 @@ export default function GoalHistoryButton( ); } interface GoalInfoProps { goal?: Goal; + small?: boolean; } function GoalInfo(props: GoalInfoProps): ReactElement { + const { goal, small } = props; + const { t } = useTranslation(); - const goal = props.goal; if (!goal) { return {t("goal.selector.noHistory")}; } return ( - - - {goalNameToIcon(goal.name)} - {t(goal.name + ".title")} -
- - + + + {t(goal.name + ".title")} + + {goal.changes ? getCompletedGoalInfo(goal) : null} diff --git a/src/components/GoalTimeline/GoalNameButton.tsx b/src/components/GoalTimeline/GoalNameButton.tsx index 7306dd5018..210dd6789c 100644 --- a/src/components/GoalTimeline/GoalNameButton.tsx +++ b/src/components/GoalTimeline/GoalNameButton.tsx @@ -2,6 +2,7 @@ import { Button, Stack, Typography } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; +import IconTypography from "components/GoalTimeline/IconTypography"; import { GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; @@ -9,25 +10,35 @@ interface GoalNameButtonProps { goalName: GoalName; onClick: () => void; recommended?: boolean; + small?: boolean; } export default function GoalNameButton( props: GoalNameButtonProps ): ReactElement { + const { goalName, onClick, recommended, small } = props; const { t } = useTranslation(); - const { onClick, goalName, recommended } = props; + return ( diff --git a/src/components/GoalTimeline/IconTypography.tsx b/src/components/GoalTimeline/IconTypography.tsx new file mode 100644 index 0000000000..9c37965f1d --- /dev/null +++ b/src/components/GoalTimeline/IconTypography.tsx @@ -0,0 +1,26 @@ +import { Box, Typography, type TypographyProps } from "@mui/material"; +import { type ReactElement } from "react"; + +interface IconTypographyProps extends TypographyProps { + icon: ReactElement; +} + +export default function IconTypography( + props: IconTypographyProps +): ReactElement { + const { children, icon, ...typographyProps } = props; + return ( + + + {icon} + + {children} + + ); +} diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 886730b24a..40bc315891 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -1,4 +1,11 @@ -import { Box, Grid2, Stack, Typography } from "@mui/material"; +import { + Box, + Grid2, + Stack, + Theme, + Typography, + useMediaQuery, +} from "@mui/material"; import { ReactElement, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -57,6 +64,8 @@ export default function GoalTimeline(): ReactElement { (state: StoreState) => state.goalsState ); + const small = useMediaQuery((th) => th.breakpoints.down("md")); + const [goalOptions, setGoalOptions] = useState([]); const [hasGraylist, setHasGraylist] = useState(false); @@ -87,14 +96,16 @@ export default function GoalTimeline(): ReactElement { return ( <> {/* Goals */} - + {goalOptions.map((g, i) => ( - dispatch(asyncAddGoal(goalNameToGoal(g)))} - recommended={i === 0 && goalTypeSuggestions.length > 0} - /> + + dispatch(asyncAddGoal(goalNameToGoal(g)))} + recommended={i === 0 && goalTypeSuggestions.length > 0} + small={small} + /> + ))} @@ -110,6 +121,7 @@ export default function GoalTimeline(): ReactElement { goal={g} key={i} onClick={() => dispatch(asyncAddGoal(g))} + small={small} /> )) ) : ( diff --git a/src/utilities/goalUtilities.tsx b/src/utilities/goalUtilities.tsx index 172694f168..006d988ebf 100644 --- a/src/utilities/goalUtilities.tsx +++ b/src/utilities/goalUtilities.tsx @@ -41,93 +41,72 @@ export function requiredPermission(type: GoalType): Permission { } } +const goalNameToGoalMap: Record Goal> = { + [GoalName.CreateCharInv]: () => new CreateCharInv(), + [GoalName.CreateStrWordInv]: () => new CreateStrWordInv(), + [GoalName.HandleFlags]: () => new HandleFlags(), + [GoalName.MergeDups]: () => new MergeDups(), + [GoalName.ReviewDeferredDups]: () => new ReviewDeferredDups(), + [GoalName.ReviewEntries]: () => new ReviewEntries(), + [GoalName.SpellCheckGloss]: () => new SpellCheckGloss(), + [GoalName.ValidateChars]: () => new ValidateChars(), + [GoalName.ValidateStrWords]: () => new ValidateStrWords(), + [GoalName.Default]: () => new Goal(), +}; + export function goalNameToGoal(name: GoalName): Goal { - switch (name) { - case GoalName.CreateCharInv: - return new CreateCharInv(); - case GoalName.CreateStrWordInv: - return new CreateStrWordInv(); - case GoalName.HandleFlags: - return new HandleFlags(); - case GoalName.MergeDups: - return new MergeDups(); - case GoalName.ReviewDeferredDups: - return new ReviewDeferredDups(); - case GoalName.ReviewEntries: - return new ReviewEntries(); - case GoalName.SpellCheckGloss: - return new SpellCheckGloss(); - case GoalName.ValidateChars: - return new ValidateChars(); - case GoalName.ValidateStrWords: - return new ValidateStrWords(); - default: - return new Goal(); - } + return goalNameToGoalMap[name](); } +const goalTypeToGoalMap: Record Goal> = { + [GoalType.CreateCharInv]: () => new CreateCharInv(), + [GoalType.CreateStrWordInv]: () => new CreateStrWordInv(), + [GoalType.HandleFlags]: () => new HandleFlags(), + [GoalType.MergeDups]: () => new MergeDups(), + [GoalType.ReviewDeferredDups]: () => new ReviewDeferredDups(), + [GoalType.ReviewEntries]: () => new ReviewEntries(), + [GoalType.SpellCheckGloss]: () => new SpellCheckGloss(), + [GoalType.ValidateChars]: () => new ValidateChars(), + [GoalType.ValidateStrWords]: () => new ValidateStrWords(), + [GoalType.Default]: () => new Goal(), +}; + export function goalTypeToGoal(type: GoalType): Goal { - switch (type) { - case GoalType.CreateCharInv: - return new CreateCharInv(); - case GoalType.CreateStrWordInv: - return new CreateStrWordInv(); - case GoalType.HandleFlags: - return new HandleFlags(); - case GoalType.MergeDups: - return new MergeDups(); - case GoalType.ReviewDeferredDups: - return new ReviewDeferredDups(); - case GoalType.ReviewEntries: - return new ReviewEntries(); - case GoalType.SpellCheckGloss: - return new SpellCheckGloss(); - case GoalType.ValidateChars: - return new ValidateChars(); - case GoalType.ValidateStrWords: - return new ValidateStrWords(); - default: - return new Goal(); - } + return goalTypeToGoalMap[type](); } +const goalTypeToNameMap: Record = { + [GoalType.CreateCharInv]: GoalName.CreateCharInv, + [GoalType.CreateStrWordInv]: GoalName.CreateStrWordInv, + [GoalType.Default]: GoalName.Default, + [GoalType.HandleFlags]: GoalName.HandleFlags, + [GoalType.MergeDups]: GoalName.MergeDups, + [GoalType.ReviewDeferredDups]: GoalName.ReviewDeferredDups, + [GoalType.ReviewEntries]: GoalName.ReviewEntries, + [GoalType.SpellCheckGloss]: GoalName.SpellCheckGloss, + [GoalType.ValidateChars]: GoalName.ValidateChars, + [GoalType.ValidateStrWords]: GoalName.ValidateStrWords, +}; + export function goalTypeToName(type: GoalType): GoalName { - switch (type) { - case GoalType.CreateCharInv: - return GoalName.CreateCharInv; - case GoalType.CreateStrWordInv: - return GoalName.CreateStrWordInv; - case GoalType.HandleFlags: - return GoalName.HandleFlags; - case GoalType.MergeDups: - return GoalName.MergeDups; - case GoalType.ReviewDeferredDups: - return GoalName.ReviewDeferredDups; - case GoalType.ReviewEntries: - return GoalName.ReviewEntries; - case GoalType.SpellCheckGloss: - return GoalName.SpellCheckGloss; - case GoalType.ValidateChars: - return GoalName.ValidateChars; - case GoalType.ValidateStrWords: - return GoalName.ValidateStrWords; - default: - return GoalName.Default; - } + return goalTypeToNameMap[type]; } -export function goalNameToIcon(name: GoalName): ReactElement { +export function goalNameToIcon( + name: GoalName, + size?: "small" | "medium" | "large" +): ReactElement { switch (name) { case GoalName.CreateCharInv: - return ; + return ; case GoalName.MergeDups: - return ; + return ; case GoalName.ReviewDeferredDups: - return ; + return ; case GoalName.ReviewEntries: - return ; + return ; default: - return ; + return ; } } From 2871c5b3840d3a83be1a9384ad4b3c389ae128d4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Jun 2025 11:04:56 -0400 Subject: [PATCH 07/31] Fix test --- src/components/GoalTimeline/tests/index.test.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 93371898f0..97e311b30f 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -1,3 +1,4 @@ +import { ThemeProvider } from "@mui/material"; import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Provider } from "react-redux"; @@ -8,7 +9,9 @@ import GoalTimeline, { createSuggestionData } from "components/GoalTimeline"; import { implementedTypes, type GoalsState } from "goals/Redux/GoalReduxTypes"; import { defaultState } from "rootRedux/types"; import { Goal } from "types/goals"; +import theme from "types/theme"; import { goalTypeToGoal } from "utilities/goalUtilities"; +import { setMatchMedia } from "utilities/testingLibraryUtilities"; jest.mock("backend", () => ({ getCurrentPermissions: () => mockGetCurrentPermissions(), @@ -35,6 +38,11 @@ const allGoals = implementedTypes.map((t) => goalTypeToGoal(t)); const goalWithAnyGuid = (g: Goal): Goal => ({ ...g, guid: expect.any(String) }); const allGoalsWithAnyGuids = allGoals.map(goalWithAnyGuid); +beforeAll(async () => { + // Required (along with a `ThemeProvider`) for `useMediaQuery` to work + setMatchMedia(); +}); + beforeEach(() => { jest.clearAllMocks(); mockGetCurrentPermissions.mockResolvedValue([ @@ -105,9 +113,11 @@ async function renderTimeline( }; await act(async () => { render( - - - + + + + + ); }); } From 3c53a994cc27e3ffbc34286da42497d0923ee77a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Jun 2025 14:01:42 -0400 Subject: [PATCH 08/31] [GoalTimeline] Primarily use GoalName rather than GoalType --- src/components/GoalTimeline/index.tsx | 35 ++++------- .../GoalTimeline/tests/index.test.tsx | 31 +++++----- src/goals/DefaultGoal/NextGoalScreen.tsx | 14 ++--- .../Redux/tests/MergeDupsActions.test.tsx | 4 +- src/goals/Redux/GoalReducer.ts | 30 ++++----- src/goals/Redux/GoalReduxTypes.ts | 24 ++++---- src/utilities/goalUtilities.tsx | 61 +++++++------------ 7 files changed, 85 insertions(+), 114 deletions(-) diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 40bc315891..67f4f92785 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -15,12 +15,8 @@ import GoalNameButton from "components/GoalTimeline/GoalNameButton"; import { asyncAddGoal, asyncGetUserEdits } from "goals/Redux/GoalActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; -import { Goal, GoalName, GoalType } from "types/goals"; -import { - goalNameToGoal, - goalTypeToName, - requiredPermission, -} from "utilities/goalUtilities"; +import { Goal, GoalName } from "types/goals"; +import { goalNameToGoal, requiredPermission } from "utilities/goalUtilities"; /** Collapse history items with same name & changes. */ function collapseHistory(goals: Goal[]): Goal[] { @@ -43,24 +39,19 @@ function collapseHistory(goals: Goal[]): Goal[] { /** Creates a list of suggestions, with non-suggested goals at the end. * Extracted for testing purposes. */ export function createSuggestionData( - availableGoalTypes: GoalType[], - goalTypeSuggestions: GoalType[] + availableGoals: GoalName[], + goalSuggestions: GoalName[] ): GoalName[] { - const suggestions = goalTypeSuggestions.filter((t) => - availableGoalTypes.includes(t) - ); - const nonSuggestions = availableGoalTypes.filter( - (t) => !suggestions.includes(t) - ); - suggestions.push(...nonSuggestions); - return suggestions.map(goalTypeToName); + const suggestions = goalSuggestions.filter((t) => availableGoals.includes(t)); + const nonSuggestions = availableGoals.filter((t) => !suggestions.includes(t)); + return [...suggestions, ...nonSuggestions]; } /** List of goals, followed by goal history. */ export default function GoalTimeline(): ReactElement { const dispatch = useAppDispatch(); - const { allGoalTypes, goalTypeSuggestions, history } = useAppSelector( + const { allGoals, goalSuggestions, history } = useAppSelector( (state: StoreState) => state.goalsState ); @@ -79,13 +70,11 @@ export default function GoalTimeline(): ReactElement { const getGoalTypes = useCallback(async (): Promise => { const permissions = await getCurrentPermissions(); const goalTypes = ( - hasGraylist - ? allGoalTypes.concat([GoalType.ReviewDeferredDups]) - : allGoalTypes + hasGraylist ? allGoals.concat([GoalName.ReviewDeferredDups]) : allGoals ).filter((t) => permissions.includes(requiredPermission(t))); - setGoalOptions(createSuggestionData(goalTypes, goalTypeSuggestions)); - }, [allGoalTypes, goalTypeSuggestions, hasGraylist]); + setGoalOptions(createSuggestionData(goalTypes, goalSuggestions)); + }, [allGoals, goalSuggestions, hasGraylist]); useEffect(() => { getGoalTypes(); @@ -102,7 +91,7 @@ export default function GoalTimeline(): ReactElement { dispatch(asyncAddGoal(goalNameToGoal(g)))} - recommended={i === 0 && goalTypeSuggestions.length > 0} + recommended={i === 0 && goalSuggestions.length > 0} small={small} /> diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 97e311b30f..366e649be8 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -6,11 +6,11 @@ import createMockStore from "redux-mock-store"; import { Permission } from "api/models"; import GoalTimeline, { createSuggestionData } from "components/GoalTimeline"; -import { implementedTypes, type GoalsState } from "goals/Redux/GoalReduxTypes"; +import { implementedGoals, type GoalsState } from "goals/Redux/GoalReduxTypes"; import { defaultState } from "rootRedux/types"; import { Goal } from "types/goals"; import theme from "types/theme"; -import { goalTypeToGoal } from "utilities/goalUtilities"; +import { goalNameToGoal } from "utilities/goalUtilities"; import { setMatchMedia } from "utilities/testingLibraryUtilities"; jest.mock("backend", () => ({ @@ -34,7 +34,7 @@ const mockChooseGoal = jest.fn(); const mockGetCurrentPermissions = jest.fn(); const mockHasGraylistEntries = jest.fn(); -const allGoals = implementedTypes.map((t) => goalTypeToGoal(t)); +const allGoals = implementedGoals.map(goalNameToGoal); const goalWithAnyGuid = (g: Goal): Goal => ({ ...g, guid: expect.any(String) }); const allGoalsWithAnyGuids = allGoals.map(goalWithAnyGuid); @@ -54,16 +54,16 @@ beforeEach(() => { describe("GoalTimeline", () => { it("has the expected number of buttons", async () => { - await renderTimeline(implementedTypes, allGoals); + await renderTimeline(implementedGoals, allGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength(implementedTypes.length + allGoals.length); + expect(buttons).toHaveLength(implementedGoals.length + allGoals.length); }); it("has one more button if there's a graylist entry", async () => { mockHasGraylistEntries.mockResolvedValue(true); - await renderTimeline(implementedTypes, allGoals); + await renderTimeline(implementedGoals, allGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength(implementedTypes.length + allGoals.length + 1); + expect(buttons).toHaveLength(implementedGoals.length + allGoals.length + 1); }); it("selects a goal from suggestions", async () => { @@ -71,22 +71,21 @@ describe("GoalTimeline", () => { await renderTimeline(); await userEvent.click(screen.getByText(`${allGoals[goalNum].name}.title`)); expect(mockChooseGoal).toHaveBeenCalledTimes(1); - expect(mockChooseGoal.mock.calls[0][0].goalType).toEqual( - implementedTypes[goalNum] - ); + const calledGoalName = mockChooseGoal.mock.calls[0][0].name; + expect(calledGoalName).toEqual(implementedGoals[goalNum]); }); describe("createSuggestionData", () => { it("don't suggests goal types that aren't available", () => { - const suggestions = createSuggestionData([], implementedTypes); + const suggestions = createSuggestionData([], implementedGoals); expect(suggestions).toEqual([]); }); it("appends non-suggested available goal types to the end", () => { const sliceIndex = 2; const suggestions = createSuggestionData( - implementedTypes, - implementedTypes.slice(sliceIndex) + implementedGoals, + implementedGoals.slice(sliceIndex) ); const expectedGoals = [ ...allGoalsWithAnyGuids.slice(sliceIndex), @@ -96,19 +95,19 @@ describe("GoalTimeline", () => { }); it("has a fallback for empty suggestion data", () => { - const suggestions = createSuggestionData(implementedTypes, []); + const suggestions = createSuggestionData(implementedGoals, []); expect(suggestions).toEqual(allGoalsWithAnyGuids.map((g) => g.name)); }); }); }); async function renderTimeline( - goalTypeSuggestions = [...implementedTypes], + goalSuggestions = [...implementedGoals], history: Goal[] = [] ): Promise { const goalsState: GoalsState = { ...defaultState.goalsState, - goalTypeSuggestions, + goalSuggestions, history, }; await act(async () => { diff --git a/src/goals/DefaultGoal/NextGoalScreen.tsx b/src/goals/DefaultGoal/NextGoalScreen.tsx index f7b6b9ca21..c8141c13ea 100644 --- a/src/goals/DefaultGoal/NextGoalScreen.tsx +++ b/src/goals/DefaultGoal/NextGoalScreen.tsx @@ -6,16 +6,16 @@ import MergeDupsContinueDialog from "goals/MergeDuplicates/MergeDupsContinueDial import { asyncAddGoal } from "goals/Redux/GoalActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; -import { GoalType } from "types/goals"; +import { GoalName } from "types/goals"; import { Path } from "types/path"; -import { goalTypeToGoal } from "utilities/goalUtilities"; +import { goalNameToGoal } from "utilities/goalUtilities"; /** * Dialog for continuing to a new goal or returning to GoalTimeline. */ export default function NextGoalScreen(): ReactElement { - const goalType = useAppSelector( - (state: StoreState) => state.goalsState.previousGoalType + const prevGoal = useAppSelector( + (state: StoreState) => state.goalsState.previousGoal ); const dispatch = useAppDispatch(); @@ -23,14 +23,14 @@ export default function NextGoalScreen(): ReactElement { function loadNextGoal(shouldContinue: boolean): void { if (shouldContinue) { - dispatch(asyncAddGoal(goalTypeToGoal(goalType))); + dispatch(asyncAddGoal(goalNameToGoal(prevGoal))); } else { navigate(Path.Goals); } } - switch (goalType) { - case GoalType.MergeDups: + switch (prevGoal) { + case GoalName.MergeDups: return ; default: return ; diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx index 80b29c304a..bce5300908 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx @@ -66,9 +66,9 @@ const preloadedState = { ...persistedDefaultState, goalsState: { ...defaultGoalsState, - allGoalTypes: [], + allGoals: [], currentGoal: new MergeDups(), - goalTypeSuggestions: [], + goalSuggestions: [], history: [mockGoal], }, }; diff --git a/src/goals/Redux/GoalReducer.ts b/src/goals/Redux/GoalReducer.ts index e1fba17017..6bc8cfce27 100644 --- a/src/goals/Redux/GoalReducer.ts +++ b/src/goals/Redux/GoalReducer.ts @@ -10,21 +10,21 @@ import { EntryEdit, } from "goals/ReviewEntries/ReviewEntriesTypes"; import { StoreActionTypes } from "rootRedux/actions"; -import { GoalType } from "types/goals"; +import { GoalName } from "types/goals"; const goalSlice = createSlice({ name: "goalsState", initialState: defaultState, reducers: { addCharInvChangesToGoalAction: (state, action) => { - if (state.currentGoal.goalType === GoalType.CreateCharInv) { + if (state.currentGoal.name === GoalName.CreateCharInv) { state.currentGoal.changes = action.payload; } }, addCompletedMergeToGoalAction: (state, action) => { if ( - state.currentGoal.goalType === GoalType.MergeDups || - state.currentGoal.goalType === GoalType.ReviewDeferredDups + state.currentGoal.name === GoalName.MergeDups || + state.currentGoal.name === GoalName.ReviewDeferredDups ) { const changes = { ...state.currentGoal.changes } as MergesCompleted; if (!changes.merges) { @@ -35,7 +35,7 @@ const goalSlice = createSlice({ } }, addEntryEditToGoalAction: (state, action) => { - if (state.currentGoal.goalType === GoalType.ReviewEntries) { + if (state.currentGoal.name === GoalName.ReviewEntries) { const changes = { ...state.currentGoal.changes } as EntriesEdited; if (!changes.entryEdits) { changes.entryEdits = []; @@ -59,19 +59,19 @@ const goalSlice = createSlice({ }, loadUserEditsAction: (state, action) => { const history = [...action.payload]; - state.previousGoalType = - history[history.length - 1]?.goalType ?? GoalType.Default; + state.previousGoal = + history[history.length - 1]?.name ?? GoalName.Default; state.history = history; }, setCurrentGoalAction: (state, action) => { state.currentGoal = action.payload; - state.goalTypeSuggestions = state.goalTypeSuggestions.filter( - (type, index) => index !== 0 || action.payload.goalType !== type + state.goalSuggestions = state.goalSuggestions.filter( + (g, i) => i || action.payload.name !== g ); // Remove top suggestion if same as goal to add. - state.previousGoalType = - state.currentGoal.goalType !== GoalType.Default - ? state.currentGoal.goalType - : state.previousGoalType; + state.previousGoal = + state.currentGoal.name !== GoalName.Default + ? state.currentGoal.name + : state.previousGoal; }, setDataLoadStatusAction: (state, action) => { state.dataLoadStatus = action.payload; @@ -89,8 +89,8 @@ const goalSlice = createSlice({ }, updateStepFromDataAction: (state) => { if ( - state.currentGoal.goalType === GoalType.MergeDups || - state.currentGoal.goalType === GoalType.ReviewDeferredDups + state.currentGoal.name === GoalName.MergeDups || + state.currentGoal.name === GoalName.ReviewDeferredDups ) { const currentGoalData = state.currentGoal.data as MergeDupsData; state.currentGoal.steps[state.currentGoal.currentStep] = { diff --git a/src/goals/Redux/GoalReduxTypes.ts b/src/goals/Redux/GoalReduxTypes.ts index 4a65434b7a..65cef99478 100644 --- a/src/goals/Redux/GoalReduxTypes.ts +++ b/src/goals/Redux/GoalReduxTypes.ts @@ -1,4 +1,4 @@ -import { Goal, GoalType } from "types/goals"; +import { Goal, GoalName } from "types/goals"; export enum DataLoadStatus { Default = "DEFAULT", @@ -9,27 +9,27 @@ export enum DataLoadStatus { // The representation of goals in the redux store export interface GoalsState { - allGoalTypes: GoalType[]; + allGoals: GoalName[]; currentGoal: Goal; dataLoadStatus: DataLoadStatus; - goalTypeSuggestions: GoalType[]; + goalSuggestions: GoalName[]; history: Goal[]; - previousGoalType: GoalType; + previousGoal: GoalName; } -// GoalType.ReviewDeferredDups is also implemented, +// GoalName.ReviewDeferredDups is also implemented, // but is conditionally available -export const implementedTypes: GoalType[] = [ - GoalType.CreateCharInv, - GoalType.MergeDups, - GoalType.ReviewEntries, +export const implementedGoals: GoalName[] = [ + GoalName.CreateCharInv, + GoalName.MergeDups, + GoalName.ReviewEntries, ]; export const defaultState: GoalsState = { - allGoalTypes: implementedTypes, + allGoals: [...implementedGoals], currentGoal: new Goal(), dataLoadStatus: DataLoadStatus.Default, - goalTypeSuggestions: [...implementedTypes], + goalSuggestions: [...implementedGoals], history: [], - previousGoalType: GoalType.Default, + previousGoal: GoalName.Default, }; diff --git a/src/utilities/goalUtilities.tsx b/src/utilities/goalUtilities.tsx index 006d988ebf..c6c05a3e0d 100644 --- a/src/utilities/goalUtilities.tsx +++ b/src/utilities/goalUtilities.tsx @@ -28,13 +28,13 @@ export function maxNumSteps(type: GoalType): number { } /** Specify which project permission is required for a user to access a goal. */ -export function requiredPermission(type: GoalType): Permission { +export function requiredPermission(type: GoalName): Permission { switch (type) { - case GoalType.MergeDups: - case GoalType.ReviewDeferredDups: - case GoalType.ReviewEntries: + case GoalName.MergeDups: + case GoalName.ReviewDeferredDups: + case GoalName.ReviewEntries: return Permission.MergeAndReviewEntries; - case GoalType.CreateCharInv: + case GoalName.CreateCharInv: return Permission.CharacterInventory; default: return Permission.Archive; @@ -58,40 +58,6 @@ export function goalNameToGoal(name: GoalName): Goal { return goalNameToGoalMap[name](); } -const goalTypeToGoalMap: Record Goal> = { - [GoalType.CreateCharInv]: () => new CreateCharInv(), - [GoalType.CreateStrWordInv]: () => new CreateStrWordInv(), - [GoalType.HandleFlags]: () => new HandleFlags(), - [GoalType.MergeDups]: () => new MergeDups(), - [GoalType.ReviewDeferredDups]: () => new ReviewDeferredDups(), - [GoalType.ReviewEntries]: () => new ReviewEntries(), - [GoalType.SpellCheckGloss]: () => new SpellCheckGloss(), - [GoalType.ValidateChars]: () => new ValidateChars(), - [GoalType.ValidateStrWords]: () => new ValidateStrWords(), - [GoalType.Default]: () => new Goal(), -}; - -export function goalTypeToGoal(type: GoalType): Goal { - return goalTypeToGoalMap[type](); -} - -const goalTypeToNameMap: Record = { - [GoalType.CreateCharInv]: GoalName.CreateCharInv, - [GoalType.CreateStrWordInv]: GoalName.CreateStrWordInv, - [GoalType.Default]: GoalName.Default, - [GoalType.HandleFlags]: GoalName.HandleFlags, - [GoalType.MergeDups]: GoalName.MergeDups, - [GoalType.ReviewDeferredDups]: GoalName.ReviewDeferredDups, - [GoalType.ReviewEntries]: GoalName.ReviewEntries, - [GoalType.SpellCheckGloss]: GoalName.SpellCheckGloss, - [GoalType.ValidateChars]: GoalName.ValidateChars, - [GoalType.ValidateStrWords]: GoalName.ValidateStrWords, -}; - -export function goalTypeToName(type: GoalType): GoalName { - return goalTypeToNameMap[type]; -} - export function goalNameToIcon( name: GoalName, size?: "small" | "medium" | "large" @@ -110,6 +76,23 @@ export function goalNameToIcon( } } +const goalTypeToGoalMap: Record Goal> = { + [GoalType.CreateCharInv]: () => new CreateCharInv(), + [GoalType.CreateStrWordInv]: () => new CreateStrWordInv(), + [GoalType.HandleFlags]: () => new HandleFlags(), + [GoalType.MergeDups]: () => new MergeDups(), + [GoalType.ReviewDeferredDups]: () => new ReviewDeferredDups(), + [GoalType.ReviewEntries]: () => new ReviewEntries(), + [GoalType.SpellCheckGloss]: () => new SpellCheckGloss(), + [GoalType.ValidateChars]: () => new ValidateChars(), + [GoalType.ValidateStrWords]: () => new ValidateStrWords(), + [GoalType.Default]: () => new Goal(), +}; + +function goalTypeToGoal(type: GoalType): Goal { + return goalTypeToGoalMap[type](); +} + export function convertGoalToEdit(goal: Goal): Edit { const guid = goal.guid; const goalType = goal.goalType as number; From 504f7b30bdcc08044d264421a04d912503d1378d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Jun 2025 14:50:33 -0400 Subject: [PATCH 09/31] Nicer scrollbar --- src/components/GoalTimeline/index.tsx | 17 +++++++++++++++-- src/goals/Redux/GoalReduxTypes.ts | 6 +++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 67f4f92785..2198325b8a 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -85,7 +85,12 @@ export default function GoalTimeline(): ReactElement { return ( <> {/* Goals */} - + {goalOptions.map((g, i) => ( {t("goal.selector.past")} - + {goalHistory.length ? ( goalHistory.map((g, i) => ( Date: Tue, 3 Jun 2025 15:16:51 -0400 Subject: [PATCH 10/31] Fix render bug --- .../CharacterInventory/CharInvCompleted.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/goals/CharacterInventory/CharInvCompleted.tsx b/src/goals/CharacterInventory/CharInvCompleted.tsx index 81f0d25020..c4fc393f92 100644 --- a/src/goals/CharacterInventory/CharInvCompleted.tsx +++ b/src/goals/CharacterInventory/CharInvCompleted.tsx @@ -66,15 +66,18 @@ export default function CharInvCompleted(): ReactElement { /** Typography summarizing find-and-replace word changes for goal history * (or undefined if no words changed). */ -function WordChangesTypography( - wordChanges: FindAndReplaceChange[] -): ReactElement | undefined { +function WordChangesTypography(props: { + wordChanges?: FindAndReplaceChange[]; +}): ReactElement | null { const { t } = useTranslation(); - const changes = wordChanges.filter((wc) => Object.keys(wc.words).length); - if (!changes.length) { - return; + const changes = props.wordChanges?.filter( + (wc) => Object.keys(wc.words).length + ); + if (!changes?.length) { + return null; } + const wordCount = changes.flatMap((wc) => Object.keys(wc.words)).length; const description = changes.length === 1 @@ -99,8 +102,8 @@ export function CharInvChangesGoalList(changes: CharInvChanges): ReactElement { const { t } = useTranslation(); const changeLimit = 3; - const wordChangesTypography = WordChangesTypography( - changes.wordChanges ?? [] + const wordChangesTypography = ( + ); if (!changes.charChanges?.length) { From de17b68fa412102de203d576a6ea5acad5053938 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Jun 2025 15:17:33 -0400 Subject: [PATCH 11/31] Tidy --- src/components/GoalTimeline/GoalHistoryButton.tsx | 5 ++++- src/components/GoalTimeline/GoalNameButton.tsx | 5 ++++- src/components/GoalTimeline/IconTypography.tsx | 1 + .../Redux}/tests/GoalRedux.test.tsx | 11 +++++------ 4 files changed, 14 insertions(+), 8 deletions(-) rename src/{components/GoalTimeline => goals/Redux}/tests/GoalRedux.test.tsx (98%) diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 09ef3a4674..5d952ecac5 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -22,6 +22,7 @@ export default function GoalHistoryButton( props: GoalHistoryButtonProps ): ReactElement { const { goal, onClick, small } = props; + return ( diff --git a/src/components/GoalTimeline/IconTypography.tsx b/src/components/GoalTimeline/IconTypography.tsx index 9c37965f1d..ed17235340 100644 --- a/src/components/GoalTimeline/IconTypography.tsx +++ b/src/components/GoalTimeline/IconTypography.tsx @@ -9,6 +9,7 @@ export default function IconTypography( props: IconTypographyProps ): ReactElement { const { children, icon, ...typographyProps } = props; + return ( { const store = setupStore(); // setup mocks for testing the action/reducers - jest.clearAllMocks(); const convertEditToGoalSpy = jest.spyOn(goalUtilities, "convertEditToGoal"); mockGetUserEditById.mockResolvedValueOnce(mockUserEdit(false)); @@ -220,8 +219,8 @@ describe("asyncLoadNewGoalData", () => { describe("asyncAdvanceStep", () => { it("advance MergeDups goal", async () => { + // setup the test scenario const store = setupStore(); - // create mergeDups goal await act(async () => { await store.dispatch(asyncAddGoal(new MergeDups())); @@ -252,8 +251,8 @@ describe("asyncAdvanceStep", () => { }); it("advance CreateCharInv goal", async () => { + // setup the test scenario const store = setupStore(); - // create character inventory goal const goal = new CreateCharInv(); await act(async () => { @@ -271,8 +270,8 @@ describe("asyncAdvanceStep", () => { describe("asyncUpdateGoal", () => { it("update CreateCharInv goal", async () => { + // setup the test scenario const store = setupStore(); - // create CreateCharInv goal const goal = new CreateCharInv(); await act(async () => { @@ -291,8 +290,8 @@ describe("asyncUpdateGoal", () => { }); it("update MergeDups goal", async () => { + // setup the test scenario const store = setupStore(); - // create MergeDups goal const goal = new MergeDups(); await act(async () => { @@ -313,8 +312,8 @@ describe("asyncUpdateGoal", () => { }); it("update ReviewDeferredDups goal", async () => { + // setup the test scenario const store = setupStore(); - // create ReviewDeferredDups goal const goal = new ReviewDeferredDups(); await act(async () => { From caca86553480e975d5145e5ab14232ec0af7e6e9 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Jun 2025 15:21:16 -0400 Subject: [PATCH 12/31] Drop the x --- src/goals/Redux/tests/{GoalRedux.test.tsx => GoalRedux.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/goals/Redux/tests/{GoalRedux.test.tsx => GoalRedux.test.ts} (100%) diff --git a/src/goals/Redux/tests/GoalRedux.test.tsx b/src/goals/Redux/tests/GoalRedux.test.ts similarity index 100% rename from src/goals/Redux/tests/GoalRedux.test.tsx rename to src/goals/Redux/tests/GoalRedux.test.ts From 6333abe3cb042a2ee211b4f5811e47c83228e2f4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Jun 2025 15:50:59 -0400 Subject: [PATCH 13/31] Refactor CharInvCompleted --- .../CharacterInventory/CharInvCompleted.tsx | 75 ++++++++++--------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/src/goals/CharacterInventory/CharInvCompleted.tsx b/src/goals/CharacterInventory/CharInvCompleted.tsx index c4fc393f92..afd81d1798 100644 --- a/src/goals/CharacterInventory/CharInvCompleted.tsx +++ b/src/goals/CharacterInventory/CharInvCompleted.tsx @@ -1,5 +1,5 @@ import { ArrowRightAlt } from "@mui/icons-material"; -import { Divider, Grid, Stack, Typography } from "@mui/material"; +import { Box, Grid2, Stack, Typography } from "@mui/material"; import { type ReactElement, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -37,35 +37,45 @@ export default function CharInvCompleted(): ReactElement { const changes: CharInvChanges = { ...defaultCharInvChanges, ...stateChanges }; return ( - <> + + {/* Title */} {t("charInventory.title")} - {changes.charChanges.length ? ( - changes.charChanges.map((c) => ) - ) : ( - - {t("charInventory.changes.noCharChanges")} - - )} + + {/* Inventory changes */} +
+ {changes.charChanges.length ? ( + changes.charChanges.map((c) => ) + ) : ( + + {t("charInventory.changes.noCharChanges")} + + )} +
+ + {/* Find-and-replace changes */} {changes.wordChanges.length ? ( - changes.wordChanges.map((wc, i) => ( - - )) +
+ {changes.wordChanges.map((wc, i) => ( + + + + ))} +
) : ( - <> - + {t("charInventory.changes.noWordChangesFindReplace")} - + )} - +
); } /** Typography summarizing find-and-replace word changes for goal history - * (or undefined if no words changed). */ + * (or null if no words changed). */ function WordChangesTypography(props: { wordChanges?: FindAndReplaceChange[]; }): ReactElement | null { @@ -115,6 +125,7 @@ export function CharInvChangesGoalList(changes: CharInvChanges): ReactElement { ) ); } + if (changes.charChanges.length > changeLimit) { return ( @@ -129,6 +140,7 @@ export function CharInvChangesGoalList(changes: CharInvChanges): ReactElement { ); } + return ( {changes.charChanges.map((c) => ( @@ -184,28 +196,25 @@ function WordChanges(props: { }; return ( - <> - +
{t("charInventory.changes.wordChangesWithStrings", { val1: props.wordChanges.find, val2: props.wordChanges.replace, })} - + {entries.slice(0, wordLimit).map(([oldId, newId]) => ( - + ))} {entries.length > wordLimit ? ( - - - {`+${entries.length - wordLimit} ${t( - "charInventory.changes.more" - )}`} - - + + {`+${entries.length - wordLimit} ${t( + "charInventory.changes.more" + )}`} + ) : null} - + {undoWordsTypography} - +
); } /** Component to show a word update that only involved a change in vernacular form. */ -function WordChangeGrid(props: { newId: string; oldId: string }): ReactElement { +function WordChange(props: { newId: string; oldId: string }): ReactElement { const [oldVern, setOldVern] = useState(""); const [newWord, setNewWord] = useState(); @@ -231,8 +240,6 @@ function WordChangeGrid(props: { newId: string; oldId: string }): ReactElement { const vernacular = `${oldVern} → ${newWord?.vernacular}`; return ( - - {newWord ? : null} - +
{newWord ? : null}
); } From a7d4db135b035c68999f956b177ed3b0ba8c9a32 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 4 Jun 2025 14:09:18 -0400 Subject: [PATCH 14/31] Cleanup --- .../GoalTimeline/GoalHistoryButton.tsx | 56 ++++++++----------- .../GoalTimeline/GoalNameButton.tsx | 16 +++--- .../GoalTimeline/IconTypography.tsx | 27 --------- src/components/GoalTimeline/index.tsx | 1 - src/utilities/goalUtilities.tsx | 15 ++--- 5 files changed, 39 insertions(+), 76 deletions(-) delete mode 100644 src/components/GoalTimeline/IconTypography.tsx diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 5d952ecac5..63e7149ba3 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -1,47 +1,39 @@ -import { Button, Grid2, Stack, Typography } from "@mui/material"; +import { Box, Button, Grid2, Stack, Typography } from "@mui/material"; import { Fragment, ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import IconTypography from "components/GoalTimeline/IconTypography"; import { CharInvChangesGoalList } from "goals/CharacterInventory/CharInvCompleted"; import { CharInvChanges } from "goals/CharacterInventory/CharacterInventoryTypes"; import { MergesCount } from "goals/MergeDuplicates/MergeDupsCompleted"; import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; -import { Goal, GoalType } from "types/goals"; +import { Goal, GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; interface GoalHistoryButtonProps { goal?: Goal; onClick?: () => void; - small?: boolean; } export default function GoalHistoryButton( props: GoalHistoryButtonProps ): ReactElement { - const { goal, onClick, small } = props; + const { goal, onClick } = props; return ( ); } -interface GoalInfoProps { - goal?: Goal; - small?: boolean; -} - -function GoalInfo(props: GoalInfoProps): ReactElement { - const { goal, small } = props; +function GoalInfo({ goal }: { goal?: Goal }): ReactElement { const { t } = useTranslation(); if (!goal) { @@ -54,19 +46,18 @@ function GoalInfo(props: GoalInfoProps): ReactElement { sx={{ alignItems: "flex-start", height: "100%", width: "100%" }} > {/* Goal name */} - + + + {goalNameToIcon(goal.name)} + {t(goal.name + ".title")} - +
{/* Change summary */} - + {goal.changes ? getCompletedGoalInfo(goal) : null}
@@ -74,14 +65,15 @@ function GoalInfo(props: GoalInfoProps): ReactElement { } function getCompletedGoalInfo(goal: Goal): ReactElement { - switch (goal.goalType) { - case GoalType.CreateCharInv: - return CharInvChangesGoalList(goal.changes as CharInvChanges); - case GoalType.MergeDups: - case GoalType.ReviewDeferredDups: - return MergesCount(goal.changes as MergesCompleted); - case GoalType.ReviewEntries: - return EditsCount(goal.changes as EntriesEdited); + const { changes, name } = goal; + switch (name) { + case GoalName.CreateCharInv: + return CharInvChangesGoalList(changes as CharInvChanges); + case GoalName.MergeDups: + case GoalName.ReviewDeferredDups: + return MergesCount(changes as MergesCompleted); + case GoalName.ReviewEntries: + return EditsCount(changes as EntriesEdited); default: return ; } diff --git a/src/components/GoalTimeline/GoalNameButton.tsx b/src/components/GoalTimeline/GoalNameButton.tsx index 3251c7e32e..61f067e2bc 100644 --- a/src/components/GoalTimeline/GoalNameButton.tsx +++ b/src/components/GoalTimeline/GoalNameButton.tsx @@ -1,8 +1,7 @@ -import { Button, Stack, Typography } from "@mui/material"; +import { Box, Button, Stack, Typography } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import IconTypography from "components/GoalTimeline/IconTypography"; import { GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; @@ -34,12 +33,15 @@ export default function GoalNameButton( > {/* Goal name */} - + + + {goalNameToIcon(goalName)} + {t(goalName + ".title")} - + {/* Goal description */} {t(goalName + ".description")} diff --git a/src/components/GoalTimeline/IconTypography.tsx b/src/components/GoalTimeline/IconTypography.tsx deleted file mode 100644 index ed17235340..0000000000 --- a/src/components/GoalTimeline/IconTypography.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Box, Typography, type TypographyProps } from "@mui/material"; -import { type ReactElement } from "react"; - -interface IconTypographyProps extends TypographyProps { - icon: ReactElement; -} - -export default function IconTypography( - props: IconTypographyProps -): ReactElement { - const { children, icon, ...typographyProps } = props; - - return ( - - - {icon} - - {children} - - ); -} diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 2198325b8a..2a06b4c961 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -123,7 +123,6 @@ export default function GoalTimeline(): ReactElement { goal={g} key={i} onClick={() => dispatch(asyncAddGoal(g))} - small={small} /> )) ) : ( diff --git a/src/utilities/goalUtilities.tsx b/src/utilities/goalUtilities.tsx index c6c05a3e0d..98a3ee5824 100644 --- a/src/utilities/goalUtilities.tsx +++ b/src/utilities/goalUtilities.tsx @@ -58,21 +58,18 @@ export function goalNameToGoal(name: GoalName): Goal { return goalNameToGoalMap[name](); } -export function goalNameToIcon( - name: GoalName, - size?: "small" | "medium" | "large" -): ReactElement { +export function goalNameToIcon(name: GoalName): ReactElement { switch (name) { case GoalName.CreateCharInv: - return ; + return ; case GoalName.MergeDups: - return ; + return ; case GoalName.ReviewDeferredDups: - return ; + return ; case GoalName.ReviewEntries: - return ; + return ; default: - return ; + return ; } } From 66179febb3e3c7fa2c13e41a6759e69e59000cbc Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 4 Jun 2025 16:58:19 -0400 Subject: [PATCH 15/31] Refactor CharInv changes summary --- .../CharacterInventory/CharInvCompleted.tsx | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/src/goals/CharacterInventory/CharInvCompleted.tsx b/src/goals/CharacterInventory/CharInvCompleted.tsx index afd81d1798..2782863a9a 100644 --- a/src/goals/CharacterInventory/CharInvCompleted.tsx +++ b/src/goals/CharacterInventory/CharInvCompleted.tsx @@ -74,6 +74,49 @@ export default function CharInvCompleted(): ReactElement { ); } +/** One-line display of the inventory status change of a character. */ +export function CharChange(props: { change: CharacterChange }): ReactElement { + return ( +
+ {`${props.change[0]}: `} + + + +
+ ); +} + +/** Components summarizing changes to status of inventory characters. */ +export function CharChangesRows(props: { + charChanges?: CharacterChange[]; +}): ReactElement | null { + const changeLimit = 3; + const { charChanges } = props; + const { t } = useTranslation(); + + if (!charChanges?.length) { + return null; + } + + return charChanges.length <= changeLimit ? ( + <> + {charChanges.map((c) => ( + + ))} + + ) : ( + <> + {charChanges.slice(0, changeLimit - 1).map((c) => ( + + ))} + + {`+${charChanges.length - (changeLimit - 1)} `} + {t("charInventory.changes.more")} + + + ); +} + /** Typography summarizing find-and-replace word changes for goal history * (or null if no words changed). */ function WordChangesTypography(props: { @@ -109,60 +152,21 @@ function WordChangesTypography(props: { * - Changes to inventory status of a character (up to 3); * - Number of words changed with the find-and-replace tool (only if more than 0). */ export function CharInvChangesGoalList(changes: CharInvChanges): ReactElement { + const { charChanges, wordChanges } = changes; const { t } = useTranslation(); - const changeLimit = 3; - - const wordChangesTypography = ( - - ); - - if (!changes.charChanges?.length) { - return ( - wordChangesTypography ?? ( - - {t("charInventory.changes.noCharChanges")} - - ) - ); - } - if (changes.charChanges.length > changeLimit) { - return ( - - {changes.charChanges.slice(0, changeLimit - 1).map((c) => ( - - ))} - - {`+${changes.charChanges.length - (changeLimit - 1)} `} - {t("charInventory.changes.more")} - - {wordChangesTypography} - - ); - } - - return ( + return !charChanges?.length && !wordChanges?.length ? ( + + {t("charInventory.changes.noCharChanges")} + + ) : ( - {changes.charChanges.map((c) => ( - - ))} - {wordChangesTypography} + + ); } -/** Component to display in one line the inventory status change of a character. */ -export function CharChange(props: { change: CharacterChange }): ReactElement { - return ( -
- {`${props.change[0]}: `} - - - -
- ); -} - /** Component to display words (max 5) changed by a single find-and-replace, with a * button to undo (if at least one word is still in the project frontier). */ function WordChanges(props: { From 2a8df20c7e5a7742f69029ed55fdf77b2582527d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 4 Jun 2025 17:29:36 -0400 Subject: [PATCH 16/31] Refactor goal history --- .../GoalTimeline/GoalHistoryButton.tsx | 62 +++++++------------ src/components/GoalTimeline/index.tsx | 37 +++++------ src/utilities/goalUtilities.tsx | 31 +++++++++- 3 files changed, 66 insertions(+), 64 deletions(-) diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 63e7149ba3..82b9866dc4 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -12,55 +12,39 @@ import { Goal, GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; interface GoalHistoryButtonProps { - goal?: Goal; - onClick?: () => void; + goal: Goal; + onClick: () => void; } export default function GoalHistoryButton( props: GoalHistoryButtonProps ): ReactElement { const { goal, onClick } = props; - - return ( - - ); -} - -function GoalInfo({ goal }: { goal?: Goal }): ReactElement { const { t } = useTranslation(); - if (!goal) { - return {t("goal.selector.noHistory")}; - } - return ( - - {/* Goal name */} - - - {goalNameToIcon(goal.name)} - - {t(goal.name + ".title")} - + ); } diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 2a06b4c961..a93ccd73a0 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -1,12 +1,13 @@ import { Box, + Button, Grid2, Stack, Theme, Typography, useMediaQuery, } from "@mui/material"; -import { ReactElement, useCallback, useEffect, useState } from "react"; +import { type ReactElement, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getCurrentPermissions, hasGraylistEntries } from "backend"; @@ -15,26 +16,12 @@ import GoalNameButton from "components/GoalTimeline/GoalNameButton"; import { asyncAddGoal, asyncGetUserEdits } from "goals/Redux/GoalActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; -import { Goal, GoalName } from "types/goals"; -import { goalNameToGoal, requiredPermission } from "utilities/goalUtilities"; - -/** Collapse history items with same name & changes. */ -function collapseHistory(goals: Goal[]): Goal[] { - const seen: Record = {}; - const collapsed: Goal[] = []; - - for (const goal of goals) { - const key = `${goal.name}-${JSON.stringify(goal.changes)}`; - if (!seen[key]) { - seen[key] = 1; - collapsed.push(goal); - } else { - seen[key] += 1; - } - } - - return collapsed; -} +import { GoalName } from "types/goals"; +import { + goalNameToGoal, + hasChanges, + requiredPermission, +} from "utilities/goalUtilities"; /** Creates a list of suggestions, with non-suggested goals at the end. * Extracted for testing purposes. */ @@ -80,7 +67,7 @@ export default function GoalTimeline(): ReactElement { getGoalTypes(); }, [getGoalTypes]); - const goalHistory = collapseHistory([...history].reverse()); + const goalHistory = history.filter(hasChanges).reverse(); return ( <> @@ -126,7 +113,11 @@ export default function GoalTimeline(): ReactElement { /> )) ) : ( - + )} diff --git a/src/utilities/goalUtilities.tsx b/src/utilities/goalUtilities.tsx index 98a3ee5824..0ae4c8404c 100644 --- a/src/utilities/goalUtilities.tsx +++ b/src/utilities/goalUtilities.tsx @@ -3,14 +3,21 @@ import { Icon } from "@mui/material"; import { ReactElement } from "react"; import { Edit, Permission } from "api/models"; -import { CreateCharInv } from "goals/CharacterInventory/CharacterInventoryTypes"; +import { + CharInvChanges, + CreateCharInv, +} from "goals/CharacterInventory/CharacterInventoryTypes"; import { CreateStrWordInv } from "goals/CreateStrWordInv/CreateStrWordInv"; import { HandleFlags } from "goals/HandleFlags/HandleFlags"; import { MergeDups, + MergesCompleted, ReviewDeferredDups, } from "goals/MergeDuplicates/MergeDupsTypes"; -import { ReviewEntries } from "goals/ReviewEntries/ReviewEntriesTypes"; +import { + EntriesEdited, + ReviewEntries, +} from "goals/ReviewEntries/ReviewEntriesTypes"; import { SpellCheckGloss } from "goals/SpellCheckGloss/SpellCheckGloss"; import { ValidateChars } from "goals/ValidateChars/ValidateChars"; import { ValidateStrWords } from "goals/ValidateStrWords/ValidateStrWords"; @@ -41,6 +48,26 @@ export function requiredPermission(type: GoalName): Permission { } } +export function hasChanges(g: Goal): boolean { + if (!g.changes) { + return false; + } + switch (g.name) { + case GoalName.CreateCharInv: + const cic = g.changes as CharInvChanges; + return cic.charChanges?.length > 0 || cic.wordChanges?.length > 0; + case GoalName.MergeDups: + case GoalName.ReviewDeferredDups: + const mc = g.changes as MergesCompleted; + return mc.merges?.length > 0; + case GoalName.ReviewEntries: + const ee = g.changes as EntriesEdited; + return ee.entryEdits?.length > 0; + default: + return false; + } +} + const goalNameToGoalMap: Record Goal> = { [GoalName.CreateCharInv]: () => new CreateCharInv(), [GoalName.CreateStrWordInv]: () => new CreateStrWordInv(), From 63ea3b6ba90e8e8ec8e1529d008a502fa34437e1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 4 Jun 2025 18:00:02 -0400 Subject: [PATCH 17/31] Update tests --- .../GoalTimeline/tests/index.test.tsx | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 366e649be8..11aaa077f3 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -6,7 +6,16 @@ import createMockStore from "redux-mock-store"; import { Permission } from "api/models"; import GoalTimeline, { createSuggestionData } from "components/GoalTimeline"; +import { + CharacterStatus, + CreateCharInv, +} from "goals/CharacterInventory/CharacterInventoryTypes"; +import { + MergeDups, + ReviewDeferredDups, +} from "goals/MergeDuplicates/MergeDupsTypes"; import { implementedGoals, type GoalsState } from "goals/Redux/GoalReduxTypes"; +import { ReviewEntries } from "goals/ReviewEntries/ReviewEntriesTypes"; import { defaultState } from "rootRedux/types"; import { Goal } from "types/goals"; import theme from "types/theme"; @@ -34,9 +43,10 @@ const mockChooseGoal = jest.fn(); const mockGetCurrentPermissions = jest.fn(); const mockHasGraylistEntries = jest.fn(); -const allGoals = implementedGoals.map(goalNameToGoal); const goalWithAnyGuid = (g: Goal): Goal => ({ ...g, guid: expect.any(String) }); -const allGoalsWithAnyGuids = allGoals.map(goalWithAnyGuid); +const allGoalsWithAnyGuids = implementedGoals + .map(goalNameToGoal) + .map(goalWithAnyGuid); beforeAll(async () => { // Required (along with a `ThemeProvider`) for `useMediaQuery` to work @@ -53,23 +63,53 @@ beforeEach(() => { }); describe("GoalTimeline", () => { - it("has the expected number of buttons", async () => { - await renderTimeline(implementedGoals, allGoals); + it("has the expected number of buttons plus 1 for empty history", async () => { + await renderTimeline(implementedGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength(implementedGoals.length + allGoals.length); + expect(buttons).toHaveLength(implementedGoals.length + 1); }); it("has one more button if there's a graylist entry", async () => { mockHasGraylistEntries.mockResolvedValue(true); - await renderTimeline(implementedGoals, allGoals); + await renderTimeline(implementedGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength(implementedGoals.length + allGoals.length + 1); + expect(buttons).toHaveLength(implementedGoals.length + 2); + }); + + it("only shows goal history for goals with changes", async () => { + const cci = new CreateCharInv(); + cci.changes = { + charChanges: [["a", CharacterStatus.Undecided, CharacterStatus.Accepted]], + wordChanges: [], + }; + const md = new MergeDups(); + md.changes = { merges: [{ parentIds: ["b"], childIds: ["c"] }] }; + const rdd = new ReviewDeferredDups(); + rdd.changes = { merges: [{ parentIds: ["d"], childIds: ["e"] }] }; + const re = new ReviewEntries(); + re.changes = { entryEdits: [{ oldId: "f", newId: "g" }] }; + const history = [ + new CreateCharInv(), + cci, + new MergeDups(), + md, + new ReviewDeferredDups(), + rdd, + new ReviewEntries(), + re, + ]; + + await renderTimeline(implementedGoals, history); + const buttons = screen.queryAllByRole("button"); + expect(buttons).toHaveLength(implementedGoals.length + 4); }); it("selects a goal from suggestions", async () => { const goalNum = 2; await renderTimeline(); - await userEvent.click(screen.getByText(`${allGoals[goalNum].name}.title`)); + await userEvent.click( + screen.getByText(`${implementedGoals[goalNum]}.title`) + ); expect(mockChooseGoal).toHaveBeenCalledTimes(1); const calledGoalName = mockChooseGoal.mock.calls[0][0].name; expect(calledGoalName).toEqual(implementedGoals[goalNum]); From b77065b15f5e3ab1be5e491062d829b3d5a78037 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 5 Jun 2025 14:56:26 -0400 Subject: [PATCH 18/31] Remove suggestions/recommendations --- public/locales/en/translation.json | 6 +-- .../GoalTimeline/GoalNameButton.tsx | 4 +- src/components/GoalTimeline/index.tsx | 32 +++++-------- .../GoalTimeline/tests/index.test.tsx | 45 +++---------------- src/goals/Redux/GoalReducer.ts | 3 -- src/goals/Redux/GoalReduxTypes.ts | 7 +-- 6 files changed, 25 insertions(+), 72 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 67cb85ab6f..88e3968d42 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -327,7 +327,7 @@ }, "reviewDeferredDups": { "title": "Review Deferred Duplicates", - "description": "Reconsider deferred sets of potential duplicates." + "description": "Revisit potential duplicates that were deferred during Merge Duplicates." }, "spellCheckGloss": { "title": "Spell Check Gloss", @@ -343,7 +343,7 @@ }, "reviewEntries": { "title": "Review Entries", - "description": "Review all entries in the project.", + "description": "View, sort, filter, and edit entries in the project.", "allEntriesPerPageOption": "All", "editSense": "Edit sense", "discardChanges": "Discard changes?", @@ -426,7 +426,7 @@ }, "mergeDups": { "title": "Merge Duplicates", - "description": "Consider potential duplicates.", + "description": "Review potential duplicates identified by The Combine and merge or eliminate true duplicates.", "helpText": { "dragCard": "Drag a card here to merge", "saveAndContinue": "Save changes and load a new set of words", diff --git a/src/components/GoalTimeline/GoalNameButton.tsx b/src/components/GoalTimeline/GoalNameButton.tsx index 61f067e2bc..96ed793d30 100644 --- a/src/components/GoalTimeline/GoalNameButton.tsx +++ b/src/components/GoalTimeline/GoalNameButton.tsx @@ -8,14 +8,13 @@ import { goalNameToIcon } from "utilities/goalUtilities"; interface GoalNameButtonProps { goalName: GoalName; onClick: () => void; - recommended?: boolean; small?: boolean; } export default function GoalNameButton( props: GoalNameButtonProps ): ReactElement { - const { goalName, onClick, recommended, small } = props; + const { goalName, onClick, small } = props; const { t } = useTranslation(); return ( @@ -24,7 +23,6 @@ export default function GoalNameButton( fullWidth sx={{ alignItems: "flex-start", - boxShadow: recommended ? "0 0 16px #090" : undefined, height: "100%", minHeight: small ? "150px" : "200px", p: 2, diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index a93ccd73a0..b30fd337fd 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -23,22 +23,11 @@ import { requiredPermission, } from "utilities/goalUtilities"; -/** Creates a list of suggestions, with non-suggested goals at the end. - * Extracted for testing purposes. */ -export function createSuggestionData( - availableGoals: GoalName[], - goalSuggestions: GoalName[] -): GoalName[] { - const suggestions = goalSuggestions.filter((t) => availableGoals.includes(t)); - const nonSuggestions = availableGoals.filter((t) => !suggestions.includes(t)); - return [...suggestions, ...nonSuggestions]; -} - /** List of goals, followed by goal history. */ export default function GoalTimeline(): ReactElement { const dispatch = useAppDispatch(); - const { allGoals, goalSuggestions, history } = useAppSelector( + const { allGoals, history } = useAppSelector( (state: StoreState) => state.goalsState ); @@ -56,12 +45,16 @@ export default function GoalTimeline(): ReactElement { const getGoalTypes = useCallback(async (): Promise => { const permissions = await getCurrentPermissions(); - const goalTypes = ( - hasGraylist ? allGoals.concat([GoalName.ReviewDeferredDups]) : allGoals - ).filter((t) => permissions.includes(requiredPermission(t))); - - setGoalOptions(createSuggestionData(goalTypes, goalSuggestions)); - }, [allGoals, goalSuggestions, hasGraylist]); + setGoalOptions( + allGoals.filter((g) => { + console.info(g); + return ( + (g !== GoalName.ReviewDeferredDups || hasGraylist) && + permissions.includes(requiredPermission(g)) + ); + }) + ); + }, [allGoals, hasGraylist]); useEffect(() => { getGoalTypes(); @@ -78,12 +71,11 @@ export default function GoalTimeline(): ReactElement { spacing={3} sx={{ p: 2, py: small ? 2 : 4 }} > - {goalOptions.map((g, i) => ( + {goalOptions.map((g) => ( dispatch(asyncAddGoal(goalNameToGoal(g)))} - recommended={i === 0 && goalSuggestions.length > 0} small={small} /> diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 11aaa077f3..093a19f47e 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -5,7 +5,7 @@ import { Provider } from "react-redux"; import createMockStore from "redux-mock-store"; import { Permission } from "api/models"; -import GoalTimeline, { createSuggestionData } from "components/GoalTimeline"; +import GoalTimeline from "components/GoalTimeline"; import { CharacterStatus, CreateCharInv, @@ -19,7 +19,6 @@ import { ReviewEntries } from "goals/ReviewEntries/ReviewEntriesTypes"; import { defaultState } from "rootRedux/types"; import { Goal } from "types/goals"; import theme from "types/theme"; -import { goalNameToGoal } from "utilities/goalUtilities"; import { setMatchMedia } from "utilities/testingLibraryUtilities"; jest.mock("backend", () => ({ @@ -43,11 +42,6 @@ const mockChooseGoal = jest.fn(); const mockGetCurrentPermissions = jest.fn(); const mockHasGraylistEntries = jest.fn(); -const goalWithAnyGuid = (g: Goal): Goal => ({ ...g, guid: expect.any(String) }); -const allGoalsWithAnyGuids = implementedGoals - .map(goalNameToGoal) - .map(goalWithAnyGuid); - beforeAll(async () => { // Required (along with a `ThemeProvider`) for `useMediaQuery` to work setMatchMedia(); @@ -59,7 +53,7 @@ beforeEach(() => { Permission.CharacterInventory, Permission.MergeAndReviewEntries, ]); - mockHasGraylistEntries.mockResolvedValue(false); + mockHasGraylistEntries.mockResolvedValue(true); }); describe("GoalTimeline", () => { @@ -69,11 +63,11 @@ describe("GoalTimeline", () => { expect(buttons).toHaveLength(implementedGoals.length + 1); }); - it("has one more button if there's a graylist entry", async () => { - mockHasGraylistEntries.mockResolvedValue(true); + it("has one fewer button if no graylist entry", async () => { + mockHasGraylistEntries.mockResolvedValue(false); await renderTimeline(implementedGoals); const buttons = screen.queryAllByRole("button"); - expect(buttons).toHaveLength(implementedGoals.length + 2); + expect(buttons).toHaveLength(implementedGoals.length); }); it("only shows goal history for goals with changes", async () => { @@ -114,40 +108,15 @@ describe("GoalTimeline", () => { const calledGoalName = mockChooseGoal.mock.calls[0][0].name; expect(calledGoalName).toEqual(implementedGoals[goalNum]); }); - - describe("createSuggestionData", () => { - it("don't suggests goal types that aren't available", () => { - const suggestions = createSuggestionData([], implementedGoals); - expect(suggestions).toEqual([]); - }); - - it("appends non-suggested available goal types to the end", () => { - const sliceIndex = 2; - const suggestions = createSuggestionData( - implementedGoals, - implementedGoals.slice(sliceIndex) - ); - const expectedGoals = [ - ...allGoalsWithAnyGuids.slice(sliceIndex), - ...allGoalsWithAnyGuids.slice(0, sliceIndex), - ]; - expect(suggestions).toEqual(expectedGoals.map((g) => g.name)); - }); - - it("has a fallback for empty suggestion data", () => { - const suggestions = createSuggestionData(implementedGoals, []); - expect(suggestions).toEqual(allGoalsWithAnyGuids.map((g) => g.name)); - }); - }); }); async function renderTimeline( - goalSuggestions = [...implementedGoals], + allGoals = [...implementedGoals], history: Goal[] = [] ): Promise { const goalsState: GoalsState = { ...defaultState.goalsState, - goalSuggestions, + allGoals, history, }; await act(async () => { diff --git a/src/goals/Redux/GoalReducer.ts b/src/goals/Redux/GoalReducer.ts index 6bc8cfce27..b39d122aa2 100644 --- a/src/goals/Redux/GoalReducer.ts +++ b/src/goals/Redux/GoalReducer.ts @@ -65,9 +65,6 @@ const goalSlice = createSlice({ }, setCurrentGoalAction: (state, action) => { state.currentGoal = action.payload; - state.goalSuggestions = state.goalSuggestions.filter( - (g, i) => i || action.payload.name !== g - ); // Remove top suggestion if same as goal to add. state.previousGoal = state.currentGoal.name !== GoalName.Default ? state.currentGoal.name diff --git a/src/goals/Redux/GoalReduxTypes.ts b/src/goals/Redux/GoalReduxTypes.ts index 3dfbf79c27..5001664e83 100644 --- a/src/goals/Redux/GoalReduxTypes.ts +++ b/src/goals/Redux/GoalReduxTypes.ts @@ -12,24 +12,21 @@ export interface GoalsState { allGoals: GoalName[]; currentGoal: Goal; dataLoadStatus: DataLoadStatus; - goalSuggestions: GoalName[]; history: Goal[]; previousGoal: GoalName; } -/** MergeDups, ReviewEntries, CreateCharInv (in that recommendation order); - * no ReviewDeferredDups, which is implemented but conditionally available. */ export const implementedGoals: GoalName[] = [ + GoalName.CreateCharInv, GoalName.MergeDups, + GoalName.ReviewDeferredDups, GoalName.ReviewEntries, - GoalName.CreateCharInv, ]; export const defaultState: GoalsState = { allGoals: [...implementedGoals], currentGoal: new Goal(), dataLoadStatus: DataLoadStatus.Default, - goalSuggestions: [...implementedGoals], history: [], previousGoal: GoalName.Default, }; From c6a486a0e4cd3991ef2e48f55d590177c4945583 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 9 Jun 2025 13:21:10 -0400 Subject: [PATCH 19/31] Add modified datetime to goal edit --- .../Controllers/UserEditControllerTests.cs | 5 ++-- Backend/Models/UserEdit.cs | 29 ++++++------------- Backend/Services/UserEditService.cs | 4 +++ src/api/models/edit.ts | 6 ++++ src/api/models/user-edit.ts | 6 ++++ .../GoalTimeline/GoalHistoryButton.tsx | 20 +++++++++++++ src/types/goals.ts | 1 + src/utilities/goalUtilities.tsx | 5 ++-- 8 files changed, 52 insertions(+), 24 deletions(-) diff --git a/Backend.Tests/Controllers/UserEditControllerTests.cs b/Backend.Tests/Controllers/UserEditControllerTests.cs index 73b7e56f72..463e510012 100644 --- a/Backend.Tests/Controllers/UserEditControllerTests.cs +++ b/Backend.Tests/Controllers/UserEditControllerTests.cs @@ -179,8 +179,9 @@ public async Task TestAddGoalToUserEdit() await _userEditController.UpdateUserEditGoal(_projId, userEdit.Id, newEdit); - var allUserEdits = await _userEditRepo.GetAllUserEdits(_projId); - Assert.That(allUserEdits, Does.Contain(updatedUserEdit).UsingPropertiesComparer()); + var repoUserEdit = await _userEditRepo.GetUserEdit(_projId, userEdit.Id); + newEdit.Modified = repoUserEdit!.Edits.FirstOrDefault(e => e.Guid == newEdit.Guid)!.Modified; + Assert.That(repoUserEdit, Is.EqualTo(updatedUserEdit).UsingPropertiesComparer()); } [Test] diff --git a/Backend/Models/UserEdit.cs b/Backend/Models/UserEdit.cs index 90a23f5237..84d8d35ad8 100644 --- a/Backend/Models/UserEdit.cs +++ b/Backend/Models/UserEdit.cs @@ -13,22 +13,15 @@ public class UserEdit [Required] [BsonId] [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } + public string Id { get; set; } = ""; [Required] [BsonElement("edits")] - public List Edits { get; set; } + public List Edits { get; set; } = []; [Required] [BsonElement("projectId")] - public string ProjectId { get; set; } - - public UserEdit() - { - Id = ""; - ProjectId = ""; - Edits = new(); - } + public string ProjectId { get; set; } = ""; /// Create a deep copy. public UserEdit Clone() @@ -66,7 +59,7 @@ public class Edit [BsonElement("guid")] [BsonGuidRepresentation(GuidRepresentation.CSharpLegacy)] #pragma warning disable CA1720 - public Guid Guid { get; set; } + public Guid Guid { get; set; } = Guid.NewGuid(); #pragma warning restore CA1720 /// Integer representation of enum GoalType in src/types/goals.ts @@ -76,24 +69,20 @@ public class Edit [Required] [BsonElement("stepData")] - public List StepData { get; set; } + public List StepData { get; set; } = []; [Required] [BsonElement("changes")] - public string Changes { get; set; } + public string Changes { get; set; } = "{}"; - public Edit() - { - Guid = Guid.NewGuid(); - GoalType = 0; - StepData = new(); - Changes = "{}"; - } + [BsonElement("modified")] + public DateTime? Modified { get; set; } /// Create a deep copy. public Edit Clone() { var clone = (Edit)MemberwiseClone(); + clone.Modified = Modified is null ? null : new DateTime(Modified.Value.Ticks); clone.StepData = StepData.Select(sd => sd).ToList(); return clone; } diff --git a/Backend/Services/UserEditService.cs b/Backend/Services/UserEditService.cs index 6169fafeff..12c2df6415 100644 --- a/Backend/Services/UserEditService.cs +++ b/Backend/Services/UserEditService.cs @@ -33,6 +33,8 @@ public UserEditService(IUserEditRepository userEditRepo) return new Tuple(false, null); } + edit.Modified = DateTime.UtcNow; + // Update existing Edit if guid exists, otherwise add new one at end of List. var editIndex = userEdit.Edits.FindLastIndex(e => e.Guid == edit.Guid); if (editIndex > -1) @@ -64,6 +66,7 @@ public async Task AddStepToGoal(string projectId, string userEditId, Guid { return false; } + edit.Modified = DateTime.UtcNow; edit.StepData.Add(stepString); return await _userEditRepo.Replace(projectId, userEditId, userEdit); } @@ -87,6 +90,7 @@ public async Task UpdateStepInGoal( { return false; } + edit.Modified = DateTime.UtcNow; edit.StepData[stepIndex] = stepString; return await _userEditRepo.Replace(projectId, userEditId, userEdit); } diff --git a/src/api/models/edit.ts b/src/api/models/edit.ts index 29ef88ae74..175a14fbf4 100644 --- a/src/api/models/edit.ts +++ b/src/api/models/edit.ts @@ -42,4 +42,10 @@ export interface Edit { * @memberof Edit */ changes: string; + /** + * + * @type {string} + * @memberof Edit + */ + modified?: string | null; } diff --git a/src/api/models/user-edit.ts b/src/api/models/user-edit.ts index 9fc9ae7b57..3dda9d7f85 100644 --- a/src/api/models/user-edit.ts +++ b/src/api/models/user-edit.ts @@ -38,4 +38,10 @@ export interface UserEdit { * @memberof UserEdit */ projectId: string; + /** + * + * @type {string} + * @memberof UserEdit + */ + modified?: string | null; } diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 82b9866dc4..d7acdacfec 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -8,6 +8,7 @@ import { MergesCount } from "goals/MergeDuplicates/MergeDupsCompleted"; import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; +import i18n from "i18n"; import { Goal, GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; @@ -22,6 +23,18 @@ export default function GoalHistoryButton( const { goal, onClick } = props; const { t } = useTranslation(); + const modifiedFormatted = goal.modified + ? new Date(goal.modified).toLocaleString(i18n.resolvedLanguage, { + day: "numeric", + hour: "numeric", + hour12: true, + minute: "2-digit", + month: "short", + weekday: "short", + year: "numeric", + }) + : null; + return ( );