diff --git a/CHANGELOG.md b/CHANGELOG.md index 8457c2fba5..c3cf2c1810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The types of changes are: - Added access and erasure support for Marigold Engage by Sailthru integration [#4826](https://github.com/ethyca/fides/pull/4826) - Added multiple language translations support for privacy center consent page [#4785](https://github.com/ethyca/fides/pull/4785) - Added ability to export the contents of datamap report [#1545](https://ethyca.atlassian.net/browse/PROD-1545) +- Added state persistence across sessions to the datamap report table [#4853](https://github.com/ethyca/fides/pull/4853) ### Fixed - Remove the extra 'white-space: normal' CSS for FidesJS HTML descriptions [#4850](https://github.com/ethyca/fides/pull/4850) diff --git a/clients/admin-ui/cypress/e2e/datamap-report.cy.ts b/clients/admin-ui/cypress/e2e/datamap-report.cy.ts index 6260c1abc7..d70e485dc4 100644 --- a/clients/admin-ui/cypress/e2e/datamap-report.cy.ts +++ b/clients/admin-ui/cypress/e2e/datamap-report.cy.ts @@ -82,6 +82,19 @@ describe("Minimal datamap report table", () => { cy.getByTestId("datamap-report-heading").should("be.visible"); }); + it("can group by data use", () => { + cy.getByTestId("group-by-menu").should("contain.text", "Group by system"); + cy.getByTestId("group-by-menu").click(); + cy.getByTestId("group-by-menu-list").within(() => { + cy.getByTestId("group-by-data-use-system").click(); + }); + cy.getByTestId("group-by-menu").should("contain.text", "Group by data use"); + + // should persist the grouping when navigating away + cy.reload(); + cy.getByTestId("group-by-menu").should("contain.text", "Group by data use"); + }); + describe("Undeclared data category columns", () => { it("should have the undeclared data columns disabled by default", () => { cy.getByTestId("row-0-col-system_undeclared_data_categories").should( @@ -105,6 +118,15 @@ describe("Minimal datamap report table", () => { "2 data use undeclared data categories" ); + // should persist the columns when navigating away + cy.reload(); + cy.getByTestId("row-0-col-system_undeclared_data_categories").contains( + "2 system undeclared data categories" + ); + cy.getByTestId("row-0-col-data_use_undeclared_data_categories").contains( + "2 data use undeclared data categories" + ); + // should be able to expand columns cy.getByTestId("system_undeclared_data_categories-header-menu").click(); cy.getByTestId( @@ -129,6 +151,17 @@ describe("Minimal datamap report table", () => { "row-0-col-data_use_undeclared_data_categories" ).contains(pokemon); }); + + // should persist the expanded columns when navigating away + cy.reload(); + ["User Contact Email", "Cookie ID"].forEach((pokemon) => { + cy.getByTestId("row-0-col-system_undeclared_data_categories").contains( + pokemon + ); + cy.getByTestId( + "row-0-col-data_use_undeclared_data_categories" + ).contains(pokemon); + }); }); }); diff --git a/clients/admin-ui/src/features/common/hooks/useLocalStorage.ts b/clients/admin-ui/src/features/common/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..78472c97de --- /dev/null +++ b/clients/admin-ui/src/features/common/hooks/useLocalStorage.ts @@ -0,0 +1,50 @@ +import { Dispatch, SetStateAction, useState } from "react"; + +/* +Design taken from: https://usehooks.com/useLocalStorage/ +*/ + +// eslint-disable-next-line import/prefer-default-export +export function useLocalStorage( + key: string, + initialValue: T +): [T, Dispatch>] { + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === "undefined") { + return initialValue; + } + try { + // Get from local storage by key + const item = window.localStorage.getItem(key); + // Parse stored json or if none return initialValue + return item ? JSON.parse(item) : initialValue; + } catch (error) { + // If error also return initialValue + // eslint-disable-next-line no-console + console.error(error); + return initialValue; + } + }); + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue = (value: T | ((x: T) => T)) => { + try { + // Allow value to be a function so we have same API as useState + const valueToStore = + value instanceof Function ? value(storedValue) : value; + // Save state + setStoredValue(valueToStore); + // Save to local storage + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + // A more advanced implementation would handle the error case + // eslint-disable-next-line no-console + console.error(error); + } + }; + return [storedValue, setValue]; +} diff --git a/clients/admin-ui/src/features/common/table/v2/FidesTable.tsx b/clients/admin-ui/src/features/common/table/v2/FidesTable.tsx index e67baedc26..7653218ebe 100644 --- a/clients/admin-ui/src/features/common/table/v2/FidesTable.tsx +++ b/clients/admin-ui/src/features/common/table/v2/FidesTable.tsx @@ -22,11 +22,13 @@ import { RowData, Table as TableInstance, } from "@tanstack/react-table"; -import React, { ReactNode, useMemo, useState } from "react"; +import React, { ReactNode, useMemo } from "react"; +import { useLocalStorage } from "~/features/common/hooks/useLocalStorage"; import { DisplayAllIcon, GroupedIcon } from "~/features/common/Icon"; import { FidesRow } from "~/features/common/table/v2/FidesRow"; import { getTableTHandTDStyles } from "~/features/common/table/v2/util"; +import { DATAMAP_LOCAL_STORAGE_KEYS } from "~/features/datamap/constants"; /* This was throwing a false positive for unused parameters. @@ -176,7 +178,10 @@ export const FidesTableV2 = ({ renderRowTooltipLabel, emptyTableNotice, }: Props) => { - const [displayAllColumns, setDisplayAllColumns] = useState([]); + const [displayAllColumns, setDisplayAllColumns] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.DISPLAY_ALL_COLUMNS, + [] + ); const handleAddDisplayColumn = (id: string) => { setDisplayAllColumns([...displayAllColumns, id]); diff --git a/clients/admin-ui/src/features/common/table/v2/column-settings/ColumnSettingsModal.tsx b/clients/admin-ui/src/features/common/table/v2/column-settings/ColumnSettingsModal.tsx index 562fd4094b..1f6ddc6f1c 100644 --- a/clients/admin-ui/src/features/common/table/v2/column-settings/ColumnSettingsModal.tsx +++ b/clients/admin-ui/src/features/common/table/v2/column-settings/ColumnSettingsModal.tsx @@ -30,6 +30,7 @@ type ColumnSettingsModalProps = { headerText: string; prefixColumns: string[]; tableInstance: TableInstance; + onColumnOrderChange: (columns: string[]) => void; }; export const ColumnSettingsModal = ({ @@ -38,6 +39,7 @@ export const ColumnSettingsModal = ({ headerText, tableInstance, prefixColumns, + onColumnOrderChange, }: ColumnSettingsModalProps) => { const initialColumns = useMemo( () => @@ -49,7 +51,23 @@ export const ColumnSettingsModal = ({ displayText: c.columnDef?.meta?.displayText || c.id, isVisible: tableInstance.getState().columnVisibility[c.id] ?? c.getIsVisible(), - })), + })) + .sort((a, b) => { + // columnOrder is not always a complete list. Sorts by columnOrder but leaves the rest alone + const { columnOrder } = tableInstance.getState(); + const aIndex = columnOrder.indexOf(a.id); + const bIndex = columnOrder.indexOf(b.id); + if (aIndex === -1 && bIndex === -1) { + return 0; + } + if (aIndex === -1) { + return 1; + } + if (bIndex === -1) { + return -1; + } + return aIndex - bIndex; + }), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); @@ -58,10 +76,11 @@ export const ColumnSettingsModal = ({ }); const handleSave = useCallback(() => { - tableInstance.setColumnOrder([ + const newColumnOrder: string[] = [ ...prefixColumns, ...columnEditor.columns.map((c) => c.id), - ]); + ]; + onColumnOrderChange(newColumnOrder); tableInstance.setColumnVisibility( columnEditor.columns.reduce( (acc: Record, current: DraggableColumn) => { @@ -73,7 +92,13 @@ export const ColumnSettingsModal = ({ ) ); onClose(); - }, [onClose, prefixColumns, tableInstance, columnEditor.columns]); + }, [ + onClose, + prefixColumns, + tableInstance, + columnEditor.columns, + onColumnOrderChange, + ]); return ( diff --git a/clients/admin-ui/src/features/datamap/constants.ts b/clients/admin-ui/src/features/datamap/constants.ts index fa42d9eb55..596db0a55b 100644 --- a/clients/admin-ui/src/features/datamap/constants.ts +++ b/clients/admin-ui/src/features/datamap/constants.ts @@ -73,3 +73,12 @@ COLUMN_NAME_MAP[SYSTEM_EGRESS] = "Destination Systems"; // COLUMN_NAME_MAP[] = 'Legal Name & Address'; #new; // COLUMN_NAME_MAP[] = 'Privacy Policy'; #new; // COLUMN_NAME_MAP[] = 'Data Protection Officer (DPO)'; #new; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum DATAMAP_LOCAL_STORAGE_KEYS { + GROUP_BY = "datamap-group-by", + COLUMN_ORDER = "datamap-column-order", + TABLE_GROUPING = "datamap-table-grouping", + TABLE_STATE = "datamap-report-table-state", + DISPLAY_ALL_COLUMNS = "datamap-display-all-columns", +} diff --git a/clients/admin-ui/src/features/datamap/reporting/DatamapReportTable.tsx b/clients/admin-ui/src/features/datamap/reporting/DatamapReportTable.tsx index 55b08d545a..a010ebd3bc 100644 --- a/clients/admin-ui/src/features/datamap/reporting/DatamapReportTable.tsx +++ b/clients/admin-ui/src/features/datamap/reporting/DatamapReportTable.tsx @@ -16,6 +16,7 @@ import { getCoreRowModel, getExpandedRowModel, getGroupedRowModel, + TableState, useReactTable, } from "@tanstack/react-table"; import { @@ -34,10 +35,14 @@ import _, { isArray, map } from "lodash"; import { useEffect, useMemo, useState } from "react"; import { useAppSelector } from "~/app/hooks"; +import { useLocalStorage } from "~/features/common/hooks/useLocalStorage"; import useTaxonomies from "~/features/common/hooks/useTaxonomies"; import { DownloadLightIcon } from "~/features/common/Icon"; import { getQueryParamsFromList } from "~/features/common/modals/FilterModal"; -import { ExportFormat } from "~/features/datamap/constants"; +import { + DATAMAP_LOCAL_STORAGE_KEYS, + ExportFormat, +} from "~/features/datamap/constants"; import { useExportMinimalDatamapReportMutation, useGetMinimalDatamapReportQuery, @@ -190,6 +195,17 @@ const getPrefixColumns = (groupBy: DATAMAP_GROUPING) => { }; export const DatamapReportTable = () => { + const [tableState, setTableState] = useLocalStorage( + "datamap-report-table-state", + undefined + ); + const storedTableState = useMemo( + // snag the stored table state from local storage if it exists and use it to initialize the tableInstance. + // memoize this so we don't get stuck in a loop as the tableState gets updated during the session. + () => tableState, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const { isLoading: isLoadingHealthCheck } = useGetHealthQuery(); const { PAGE_SIZES, @@ -248,10 +264,21 @@ export const DatamapReportTable = () => { setGlobalFilter(searchTerm); }; - const [groupBy, setGroupBy] = useState( + const [groupBy, setGroupBy] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.GROUP_BY, DATAMAP_GROUPING.SYSTEM_DATA_USE ); + const [columnOrder, setColumnOrder] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.COLUMN_ORDER, + getColumnOrder(groupBy) + ); + + const [grouping, setGrouping] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.TABLE_GROUPING, + getGrouping(groupBy) + ); + const onGroupChange = (group: DATAMAP_GROUPING) => { setGroupBy(group); setGroupChangeStarted(true); @@ -277,13 +304,7 @@ export const DatamapReportTable = () => { { isLoading: isExportingReport, isSuccess: isExportReportSuccess }, ] = useExportMinimalDatamapReportMutation(); - const { - items: data, - total: totalRows, - pages: totalPages, - grouping, - columnOrder, - } = useMemo(() => { + const { data, totalRows } = useMemo(() => { const report = datamapReport || emptyMinimalDatamapReportResponse; // Type workaround since extending BaseDatamapReport with custom fields causes some trouble const items = report.items as DatamapReport[]; @@ -291,26 +312,24 @@ export const DatamapReportTable = () => { setGroupChangeStarted(false); } - /* - It's important that `grouping` and `columnOrder` are updated - in this `useMemo`. It makes it so grouping and column order - updates are synced up with when the data changes. Otherwise - the table will update the grouping and column order before - the correct data loads. - */ + setTotalPages(report.pages); + return { - ...report, - items, - grouping: getGrouping(groupBy), - columnOrder: getColumnOrder(groupBy), + totalRows: report.total, + data: items, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [datamapReport]); useEffect(() => { - setTotalPages(totalPages); - }, [totalPages, setTotalPages]); + // changing the groupBy should wait until the data is loaded to update the grouping + const newGrouping = getGrouping(groupBy); + if (datamapReport) { + setGrouping(newGrouping); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [datamapReport]); // Get custom fields useGetAllCustomFieldDefinitionsQuery(); @@ -1014,19 +1033,26 @@ export const DatamapReportTable = () => { manualPagination: true, data, initialState: { - columnOrder, columnVisibility: { [COLUMN_IDS.SYSTEM_UNDECLARED_DATA_CATEGORIES]: false, [COLUMN_IDS.DATA_USE_UNDECLARED_DATA_CATEGORIES]: false, }, + ...storedTableState, }, state: { expanded: true, grouping, + columnOrder, }, - // column resizing columnResizeMode: "onChange", enableColumnResizing: true, + onStateChange: (updater) => { + const valueToStore = + updater instanceof Function + ? updater(tableInstance.getState()) + : updater; + setTableState(valueToStore); + }, }); const getMenuDisplayValue = () => { @@ -1077,6 +1103,9 @@ export const DatamapReportTable = () => { headerText="Data map settings" prefixColumns={getPrefixColumns(groupBy)} tableInstance={tableInstance} + onColumnOrderChange={(newColumnOrder) => { + setColumnOrder(newColumnOrder); + }} /> { spinnerPlacement="end" isLoading={groupChangeStarted} loadingText={`Group by ${getMenuDisplayValue()}`} + data-testid="group-by-menu" > Group by {getMenuDisplayValue()} - + { onGroupChange(DATAMAP_GROUPING.SYSTEM_DATA_USE); }} isChecked={DATAMAP_GROUPING.SYSTEM_DATA_USE === groupBy} value={DATAMAP_GROUPING.SYSTEM_DATA_USE} + data-testid="group-by-system-data-use" > System @@ -1119,6 +1150,7 @@ export const DatamapReportTable = () => { }} isChecked={DATAMAP_GROUPING.DATA_USE_SYSTEM === groupBy} value={DATAMAP_GROUPING.DATA_USE_SYSTEM} + data-testid="group-by-data-use-system" > Data use