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