diff --git a/frontend/src/components/DestructionListToolbar/DestructionListToolbar.stories.tsx b/frontend/src/components/DestructionListToolbar/DestructionListToolbar.stories.tsx new file mode 100644 index 000000000..eda289a65 --- /dev/null +++ b/frontend/src/components/DestructionListToolbar/DestructionListToolbar.stories.tsx @@ -0,0 +1,71 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; + +import { + ClearSessionStorageDecorator, + ReactRouterDecorator, +} from "../../../.storybook/decorators"; +import { destructionListFactory } from "../../fixtures"; +import { DestructionListToolbar } from "./DestructionListToolbar"; + +const meta: Meta = { + title: "Components/DestructionListToolbar", + component: DestructionListToolbar, + decorators: [ClearSessionStorageDecorator, ReactRouterDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const Tabable: Story = { + args: { + destructionList: destructionListFactory(), + }, + play: async (context) => { + const canvas = within(context.canvasElement); + // Initial tab + await expect(canvas.getByText("Auteur")).toBeVisible(); + + // Click on history tab + await userEvent.click( + await canvas.findByRole("tab", { name: "Geschiedenis" }), + ); + await expect(await canvas.findByText("Datum")).toBeVisible(); + + // Click on details tab + await userEvent.click(await canvas.findByRole("tab", { name: "Details" })); + await expect(canvas.getByText("Min/max archiefactiedatum")).toBeVisible(); + }, +}; + +export const Collapsible: Story = { + args: { + destructionList: destructionListFactory(), + }, + play: async (context) => { + const canvas = within(context.canvasElement); + // Click on history tab + await userEvent.click( + await canvas.findByRole("tab", { name: "Geschiedenis" }), + ); + + // Assert content rendered + expect((await canvas.findByRole("tabpanel")).children).toHaveLength(1); + + // Click on history tab again (collapse) + await userEvent.click( + await canvas.findByRole("tab", { name: "Geschiedenis" }), + ); + + // Assert content not rendered + expect((await canvas.findByRole("tabpanel")).children).toHaveLength(0); + + // Click on history tab again (expand) + await userEvent.click( + await canvas.findByRole("tab", { name: "Geschiedenis" }), + ); + + // Assert content rendered + expect((await canvas.findByRole("tabpanel")).children).toHaveLength(1); + }, +}; diff --git a/frontend/src/components/DestructionListToolbar/DestructionListToolbar.tsx b/frontend/src/components/DestructionListToolbar/DestructionListToolbar.tsx index 6b8e64d52..b20299667 100644 --- a/frontend/src/components/DestructionListToolbar/DestructionListToolbar.tsx +++ b/frontend/src/components/DestructionListToolbar/DestructionListToolbar.tsx @@ -14,6 +14,7 @@ import { useAlert, useFormDialog, } from "@maykin-ui/admin-ui"; +import { useEffect, useState } from "react"; import { useRevalidator } from "react-router-dom"; import { useAuditLog, useLatestReviewResponse, useWhoAmI } from "../../hooks"; @@ -26,6 +27,10 @@ import { canRenameDestructionList } from "../../lib/auth/permissions"; import { formatDate } from "../../lib/format/date"; import { collectErrors } from "../../lib/format/error"; import { formatUser } from "../../lib/format/user"; +import { + getPreference, + setPreference, +} from "../../lib/preferences/preferences"; import { REVIEW_DECISION_LEVEL_MAPPING, REVIEW_DECISION_MAPPING, @@ -37,8 +42,8 @@ import { import { DestructionListReviewer } from "../DestructionListReviewer"; export type DestructionListToolbarProps = { - title?: string; destructionList?: DestructionList; + title?: string; review?: Review; }; @@ -61,6 +66,25 @@ export function DestructionListToolbar({ const alert = useAlert(); const user = useWhoAmI(); const revalidator = useRevalidator(); + const [tabIndexState, setTabIndexState] = useState(0); + const [collapsedState, setCollapsedState] = useState(null); + + // Get collapsed state from preferences + useEffect(() => { + getPreference("destructionListToolbarCollapsed").then( + (collapsed) => setCollapsedState(Boolean(collapsed)), + ); + }, []); + + // Update collapsed state in preferences + useEffect(() => { + // Skip initial run. + if (typeof collapsedState !== "boolean") { + return; + } + setPreference("destructionListToolbarCollapsed", collapsedState); + }, [collapsedState]); + const properties = ( {destructionList && ( @@ -138,6 +162,15 @@ export function DestructionListToolbar({ }, ]; + const handleTabChange = (newTabIndex: number) => { + if (!collapsedState && tabIndexState === newTabIndex) { + setCollapsedState(true); + } else { + setCollapsedState(false); + } + setTabIndexState(newTabIndex); + }; + const handleSubmit = (data: SerializedFormData) => { if (!destructionList) { return; @@ -164,7 +197,7 @@ export function DestructionListToolbar({ }; return ( - +

{title ? title @@ -198,20 +231,24 @@ export function DestructionListToolbar({ )}

- + - {properties} + {!collapsedState && properties} {logItems?.length ? ( - + {!collapsedState && ( + + )} ) : null} {logItemsReadyForFirstReview?.length ? ( - + {!collapsedState && ( + + )} ) : null} diff --git a/frontend/src/lib/preferences/preferences.test.ts b/frontend/src/lib/preferences/preferences.test.ts new file mode 100644 index 000000000..b3e638670 --- /dev/null +++ b/frontend/src/lib/preferences/preferences.test.ts @@ -0,0 +1,124 @@ +import { clearPreference, getPreference, setPreference } from "./preferences"; + +describe("getPreference", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("should retrieve a stored string preference", async () => { + sessionStorage.setItem( + "oab.lib.preference.testKey", + JSON.stringify({ type: "string", value: "testValue" }), + ); + const result = await getPreference("testKey"); + expect(result).toBe("testValue"); + }); + + it("should retrieve a stored number preference", async () => { + sessionStorage.setItem( + "oab.lib.preference.testKey", + JSON.stringify({ type: "number", value: "1" }), + ); + const result = await getPreference("testKey"); + expect(result).toBe(1); + }); + + it("should retrieve a stored boolean preference", async () => { + sessionStorage.setItem( + "oab.lib.preference.testKey", + JSON.stringify({ type: "boolean", value: "true" }), + ); + const result = await getPreference("testKey"); + expect(result).toBe(true); + }); + + it("should retrieve a stored null preference", async () => { + sessionStorage.setItem( + "oab.lib.preference.testKey", + JSON.stringify({ type: "null", value: "null" }), + ); + const result = await getPreference("testKey"); + expect(result).toBe(null); + }); + + it("should retrieve a stored object preference", async () => { + const obj = { a: 1, b: "test" }; + sessionStorage.setItem( + "oab.lib.preference.testKey", + JSON.stringify({ type: "json", value: JSON.stringify(obj) }), + ); + const result = await getPreference("testKey"); + expect(result).toEqual(obj); + }); + + it("should return undefined for a non-existent key", async () => { + const result = await getPreference("nonExistentKey"); + expect(result).toBeUndefined(); + }); +}); + +describe("setPreference", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("should store a string preference", async () => { + await setPreference("testKey", "testValue"); + const stored = JSON.parse( + sessionStorage.getItem("oab.lib.preference.testKey")!, + ); + expect(stored).toEqual({ type: "string", value: "testValue" }); + }); + + it("should store a number preference", async () => { + await setPreference("testKey", 1); + const stored = JSON.parse( + sessionStorage.getItem("oab.lib.preference.testKey")!, + ); + expect(stored).toEqual({ type: "number", value: 1 }); + }); + + it("should store a boolean preference", async () => { + await setPreference("testKey", true); + const stored = JSON.parse( + sessionStorage.getItem("oab.lib.preference.testKey")!, + ); + expect(stored).toEqual({ type: "boolean", value: true }); + }); + + it("should store a null preference", async () => { + await setPreference("testKey", null); + const stored = JSON.parse( + sessionStorage.getItem("oab.lib.preference.testKey")!, + ); + expect(stored).toEqual({ type: "null", value: null }); + }); + + it("should store an object preference", async () => { + const obj = { a: 1, b: "test" }; + await setPreference>("testKey", obj); + const stored = JSON.parse( + sessionStorage.getItem("oab.lib.preference.testKey")!, + ); + expect(stored).toEqual({ type: "json", value: JSON.stringify(obj) }); + }); + + it("should throw an error for unsupported types like function", async () => { + const unsupportedValue = () => {}; + await expect(setPreference("testKey", unsupportedValue)).rejects.toThrow( + "Function values are not supported as preference.", + ); + }); +}); + +describe.only("clearPreference", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("should clear a stored preference", async () => { + await setPreference("testKey", "testValue"); + await clearPreference("testKey"); + expect(await getPreference("testKey")).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/preferences/preferences.ts b/frontend/src/lib/preferences/preferences.ts new file mode 100644 index 000000000..dc15c8053 --- /dev/null +++ b/frontend/src/lib/preferences/preferences.ts @@ -0,0 +1,93 @@ +type StoredPreference = T extends string + ? { type: "string"; value: string } + : T extends number + ? { type: "number"; value: string } + : T extends bigint + ? { type: "bigint"; value: string } + : T extends boolean + ? { type: "boolean"; value: string } + : T extends null + ? { type: "null"; value: string } + : T extends object + ? { type: "json"; value: string } + : never; + +/** + * Gets the preference. + * Note: This function is async to accommodate possible future refactors. + * @param key A key identifying the selection + */ +export async function getPreference< + T extends string | number | bigint | boolean | null | object, +>(key: string): Promise { + const computedKey = _getComputedKey(key); + const json = sessionStorage.getItem(computedKey); + + if (!json) { + return undefined; + } + + const { type, value } = JSON.parse(json) as StoredPreference; + + switch (type) { + case "string": + return value as T; + case "number": + return Number(value) as T; + case "bigint": + return BigInt(value) as T; + case "boolean": + return Boolean(value) as T; + case "null": + return null as T; + case "json": + return JSON.parse(value); + } +} + +/** + * Sets preference cache. + * Note: This function is async to accommodate possible future refactors. + * @param key A key identifying the selection + * @param value + */ +export async function setPreference< + T extends string | number | bigint | boolean | null | object, +>(key: string, value: T) { + const computedKey = _getComputedKey(key); + const type = + value === null ? "null" : typeof value === "object" ? "json" : typeof value; + const jsonValue = type === "json" ? JSON.stringify(value) : value; + + switch (type) { + case "function": + throw new Error("Function values are not supported as preference."); + case "symbol": + throw new Error("Symbol values are not supported as preference."); + default: { + const storedPreference: StoredPreference = { + type: type, + value: jsonValue, + } as StoredPreference; + const json = JSON.stringify(storedPreference); + sessionStorage.setItem(computedKey, json); + } + } +} + +/** + * Note: This function is async to accommodate possible future refactors. + * @param key A key identifying the selection + */ +export async function clearPreference(key: string) { + const computedKey = _getComputedKey(key); + sessionStorage.removeItem(computedKey); +} + +/** + * Computes the prefixed cache key. + * @param key A key identifying the selection + */ +function _getComputedKey(key: string): string { + return `oab.lib.preference.${key}`; +} diff --git a/frontend/src/pages/destructionlist/abstract/BaseListView.tsx b/frontend/src/pages/destructionlist/abstract/BaseListView.tsx index e2b6c09be..cd7a3e9f3 100644 --- a/frontend/src/pages/destructionlist/abstract/BaseListView.tsx +++ b/frontend/src/pages/destructionlist/abstract/BaseListView.tsx @@ -179,10 +179,6 @@ export function BaseListView({ return [...dynamicItems, ...fixedItems]; }, [selectable, hasSelection, selectedZakenOnPage, selectionActions]); - const hasVerticalOverflow = - document.documentElement.scrollHeight > - document.documentElement.clientHeight; - return ( errors={errors} @@ -196,11 +192,7 @@ export function BaseListView({ explicit: true, }, fieldsSelectable: true, - // If no vertical scrolling is applied, used (slower) 100% height to - // push paginator down at bottom of page. - // This triggers a "stickyfill" behaviour which is slower than native - // sticky which is not compatible with the percentage value. - height: hasVerticalOverflow ? undefined : "100%", + height: "fill-available-space", pageSize: 100, showPaginator: true, selectable: selectable === true, diff --git a/frontend/src/pages/settings/abstract/BaseSettingsView.tsx b/frontend/src/pages/settings/abstract/BaseSettingsView.tsx index 598176f38..f3b865da0 100644 --- a/frontend/src/pages/settings/abstract/BaseSettingsView.tsx +++ b/frontend/src/pages/settings/abstract/BaseSettingsView.tsx @@ -42,9 +42,9 @@ export function BaseSettingsView({ if (dataGridProps) { return ( {children}