@opensaas/stack-ui 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
 
2
- > @opensaas/stack-ui@0.24.0 build /home/runner/work/stack/stack/packages/ui
2
+ > @opensaas/stack-ui@0.25.0 build /home/runner/work/stack/stack/packages/ui
3
3
  > tsc && npm run build:css
4
4
 
5
5
  npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
6
6
  npm warn Unknown env config "npm-globalconfig". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
7
7
  npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
8
8
 
9
- > @opensaas/stack-ui@0.24.0 build:css
9
+ > @opensaas/stack-ui@0.25.0 build:css
10
10
  > mkdir -p dist/styles && postcss ./src/styles/globals.css -o ./dist/styles/globals.css
11
11
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @opensaas/stack-ui
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Patch Changes
6
+
7
+ - [#607](https://github.com/OpenSaasAU/stack/pull/607) [`61547be`](https://github.com/OpenSaasAU/stack/commit/61547beb5ec7d4aff30753849e36a738c49c91e4) Thanks [@borisno2](https://github.com/borisno2)! - Fix `ui.listView.initialSort` applying sort client-side instead of as a DB-level `orderBy`
8
+
9
+ Previously, `initialSort` was applied to the already-fetched page in memory, meaning a 500-row list with `initialSort: { field: 'sentAt', direction: 'desc' }` would only show the 50 most recent rows of the _current page_ rather than the 50 most recent rows overall. The sort is now passed as `orderBy` to `findMany` so pagination and sorting compose correctly.
10
+
11
+ Column-header clicks also now navigate with a `?sort=field:direction` URL param (instead of mutating local state), so subsequent sorts are also DB-level and work correctly across pages.
12
+
3
13
  ## 0.24.0
4
14
 
5
15
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"AdminUI.d.ts","sourceRoot":"","sources":["../../src/components/AdminUI.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EACL,KAAK,aAAa,EAGlB,cAAc,EACf,MAAM,sBAAsB,CAAA;AAG7B,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,YAAY,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAA;KAAE,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,MAAM,EACN,MAAW,EACX,YAAiB,EACjB,QAAmB,EACnB,YAAY,EACZ,SAAS,GACV,EAAE,YAAY,2CAuGd"}
1
+ {"version":3,"file":"AdminUI.d.ts","sourceRoot":"","sources":["../../src/components/AdminUI.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EACL,KAAK,aAAa,EAGlB,cAAc,EACf,MAAM,sBAAsB,CAAA;AAG7B,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,YAAY,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAA;KAAE,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,MAAM,EACN,MAAW,EACX,YAAiB,EACjB,QAAmB,EACnB,YAAY,EACZ,SAAS,GACV,EAAE,YAAY,2CAsHd"}
@@ -60,7 +60,20 @@ export function AdminUI({ context, config, params = [], searchParams = {}, baseP
60
60
  // ListView falls back to its existing defaults (all non-system fields,
61
61
  // no default sort).
62
62
  const listView = config.lists[listKey]?.ui?.listView;
63
- content = (_jsx(ListView, { context: context, config: config, listKey: listKey, basePath: basePath, search: search, page: page, columns: listView?.initialColumns, initialSort: listView?.initialSort }));
63
+ // Parse `?sort=field:direction` URL param for user-triggered column sorts.
64
+ const sortParam = typeof searchParams.sort === 'string' ? searchParams.sort : undefined;
65
+ let urlSort;
66
+ if (sortParam) {
67
+ const colonIdx = sortParam.lastIndexOf(':');
68
+ if (colonIdx > 0) {
69
+ const field = sortParam.slice(0, colonIdx);
70
+ const dir = sortParam.slice(colonIdx + 1);
71
+ if (dir === 'asc' || dir === 'desc') {
72
+ urlSort = { field, direction: dir };
73
+ }
74
+ }
75
+ }
76
+ content = (_jsx(ListView, { context: context, config: config, listKey: listKey, basePath: basePath, search: search, page: page, columns: listView?.initialColumns, initialSort: listView?.initialSort, sort: urlSort }));
64
77
  }
65
78
  // Generate theme styles if custom theme is configured
66
79
  const themeStyles = config.ui?.theme ? generateThemeCSS(config.ui.theme) : null;
@@ -17,14 +17,19 @@ export interface ListViewProps {
17
17
  pageSize?: number;
18
18
  search?: string;
19
19
  /**
20
- * Default sort applied to the table (from the list's `ui.listView.initialSort`).
21
- * When omitted, no default sort is applied.
20
+ * Default sort from the list's `ui.listView.initialSort` config.
21
+ * Used when no URL sort param is present.
22
22
  */
23
23
  initialSort?: ListViewSort;
24
+ /**
25
+ * Active sort from the `?sort=field:direction` URL param.
26
+ * Takes precedence over `initialSort`.
27
+ */
28
+ sort?: ListViewSort;
24
29
  }
25
30
  /**
26
31
  * List view component - displays items in a table
27
32
  * Server Component that fetches data and renders client table
28
33
  */
29
- export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, initialSort, }: ListViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
34
+ export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, initialSort, sort, }: ListViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
30
35
  //# sourceMappingURL=ListView.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ListView.d.ts","sourceRoot":"","sources":["../../src/components/ListView.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE9F;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,WAAW,CAAC,EAAE,YAAY,CAAA;CAC3B;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,OAAO,EACP,IAAQ,EACR,QAAa,EACb,MAAM,EACN,WAAW,GACZ,EAAE,aAAa,oDA8Hf"}
1
+ {"version":3,"file":"ListView.d.ts","sourceRoot":"","sources":["../../src/components/ListView.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE9F;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,WAAW,CAAC,EAAE,YAAY,CAAA;IAC1B;;;OAGG;IACH,IAAI,CAAC,EAAE,YAAY,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,OAAO,EACP,IAAQ,EACR,QAAa,EACb,MAAM,EACN,WAAW,EACX,IAAI,GACL,EAAE,aAAa,oDAqIf"}
@@ -7,13 +7,17 @@ import { getDbKey, getUrlKey } from '@opensaas/stack-core';
7
7
  * List view component - displays items in a table
8
8
  * Server Component that fetches data and renders client table
9
9
  */
10
- export async function ListView({ context, config, listKey, basePath = '/admin', columns, page = 1, pageSize = 50, search, initialSort, }) {
10
+ export async function ListView({ context, config, listKey, basePath = '/admin', columns, page = 1, pageSize = 50, search, initialSort, sort, }) {
11
11
  const key = getDbKey(listKey);
12
12
  const urlKey = getUrlKey(listKey);
13
13
  const listConfig = config.lists[listKey];
14
14
  if (!listConfig) {
15
15
  return (_jsx("div", { className: "p-8", children: _jsxs("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-6", children: [_jsx("h2", { className: "text-lg font-semibold mb-2", children: "List not found" }), _jsxs("p", { children: ["The list \"", listKey, "\" does not exist in your configuration."] })] }) }));
16
16
  }
17
+ // URL sort takes precedence over config initialSort, but only if the field
18
+ // actually exists on the list — an unknown field would cause Prisma to throw.
19
+ const validatedSort = sort && sort.field in listConfig.fields ? sort : undefined;
20
+ const activeSort = validatedSort ?? initialSort;
17
21
  // Fetch items using access-controlled context
18
22
  const skip = (page - 1) * pageSize;
19
23
  let items = [];
@@ -49,10 +53,11 @@ export async function ListView({ context, config, listKey, basePath = '/admin',
49
53
  });
50
54
  const delegate = dbContext[key];
51
55
  if (delegate?.findMany && delegate?.count) {
52
- ;
56
+ const orderBy = activeSort ? { [activeSort.field]: activeSort.direction } : undefined;
53
57
  [items, total] = await Promise.all([
54
58
  delegate.findMany({
55
59
  where,
60
+ orderBy,
56
61
  skip,
57
62
  take: pageSize,
58
63
  ...(Object.keys(include).length > 0 ? { include } : {}),
@@ -79,5 +84,5 @@ export async function ListView({ context, config, listKey, basePath = '/admin',
79
84
  return (_jsxs("div", { className: "p-8", children: [_jsxs("div", { className: "flex items-center justify-between mb-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-bold mb-2", children: formatListName(listKey) }), _jsxs("p", { className: "text-muted-foreground", children: [total, " ", total === 1 ? 'item' : 'items'] })] }), _jsxs(Link, { href: `${basePath}/${urlKey}/create`, className: "inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors", children: [_jsx("span", { className: "mr-2", children: "+" }), "Create ", formatListName(listKey)] })] }), _jsx(ListViewClient, { items: serializedItems || [], fieldTypes: Object.fromEntries(Object.entries(listConfig.fields).map(([key, field]) => [
80
85
  key,
81
86
  field.type,
82
- ])), relationshipRefs: relationshipRefs, columns: columns, initialSort: initialSort, listKey: listKey, urlKey: urlKey, basePath: basePath, page: page, pageSize: pageSize, total: total || 0, search: search })] }));
87
+ ])), relationshipRefs: relationshipRefs, columns: columns, initialSort: activeSort, listKey: listKey, urlKey: urlKey, basePath: basePath, page: page, pageSize: pageSize, total: total || 0, search: search })] }));
83
88
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ListViewClient.d.ts","sourceRoot":"","sources":["../../src/components/ListViewClient.tsx"],"names":[],"mappings":"AAoBA,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACrC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACxC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAA;IAC1D,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,UAAU,EACV,gBAAgB,EAChB,OAAO,EACP,WAAW,EACX,MAAM,EACN,QAAQ,EACR,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EAAE,aAAa,GACtB,EAAE,mBAAmB,2CAwOrB"}
1
+ {"version":3,"file":"ListViewClient.d.ts","sourceRoot":"","sources":["../../src/components/ListViewClient.tsx"],"names":[],"mappings":"AAmBA,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACrC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACxC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAA;IAC1D,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,UAAU,EACV,gBAAgB,EAChB,OAAO,EACP,WAAW,EACX,MAAM,EACN,QAAQ,EACR,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EAAE,aAAa,GACtB,EAAE,mBAAmB,2CAwOrB"}
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from 'react';
4
- import { useState } from 'react';
5
4
  import Link from 'next/link.js';
