Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ReviewEntries] Persist column order/visibility state in project #3309

Merged
merged 13 commits into from
Sep 27, 2024
Merged
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
19 changes: 19 additions & 0 deletions src/components/Project/ProjectReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,25 @@ const projectSlice = createSlice({
initialState: defaultState,
reducers: {
resetAction: () => defaultState,
setColumnOrderAction: (state, action) => {
const columns = state.reviewEntriesColumns;
if (typeof action.payload === "function") {
columns.columnOrder = action.payload(columns.columnOrder);
} else {
columns.columnOrder = action.payload;
}
},
setColumnVisibilityAction: (state, action) => {
const columns = state.reviewEntriesColumns;
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 +48,8 @@ const projectSlice = createSlice({

export const {
resetAction,
setColumnOrderAction,
setColumnVisibilityAction,
setProjectAction,
setSemanticDomainsAction,
setSpeakerAction,
Expand Down
10 changes: 10 additions & 0 deletions src/components/Project/ProjectReduxTypes.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
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;
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
Loading