@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +43 -0
- package/dist/components/AdminUI.d.ts.map +1 -1
- package/dist/components/AdminUI.js +19 -1
- package/dist/components/ListView.d.ts +19 -1
- package/dist/components/ListView.d.ts.map +1 -1
- package/dist/components/ListView.js +8 -3
- package/dist/components/ListViewClient.d.ts +10 -1
- package/dist/components/ListViewClient.d.ts.map +1 -1
- package/dist/components/ListViewClient.js +25 -28
- package/package.json +3 -3
- package/src/components/AdminUI.tsx +23 -0
- package/src/components/ListView.tsx +29 -0
- package/src/components/ListViewClient.tsx +32 -26
- package/tests/components/AdminUIListView.test.tsx +230 -0
- package/tests/components/ListViewClient.test.tsx +110 -27
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
|
|
2
|
-
> @opensaas/stack-ui@0.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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;
|
|
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":"
|
|
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
|
|
20
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
53
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
168
|
+
it('should navigate with asc when switching to a different column', async () => {
|
|
117
169
|
const user = userEvent.setup()
|
|
118
|
-
render(
|
|
170
|
+
render(
|
|
171
|
+
<ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
|
|
172
|
+
)
|
|
119
173
|
|
|
120
174
|
await user.click(screen.getByText('Views'))
|
|
121
175
|
|
|
122
|
-
|
|
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', () => {
|