@opensaas/stack-ui 0.23.0 → 0.24.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.24.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.24.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,38 @@
1
1
  # @opensaas/stack-ui
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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
8
+
9
+ Lists now support a `ui.listView` block in `opensaas.config.ts` that sets the
10
+ admin list table's default column selection/order and default sort. Naming
11
+ mirrors Keystone's `ui.listView` so migrators can map defaults directly.
12
+
13
+ ```typescript
14
+ lists: {
15
+ Post: list({
16
+ fields: {
17
+ title: text(),
18
+ status: text(),
19
+ createdAt: timestamp(),
20
+ },
21
+ ui: {
22
+ listView: {
23
+ // Column selection AND order
24
+ initialColumns: ['title', 'status'],
25
+ // Default sort
26
+ initialSort: { field: 'createdAt', direction: 'desc' },
27
+ },
28
+ },
29
+ }),
30
+ }
31
+ ```
32
+
33
+ When `ui.listView` is absent, behaviour is unchanged: the table shows all
34
+ non-system fields and applies no default sort.
35
+
3
36
  ## 0.23.0
4
37
 
5
38
  ### 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,2CAuGd"}
@@ -55,7 +55,12 @@ 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
+ content = (_jsx(ListView, { context: context, config: config, listKey: listKey, basePath: basePath, search: search, page: page, columns: listView?.initialColumns, initialSort: listView?.initialSort }));
59
64
  }
60
65
  // Generate theme styles if custom theme is configured
61
66
  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,15 @@ export interface ListViewProps {
8
16
  page?: number;
9
17
  pageSize?: number;
10
18
  search?: string;
19
+ /**
20
+ * Default sort applied to the table (from the list's `ui.listView.initialSort`).
21
+ * When omitted, no default sort is applied.
22
+ */
23
+ initialSort?: ListViewSort;
11
24
  }
12
25
  /**
13
26
  * List view component - displays items in a table
14
27
  * Server Component that fetches data and renders client table
15
28
  */
16
- export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, }: ListViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
29
+ export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, initialSort, }: ListViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
17
30
  //# 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;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"}
@@ -7,7 +7,7 @@ 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, }) {
11
11
  const key = getDbKey(listKey);
12
12
  const urlKey = getUrlKey(listKey);
13
13
  const listConfig = config.lists[listKey];
@@ -79,5 +79,5 @@ export async function ListView({ context, config, listKey, basePath = '/admin',
79
79
  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
80
  key,
81
81
  field.type,
82
- ])), relationshipRefs: relationshipRefs, columns: columns, listKey: listKey, urlKey: urlKey, basePath: basePath, page: page, pageSize: pageSize, total: total || 0, search: search })] }));
82
+ ])), relationshipRefs: relationshipRefs, columns: columns, initialSort: initialSort, listKey: listKey, urlKey: urlKey, basePath: basePath, page: page, pageSize: pageSize, total: total || 0, search: search })] }));
83
83
  }
@@ -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":"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"}
@@ -14,10 +14,10 @@ import { getUrlKey } from '@opensaas/stack-core';
14
14
  * Client component for interactive list table
15
15
  * Handles sorting, pagination, and row interactions
16
16
  */
