From 6bbfc5bb9ca75a81b83c56d8e139f407029f465d Mon Sep 17 00:00:00 2001 From: "D. Ror." Date: Fri, 27 Sep 2024 12:06:33 -0400 Subject: [PATCH] [ReviewEntries] Persist column order/visibility state in project (#3309) --- src/components/Project/ProjectActions.ts | 19 ++++ src/components/Project/ProjectReducer.ts | 23 +++++ src/components/Project/ProjectReduxTypes.ts | 12 +++ .../ReviewEntriesTable/index.tsx | 88 ++++++++++++++----- .../ReviewEntriesTable/tests/index.test.tsx | 19 ++-- 5 files changed, 126 insertions(+), 35 deletions(-) diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index 672b9bf341..1984d7acf3 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -1,4 +1,9 @@ import { type Action, type PayloadAction } from "@reduxjs/toolkit"; +import { + type MRT_ColumnOrderState, + type MRT_Updater, + type MRT_VisibilityState, +} from "material-react-table"; import { type Project, type Speaker, type User } from "api/models"; import { @@ -9,6 +14,8 @@ import { import { setProjectId } from "backend/localStorage"; import { resetAction, + setColumnOrderAction, + setColumnVisibilityAction, setProjectAction, setSemanticDomainsAction, setSpeakerAction, @@ -25,6 +32,18 @@ export function resetCurrentProject(): Action { return resetAction(); } +export function setReviewEntriesColumnOrder( + updater: MRT_Updater +): PayloadAction { + return setColumnOrderAction(updater); +} + +export function setReviewEntriesColumnVisibility( + updater: MRT_Updater +): PayloadAction { + return setColumnVisibilityAction(updater); +} + export function setCurrentProject(project?: Project): PayloadAction { return setProjectAction(project ?? newProject()); } diff --git a/src/components/Project/ProjectReducer.ts b/src/components/Project/ProjectReducer.ts index e17e067f6c..2abfce0f3f 100644 --- a/src/components/Project/ProjectReducer.ts +++ b/src/components/Project/ProjectReducer.ts @@ -8,8 +8,29 @@ const projectSlice = createSlice({ initialState: defaultState, reducers: { resetAction: () => defaultState, + setColumnOrderAction: (state, action) => { + const columns = state.reviewEntriesColumns; + // Payload is a state updater, which can either be a new state + // or a function that takes the previous state and returns a new state. + if (typeof action.payload === "function") { + columns.columnOrder = action.payload(columns.columnOrder); + } else { + columns.columnOrder = action.payload; + } + }, + setColumnVisibilityAction: (state, action) => { + const columns = state.reviewEntriesColumns; + // Payload is a state updater, which can either be a new state + // or a function that takes the previous state and returns a new state. + if (typeof action.payload === "function") { + columns.columnVisibility = action.payload(columns.columnVisibility); + } else { + columns.columnVisibility = action.payload; + } + }, setProjectAction: (state, action) => { if (state.project.id !== action.payload.id) { + state.reviewEntriesColumns = defaultState.reviewEntriesColumns; state.speaker = undefined; state.users = []; } @@ -31,6 +52,8 @@ const projectSlice = createSlice({ export const { resetAction, + setColumnOrderAction, + setColumnVisibilityAction, setProjectAction, setSemanticDomainsAction, setSpeakerAction, diff --git a/src/components/Project/ProjectReduxTypes.ts b/src/components/Project/ProjectReduxTypes.ts index f1335688e4..ee575f5a1a 100644 --- a/src/components/Project/ProjectReduxTypes.ts +++ b/src/components/Project/ProjectReduxTypes.ts @@ -1,9 +1,20 @@ +import { + type MRT_ColumnOrderState, + type MRT_VisibilityState, +} from "material-react-table"; + import { type Project, type Speaker, type User } from "api/models"; import { type Hash } from "types/hash"; import { newProject } from "types/project"; export interface CurrentProjectState { project: Project; + /** For project-level persistance of ReviewEntriesTable's managed states + * per https://www.material-react-table.com/docs/guides/state-management */ + reviewEntriesColumns: { + columnOrder: MRT_ColumnOrderState; + columnVisibility: MRT_VisibilityState; + }; semanticDomains?: Hash; speaker?: Speaker; users: User[]; @@ -11,5 +22,6 @@ export interface CurrentProjectState { export const defaultState: CurrentProjectState = { project: newProject(), + reviewEntriesColumns: { columnOrder: [], columnVisibility: {} }, users: [], }; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx index eda333755c..5bfd9b55a5 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx @@ -4,12 +4,14 @@ import { PlayArrow, } from "@mui/icons-material"; import { Typography } from "@mui/material"; +import { createSelector } from "@reduxjs/toolkit"; import { MaterialReactTable, type MRT_Localization, type MRT_PaginationState, type MRT_Row, type MRT_RowVirtualizer, + type MRT_VisibilityState, createMRTColumnHelper, useMaterialReactTable, } from "material-react-table"; @@ -19,10 +21,14 @@ import { useTranslation } from "react-i18next"; import { GramCatGroup, type GrammaticalInfo, type Word } from "api/models"; import { getAllSpeakers, getFrontierWords, getWord } from "backend"; import { topBarHeight } from "components/LandingPage/TopBar"; +import { + setReviewEntriesColumnOrder, + setReviewEntriesColumnVisibility, +} from "components/Project/ProjectActions"; import * as Cell from "goals/ReviewEntries/ReviewEntriesTable/Cells"; import * as ff from "goals/ReviewEntries/ReviewEntriesTable/filterFn"; import * as sf from "goals/ReviewEntries/ReviewEntriesTable/sortingFn"; -import { useAppSelector } from "rootRedux/hooks"; +import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; import { type Hash } from "types/hash"; @@ -61,6 +67,20 @@ const IconHeaderPaddingTop = "2px"; // Vertical offset for a small icon as Heade const IconHeaderWidth = 20; // Width for a small icon as Header const SensesHeaderWidth = 15; // Width for # as Header +export enum ColumnId { + Definitions = "definitions", + Delete = "delete", + Domains = "domains", + Edit = "edit", + Flag = "flag", + Glosses = "glosses", + Note = "note", + PartOfSpeech = "partOfSpeech", + Pronunciations = "pronunciations", + Senses = "senses", + Vernacular = "vernacular", +} + // Constants for pagination state. const rowsPerPage = [10, 100]; const initPaginationState: MRT_PaginationState = { @@ -76,12 +96,32 @@ interface RowsPerPageOption { export default function ReviewEntriesTable(props: { disableVirtualization?: boolean; }): ReactElement { - const showDefinitions = useAppSelector( - (state: StoreState) => state.currentProjectState.project.definitionsEnabled - ); - const showGrammaticalInfo = useAppSelector( + const dispatch = useAppDispatch(); + + const columnOrder = useAppSelector( (state: StoreState) => - state.currentProjectState.project.grammaticalInfoEnabled + state.currentProjectState.reviewEntriesColumns.columnOrder + ); + const columnVisibility: MRT_VisibilityState = useAppSelector( + // Memoized selector that ensures correct column visibility. + createSelector( + [ + (state: StoreState) => + state.currentProjectState.reviewEntriesColumns.columnVisibility, + (state: StoreState) => + state.currentProjectState.project.definitionsEnabled, + (state: StoreState) => + state.currentProjectState.project.grammaticalInfoEnabled, + ], + (colVis, def, pos) => ({ + ...colVis, + [ColumnId.Definitions]: (colVis[ColumnId.Definitions] ?? def) && def, + [ColumnId.PartOfSpeech]: (colVis[ColumnId.PartOfSpeech] ?? pos) && pos, + }) + ) + ); + const { definitionsEnabled, grammaticalInfoEnabled } = useAppSelector( + (state: StoreState) => state.currentProjectState.project ); const autoResetPageIndexRef = useRef(true); @@ -171,6 +211,7 @@ export default function ReviewEntriesTable(props: { enableHiding: false, Header: "", header: t("reviewEntries.columns.edit"), + id: ColumnId.Edit, size: IconColumnSize, visibleInShowHideMenu: false, }), @@ -181,6 +222,7 @@ export default function ReviewEntriesTable(props: { enableColumnOrdering: false, enableHiding: false, header: t("reviewEntries.columns.vernacular"), + id: ColumnId.Vernacular, size: BaselineColumnSize - 40, }), @@ -190,7 +232,7 @@ export default function ReviewEntriesTable(props: { filterFn: "equals", Header: #, header: t("reviewEntries.columns.sensesCount"), - id: "senses", + id: ColumnId.Senses, muiTableHeadCellProps: { sx: { "& .Mui-TableHeadCell-Content-Wrapper": { @@ -207,10 +249,10 @@ export default function ReviewEntriesTable(props: { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnDefinitions, header: t("reviewEntries.columns.definitions"), - id: "definitions", + id: ColumnId.Definitions, size: BaselineColumnSize + 20, sortingFn: sf.sortingFnDefinitions, - visibleInShowHideMenu: showDefinitions, + visibleInShowHideMenu: definitionsEnabled, }), // Glosses column @@ -218,7 +260,7 @@ export default function ReviewEntriesTable(props: { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnGlosses, header: t("reviewEntries.columns.glosses"), - id: "glosses", + id: ColumnId.Glosses, sortingFn: sf.sortingFnGlosses, }), @@ -235,9 +277,9 @@ export default function ReviewEntriesTable(props: { })), filterVariant: "select", header: t("reviewEntries.columns.partOfSpeech"), - id: "partOfSpeech", + id: ColumnId.PartOfSpeech, sortingFn: sf.sortingFnPartOfSpeech, - visibleInShowHideMenu: showGrammaticalInfo, + visibleInShowHideMenu: grammaticalInfoEnabled, }), // Domains column @@ -245,7 +287,7 @@ export default function ReviewEntriesTable(props: { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnDomains, header: t("reviewEntries.columns.domains"), - id: "domains", + id: ColumnId.Domains, sortingFn: sf.sortingFnDomains, }), @@ -268,7 +310,7 @@ export default function ReviewEntriesTable(props: { ), header: t("reviewEntries.columns.pronunciations"), - id: "pronunciations", + id: ColumnId.Pronunciations, muiTableHeadCellProps: { sx: { "& .Mui-TableHeadCell-Content-Wrapper": { @@ -286,7 +328,7 @@ export default function ReviewEntriesTable(props: { columnHelper.accessor((w) => w.note.text || undefined, { Cell: ({ row }: CellProps) => , header: t("reviewEntries.columns.note"), - id: "note", + id: ColumnId.Note, size: BaselineColumnSize - 40, }), @@ -301,6 +343,7 @@ export default function ReviewEntriesTable(props: { /> ), header: t("reviewEntries.columns.flag"), + id: ColumnId.Flag, muiTableHeadCellProps: { sx: { "& .Mui-TableHeadCell-Content-Wrapper": { @@ -323,6 +366,7 @@ export default function ReviewEntriesTable(props: { enableHiding: false, Header: "", header: t("reviewEntries.columns.delete"), + id: ColumnId.Delete, size: IconColumnSize, visibleInShowHideMenu: false, }), @@ -341,13 +385,7 @@ export default function ReviewEntriesTable(props: { enableGlobalFilter: false, enablePagination, enableRowVirtualization: !props.disableVirtualization, - initialState: { - columnVisibility: { - definitions: showDefinitions, - partOfSpeech: showGrammaticalInfo, - }, - density: "compact", - }, + initialState: { density: "compact" }, localization, muiPaginationProps: { rowsPerPageOptions }, // Override whiteSpace: "nowrap" from having density: "compact" @@ -357,13 +395,17 @@ export default function ReviewEntriesTable(props: { sx: { maxHeight: `calc(100vh - ${enablePagination ? 180 : 130}px)` }, }, muiTablePaperProps: { sx: { height: `calc(100vh - ${topBarHeight}px)` } }, + onColumnOrderChange: (updater) => + dispatch(setReviewEntriesColumnOrder(updater)), + onColumnVisibilityChange: (updater) => + dispatch(setReviewEntriesColumnVisibility(updater)), onPaginationChange: (updater) => { setPagination(updater); scrollToTop(); }, rowVirtualizerInstanceRef, sortDescFirst: false, - state: { isLoading, pagination }, + state: { columnOrder, columnVisibility, isLoading, pagination }, }); return ; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx index 179279f474..7c1c6239d3 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx @@ -5,7 +5,9 @@ import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import { defaultState } from "components/Project/ProjectReduxTypes"; -import ReviewEntriesTable from "goals/ReviewEntries/ReviewEntriesTable"; +import ReviewEntriesTable, { + ColumnId, +} from "goals/ReviewEntries/ReviewEntriesTable"; import VernacularCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell"; import { mockWords, @@ -38,10 +40,6 @@ jest.mock("backend", () => ({ })); jest.mock("components/Pronunciations/PronunciationsBackend"); jest.mock("i18n", () => ({})); -jest.mock("rootRedux/hooks", () => ({ - ...jest.requireActual("rootRedux/hooks"), - useAppDispatch: () => jest.fn(), -})); const mockClickEvent = { stopPropagation: jest.fn() }; const mockGetAllSpeakers = jest.fn(); @@ -153,16 +151,13 @@ describe("ReviewEntriesTable", () => { }); describe("definitionsEnabled & grammaticalInfoEnabled", () => { - const definitionsId = "definitions"; - const partOfSpeechId = "partOfSpeech"; - test("show definitions when definitionsEnabled is true", async () => { await renderReviewEntriesTable(true, false); const colIds = renderer.root .findAllByType(MRT_TableHeadCell) .map((col) => col.props.header.id); - expect(colIds).toContain(definitionsId); - expect(colIds).not.toContain(partOfSpeechId); + expect(colIds).toContain(ColumnId.Definitions); + expect(colIds).not.toContain(ColumnId.PartOfSpeech); }); test("show part of speech when grammaticalInfoEnabled is true", async () => { @@ -170,8 +165,8 @@ describe("ReviewEntriesTable", () => { const colIds = renderer.root .findAllByType(MRT_TableHeadCell) .map((col) => col.props.header.id); - expect(colIds).not.toContain(definitionsId); - expect(colIds).toContain(partOfSpeechId); + expect(colIds).not.toContain(ColumnId.Definitions); + expect(colIds).toContain(ColumnId.PartOfSpeech); }); });