feat(web): add Columns slide-over with Stampli HTML defaults

Refs: AP-45
This commit is contained in:
Adam Moussa 2026-08-11 15:40:27 -04:00
parent 36d9fcdc6c
commit 5a02da2e96
No known key found for this signature in database
7 changed files with 438 additions and 17 deletions

View file

@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
test.describe("Invoice Processing columns drawer (AP-45)", () => {
test("Columns drawer can hide Tagging and Reset restores it", async ({ page }) => {
await page.goto("/");
await expect(
page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'),
).toBeVisible();
await page.getByTestId("columns-toggle").click();
const drawer = page.getByTestId("columns-drawer");
await expect(drawer).toBeVisible();
const taggingRow = drawer.getByTestId("columns-visible-tagging");
await taggingRow.scrollIntoViewIfNeeded();
await taggingRow.locator('input[type="checkbox"]').evaluate((el: HTMLInputElement) => {
el.click();
});
await expect(
page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'),
).toHaveCount(0);
await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => {
el.click();
});
await expect(
page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'),
).toBeVisible();
});
});

56
package-lock.json generated
View file

@ -11,6 +11,9 @@
"packages/*"
],
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/ibm-plex-sans": "^5.3.0",
@ -756,6 +759,59 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",

View file

@ -34,6 +34,9 @@
},
"packageManager": "npm@11.16.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/ibm-plex-sans": "^5.3.0",

View file

@ -0,0 +1,199 @@
import {
DndContext,
PointerSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import CloseIcon from "@mui/icons-material/Close";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
Box,
Button,
Checkbox,
Drawer,
FormControlLabel,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { CSSProperties, ReactElement } from "react";
import { useMemo, useState } from "react";
import type { InvoiceColumnKey } from "@/dashboard/column-catalog";
import {
catalogLabel,
hiddenColumnKeys,
isColumnLocked,
type ColumnPrefs,
} from "@/dashboard/column-prefs";
type ColumnsDrawerProps = {
open: boolean;
onClose: () => void;
prefs: ColumnPrefs;
onPrefsChange: (next: ColumnPrefs) => void;
onReset: () => void;
};
function SortableVisibleRow({
columnKey,
onToggle,
}: {
columnKey: InvoiceColumnKey;
onToggle: (key: InvoiceColumnKey, checked: boolean) => void;
}): ReactElement {
const locked = isColumnLocked(columnKey);
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: columnKey,
});
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
display: "flex",
alignItems: "center",
gap: 8,
};
return (
<Box ref={setNodeRef} style={style} data-testid={`columns-visible-${columnKey}`}>
<IconButton size="small" {...attributes} {...listeners} aria-label={`Reorder ${columnKey}`}>
<DragIndicatorIcon fontSize="small" />
</IconButton>
<FormControlLabel
control={
<Checkbox
checked
disabled={locked}
onChange={(event) => onToggle(columnKey, event.target.checked)}
/>
}
label={catalogLabel(columnKey)}
/>
</Box>
);
}
export function ColumnsDrawer({
open,
onClose,
prefs,
onPrefsChange,
onReset,
}: ColumnsDrawerProps): ReactElement {
const [hiddenQuery, setHiddenQuery] = useState("");
const [viewAllHidden, setViewAllHidden] = useState(false);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const hidden = useMemo(() => hiddenColumnKeys(prefs.visibleOrder), [prefs.visibleOrder]);
const filteredHidden = useMemo(() => {
const q = hiddenQuery.trim().toLowerCase();
const list = q ? hidden.filter((key) => catalogLabel(key).toLowerCase().includes(q)) : hidden;
if (viewAllHidden || q) {
return list;
}
return list.slice(0, 5);
}, [hidden, hiddenQuery, viewAllHidden]);
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = prefs.visibleOrder.indexOf(active.id as InvoiceColumnKey);
const newIndex = prefs.visibleOrder.indexOf(over.id as InvoiceColumnKey);
if (oldIndex < 0 || newIndex < 0) {
return;
}
onPrefsChange({ visibleOrder: arrayMove(prefs.visibleOrder, oldIndex, newIndex) });
};
const onToggle = (key: InvoiceColumnKey, checked: boolean) => {
if (isColumnLocked(key)) {
return;
}
if (checked) {
if (prefs.visibleOrder.includes(key)) {
return;
}
onPrefsChange({ visibleOrder: [...prefs.visibleOrder, key] });
return;
}
onPrefsChange({ visibleOrder: prefs.visibleOrder.filter((item) => item !== key) });
};
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
data-testid="columns-drawer"
PaperProps={{ sx: { width: 360, p: 2 } }}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h6" data-testid="columns-selector-header">
Columns
</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" data-testid="columns-selector-reset-button" onClick={onReset}>
Reset
</Button>
<IconButton aria-label="Close columns" onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Stack>
</Stack>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Visible
</Typography>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={prefs.visibleOrder} strategy={verticalListSortingStrategy}>
<Stack spacing={0.5} sx={{ mb: 2 }}>
{prefs.visibleOrder.map((key) => (
<SortableVisibleRow key={key} columnKey={key} onToggle={onToggle} />
))}
</Stack>
</SortableContext>
</DndContext>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Hidden
</Typography>
<TextField
size="small"
fullWidth
placeholder="Search"
value={hiddenQuery}
onChange={(event) => setHiddenQuery(event.target.value)}
sx={{ mb: 1 }}
data-testid="columns-hidden-search"
/>
<Stack spacing={0.5} sx={{ mb: 1 }}>
{filteredHidden.map((key) => (
<FormControlLabel
key={key}
data-testid={`columns-hidden-${key}`}
control={
<Checkbox checked={false} onChange={(event) => onToggle(key, event.target.checked)} />
}
label={catalogLabel(key)}
/>
))}
</Stack>
{!viewAllHidden && !hiddenQuery && hidden.length > 5 ? (
<Button size="small" onClick={() => setViewAllHidden(true)}>
View all
</Button>
) : null}
</Drawer>
);
}