17
- export function ListViewClient({ items, fieldTypes, relationshipRefs, columns, urlKey, basePath, page, pageSize, total, search: initialSearch, }) {
17
+ export function ListViewClient({ items, fieldTypes, relationshipRefs, columns, initialSort, urlKey, basePath, page, pageSize, total, search: initialSearch, }) {
18
18
  const router = useRouter();
19
- const [sortBy, setSortBy] = useState(null);
20
- const [sortOrder, setSortOrder] = useState('asc');
19
+ const [sortBy, setSortBy] = useState(initialSort?.field ?? null);
20
+ const [sortOrder, setSortOrder] = useState(initialSort?.direction ?? 'asc');
21
21
  const [searchInput, setSearchInput] = useState(initialSearch || '');
22
22
  // Determine which columns to show
23
23
  const displayColumns = columns ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-ui",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Composable React UI components for OpenSaas Stack",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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.24.0"
98
98
  },
99
99
  "scripts": {
100
100
  "build": "tsc && npm run build:css",
@@ -108,6 +108,12 @@ 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
+
111
117
  content = (
112
118
  <ListView
113
119
  context={context}
@@ -116,6 +122,8 @@ export function AdminUI({
116
122
  basePath={basePath}
117
123
  search={search}
118
124
  page={page}
125
+ columns={listView?.initialColumns}
126
+ initialSort={listView?.initialSort}
119
127
  />
120
128
  )
121
129
  }
@@ -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,11 @@ export interface ListViewProps {
12
21
  page?: number
13
22
  pageSize?: number
14
23
  search?: string
24
+ /**
25
+ * Default sort applied to the table (from the list's `ui.listView.initialSort`).
26
+ * When omitted, no default sort is applied.
27
+ */
28
+ initialSort?: ListViewSort
15
29
  }
16
30
 
17
31
  /**
@@ -27,6 +41,7 @@ export async function ListView({
27
41
  page = 1,
28
42
  pageSize = 50,
29
43
  search,
44
+ initialSort,
30
45
  }: ListViewProps) {
31
46
  const key = getDbKey(listKey)
32
47
  const urlKey = getUrlKey(listKey)
@@ -142,6 +157,7 @@ export async function ListView({
142
157
  )}
143
158
  relationshipRefs={relationshipRefs}
144
159
  columns={columns}
160
+ initialSort={initialSort}
145
161
  listKey={listKey}
146
162
  urlKey={urlKey}
147
163
  basePath={basePath}
@@ -23,6 +23,12 @@ export interface ListViewClientProps {
23
23
  fieldTypes: Record<string, string>
24
24
  relationshipRefs: Record<string, string>
25
25
  columns?: string[]
26
+ /**
27
+ * Default sort for the table (from the list's `ui.listView.initialSort`).
28
+ * Seeds the initial sort column/direction. When omitted, the table starts
29
+ * unsorted (current default behaviour).
30
+ */
31
+ initialSort?: { field: string; direction: 'asc' | 'desc' }
26
32
  listKey: string
27
33
  urlKey: string
28
34
  basePath: string
@@ -41,6 +47,7 @@ export function ListViewClient({
41
47
  fieldTypes,
42
48
  relationshipRefs,
43
49
  columns,
50
+ initialSort,
44
51
  urlKey,
45
52
  basePath,
46
53
  page,
@@ -49,8 +56,8 @@ export function ListViewClient({
49
56
  search: initialSearch,
50
57
  }: ListViewClientProps) {
51
58
  const router = useRouter()
52
- const [sortBy, setSortBy] = useState<string | null>(null)
53
- const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
59
+ const [sortBy, setSortBy] = useState<string | null>(initialSort?.field ?? null)
60
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialSort?.direction ?? 'asc')
54
61
  const [searchInput, setSearchInput] = useState(initialSearch || '')
55
62
 
56
63
  // Determine which columns to show
@@ -0,0 +1,134 @@
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 initialColumns + initialSort from ui.listView to ListView', async () => {
65
+ const config: OpenSaasConfig = {
66
+ db: { provider: 'sqlite', url: 'file:./test.db' },
67
+ lists: {
68
+ Post: list({
69
+ fields: {
70
+ title: text(),
71
+ status: text(),
72
+ createdAt: timestamp(),
73
+ },
74
+ ui: {
75
+ listView: {
76
+ initialColumns: ['title', 'status'],
77
+ initialSort: { field: 'createdAt', direction: 'desc' },
78
+ },
79
+ },
80
+ }),
81
+ },
82
+ }
83
+
84
+ const context = makeContext({
85
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
86
+ })
87
+
88
+ const tree = await AdminUI({
89
+ context,
90
+ config,
91
+ params: ['post'],
92
+ basePath: '/admin',
93
+ serverAction: noopServerAction,
94
+ })
95
+
96
+ const content = routedContent(tree)
97
+ expect(content.type).toBe(ListView)
98
+ // initialColumns drives the `columns` prop (selection + order).
99
+ expect(content.props.columns).toEqual(['title', 'status'])
100
+ // initialSort flows through as the default sort.
101
+ expect(content.props.initialSort).toEqual({ field: 'createdAt', direction: 'desc' })
102
+ })
103
+
104
+ it('passes undefined columns + initialSort when ui.listView is absent', async () => {
105
+ const config: OpenSaasConfig = {
106
+ db: { provider: 'sqlite', url: 'file:./test.db' },
107
+ lists: {
108
+ Post: list({
109
+ fields: {
110
+ title: text(),
111
+ },
112
+ }),
113
+ },
114
+ }
115
+
116
+ const context = makeContext({
117
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
118
+ })
119
+
120
+ const tree = await AdminUI({
121
+ context,
122
+ config,
123
+ params: ['post'],
124
+ basePath: '/admin',
125
+ serverAction: noopServerAction,
126
+ })
127
+
128
+ const content = routedContent(tree)
129
+ expect(content.type).toBe(ListView)
130
+ // Absent config → current behaviour unchanged (no column override, no default sort).
131
+ expect(content.props.columns).toBeUndefined()
132
+ expect(content.props.initialSort).toBeUndefined()
133
+ })
134
+ })
@@ -73,6 +73,66 @@ describe('ListViewClient', () => {
73
73
  expect(screen.getByText('Status')).toBeInTheDocument()
74
74
  expect(screen.queryByText('Views')).not.toBeInTheDocument()
75
75
  })
76
+
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']} />)
81
+
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
+ })
91
+
92
+ describe('initialSort', () => {
93
+ it('should apply default sort from initialSort without any click', () => {
94
+ render(
95
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
96
+ )
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')
103
+ })
104
+
105
+ it('should respect descending direction from initialSort', () => {
106
+ render(
107
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
108
+ )
109
+
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')
115
+ })
116
+
117
+ it('should show sort indicator on the initialSort column', () => {
118
+ render(
119
+ <ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
120
+ )
121
+
122
+ expect(screen.getByText('↓')).toBeInTheDocument()
123
+ })
124
+
125
+ it('should not apply any default sort when initialSort is absent', () => {
126
+ render(<ListViewClient {...defaultProps} />)
127
+
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
+ expect(screen.queryByText('↑')).not.toBeInTheDocument()
134
+ expect(screen.queryByText('↓')).not.toBeInTheDocument()
135
+ })
76
136
  })
77
137
 
78
138
  describe('sorting', () => {