@opensaas/stack-ui 0.23.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.23.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.23.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,48 @@
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
+
13
+ ## 0.24.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#552](https://github.com/OpenSaasAU/stack/pull/552) [`66496b4`](https://github.com/OpenSaasAU/stack/commit/66496b487bae61f3cdea26fcfcaf605caaaa5520) Thanks [@borisno2](https://github.com/borisno2)! - Add list-level `ui.listView` config (mirroring Keystone) for default columns and sort
18
+
19
+ Lists now support a `ui.listView` block in `opensaas.config.ts` that sets the
20
+ admin list table's default column selection/order and default sort. Naming
21
+ mirrors Keystone's `ui.listView` so migrators can map defaults directly.
22
+
23
+ ```typescript
24
+ lists: {
25
+ Post: list({
26
+ fields: {
27
+ title: text(),
28
+ status: text(),
29
+ createdAt: timestamp(),
30
+ },
31
+ ui: {
32
+ listView: {
33
+ // Column selection AND order
34
+ initialColumns: ['title', 'status'],
35
+ // Default sort
36
+ initialSort: { field: 'createdAt', direction: 'desc' },
37
+ },
38
+ },
39
+ }),
40
+ }
41
+ ```
42
+
43
+ When `ui.listView` is absent, behaviour is unchanged: the table shows all
44
+ non-system fields and applies no default sort.
45
+
3
46
  ## 0.23.0
4
47
 
5
48
  ### 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,2CA+Fd"}
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"}
@@ -55,7 +55,25 @@ export function AdminUI({ context, config, params = [], searchParams = {}, baseP
55
55
  // List view
56
56
  const search = typeof searchParams.search === 'string' ? searchParams.search : undefined;
57
57
  const page = typeof searchParams.page === 'string' ? parseInt(searchParams.page, 10) : 1;
58
- content = (_jsx(ListView, { context: context, config: config, listKey: listKey, basePath: basePath, search: search, page: page }));
58
+ // Read list-view defaults (column selection/order + default sort) from the
59
+ // list-level `ui.listView` config (mirrors Keystone). When absent, the
60
+ // ListView falls back to its existing defaults (all non-system fields,
61
+ // no default sort).
62
+ const listView = config.lists[listKey]?.ui?.listView;
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 }));
59
77
  }
60
78
  // Generate theme styles if custom theme is configured
61
79
  const themeStyles = config.ui?.theme ? generateThemeCSS(config.ui.theme) : null;
@@ -1,4 +1,12 @@
1
1
  import { type AccessContext, OpenSaasConfig } from '@opensaas/stack-core';
2
+ /**
3
+ * Default sort for the list table, mirroring Keystone's `ui.listView.initialSort`.
4
+ * Plain serializable data so it can cross the server/client boundary.
5
+ */
6
+ export interface ListViewSort {
7
+ field: string;
8
+ direction: 'asc' | 'desc';
9
+ }
2
10
  export interface ListViewProps {
3
11
  context: AccessContext<unknown>;
4
12
  config: OpenSaasConfig;
@@ -8,10 +16,20 @@ export interface ListViewProps {
8
16
  page?: number;
9
17
  pageSize?: number;
10
18
  search?: string;
19
+ /**
20
+ * Default sort from the list's `ui.listView.initialSort` config.
21
+ * Used when no URL sort param is present.
22
+ */
23
+ initialSort?: ListViewSort;
24
+ /**
25
+ * Active sort from the `?sort=field:direction` URL param.
26
+ * Takes precedence over `initialSort`.
27
+ */
28
+ sort?: ListViewSort;
11
29
  }
12
30
  /**
13
31
  * List view component - displays items in a table
14
32
  * Server Component that fetches data and renders client table
15
33
  */
16
- export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, }: 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>;
17
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,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;CAChB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,OAAO,EACP,IAAQ,EACR,QAAa,EACb,MAAM,GACP,EAAE,aAAa,oDA6Hf"}
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, }) {
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, 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
  }
