Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/reference/dashboard/list-views/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ generated: true
---
## DataTable

<GenerationInfo sourceFile="packages/dashboard/src/lib/components/data-table/data-table.tsx" sourceLine="258" packageName="@vendure/dashboard" since="3.4.0" />
<GenerationInfo sourceFile="packages/dashboard/src/lib/components/data-table/data-table.tsx" sourceLine="259" packageName="@vendure/dashboard" since="3.4.0" />

A data table which includes sorting, filtering, pagination, bulk actions, column controls etc.

Expand All @@ -21,7 +21,7 @@ Parameters

## DataTableProps

<GenerationInfo sourceFile="packages/dashboard/src/lib/components/data-table/data-table.tsx" sourceLine="129" packageName="@vendure/dashboard" since="3.4.0" />
<GenerationInfo sourceFile="packages/dashboard/src/lib/components/data-table/data-table.tsx" sourceLine="130" packageName="@vendure/dashboard" since="3.4.0" />

Props for configuring the [DataTable](/reference/dashboard/list-views/data-table#datatable).

Expand Down
32 changes: 30 additions & 2 deletions docs/docs/reference/dashboard/list-views/list-page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ generated: true
---
## ListPage

<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="524" packageName="@vendure/dashboard" since="3.3.0" />
<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="552" packageName="@vendure/dashboard" since="3.3.0" />

Auto-generates a list page with columns generated based on the provided query document fields.

Expand Down Expand Up @@ -103,7 +103,7 @@ Parameters

## ListPageProps

<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="37" packageName="@vendure/dashboard" since="3.3.0" />
<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="38" packageName="@vendure/dashboard" since="3.3.0" />

Props to configure the [ListPage](/reference/dashboard/list-views/list-page#listpage) component.

Expand All @@ -121,6 +121,7 @@ interface ListPageProps<T extends TypedDocumentNode<U, V>, U extends ListQuerySh
additionalColumns?: AC;
defaultColumnOrder?: (keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>)[];
defaultSort?: SortingState;
defaultColumnFilters?: ColumnFiltersState;
defaultVisibility?: Partial<
Record<keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>, boolean>
>;
Expand Down Expand Up @@ -378,6 +379,33 @@ Allows you to specify the default sorting applied to the table.
```tsx
defaultSort={[{ id: 'updatedAt', desc: true }]}
```
### defaultColumnFilters

<MemberInfo kind="property" type={`ColumnFiltersState`} since="3.8.0" />

Allows you to specify column filters which are applied when the current user has not
yet configured any filters for this page. They behave exactly as if the user had set
them: they show up as active filter chips, and can be edited or removed.

Once the user edits the filters, their choice is persisted and these defaults are no
longer applied — including when the user removes every filter. Clearing the filters is
persisted as a deliberate choice, so the defaults do not reappear on the next visit.

Requires `pageId` to be set, since the persisted table settings are what distinguishes
"not configured yet" from "cleared by the user". Without a `pageId` the filters live
only in the URL, which cannot express that difference, and the defaults are ignored.

*Example*

```tsx
<ListPage
pageId="product-list"
listQuery={productListQuery}
title="Products"
// hide archived products until the user says otherwise
defaultColumnFilters={[{ id: 'isArchived', value: { eq: false } }]}
/>
```
### defaultVisibility

<MemberInfo kind="property" type={`Partial< Record<keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>, boolean> >`} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { type Page, expect, test } from '@playwright/test';

import { BaseListPage } from '../../page-objects/list-page.base.js';

// Must match `LS_KEY_USER_SETTINGS` in src/lib/constants.ts. Inlined rather than imported:
// that module pulls in the generated GraphQL schema enums, which do not belong in the
// Playwright process.
const USER_SETTINGS_KEY = 'vendure-user-settings';
const PAGE_ID = 'product-list';

/**
* Reads the saved column filters for a page, distinguishing "no value has been saved"
* from "an empty value has been saved" — `undefined` alone cannot survive the trip out
* of `page.evaluate`.
*/
async function readSavedColumnFilters(page: Page, pageId: string) {
return page.evaluate(
([key, id]) => {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
const tableSettings = settings.tableSettings?.[id] ?? {};
return {
saved: Object.prototype.hasOwnProperty.call(tableSettings, 'columnFilters'),
value: tableSettings.columnFilters ?? null,
};
},
[USER_SETTINGS_KEY, pageId] as const,
);
}

/**
* Puts the page back into the "user has never configured filters here" state that a
* first-time visitor sees. The stored auth state is shared between tests, so the saved
* settings cannot be assumed to be empty at the start of a test.
*/
async function resetSavedTableSettings(page: Page, pageId: string) {
await page.evaluate(
([key, id]) => {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
if (settings.tableSettings) {
delete settings.tableSettings[id];
}
localStorage.setItem(key, JSON.stringify(settings));
},
[USER_SETTINGS_KEY, pageId] as const,
);
}

/**
* Waits for the products list query itself to come back, rather than for any admin-api
* response — the dashboard has other requests in flight (user settings, saved views), and
* matching one of those would let the assertions run against a list that has not refreshed.
*
* Returns the pending wait, so callers can register it before the action that triggers the
* refetch; awaiting it afterwards would race the response.
*/
function waitForProductList(page: Page) {
return page.waitForResponse(
resp =>
resp.url().includes('/admin-api') &&
resp.request().postData()?.includes('ProductList') === true &&
resp.status() === 200,
);
}

function productList(page: Page) {
return new BaseListPage(page, {
path: '/products',
title: 'Products',
newButtonLabel: 'New Product',
});
}

async function applyNameFilter(page: Page, value: string) {
const lp = productList(page);
await lp.openAddFilterMenu();
const dropdown = page.locator('[data-slot="dropdown-menu-content"]');
await expect(dropdown).toBeVisible();
await dropdown.getByRole('menuitem', { name: /name/i }).click();

const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
await dialog.getByPlaceholder('Enter filter value...').fill(value);
await Promise.all([
waitForProductList(page),
dialog.getByRole('button', { name: 'Apply filter' }).click(),
]);
}

test.describe('List page column filter persistence', () => {
// #5294 — `ListPage`'s `defaultColumnFilters` applies a page's default filters only until
// the user configures their own, which requires the saved table settings to tell "never
// configured filters here" apart from "cleared every filter". Both used to be an empty
// array, because the data table reported its filter state on mount and the list page
// persisted whatever it was told.
test('should not save a filter state merely because the list page was visited', async ({ page }) => {
const lp = productList(page);
await lp.goto();
await lp.expectLoaded();

// Start from a clean slate, then load the page as a first-time visitor would.
await resetSavedTableSettings(page, PAGE_ID);
await page.reload();
await lp.expectLoaded();
await lp.expectRowsLoaded();

const savedFilters = await readSavedColumnFilters(page, PAGE_ID);
expect(savedFilters.saved).toBe(false);
});

// #5294 — clearing every filter is a deliberate choice and has to be saved as such, so
// that a page's `defaultColumnFilters` are not re-applied on the next visit.
test('should save cleared filters as an empty state that survives a reload', async ({ page }) => {
const lp = productList(page);
await lp.goto();
await lp.expectLoaded();

await resetSavedTableSettings(page, PAGE_ID);
await page.reload();
await lp.expectLoaded();
await lp.expectRowsLoaded();

const initialCount = await lp.getRows().count();

await applyNameFilter(page, 'Camera');
const filteredCount = await lp.getRows().count();
expect(filteredCount).toBeLessThan(initialCount);

const afterFiltering = await readSavedColumnFilters(page, PAGE_ID);
expect(afterFiltering.saved).toBe(true);
expect(afterFiltering.value).toHaveLength(1);

await Promise.all([
waitForProductList(page),
page.getByRole('button', { name: 'Clear all' }).click(),
]);
await lp.expectRowCount(initialCount);

// Saved, but empty: the user has configured the filters and chosen to have none.
const afterClearing = await readSavedColumnFilters(page, PAGE_ID);
expect(afterClearing.saved).toBe(true);
expect(afterClearing.value).toEqual([]);

await page.reload();
await lp.expectLoaded();
await lp.expectRowsLoaded();
await lp.expectRowCount(initialCount);

const afterReload = await readSavedColumnFilters(page, PAGE_ID);
expect(afterReload.saved).toBe(true);
expect(afterReload.value).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ColumnFiltersState } from '@tanstack/react-table';
import { describe, expect, it } from 'vitest';

import { columnFiltersEqual } from './data-table-column-filters.js';

describe('columnFiltersEqual', () => {
const filters: ColumnFiltersState = [{ id: 'isArchived', value: { eq: false } }];

it('treats a re-rendered copy of the same filters as unchanged', () => {
// The DataTable reports a filter change by persisting it, so an initial render — or any
// re-render that hands over an equivalent array — must not look like a user edit.
expect(columnFiltersEqual(filters, [{ id: 'isArchived', value: { eq: false } }])).toBe(true);
});

it('treats two empty filter states as unchanged', () => {
expect(columnFiltersEqual([], [])).toBe(true);
});

it('treats clearing all filters as a change', () => {
// The counterpart to the above: going from some filters to none is a deliberate user
// action and must be reported, so that it can be persisted as "cleared".
expect(columnFiltersEqual(filters, [])).toBe(false);
});

it('treats a changed filter value as a change', () => {
expect(columnFiltersEqual(filters, [{ id: 'isArchived', value: { eq: true } }])).toBe(false);
});

it('treats an added filter as a change', () => {
expect(columnFiltersEqual([], filters)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ColumnFiltersState } from '@tanstack/react-table';

/**
* Compares two column filter states by value. The DataTable keeps its filters in React
* state, so a re-render can produce a new array with identical contents; only a change
* in content means the user actually edited the filters.
*
* Consumers persist the filters they are told about, so reporting a no-op change would
* write an empty filter state on mount and make "never configured filters on this page"
* indistinguishable from "cleared every filter" — see `ListPage`'s `defaultColumnFilters`.
*
* Deliberately not exported from the package barrel: this is an internal detail of the
* DataTable, not public API.
*/
export function columnFiltersEqual(a: ColumnFiltersState, b: ColumnFiltersState): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
16 changes: 9 additions & 7 deletions packages/dashboard/src/lib/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import React, { Suspense, useEffect, useMemo, useRef } from 'react';
import { AddFilterMenu } from './add-filter-menu.js';
import { ActiveFiltersPopover } from './data-table-active-filters-popover.js';
import { DataTableBulkActions, getRowItemId } from './data-table-bulk-actions.js';
import { columnFiltersEqual } from './data-table-column-filters.js';
import { DataTableProvider } from './data-table-context.js';
import { createPaginationState, syncPaginationState } from './data-table-pagination-state.js';
import {
Expand Down Expand Up @@ -434,20 +435,21 @@ export function DataTable<TData>({
}, [onPageChange, searchTerm]);

useEffect(() => {
// `prevColumnFiltersRef` starts out holding the initial filter state, so this also
// covers the mount run: the filters the table was given are not a change the user
// made, and reporting them would persist them as if they were.
if (columnFiltersEqual(prevColumnFiltersRef.current, columnFilters)) {
return;
}
prevColumnFiltersRef.current = columnFilters;
onFilterChange?.(tableRef.current, columnFilters);
if (
page &&
page > 1 &&
itemsPerPage &&
JSON.stringify(prevColumnFiltersRef.current) !== JSON.stringify(columnFilters)
) {
if (page && page > 1 && itemsPerPage) {
// Set the page back to 1 when filters change
setPagination({
...pagination,
pageIndex: 0,
});
}
prevColumnFiltersRef.current = columnFilters;
}, [columnFilters]);

const handleSearchChange = (value: string) => {
Expand Down
Loading
Loading