@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +33 -0
- package/dist/components/AdminUI.d.ts.map +1 -1
- package/dist/components/AdminUI.js +6 -1
- package/dist/components/ListView.d.ts +14 -1
- package/dist/components/ListView.d.ts.map +1 -1
- package/dist/components/ListView.js +2 -2
- package/dist/components/ListViewClient.d.ts +10 -1
- package/dist/components/ListViewClient.d.ts.map +1 -1
- package/dist/components/ListViewClient.js +3 -3
- package/package.json +2 -2
- package/src/components/AdminUI.tsx +8 -0
- package/src/components/ListView.tsx +16 -0
- package/src/components/ListViewClient.tsx +9 -2
- package/tests/components/AdminUIListView.test.tsx +134 -0
- package/tests/components/ListViewClient.test.tsx +60 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
|
|
2
|
-
> @opensaas/stack-ui@0.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
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', () => {
|