Skip to content

Commit

Permalink
[ReviewEntries] Persist column order/visibility state in project (#3309)
Browse files Browse the repository at this point in the history
  • Loading branch information
imnasnainaec authored Sep 27, 2024
1 parent f08ded3 commit 6bbfc5b
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 35 deletions.
19 changes: 19 additions & 0 deletions src/components/Project/ProjectActions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,6 +14,8 @@ import {
import { setProjectId } from "backend/localStorage";
import {
resetAction,
setColumnOrderAction,
setColumnVisibilityAction,
setProjectAction,
setSemanticDomainsAction,
setSpeakerAction,
Expand All @@ -25,6 +32,18 @@ export function resetCurrentProject(): Action {
return resetAction();
}

export function setReviewEntriesColumnOrder(
updater: MRT_Updater<MRT_ColumnOrderState>
): PayloadAction {
return setColumnOrderAction(updater);
}

export function setReviewEntriesColumnVisibility(
updater: MRT_Updater<MRT_VisibilityState>
): PayloadAction {
return setColumnVisibilityAction(updater);
}

export function setCurrentProject(project?: Project): PayloadAction {
return setProjectAction(project ?? newProject());
}
Expand Down
23 changes: 23 additions & 0 deletions src/components/Project/ProjectReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
}
Expand All @@ -31,6 +52,8 @@ const projectSlice = createSlice({

export const {
resetAction,
setColumnOrderAction,
setColumnVisibilityAction,
setProjectAction,
setSemanticDomainsAction,
setSpeakerAction,
Expand Down
12 changes: 12 additions & 0 deletions src/components/Project/ProjectReduxTypes.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
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<string>;
speaker?: Speaker;
users: User[];
}

export const defaultState: CurrentProjectState = {
project: newProject(),
reviewEntriesColumns: { columnOrder: [], columnVisibility: {} },
users: [],
};
88 changes: 65 additions & 23 deletions src/goals/ReviewEntries/ReviewEntriesTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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 = {
Expand All @@ -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);
Expand Down Expand Up @@ -171,6 +211,7 @@ export default function ReviewEntriesTable(props: {
enableHiding: false,
Header: "",
header: t("reviewEntries.columns.edit"),
id: ColumnId.Edit,
size: IconColumnSize,
visibleInShowHideMenu: false,
}),
Expand All @@ -181,6 +222,7 @@ export default function ReviewEntriesTable(props: {
enableColumnOrdering: false,
enableHiding: false,
header: t("reviewEntries.columns.vernacular"),
id: ColumnId.Vernacular,
size: BaselineColumnSize - 40,
}),

Expand All @@ -190,7 +232,7 @@ export default function ReviewEntriesTable(props: {
filterFn: "equals",
Header: <Typography>#</Typography>,
header: t("reviewEntries.columns.sensesCount"),
id: "senses",
id: ColumnId.Senses,
muiTableHeadCellProps: {
sx: {
"& .Mui-TableHeadCell-Content-Wrapper": {
Expand All @@ -207,18 +249,18 @@ export default function ReviewEntriesTable(props: {
Cell: ({ row }: CellProps) => <Cell.Definitions word={row.original} />,
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
columnHelper.accessor((w) => w.senses.flatMap((s) => s.glosses), {
Cell: ({ row }: CellProps) => <Cell.Glosses word={row.original} />,
filterFn: ff.filterFnGlosses,
header: t("reviewEntries.columns.glosses"),
id: "glosses",
id: ColumnId.Glosses,
sortingFn: sf.sortingFnGlosses,
}),

Expand All @@ -235,17 +277,17 @@ 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
columnHelper.accessor((w) => w.senses.flatMap((s) => s.semanticDomains), {
Cell: ({ row }: CellProps) => <Cell.Domains word={row.original} />,
filterFn: ff.filterFnDomains,
header: t("reviewEntries.columns.domains"),
id: "domains",
id: ColumnId.Domains,
sortingFn: sf.sortingFnDomains,
}),

Expand All @@ -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": {
Expand All @@ -286,7 +328,7 @@ export default function ReviewEntriesTable(props: {
columnHelper.accessor((w) => w.note.text || undefined, {
Cell: ({ row }: CellProps) => <Cell.Note word={row.original} />,
header: t("reviewEntries.columns.note"),
id: "note",
id: ColumnId.Note,
size: BaselineColumnSize - 40,
}),

Expand All @@ -301,6 +343,7 @@ export default function ReviewEntriesTable(props: {
/>
),
header: t("reviewEntries.columns.flag"),
id: ColumnId.Flag,
muiTableHeadCellProps: {
sx: {
"& .Mui-TableHeadCell-Content-Wrapper": {
Expand All @@ -323,6 +366,7 @@ export default function ReviewEntriesTable(props: {
enableHiding: false,
Header: "",
header: t("reviewEntries.columns.delete"),
id: ColumnId.Delete,
size: IconColumnSize,
visibleInShowHideMenu: false,
}),
Expand All @@ -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"
Expand All @@ -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 <MaterialReactTable table={table} />;
Expand Down
19 changes: 7 additions & 12 deletions src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -153,25 +151,22 @@ 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 () => {
await renderReviewEntriesTable(false, true);
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);
});
});

Expand Down

0 comments on commit 6bbfc5b

Please sign in to comment.