6
5
  import { useRouter } from 'next/navigation.js';
7
6
  import { formatFieldName, getFieldDisplayValue } from '../lib/utils.js';
@@ -16,38 +15,25 @@ import { getUrlKey } from '@opensaas/stack-core';
16
15
  */
17
16
  export function ListViewClient({ items, fieldTypes, relationshipRefs, columns, initialSort, urlKey, basePath, page, pageSize, total, search: initialSearch, }) {
18
17
  const router = useRouter();
19
- const [sortBy, setSortBy] = useState(initialSort?.field ?? null);
20
- const [sortOrder, setSortOrder] = useState(initialSort?.direction ?? 'asc');
21
- const [searchInput, setSearchInput] = useState(initialSearch || '');
18
+ const sortBy = initialSort?.field ?? null;
19
+ const sortOrder = initialSort?.direction ?? 'asc';
20
+ const [searchInput, setSearchInput] = React.useState(initialSearch || '');
22
21
  // Determine which columns to show
23
22
  const displayColumns = columns ||
24
23
  Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key));
25
- // Sort items if needed
26
- const sortedItems = [...items];
27
- if (sortBy) {
28
- sortedItems.sort((a, b) => {
29
- const aVal = a[sortBy];
30
- const bVal = b[sortBy];
31
- if (aVal === bVal)
32
- return 0;
33
- // Handle unknown types for comparison - convert to string for safety
34
- const aStr = String(aVal ?? '');
35
- const bStr = String(bVal ?? '');
36
- const comparison = aStr > bStr ? 1 : -1;
37
- return sortOrder === 'asc' ? comparison : -comparison;
38
- });
39
- }
24
+ // Items are already sorted by the server via orderBy; no in-memory sort needed.
40
25
  const totalPages = Math.ceil(total / pageSize);