@@ -3,6 +3,15 @@ export interface ListViewClientProps {
3
3
  fieldTypes: Record<string, string>;
4
4
  relationshipRefs: Record<string, string>;
5
5
  columns?: string[];
6
+ /**
7
+ * Default sort for the table (from the list's `ui.listView.initialSort`).
8
+ * Seeds the initial sort column/direction. When omitted, the table starts
9
+ * unsorted (current default behaviour).
10
+ */
11
+ initialSort?: {
12
+ field: string;
13
+ direction: 'asc' | 'desc';
14
+ };
6
15
  listKey: string;
7
16
  urlKey: string;
8
17
  basePath: string;
@@ -15,5 +24,5 @@ export interface ListViewClientProps {
15
24
  * Client component for interactive list table
16
25
  * Handles sorting, pagination, and row interactions
17
26
  */
18
- export declare function ListViewClient({ items, fieldTypes, relationshipRefs, columns, urlKey, basePath, page, pageSize, total, search: initialSearch, }: ListViewClientProps): import("react/jsx-runtime").JSX.Element;
27
+ export declare function ListViewClient({ items, fieldTypes, relationshipRefs, columns, initialSort, urlKey, basePath, page, pageSize, total, search: initialSearch, }: ListViewClientProps): import("react/jsx-runtime").JSX.Element;
19
28
  //# sourceMappingURL=ListViewClient.d.ts.map
@@ -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,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,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';
@@ -14,40 +13,27 @@ import { getUrlKey } from '@opensaas/stack-core';
14
13
  * Client component for interactive list table
15
14
  * Handles sorting, pagination, and row interactions
16
15
  */
17
- export function ListViewClient({ items, fieldTypes, relationshipRefs, columns, urlKey, basePath, page, pageSize, total, search: initialSearch, }) {
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(null);
20
- const [sortOrder, setSortOrder] = useState('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, u
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, u
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.23.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.23.0"
97
+ "@opensaas/stack-core": "0.25.0"
98
98
  },
99
99
  "scripts": {
100
100
  "build": "tsc && npm run build:css",
@@ -108,6 +108,26 @@ export function AdminUI({
108
108
  const search = typeof searchParams.search === 'string' ? searchParams.search : undefined
109
109
  const page = typeof searchParams.page === 'string' ? parseInt(searchParams.page, 10) : 1
110
110
 
111
+ // Read list-view defaults (column selection/order + default sort) from the
112
+ // list-level `ui.listView` config (mirrors Keystone). When absent, the
113
+ // ListView falls back to its existing defaults (all non-system fields,
114
+ // no default sort).
115
+ const listView = config.lists[listKey]?.ui?.listView
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
+
111
131
  content = (
112
132
  <ListView
113
133
  context={context}
@@ -116,6 +136,9 @@ export function AdminUI({
116
136
  basePath={basePath}
117
137
  search={search}
118
138
  page={page}
139
+ columns={listView?.initialColumns}
140
+ initialSort={listView?.initialSort}
141
+ sort={urlSort}
119
142
  />
120
143
  )
121
144
  }
@@ -3,6 +3,15 @@ import { ListViewClient } from './ListViewClient.js'
3
3
  import { formatListName } from '../lib/utils.js'
4
4
  import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
5
5
 
6
+ /**
7
+ * Default sort for the list table, mirroring Keystone's `ui.listView.initialSort`.
8
+ * Plain serializable data so it can cross the server/client boundary.
9
+ */
10
+ export interface ListViewSort {
11
+ field: string
12
+ direction: 'asc' | 'desc'
13
+ }
14
+
6
15
  export interface ListViewProps {
7
16
  context: AccessContext<unknown>
8
17
  config: OpenSaasConfig
@@ -12,6 +21,16 @@ export interface ListViewProps {
12
21
  page?: number
13
22
  pageSize?: number
14
23
  search?: string
24
+ /**
25
+ * Default sort from the list's `ui.listView.initialSort` config.
26
+ * Used when no URL sort param is present.
27
+ */
28
+ initialSort?: ListViewSort
29
+ /**
30
+ * Active sort from the `?sort=field:direction` URL param.
31
+ * Takes precedence over `initialSort`.
32
+ */
33
+ sort?: ListViewSort
15
34
  }
16
35
 
17
36
  /**
@@ -27,6 +46,8 @@ export async function ListView({
27
46
  page = 1,
28
47
  pageSize = 50,
29
48
  search,
49
+ initialSort,
50
+ sort,
30
51
  }: ListViewProps) {
31
52
  const key = getDbKey(listKey)
32
53
  const urlKey = getUrlKey(listKey)
@@ -43,6 +64,11 @@ export async function ListView({
43
64
  )
44
65
  }
45
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
+
46
72
  // Fetch items using access-controlled context
47
73
  const skip = (page - 1) * pageSize
48
74
  let items: Array<Record<string, unknown>> = []
@@ -82,9 +108,11 @@ export async function ListView({
82
108
  })
83
109
  const delegate = dbContext[key]
84
110
  if (delegate?.findMany && delegate?.count) {
111
+ const orderBy = activeSort ? { [activeSort.field]: activeSort.direction } : undefined
85
112
  ;[items, total] = await Promise.all([
86
113
  delegate.findMany({
87
114
  where,
115
+ orderBy,
88
116
  skip,
89
117
  take: pageSize,
90
118
  ...(Object.keys(include).length > 0 ? { include } : {}),
@@ -142,6 +170,7 @@ export async function ListView({
142
170
  )}
143
171
  relationshipRefs={relationshipRefs}
144
172
  columns={columns}
173
+ initialSort={activeSort}
145
174
  listKey={listKey}
146
175
  urlKey={urlKey}
147
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'
@@ -23,6 +22,12 @@ export interface ListViewClientProps {
23
22
  fieldTypes: Record<string, string>
24
23
  relationshipRefs: Record<string, string>
25
24
  columns?: string[]
25
+ /**
26
+ * Default sort for the table (from the list's `ui.listView.initialSort`).
27
+ * Seeds the initial sort column/direction. When omitted, the table starts
28
+ * unsorted (current default behaviour).
29
+ */
30
+ initialSort?: { field: string; direction: 'asc' | 'desc' }
26
31
  listKey: string
27
32
  urlKey: string
28
33
  basePath: string
@@ -41,6 +46,7 @@ export function ListViewClient({
41
46
  fieldTypes,
42
47
  relationshipRefs,
43
48
  columns,
49
+ initialSort,
44
50
  urlKey,
45
51
  basePath,
46
52
  page,
@@ -49,41 +55,30 @@ export function ListViewClient({
49
55
  search: initialSearch,
50
56
  }: ListViewClientProps) {
51
57
  const router = useRouter()
52
- const [sortBy, setSortBy] = useState<string | null>(null)
53
- const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
54
- const [searchInput, setSearchInput] = useState(initialSearch || '')
58
+ const sortBy = initialSort?.field ?? null
59
+ const sortOrder = initialSort?.direction ?? 'asc'
60
+ const [searchInput, setSearchInput] = React.useState(initialSearch || '')
55
61
 
56
62
  // Determine which columns to show
57
63
  const displayColumns =
58
64
  columns ||
59
65
  Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key))
60
66
 
61
- // Sort items if needed
62
- const sortedItems = [...items]
63
- if (sortBy) {
64
- sortedItems.sort((a, b) => {
65
- const aVal = a[sortBy]
66
- const bVal = b[sortBy]
67
- if (aVal === bVal) return 0
68
- // Handle unknown types for comparison - convert to string for safety
69
- const aStr = String(aVal ?? '')
70
- const bStr = String(bVal ?? '')
71
- const comparison = aStr > bStr ? 1 : -1
72
- return sortOrder === 'asc' ? comparison : -comparison
73
- })
74
- }
67
+ // Items are already sorted by the server via orderBy; no in-memory sort needed.
75
68
 
76
69
  const totalPages = Math.ceil(total / pageSize)
77
70
  const hasNextPage = page < totalPages
78
71
  const hasPrevPage = page > 1
79
72
 
80
73
  const handleSort = (column: string) => {
81
- if (sortBy === column) {
82
- setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
83
- } else {
84
- setSortBy(column)
85
- 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)
86
78
  }
79
+ params.set('sort', `${column}:${newDirection}`)
80
+ params.set('page', '1')
81
+ router.push(`${basePath}/${urlKey}?${params.toString()}`)
87
82
  }
88
83
 
89
84
  const handleSearch = (e: React.FormEvent) => {
@@ -92,13 +87,21 @@ export function ListViewClient({
92
87
  if (searchInput.trim()) {
93
88
  params.set('search', searchInput.trim())
94
89
  }
90
+ if (sortBy) {
91
+ params.set('sort', `${sortBy}:${sortOrder}`)
92
+ }
95
93
  params.set('page', '1') // Reset to page 1 on new search
96
94
  router.push(`${basePath}/${urlKey}?${params.toString()}`)
97
95
  }
98
96
 
99
97
  const handleClearSearch = () => {
100
98
  setSearchInput('')
101
- 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}` : ''}`)
102
105
  }
103
106
 
104
107
  const buildPaginationUrl = (newPage: number) => {
@@ -106,6 +109,9 @@ export function ListViewClient({
106
109
  if (initialSearch) {
107
110
  params.set('search', initialSearch)
108
111
  }
112
+ if (sortBy) {
113
+ params.set('sort', `${sortBy}:${sortOrder}`)
114
+ }
109
115
  params.set('page', newPage.toString())
110
116
  return `${basePath}/${urlKey}?${params.toString()}`
111
117
  }
@@ -218,14 +224,14 @@ export function ListViewClient({
218
224
  </TableRow>
219
225
  </TableHeader>
220
226
  <TableBody>
221
- {sortedItems.length === 0 ? (
227
+ {items.length === 0 ? (
222
228
  <TableRow>
223
229
  <TableCell colSpan={displayColumns.length + 1} className="h-24 text-center">
224
230
  No items found
225
231
  </TableCell>
226
232
  </TableRow>
227
233
  ) : (
228
- sortedItems.map((item) => (
234
+ items.map((item) => (
229
235
  <TableRow key={String(item.id)}>
230
236
  {displayColumns.map((column) => (
231
237
  <TableCell key={column}>
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import * as React from 'react'
3
+ import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { list } from '@opensaas/stack-core'
5
+ import { text, timestamp } from '@opensaas/stack-core/fields'
6
+ import { AdminUI } from '../../src/components/AdminUI.js'
7
+ import { ListView } from '../../src/components/ListView.js'
8
+
9
+ // Mock Next.js navigation — client components call useRouter().
10
+ const mockPush = vi.fn()
11
+ const mockRefresh = vi.fn()
12
+ vi.mock('next/navigation.js', () => ({
13
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
14
+ }))
15
+
16
+ vi.mock('next/link.js', () => ({
17
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
18
+ <a href={href}>{children}</a>
19
+ ),
20
+ }))
21
+
22
+ interface DelegateStub {
23
+ findMany?: (args: unknown) => Promise<Array<Record<string, unknown>>>
24
+ count?: (args: unknown) => Promise<number>
25
+ }
26
+
27
+ function makeContext(delegates: Record<string, DelegateStub>): AccessContext<unknown> {
28
+ const context = {
29
+ db: delegates,
30
+ session: null,
31
+ storage: {},
32
+ plugins: {},
33
+ _isSudo: false,
34
+ _resolveOutputCounter: { depth: 0 },
35
+ }
36
+ return context as unknown as AccessContext<unknown>
37
+ }
38
+
39
+ const noopServerAction = vi.fn(async () => ({ success: true }))
40
+
41
+ /**
42
+ * AdminUI returns <> {style?} <div><Navigation/><main>{content}</main></div> </>.
43
+ * Drill into <main> to recover the routed content element (the <ListView /> the
44
+ * router chose for the bare [list] route) so we can inspect the props AdminUI
45
+ * passed to it.
46
+ */
47
+ function routedContent(tree: React.ReactNode): React.ReactElement {
48
+ const fragment = tree as React.ReactElement<{ children: React.ReactNode }>
49
+ const children = React.Children.toArray(fragment.props.children)
50
+ const wrapper = children.find(
51
+ (child): child is React.ReactElement<{ children: React.ReactNode }> =>
52
+ React.isValidElement(child) && child.type === 'div',
53
+ )
54
+ if (!wrapper) throw new Error('AdminUI layout wrapper not found')
55
+ const main = React.Children.toArray(wrapper.props.children).find(
56
+ (child): child is React.ReactElement<{ children: React.ReactNode }> =>
57
+ React.isValidElement(child) && child.type === 'main',
58
+ )
59
+ if (!main) throw new Error('AdminUI <main> not found')
60
+ return main.props.children as React.ReactElement
61
+ }
62
+
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
+
160
+ it('passes initialColumns + initialSort from ui.listView to ListView', async () => {
161
+ const config: OpenSaasConfig = {
162
+ db: { provider: 'sqlite', url: 'file:./test.db' },
163
+ lists: {
164
+ Post: list({
165
+ fields: {
166
+ title: text(),
167
+ status: text(),
168
+ createdAt: timestamp(),
169
+ },
170
+ ui: {
171
+ listView: {
172
+ initialColumns: ['title', 'status'],
173
+ initialSort: { field: 'createdAt', direction: 'desc' },
174
+ },
175
+ },
176
+ }),
177
+ },
178
+ }
179
+
180
+ const context = makeContext({
181
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
182
+ })
183
+
184
+ const tree = await AdminUI({
185
+ context,
186
+ config,
187
+ params: ['post'],
188
+ basePath: '/admin',
189
+ serverAction: noopServerAction,
190
+ })
191
+
192
+ const content = routedContent(tree)
193
+ expect(content.type).toBe(ListView)
194
+ // initialColumns drives the `columns` prop (selection + order).
195
+ expect(content.props.columns).toEqual(['title', 'status'])
196
+ // initialSort flows through as the default sort.
197
+ expect(content.props.initialSort).toEqual({ field: 'createdAt', direction: 'desc' })
198
+ })
199
+
200
+ it('passes undefined columns + initialSort when ui.listView is absent', async () => {
201
+ const config: OpenSaasConfig = {
202
+ db: { provider: 'sqlite', url: 'file:./test.db' },
203
+ lists: {
204
+ Post: list({
205
+ fields: {
206
+ title: text(),
207
+ },
208
+ }),
209
+ },
210
+ }
211
+
212
+ const context = makeContext({
213
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
214
+ })
215
+
216
+ const tree = await AdminUI({
217
+ context,
218
+ config,
219
+ params: ['post'],
220
+ basePath: '/admin',
221
+ serverAction: noopServerAction,
222
+ })
223
+
224
+ const content = routedContent(tree)
225
+ expect(content.type).toBe(ListView)
226
+ // Absent config → current behaviour unchanged (no column override, no default sort).
227
+ expect(content.props.columns).toBeUndefined()
228
+ expect(content.props.initialSort).toBeUndefined()
229
+ })
230
+ })
@@ -73,58 +73,107 @@ describe('ListViewClient', () => {
73
73
  expect(screen.getByText('Status')).toBeInTheDocument()
74
74
  expect(screen.queryByText('Views')).not.toBeInTheDocument()
75
75
  })
76
- })
77
76
 
78
- describe('sorting', () => {
79
- it('should sort items ascending when header clicked', async () => {
80
- const user = userEvent.setup()
81
- render(<ListViewClient {...defaultProps} />)
77
+ it('should render columns in the provided order (initialColumns)', () => {
78
+ // initialColumns flows through AdminUI as the `columns` prop and drives
79
+ // both selection AND order.
80
+ render(<ListViewClient {...defaultProps} columns={['views', 'title']} />)
82
81
 
83
- await user.click(screen.getByText('Title'))
82
+ const headers = screen.getAllByRole('columnheader')
83
+ // First two headers should follow the provided order, then Actions.
84
+ expect(headers[0]).toHaveTextContent('Views')
85
+ expect(headers[1]).toHaveTextContent('Title')
86
+ expect(headers[2]).toHaveTextContent('Actions')
87
+ // Unlisted column is excluded.
88
+ expect(screen.queryByText('Status')).not.toBeInTheDocument()
89
+ })
90
+ })
84
91
 
85
- const rows = screen.getAllByRole('row')
86
- // First row is header, so data rows start at index 1
87
- expect(rows[1]).toHaveTextContent('First Post')
88
- expect(rows[2]).toHaveTextContent('Second Post')
89
- expect(rows[3]).toHaveTextContent('Third Post')
92
+ describe('initialSort', () => {
93
+ it('should show sort indicator on the initialSort column (asc)', () => {
94
+ render(
95
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
96
+ )
97
+
98
+ expect(screen.getByText('↑')).toBeInTheDocument()
90
99
  })
91
100
 
92
- it('should toggle sort order when same header clicked twice', async () => {
93
- const user = userEvent.setup()
94
- render(<ListViewClient {...defaultProps} />)
101
+ it('should show sort indicator on the initialSort column (desc)', () => {
102
+ render(
103
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
104
+ )
95
105
 
96
- const titleHeader = screen.getByText('Title')
97
- await user.click(titleHeader)
98
- await user.click(titleHeader)
106
+ expect(screen.getByText('')).toBeInTheDocument()
107
+ })
108
+
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
+ ]
116
+ render(
117
+ <ListViewClient
118
+ {...defaultProps}
119
+ items={sortedItems}
120
+ initialSort={{ field: 'title', direction: 'desc' }}
121
+ />,
122
+ )
99
123
 
100
124
  const rows = screen.getAllByRole('row')
101
- // Should be descending now
102
125
  expect(rows[1]).toHaveTextContent('Third Post')
103
126
  expect(rows[2]).toHaveTextContent('Second Post')
104
127
  expect(rows[3]).toHaveTextContent('First Post')
105
128
  })
106
129
 
107
- it('should show sort indicator on active column', async () => {
130
+ it('should not apply any default sort when initialSort is absent', () => {
131
+ render(<ListViewClient {...defaultProps} />)
132
+
133
+ // No sort indicator when no initialSort.
134
+ expect(screen.queryByText('↑')).not.toBeInTheDocument()
135
+ expect(screen.queryByText('↓')).not.toBeInTheDocument()
136
+ })
137
+ })
138
+
139
+ describe('sorting', () => {
140
+ it('should navigate with sort param when column header clicked', async () => {
108
141
  const user = userEvent.setup()
109
142
  render(<ListViewClient {...defaultProps} />)
110
143
 
111
144
  await user.click(screen.getByText('Title'))
112
145
 
146
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=title%3Aasc&page=1')
147
+ })
148
+
149
+ it('should navigate with toggled direction when already-sorted column clicked', async () => {
150
+ const user = userEvent.setup()
151
+ render(
152
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
153
+ )
154
+
155
+ await user.click(screen.getByText('Title'))
156
+
157
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=title%3Adesc&page=1')
158
+ })
159
+
160
+ it('should show sort indicator on active column', async () => {
161
+ render(
162
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
163
+ )
164
+
113
165
  expect(screen.getByText('↑')).toBeInTheDocument()
114
166
  })
115
167
 
116
- it('should sort numeric fields correctly', async () => {
168
+ it('should navigate with asc when switching to a different column', async () => {
117
169
  const user = userEvent.setup()
118
- render(<ListViewClient {...defaultProps} />)
170
+ render(
171
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
172
+ )
119
173
 
120
174
  await user.click(screen.getByText('Views'))
121
175
 
122
- const rows = screen.getAllByRole('row')
123
- // Should sort by views: 100, 200, 50 (string comparison)
124
- // Note: The current implementation sorts as strings, not numbers
125
- expect(rows[1]).toHaveTextContent('100')
126
- expect(rows[2]).toHaveTextContent('200')
127
- expect(rows[3]).toHaveTextContent('50')
176
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?sort=views%3Aasc&page=1')
128
177
  })
129
178
  })
130
179
 
@@ -181,6 +230,22 @@ describe('ListViewClient', () => {
181
230
  expect(mockPush).toHaveBeenCalledWith('/admin/post')
182
231
  })
183
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
+
184
249
  it('should reset to page 1 when new search submitted', async () => {
185
250
  const user = userEvent.setup()
186
251
  render(<ListViewClient {...defaultProps} page={3} />)
@@ -264,6 +329,24 @@ describe('ListViewClient', () => {
264
329
 
265
330
  expect(mockPush).toHaveBeenCalledWith('/admin/post?search=test&page=2')
266
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
+ })
267
350
  })
268
351
 
269
352
  describe('relationships', () => {