View file

@ -1,14 +1,17 @@
import { useMemo, useState } from "react";
import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material";
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer";
import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar";
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
import { columnsFromCatalogKeys, readyToPayColumns } from "@/dashboard/columns";
import {
allInvoicesColumns,
invoiceAttentionColumns,
readyToPayColumns,
} from "@/dashboard/columns";
loadColumnPrefs,
resetColumnPrefs,
saveColumnPrefs,
type ColumnPrefs,
} from "@/dashboard/column-prefs";
import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters";
import { InvoiceTabLabel } from "@/dashboard/invoice-tabs";
import { countSelectedRows } from "@/dashboard/selection";
@ -25,6 +28,14 @@ const emptySelection: GridRowSelectionModel = {
const emptyFilters = EMPTY_INVOICE_FILTERS;
const rowClickSx = {
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
cursor: "pointer",
borderRadius: 1,
} as const;
const invoiceTabsSx = {
minHeight: 44,
"& .MuiTab-root": {
@ -46,14 +57,6 @@ const invoiceTabsSx = {
},
} as const;
const rowClickSx = {
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
cursor: "pointer",
borderRadius: 1,
} as const;
function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] {
const vendorQuery = filters.vendorQuery.trim().toLowerCase();
return rows.filter((invoice) => {
@ -72,11 +75,17 @@ export function DashboardGrids() {
const [tab, setTab] = useState<InvoiceTab>("attention");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const [filters, setFilters] = useState<InvoiceFilterState>(emptyFilters);
const [, setColumnsDrawerOpen] = useState(false);
const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false);
const [, setFilterDrawerOpen] = useState(false);
const [columnPrefs, setColumnPrefs] = useState<ColumnPrefs>(() => loadColumnPrefs());
const { data: invoices = [], isLoading, isError, error } = useInvoices();
const updatePayDate = useUpdateInvoicePayDate();
const catalogColumns = useMemo(
() => columnsFromCatalogKeys(columnPrefs.visibleOrder),
[columnPrefs.visibleOrder],
);
const openInvoice = (params: GridRowParams<MockInvoice>) => {
navigate("/invoices/:id", { params: { id: params.row.id } });
};
@ -129,6 +138,11 @@ export function DashboardGrids() {
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
};
const updateColumnPrefs = (next: ColumnPrefs) => {
setColumnPrefs(next);
saveColumnPrefs(next);
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
@ -253,7 +267,7 @@ export function DashboardGrids() {
<InvoiceDataGrid
data-testid="grid-pending"
rows={filteredAttention}
columns={invoiceAttentionColumns}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -263,7 +277,7 @@ export function DashboardGrids() {
<InvoiceDataGrid
data-testid="grid-all"
rows={filteredAll}
columns={allInvoicesColumns}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -289,7 +303,7 @@ export function DashboardGrids() {
<InvoiceDataGrid
data-testid="grid-rejected"
rows={filteredRejected}
columns={allInvoicesColumns}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -299,7 +313,7 @@ export function DashboardGrids() {
<InvoiceDataGrid
data-testid="grid-late"
rows={filteredLate}
columns={allInvoicesColumns}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -309,6 +323,17 @@ export function DashboardGrids() {
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
</Typography>
</Box>
<ColumnsDrawer
open={columnsDrawerOpen}
onClose={() => setColumnsDrawerOpen(false)}
prefs={columnPrefs}
onPrefsChange={updateColumnPrefs}
onReset={() => {
const next = resetColumnPrefs();
setColumnPrefs(next);
}}
/>
</Stack>
);
}

View file

@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import {
COLUMN_PREFS_STORAGE_KEY,
defaultColumnPrefs,
hiddenColumnKeys,
loadColumnPrefs,
resetColumnPrefs,
saveColumnPrefs,
} from "@/dashboard/column-prefs";
import { DEFAULT_VISIBLE_COLUMN_KEYS } from "@/dashboard/column-catalog";
describe("column-prefs", () => {
afterEach(() => {
sessionStorage.removeItem(COLUMN_PREFS_STORAGE_KEY);
});
it("defaults to HTML Visible order", () => {
expect(defaultColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]);
});
it("persists and reloads prefs", () => {
saveColumnPrefs({ visibleOrder: ["invoiceId", "vendor", "dueDate"] });
expect(loadColumnPrefs().visibleOrder).toEqual([
"bolt",
"bell",
"invoiceId",
"vendor",
"dueDate",
]);
});
it("reset restores defaults", () => {
saveColumnPrefs({ visibleOrder: ["invoiceId"] });
expect(resetColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]);
});
it("computes hidden keys from visible order", () => {
const hidden = hiddenColumnKeys(["invoiceId", "vendor"]);
expect(hidden).toContain("tagging");
expect(hidden).not.toContain("invoiceId");
});
});

View file

@ -0,0 +1,66 @@
import {
DEFAULT_VISIBLE_COLUMN_KEYS,
INVOICE_COLUMN_CATALOG,
LOCKED_COLUMN_KEYS,
type InvoiceColumnKey,
} from "@/dashboard/column-catalog";
export const COLUMN_PREFS_STORAGE_KEY = "seahaven-ap.invoice-columns.v1";
export type ColumnPrefs = {
visibleOrder: InvoiceColumnKey[];
};
export function defaultColumnPrefs(): ColumnPrefs {
return { visibleOrder: [...DEFAULT_VISIBLE_COLUMN_KEYS] };
}
export function loadColumnPrefs(): ColumnPrefs {
if (typeof sessionStorage === "undefined") {
return defaultColumnPrefs();
}
try {
const raw = sessionStorage.getItem(COLUMN_PREFS_STORAGE_KEY);
if (!raw) {
return defaultColumnPrefs();
}
const parsed = JSON.parse(raw) as ColumnPrefs;
if (!Array.isArray(parsed.visibleOrder) || parsed.visibleOrder.length === 0) {
return defaultColumnPrefs();
}
const known = new Set(INVOICE_COLUMN_CATALOG.map((entry) => entry.key));
const visibleOrder = parsed.visibleOrder.filter((key) => known.has(key));
const missingLocked = LOCKED_COLUMN_KEYS.filter((key) => !visibleOrder.includes(key));
return { visibleOrder: [...missingLocked, ...visibleOrder] };
} catch {
return defaultColumnPrefs();
}
}
export function saveColumnPrefs(prefs: ColumnPrefs): void {
if (typeof sessionStorage === "undefined") {
return;
}
sessionStorage.setItem(COLUMN_PREFS_STORAGE_KEY, JSON.stringify(prefs));
}
export function resetColumnPrefs(): ColumnPrefs {
const prefs = defaultColumnPrefs();
saveColumnPrefs(prefs);
return prefs;
}
export function hiddenColumnKeys(visibleOrder: InvoiceColumnKey[]): InvoiceColumnKey[] {
const visible = new Set(visibleOrder);
return INVOICE_COLUMN_CATALOG.filter((entry) => !visible.has(entry.key)).map(
(entry) => entry.key,
);
}
export function isColumnLocked(key: InvoiceColumnKey): boolean {
return LOCKED_COLUMN_KEYS.includes(key);
}
export function catalogLabel(key: InvoiceColumnKey): string {
return INVOICE_COLUMN_CATALOG.find((entry) => entry.key === key)?.label ?? key;
}