41
26
  const hasNextPage = page < totalPages;
42
27
  const hasPrevPage = page > 1;
43
28
  const handleSort = (column) => {
44
- if (sortBy === column) {
45
- setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
46
- }
47
- else {
48
- setSortBy(column);
49
- setSortOrder('asc');
29
+ const newDirection = sortBy === column ? (sortOrder === 'asc' ? 'desc' : 'asc') : 'asc';
30
+ const params = new URLSearchParams();
31
+ if (initialSearch) {
32
+ params.set('search', initialSearch);
50
33
  }
34
+ params.set('sort', `${column}:${newDirection}`);
35
+ params.set('page', '1');
36
+ router.push(`${basePath}/${urlKey}?${params.toString()}`);
51
37
  };
52
38
  const handleSearch = (e) => {
53
39
  e.preventDefault();
@@ -55,18 +41,29 @@ export function ListViewClient({ items, fieldTypes, relationshipRefs, columns, i
55
41
  if (searchInput.trim()) {
56
42
  params.set('search', searchInput.trim());
57
43
  }
44
+ if (sortBy) {
45
+ params.set('sort', `${sortBy}:${sortOrder}`);
46
+ }
58
47
  params.set('page', '1'); // Reset to page 1 on new search
59
48
  router.push(`${basePath}/${urlKey}?${params.toString()}`);
60
49
  };
61
50
  const handleClearSearch = () => {
62
51
  setSearchInput('');
63
- router.push(`${basePath}/${urlKey}`);
52
+ const params = new URLSearchParams();
53
+ if (sortBy) {
54
+ params.set('sort', `${sortBy}:${sortOrder}`);
55
+ }
56
+ const qs = params.toString();
57
+ router.push(`${basePath}/${urlKey}${qs ? `?${qs}` : ''}`);
64
58
  };
65
59
  const buildPaginationUrl = (newPage) => {
66
60
  const params = new URLSearchParams();
67
61
  if (initialSearch) {
68
62
  params.set('search', initialSearch);
69
63
  }
64
+ if (sortBy) {
65
+ params.set('sort', `${sortBy}:${sortOrder}`);
66
+ }
70
67
  params.set('page', newPage.toString());
71
68
  return `${basePath}/${urlKey}?${params.toString()}`;
72
69
  };
@@ -102,7 +99,7 @@ export function ListViewClient({ items, fieldTypes, relationshipRefs, columns, i
102
99
  const displayValue = getFieldDisplayValue(value, 'relationship');
103
100
  return (_jsx(Link, { href: `${basePath}/${relatedUrlKey}/${itemId}`, className: "text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: displayValue }));
104
101
  };
105
- return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { className: "p-4", children: _jsxs("form", { onSubmit: handleSearch, className: "flex gap-2", children: [_jsxs("div", { className: "flex-1 relative", children: [_jsx(Input, { type: "text", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search...", className: "pr-10" }), searchInput && (_jsx("button", { type: "button", onClick: handleClearSearch, className: "absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground", children: "\u2715" }))] }), _jsx(Button, { type: "submit", children: "Search" })] }) }), _jsx("div", { className: "rounded-lg border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [displayColumns.map((column) => (_jsx(TableHead, { className: "cursor-pointer hover:bg-muted/70 transition-colors", onClick: () => handleSort(column), children: _jsxs("div", { className: "flex items-center space-x-1", children: [_jsx("span", { children: formatFieldName(column) }), sortBy === column && (_jsx("span", { className: "text-primary", children: sortOrder === 'asc' ? '↑' : '↓' }))] }) }, column))), _jsx(TableHead, { className: "text-right", children: "Actions" })] }) }), _jsx(TableBody, { children: sortedItems.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: displayColumns.length + 1, className: "h-24 text-center", children: "No items found" }) })) : (sortedItems.map((item) => (_jsxs(TableRow, { children: [displayColumns.map((column) => (_jsx(TableCell, { children: fieldTypes[column] === 'relationship'
102
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { className: "p-4", children: _jsxs("form", { onSubmit: handleSearch, className: "flex gap-2", children: [_jsxs("div", { className: "flex-1 relative", children: [_jsx(Input, { type: "text", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search...", className: "pr-10" }), searchInput && (_jsx("button", { type: "button", onClick: handleClearSearch, className: "absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground", children: "\u2715" }))] }), _jsx(Button, { type: "submit", children: "Search" })] }) }), _jsx("div", { className: "rounded-lg border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [displayColumns.map((column) => (_jsx(TableHead, { className: "cursor-pointer hover:bg-muted/70 transition-colors", onClick: () => handleSort(column), children: _jsxs("div", { className: "flex items-center space-x-1", children: [_jsx("span", { children: formatFieldName(column) }), sortBy === column && (_jsx("span", { className: "text-primary", children: sortOrder === 'asc' ? '↑' : '↓' }))] }) }, column))), _jsx(TableHead, { className: "text-right", children: "Actions" })] }) }), _jsx(TableBody, { children: items.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: displayColumns.length + 1, className: "h-24 text-center", children: "No items found" }) })) : (items.map((item) => (_jsxs(TableRow, { children: [displayColumns.map((column) => (_jsx(TableCell, { children: fieldTypes[column] === 'relationship'
106
103
  ? renderRelationshipCell(item[column], column)
107
104
  : getFieldDisplayValue(item[column], fieldTypes[column]) }, column))), _jsx(TableCell, { className: "text-right", children: _jsx(Link, { href: `${basePath}/${urlKey}/${item.id}`, className: "text-primary hover:underline", children: "Edit" }) })] }, String(item.id))))) })] }) }), totalPages > 1 && (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: ["Showing ", (page - 1) * pageSize + 1, " to ", Math.min(page * pageSize, total), " of ", total, ' ', "results"] }), _jsxs("div", { className: "flex items-center space-x-2", children: [_jsx(Button, { variant: "outline", onClick: () => router.push(buildPaginationUrl(page - 1)), disabled: !hasPrevPage, children: "Previous" }), _jsxs("span", { className: "text-sm text-muted-foreground", children: ["Page ", page, " of ", totalPages] }), _jsx(Button, { variant: "outline", onClick: () => router.push(buildPaginationUrl(page + 1)), disabled: !hasNextPage, children: "Next" })] })] }))] }));
108
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-ui",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Composable React UI components for OpenSaas Stack",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@radix-ui/react-checkbox": "^1.3.3",
61
- "@radix-ui/react-dialog": "^1.1.15",
61
+ "@radix-ui/react-dialog": "^1.1.17",
62
62
  "@radix-ui/react-dropdown-menu": "^2.1.16",
63
63
  "@radix-ui/react-label": "^2.1.8",
64
64
  "@radix-ui/react-popover": "^1.1.15",
@@ -94,7 +94,7 @@
94
94
  "tailwindcss": "^4.2.1",
95
95
  "typescript": "^5.9.3",
96
96
  "vitest": "^4.1.0",
97
- "@opensaas/stack-core": "0.24.0"
97
+ "@opensaas/stack-core": "0.25.0"
98
98
  },
99
99
  "scripts": {
100
100
  "build": "tsc && npm run build:css",
@@ -114,6 +114,20 @@ export function AdminUI({
114
114
  // no default sort).
115
115
  const listView = config.lists[listKey]?.ui?.listView
116
116
 
117
+ // Parse `?sort=field:direction` URL param for user-triggered column sorts.
118
+ const sortParam = typeof searchParams.sort === 'string' ? searchParams.sort : undefined
119
+ let urlSort: { field: string; direction: 'asc' | 'desc' } | undefined
120
+ if (sortParam) {
121
+ const colonIdx = sortParam.lastIndexOf(':')
122
+ if (colonIdx > 0) {
123
+ const field = sortParam.slice(0, colonIdx)
124
+ const dir = sortParam.slice(colonIdx + 1)
125
+ if (dir === 'asc' || dir === 'desc') {
126
+ urlSort = { field, direction: dir }
127
+ }
128
+ }
129
+ }
130
+
117
131
  content = (
118
132
  <ListView
119
133
  context={context}
@@ -124,6 +138,7 @@ export function AdminUI({
124
138
  page={page}
125
139
  columns={listView?.initialColumns}
126
140
  initialSort={listView?.initialSort}
141
+ sort={urlSort}
127
142
  />
128
143
  )
129
144
  }
@@ -22,10 +22,15 @@ export interface ListViewProps {
22
22
  pageSize?: number
23
23
  search?: string
24
24
  /**
25
- * Default sort applied to the table (from the list's `ui.listView.initialSort`).
26
- * When omitted, no default sort is applied.
25
+ * Default sort from the list's `ui.listView.initialSort` config.
26
+ * Used when no URL sort param is present.
27
27
  */
28
28
  initialSort?: ListViewSort
29
+ /**
30
+ * Active sort from the `?sort=field:direction` URL param.
31
+ * Takes precedence over `initialSort`.
32
+ */
33
+ sort?: ListViewSort
29
34
  }
30
35
 
31
36
  /**
@@ -42,6 +47,7 @@ export async function ListView({
42
47
  pageSize = 50,
43
48
  search,
44
49
  initialSort,
50
+ sort,
45
51
  }: ListViewProps) {
46
52
  const key = getDbKey(listKey)
47
53
  const urlKey = getUrlKey(listKey)
@@ -58,6 +64,11 @@ export async function ListView({
58
64
  )
59
65
  }
60
66
 
67
+ // URL sort takes precedence over config initialSort, but only if the field
68
+ // actually exists on the list — an unknown field would cause Prisma to throw.
69
+ const validatedSort = sort && sort.field in listConfig.fields ? sort : undefined
70
+ const activeSort = validatedSort ?? initialSort
71
+
61
72
  // Fetch items using access-controlled context
62
73
  const skip = (page - 1) * pageSize
63
74
  let items: Array<Record<string, unknown>> = []
@@ -97,9 +108,11 @@ export async function ListView({
97
108
  })
98
109
  const delegate = dbContext[key]
99
110
  if (delegate?.findMany && delegate?.count) {
111
+ const orderBy = activeSort ? { [activeSort.field]: activeSort.direction } : undefined
100
112
  ;[items, total] = await Promise.all([
101
113
  delegate.findMany({
102
114
  where,
115
+ orderBy,
103
116
  skip,
104
117
  take: pageSize,
105
118
  ...(Object.keys(include).length > 0 ? { include } : {}),
@@ -157,7 +170,7 @@ export async function ListView({
157
170
  )}
158
171
  relationshipRefs={relationshipRefs}
159
172
  columns={columns}
160
- initialSort={initialSort}
173
+ initialSort={activeSort}
161
174
  listKey={listKey}
162
175
  urlKey={urlKey}
163
176
  basePath={basePath}
@@ -1,7 +1,6 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { useState } from 'react'
5
4
  import Link from 'next/link.js'
6
5
  import { useRouter } from 'next/navigation.js'
7
6
  import { formatFieldName, getFieldDisplayValue } from '../lib/utils.js'
@@ -56,41 +55,30 @@ export function ListViewClient({
56
55
  search: initialSearch,
57
56
  }: ListViewClientProps) {
58
57
  const router = useRouter()
59
- const [sortBy, setSortBy] = useState<string | null>(initialSort?.field ?? null)
60
- const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialSort?.direction ?? 'asc')
61
- const [searchInput, setSearchInput] = useState(initialSearch || '')
58
+ const sortBy = initialSort?.field ?? null
59
+ const sortOrder = initialSort?.direction ?? 'asc'
60
+ const [searchInput, setSearchInput] = React.useState(initialSearch || '')
62
61
 
63
62
  // Determine which columns to show
64
63
  const displayColumns =
65
64
  columns ||
66
65
  Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key))
67
66
 
68
- // Sort items if needed
69
- const sortedItems = [...items]
70
- if (sortBy) {
71
- sortedItems.sort((a, b) => {
72
- const aVal = a[sortBy]
73
- const bVal = b[sortBy]
74
- if (aVal === bVal) return 0
75
- // Handle unknown types for comparison - convert to string for safety
76
- const aStr = String(aVal ?? '')
77
- const bStr = String(bVal ?? '')
78
- const comparison = aStr > bStr ? 1 : -1
79
- return sortOrder === 'asc' ? comparison : -comparison
80
- })
81
- }
67
+ // Items are already sorted by the server via orderBy; no in-memory sort needed.
82
68
 
83
69
  const totalPages = Math.ceil(total / pageSize)
84
70
  const hasNextPage = page < totalPages
85
71
  const hasPrevPage = page > 1
86
72
 
87
73
  const handleSort = (column: string) => {
88
- if (sortBy === column) {
89
- setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
90
- } else {
91
- setSortBy(column)
92
- setSortOrder('asc')
74
+ const newDirection = sortBy === column ? (sortOrder === 'asc' ? 'desc' : 'asc') : 'asc'
75
+ const params = new URLSearchParams()
76
+ if (initialSearch) {
77
+ params.set('search', initialSearch)
93
78
  }
79
+ params.set('sort', `${column}:${newDirection}`)
80
+ params.set('page', '1')
81
+ router.push(`${basePath}/${urlKey}?${params.toString()}`)
94
82
  }
95
83
 
96
84
  const handleSearch = (e: React.FormEvent) => {
@@ -99,13 +87,21 @@ export function ListViewClient({
99
87
  if (searchInput.trim()) {
100
88
  params.set('search', searchInput.trim())
101
89
  }
90
+ if (sortBy) {
91
+ params.set('sort', `${sortBy}:${sortOrder}`)
92
+ }
102
93
  params.set('page', '1') // Reset to page 1 on new search
103
94
  router.push(`${basePath}/${urlKey}?${params.toString()}`)
104
95
  }
105
96
 
106
97
  const handleClearSearch = () => {
107
98
  setSearchInput('')
108
- router.push(`${basePath}/${urlKey}`)
99
+ const params = new URLSearchParams()
100
+ if (sortBy) {
101
+ params.set('sort', `${sortBy}:${sortOrder}`)
102
+ }
103
+ const qs = params.toString()
104
+ router.push(`${basePath}/${urlKey}${qs ? `?${qs}` : ''}`)
109
105
  }
110
106
 
111
107
  const buildPaginationUrl = (newPage: number) => {
@@ -113,6 +109,9 @@ export function ListViewClient({
113
109
  if (initialSearch) {
114
110
  params.set('search', initialSearch)
115
111
  }
112
+ if (sortBy) {
113
+ params.set('sort', `${sortBy}:${sortOrder}`)
114
+ }
116
115
  params.set('page', newPage.toString())
117
116
  return `${basePath}/${urlKey}?${params.toString()}`
118
117
  }
@@ -225,14 +224,14 @@ export function ListViewClient({
225
224
  </TableRow>
226
225
  </TableHeader>
227
226
  <TableBody>
228
- {sortedItems.length === 0 ? (
227
+ {items.length === 0 ? (
229
228
  <TableRow>
230
229
  <TableCell colSpan={displayColumns.length + 1} className="h-24 text-center">
231
230
  No items found
232
231
  </TableCell>
233
232
  </TableRow>
234
233
  ) : (
235
- sortedItems.map((item) => (
234
+ items.map((item) => (
236
235
  <TableRow key={String(item.id)}>
237
236
  {displayColumns.map((column) => (
238
237
  <TableCell key={column}>
@@ -61,6 +61,102 @@ function routedContent(tree: React.ReactNode): React.ReactElement {
61
61
  }
62
62
 
63
63
  describe('AdminUI ui.listView wiring', () => {
64
+ it('passes orderBy to findMany based on initialSort', async () => {
65
+ const findMany = vi.fn(async () => [])
66
+ const count = vi.fn(async () => 0)
67
+
68
+ const config: OpenSaasConfig = {
69
+ db: { provider: 'sqlite', url: 'file:./test.db' },
70
+ lists: {
71
+ Post: list({
72
+ fields: { title: text(), sentAt: timestamp() },
73
+ ui: {
74
+ listView: {
75
+ initialSort: { field: 'sentAt', direction: 'desc' },
76
+ },
77
+ },
78
+ }),
79
+ },
80
+ }
81
+
82
+ const context = makeContext({ post: { findMany, count } })
83
+
84
+ await ListView({
85
+ context,
86
+ config,
87
+ listKey: 'Post',
88
+ basePath: '/admin',
89
+ initialSort: { field: 'sentAt', direction: 'desc' },
90
+ })
91
+
92
+ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ orderBy: { sentAt: 'desc' } }))
93
+ })
94
+
95
+ it('URL sort param takes precedence over initialSort in orderBy', async () => {
96
+ const findMany = vi.fn(async () => [])
97
+ const count = vi.fn(async () => 0)
98
+
99
+ const config: OpenSaasConfig = {
100
+ db: { provider: 'sqlite', url: 'file:./test.db' },
101
+ lists: {
102
+ Post: list({
103
+ fields: { title: text(), sentAt: timestamp() },
104
+ ui: {
105
+ listView: {
106
+ initialSort: { field: 'sentAt', direction: 'desc' },
107
+ },
108
+ },
109
+ }),
110
+ },
111
+ }
112
+
113
+ const context = makeContext({ post: { findMany, count } })
114
+
115
+ await ListView({
116
+ context,
117
+ config,
118
+ listKey: 'Post',
119
+ basePath: '/admin',
120
+ initialSort: { field: 'sentAt', direction: 'desc' },
121
+ sort: { field: 'title', direction: 'asc' },
122
+ })
123
+
124
+ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ orderBy: { title: 'asc' } }))
125
+ })
126
+
127
+ it('discards URL sort param when field does not exist on the list, falling back to initialSort', async () => {
128
+ const findMany = vi.fn(async () => [])
129
+ const count = vi.fn(async () => 0)
130
+
131
+ const config: OpenSaasConfig = {
132
+ db: { provider: 'sqlite', url: 'file:./test.db' },
133
+ lists: {
134
+ Post: list({
135
+ fields: { title: text(), sentAt: timestamp() },
136
+ ui: {
137
+ listView: {
138
+ initialSort: { field: 'sentAt', direction: 'desc' },
139
+ },
140
+ },
141
+ }),
142
+ },
143
+ }
144
+
145
+ const context = makeContext({ post: { findMany, count } })
146
+
147
+ await ListView({
148
+ context,
149
+ config,
150
+ listKey: 'Post',
151
+ basePath: '/admin',
152
+ initialSort: { field: 'sentAt', direction: 'desc' },
153
+ sort: { field: 'nonExistentField', direction: 'asc' },
154
+ })
155
+
156
+ // Unknown field is rejected; falls back to initialSort.
157
+ expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ orderBy: { sentAt: 'desc' } }))
158
+ })
159
+
64
160
  it('passes initialColumns + initialSort from ui.listView to ListView', async () => {
65
161
  const config: OpenSaasConfig = {
66
162
  db: { provider: 'sqlite', url: 'file:./test.db' },
@@ -90,101 +90,90 @@ describe('ListViewClient', () => {
90
90
  })
91
91
 
92
92
  describe('initialSort', () => {
93
- it('should apply default sort from initialSort without any click', () => {
93
+ it('should show sort indicator on the initialSort column (asc)', () => {
94
94
  render(
95
95
  <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
96
96
  )
97
97
 
98
- const rows = screen.getAllByRole('row')
99
- // Sorted ascending by title: First, Second, Third
100
- expect(rows[1]).toHaveTextContent('First Post')
101
- expect(rows[2]).toHaveTextContent('Second Post')
102
- expect(rows[3]).toHaveTextContent('Third Post')
98
+ expect(screen.getByText('')).toBeInTheDocument()
103
99
  })
104
100
 
105
- it('should respect descending direction from initialSort', () => {
101
+ it('should show sort indicator on the initialSort column (desc)', () => {
106
102
  render(
107
103
  <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
108
104
  )
109
105
 
110
- const rows = screen.getAllByRole('row')
111
- // Sorted descending by title: Third, Second, First
112
- expect(rows[1]).toHaveTextContent('Third Post')
113
- expect(rows[2]).toHaveTextContent('Second Post')
114
- expect(rows[3]).toHaveTextContent('First Post')
106
+ expect(screen.getByText('')).toBeInTheDocument()
115
107
  })
116
108
 
117
- it('should show sort indicator on the initialSort column', () => {
109
+ it('should preserve item order provided by server (no in-memory sort)', () => {
110
+ // Items arrive pre-sorted by the server; the client renders them as-is.
111
+ const sortedItems = [
112
+ { id: '3', title: 'Third Post', status: 'published', views: 200 },
113
+ { id: '2', title: 'Second Post', status: 'draft', views: 50 },
114
+ { id: '1', title: 'First Post', status: 'published', views: 100 },
115
+ ]
118
116
  render(
119
- <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
117
+ <ListViewClient
118
+ {...defaultProps}
119
+ items={sortedItems}
120
+ initialSort={{ field: 'title', direction: 'desc' }}
121
+ />,
120
122
  )
121
123
 
122
- expect(screen.getByText('')).toBeInTheDocument()
124
+ const rows = screen.getAllByRole('row')
125
+ expect(rows[1]).toHaveTextContent('Third Post')
126
+ expect(rows[2]).toHaveTextContent('Second Post')
127
+ expect(rows[3]).toHaveTextContent('First Post')
123
128
  })
124
129
 
125
130
  it('should not apply any default sort when initialSort is absent', () => {
126
131
  render(<ListViewClient {...defaultProps} />)
127
132
 
128
- const rows = screen.getAllByRole('row')
129
- // Unchanged: items render in their original (input) order, no indicator.
130
- expect(rows[1]).toHaveTextContent('First Post')
131
- expect(rows[2]).toHaveTextContent('Second Post')
132
- expect(rows[3]).toHaveTextContent('Third Post')
133
+ // No sort indicator when no initialSort.
133
134
  expect(screen.queryByText('↑')).not.toBeInTheDocument()
134
135
  expect(screen.queryByText('↓')).not.toBeInTheDocument()
135
136
  })
136
137
  })
137
138
 
138
139
  describe('sorting', () => {
139
- it('should sort items ascending when header clicked', async () => {
140
+ it('should navigate with sort param when column header clicked', async () => {
140
141
  const user = userEvent.setup()
141
142
  render(<ListViewClient {...defaultProps} />)
142
143
 
143
144
  await user.click(screen.getByText('Title'))
144
145
 
145
- const rows = screen.getAllByRole('row')
146
- // First row is header, so data rows start at index 1
147
- expect(rows[1]).toHaveTextContent('First Post')
148
- expect(rows[2]).toHaveTextContent('Second Post')
149
- expect(rows[3]).toHaveTextContent('Third Post')
146
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=title%3Aasc&page=1')
150
147
  })
151
148
 
152
- it('should toggle sort order when same header clicked twice', async () => {
149
+ it('should navigate with toggled direction when already-sorted column clicked', async () => {
153
150
  const user = userEvent.setup()
154
- render(<ListViewClient {...defaultProps} />)
151
+ render(
152
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
153
+ )
155
154
 
156
- const titleHeader = screen.getByText('Title')
157
- await user.click(titleHeader)
158
- await user.click(titleHeader)
155
+ await user.click(screen.getByText('Title'))
159
156
 
160
- const rows = screen.getAllByRole('row')
161
- // Should be descending now
162
- expect(rows[1]).toHaveTextContent('Third Post')
163
- expect(rows[2]).toHaveTextContent('Second Post')
164
- expect(rows[3]).toHaveTextContent('First Post')
157
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=title%3Adesc&page=1')
165
158
  })
166
159
 
167
160
  it('should show sort indicator on active column', async () => {
168
- const user = userEvent.setup()
169
- render(<ListViewClient {...defaultProps} />)
170
-
171
- await user.click(screen.getByText('Title'))
161
+ render(
162
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
163
+ )
172
164
 
173
165
  expect(screen.getByText('↑')).toBeInTheDocument()
174
166
  })
175
167
 
176
- it('should sort numeric fields correctly', async () => {
168
+ it('should navigate with asc when switching to a different column', async () => {
177
169
  const user = userEvent.setup()
178
- render(<ListViewClient {...defaultProps} />)
170
+ render(
171
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
172
+ )
179
173
 
180
174
  await user.click(screen.getByText('Views'))
181
175
 
182
- const rows = screen.getAllByRole('row')
183
- // Should sort by views: 100, 200, 50 (string comparison)
184
- // Note: The current implementation sorts as strings, not numbers
185
- expect(rows[1]).toHaveTextContent('100')
186
- expect(rows[2]).toHaveTextContent('200')
187
- expect(rows[3]).toHaveTextContent('50')
176
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=views%3Aasc&page=1')
188
177
  })
189
178
  })
190
179
 
@@ -241,6 +230,22 @@ describe('ListViewClient', () => {
241
230
  expect(mockPush).toHaveBeenCalledWith('/admin/post')
242
231
  })
243
232
 
233
+ it('should preserve active sort when search is cleared', async () => {
234
+ const user = userEvent.setup()
235
+ render(
236
+ <ListViewClient
237
+ {...defaultProps}
238
+ search="existing"
239
+ initialSort={{ field: 'title', direction: 'desc' }}
240
+ />,
241
+ )
242
+
243
+ const clearButton = screen.getByText('✕')
244
+ await user.click(clearButton)
245
+
246
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=title%3Adesc')
247
+ })
248
+
244
249
  it('should reset to page 1 when new search submitted', async () => {
245
250
  const user = userEvent.setup()
246
251
  render(<ListViewClient {...defaultProps} page={3} />)
@@ -324,6 +329,24 @@ describe('ListViewClient', () => {
324
329
 
325
330
  expect(mockPush).toHaveBeenCalledWith('/admin/post?search=test&page=2')
326
331
  })
332
+
333
+ it('should preserve sort in pagination URLs', async () => {
334
+ const user = userEvent.setup()
335
+ render(
336
+ <ListViewClient
337
+ {...defaultProps}
338
+ total={100}
339
+ pageSize={10}
340
+ page={1}
341
+ initialSort={{ field: 'title', direction: 'desc' }}
342
+ />,
343
+ )
344
+
345
+ const nextButton = screen.getByRole('button', { name: /next/i })
346
+ await user.click(nextButton)
347
+
348
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=title%3Adesc&page=2')
349
+ })
327
350
  })
328
351
 
329
352
  describe('relationships', () => {