@opensaas/stack-ui 0.22.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 +95 -0
- package/CLAUDE.md +46 -9
- package/README.md +41 -10
- package/dist/components/AdminUI.d.ts +1 -1
- package/dist/components/AdminUI.d.ts.map +1 -1
- package/dist/components/AdminUI.js +23 -3
- package/dist/components/Dashboard.d.ts.map +1 -1
- package/dist/components/Dashboard.js +13 -4
- package/dist/components/ItemForm.d.ts.map +1 -1
- package/dist/components/ItemForm.js +6 -65
- package/dist/components/ItemFormClient.d.ts +8 -1
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +2 -2
- 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/dist/components/Navigation.d.ts.map +1 -1
- package/dist/components/Navigation.js +12 -1
- package/dist/components/SingletonView.d.ts +37 -0
- package/dist/components/SingletonView.d.ts.map +1 -0
- package/dist/components/SingletonView.js +82 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lib/operationAccess.d.ts +34 -0
- package/dist/lib/operationAccess.d.ts.map +1 -0
- package/dist/lib/operationAccess.js +43 -0
- package/dist/lib/prepareItemForm.d.ts +35 -0
- package/dist/lib/prepareItemForm.d.ts.map +1 -0
- package/dist/lib/prepareItemForm.js +85 -0
- package/dist/styles/globals.css +12 -0
- package/package.json +2 -2
- package/src/components/AdminUI.tsx +36 -2
- package/src/components/Dashboard.tsx +108 -5
- package/src/components/ItemForm.tsx +11 -77
- package/src/components/ItemFormClient.tsx +10 -2
- package/src/components/ListView.tsx +16 -0
- package/src/components/ListViewClient.tsx +9 -2
- package/src/components/Navigation.tsx +58 -1
- package/src/components/SingletonView.tsx +228 -0
- package/src/index.ts +2 -0
- package/src/lib/operationAccess.ts +53 -0
- package/src/lib/prepareItemForm.ts +121 -0
- package/tests/components/AdminUIListView.test.tsx +134 -0
- package/tests/components/AdminUISingleton.test.tsx +296 -0
- package/tests/components/AdminUISingletonSuppress.test.tsx +259 -0
- package/tests/components/ListViewClient.test.tsx +60 -0
- package/tests/components/SingletonNavDashboard.test.tsx +141 -0
|
@@ -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 ||
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../src/components/Navigation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAa,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAGpF,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,MAAM,EACN,QAAmB,EACnB,WAAgB,EAChB,SAAS,GACV,EAAE,eAAe,
|
|
1
|
+
{"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../src/components/Navigation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAa,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAGpF,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,MAAM,EACN,QAAmB,EACnB,WAAgB,EAChB,SAAS,GACV,EAAE,eAAe,2CA2IjB"}
|
|
@@ -8,7 +8,12 @@ import { UserMenu } from './UserMenu.js';
|
|
|
8
8
|
* Server Component
|
|
9
9
|
*/
|
|
10
10
|
export function Navigation({ context, config, basePath = '/admin', currentPath = '', onSignOut, }) {
|
|
11
|
-
const
|
|
11
|
+
const allLists = Object.keys(config.lists || {});
|
|
12
|
+
// Split lists into standard lists (under "Lists") and singletons (under
|
|
13
|
+
// "Settings"). A singleton edits a single record, so it belongs in a distinct
|
|
14
|
+
// Settings group rather than the standard list grid.
|
|
15
|
+
const lists = allLists.filter((listKey) => !config.lists[listKey]?.isSingleton);
|
|
16
|
+
const singletons = allLists.filter((listKey) => config.lists[listKey]?.isSingleton);
|
|
12
17
|
return (_jsxs("nav", { className: "w-64 border-r border-border bg-card h-screen sticky top-0 flex flex-col", children: [_jsxs("div", { className: "p-6 border-b border-border relative overflow-hidden", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-br from-primary/10 to-accent/10 opacity-50" }), _jsx(Link, { href: basePath, className: "block relative", children: _jsx("h1", { className: "text-xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent", children: "OpenSaas Admin" }) })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-4", children: _jsxs("div", { className: "space-y-1", children: [_jsxs(Link, { href: basePath, className: `block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${currentPath === ''
|
|
13
18
|
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
|
|
14
19
|
: 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'}`, children: [currentPath === '' && (_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" })), _jsxs("span", { className: "relative flex items-center gap-2", children: [_jsx("span", { className: currentPath === '' ? 'text-lg' : 'text-base', children: "\uD83D\uDCCA" }), "Dashboard"] })] }), lists.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "pt-4 pb-2 px-3", children: _jsx("p", { className: "text-xs font-semibold text-muted-foreground uppercase tracking-wider", children: "Lists" }) }), lists.map((listKey) => {
|
|
@@ -17,5 +22,11 @@ export function Navigation({ context, config, basePath = '/admin', currentPath =
|
|
|
17
22
|
return (_jsxs(Link, { href: `${basePath}/${urlKey}`, className: `block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${isActive
|
|
18
23
|
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
|
|
19
24
|
: 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'}`, children: [isActive && (_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" })), _jsxs("span", { className: "relative flex items-center gap-2", children: [_jsx("span", { className: "opacity-60 group-hover:opacity-100 transition-opacity", children: "\uD83D\uDCC1" }), formatListName(listKey)] })] }, listKey));
|
|
25
|
+
})] })), singletons.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "pt-4 pb-2 px-3", children: _jsx("p", { className: "text-xs font-semibold text-muted-foreground uppercase tracking-wider", children: "Settings" }) }), singletons.map((listKey) => {
|
|
26
|
+
const urlKey = getUrlKey(listKey);
|
|
27
|
+
const isActive = currentPath.startsWith(`/${urlKey}`);
|
|
28
|
+
return (_jsxs(Link, { href: `${basePath}/${urlKey}`, className: `block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${isActive
|
|
29
|
+
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
|
|
30
|
+
: 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'}`, children: [isActive && (_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" })), _jsxs("span", { className: "relative flex items-center gap-2", children: [_jsxs("svg", { className: "w-4 h-4 opacity-60 group-hover:opacity-100 transition-opacity", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" }), _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" })] }), formatListName(listKey)] })] }, listKey));
|
|
20
31
|
})] }))] }) }), context.session && (_jsx(UserMenu, { userName: String(context.session.data?.name) || 'User', userEmail: String(context.session.data?.email) || '', onSignOut: onSignOut }))] }));
|
|
21
32
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ServerActionInput } from '../server/types.js';
|
|
2
|
+
import { type AccessContext, OpenSaasConfig } from '@opensaas/stack-core';
|
|
3
|
+
export interface SingletonViewProps {
|
|
4
|
+
context: AccessContext<unknown>;
|
|
5
|
+
config: OpenSaasConfig;
|
|
6
|
+
listKey: string;
|
|
7
|
+
basePath?: string;
|
|
8
|
+
serverAction: (input: ServerActionInput) => Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Singleton editor — renders a single-record edit form for a list configured
|
|
12
|
+
* with `isSingleton: true`.
|
|
13
|
+
*
|
|
14
|
+
* Resolves the record via the singleton `get()` operation (which auto-creates
|
|
15
|
+
* the row with field defaults when absent, unless `autoCreate: false`), then
|
|
16
|
+
* reuses the same `ItemFormClient` + serialization path as `ItemForm` so the
|
|
17
|
+
* existing field rendering, validation, and `serverAction` save flow apply.
|
|
18
|
+
*
|
|
19
|
+
* A `null` from `get()` is ambiguous at the boundary — it means EITHER an
|
|
20
|
+
* `autoCreate: false` singleton with no row yet, OR that `query` access is
|
|
21
|
+
* denied (access-controlled reads return null/[] silently). We disambiguate
|
|
22
|
+
* using the list's operation-level access:
|
|
23
|
+
*
|
|
24
|
+
* - `query` denied → friendly "no access" message (never an editable form).
|
|
25
|
+
* - `query` allowed + `create` allowed → a create-on-first-save form
|
|
26
|
+
* (`ItemFormClient` in `mode="create"`); core assigns the singleton `id` and
|
|
27
|
+
* enforces the single-record constraint on create.
|
|
28
|
+
* - `query` allowed + `create` denied → friendly "no record yet" message.
|
|
29
|
+
*
|
|
30
|
+
* An update-denied singleton still renders the edit form (the happy path), but
|
|
31
|
+
* the save fails gracefully: the server action's `update` access check returns
|
|
32
|
+
* a denied envelope, which `ItemFormClient` surfaces as an error.
|
|
33
|
+
*
|
|
34
|
+
* Server Component that fetches data and sets up actions.
|
|
35
|
+
*/
|
|
36
|
+
export declare function SingletonView({ context, config, listKey, basePath, serverAction, }: SingletonViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
|
|
37
|
+
//# sourceMappingURL=SingletonView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SingletonView.d.ts","sourceRoot":"","sources":["../../src/components/SingletonView.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAI9F,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC7D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,aAAa,CAAC,EAClC,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,YAAY,GACb,EAAE,kBAAkB,oDAiLpB"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Link from 'next/link.js';
|
|
3
|
+
import { ItemFormClient } from './ItemFormClient.js';
|
|
4
|
+
import { formatListName } from '../lib/utils.js';
|
|
5
|
+
import { getDbKey, getUrlKey } from '@opensaas/stack-core';
|
|
6
|
+
import { prepareItemForm } from '../lib/prepareItemForm.js';
|
|
7
|
+
import { isOperationPotentiallyAllowed } from '../lib/operationAccess.js';
|
|
8
|
+
/**
|
|
9
|
+
* Singleton editor — renders a single-record edit form for a list configured
|
|
10
|
+
* with `isSingleton: true`.
|
|
11
|
+
*
|
|
12
|
+
* Resolves the record via the singleton `get()` operation (which auto-creates
|
|
13
|
+
* the row with field defaults when absent, unless `autoCreate: false`), then
|
|
14
|
+
* reuses the same `ItemFormClient` + serialization path as `ItemForm` so the
|
|
15
|
+
* existing field rendering, validation, and `serverAction` save flow apply.
|
|
16
|
+
*
|
|
17
|
+
* A `null` from `get()` is ambiguous at the boundary — it means EITHER an
|
|
18
|
+
* `autoCreate: false` singleton with no row yet, OR that `query` access is
|
|
19
|
+
* denied (access-controlled reads return null/[] silently). We disambiguate
|
|
20
|
+
* using the list's operation-level access:
|
|
21
|
+
*
|
|
22
|
+
* - `query` denied → friendly "no access" message (never an editable form).
|
|
23
|
+
* - `query` allowed + `create` allowed → a create-on-first-save form
|
|
24
|
+
* (`ItemFormClient` in `mode="create"`); core assigns the singleton `id` and
|
|
25
|
+
* enforces the single-record constraint on create.
|
|
26
|
+
* - `query` allowed + `create` denied → friendly "no record yet" message.
|
|
27
|
+
*
|
|
28
|
+
* An update-denied singleton still renders the edit form (the happy path), but
|
|
29
|
+
* the save fails gracefully: the server action's `update` access check returns
|
|
30
|
+
* a denied envelope, which `ItemFormClient` surfaces as an error.
|
|
31
|
+
*
|
|
32
|
+
* Server Component that fetches data and sets up actions.
|
|
33
|
+
*/
|
|
34
|
+
export async function SingletonView({ context, config, listKey, basePath = '/admin', serverAction, }) {
|
|
35
|
+
const listConfig = config.lists[listKey];
|
|
36
|
+
const urlKey = getUrlKey(listKey);
|
|
37
|
+
if (!listConfig) {
|
|
38
|
+
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."] })] }) }));
|
|
39
|
+
}
|
|
40
|
+
// Resolve the singleton record. `get()` auto-creates with field defaults when
|
|
41
|
+
// absent (the default), so a record is the common case. It returns null when
|
|
42
|
+
// either `autoCreate: false` with no row yet, OR `query` access is denied —
|
|
43
|
+
// these are indistinguishable here, so we disambiguate via access below.
|
|
44
|
+
let record = null;
|
|
45
|
+
try {
|
|
46
|
+
const delegate = context.db[getDbKey(listKey)];
|
|
47
|
+
if (delegate?.get) {
|
|
48
|
+
record = await delegate.get();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(`Failed to resolve singleton ${listKey}:`, error);
|
|
53
|
+
}
|
|
54
|
+
if (!record) {
|
|
55
|
+
// A null `get()` is ambiguous (autoCreate:false-empty vs query-denied).
|
|
56
|
+
// Evaluate operation access to choose the safe affordance.
|
|
57
|
+
const accessArgs = { session: context.session, context };
|
|
58
|
+
const canQuery = await isOperationPotentiallyAllowed(listConfig.access?.operation, 'query', accessArgs);
|
|
59
|
+
// Query denied → the session cannot read this singleton at all. Show a
|
|
60
|
+
// friendly message; never an editable/create form.
|
|
61
|
+
if (!canQuery) {
|
|
62
|
+
return (_jsxs("div", { className: "p-8 max-w-4xl", children: [_jsx("div", { className: "mb-8", children: _jsx("h1", { className: "text-3xl font-bold", children: formatListName(listKey) }) }), _jsx("div", { className: "bg-muted/50 border border-border rounded-lg p-6", children: _jsxs("p", { className: "text-muted-foreground", children: ["You don't have access to ", formatListName(listKey), "."] }) })] }));
|
|
63
|
+
}
|
|
64
|
+
// Query allowed but no row → an `autoCreate: false` singleton. Offer a
|
|
65
|
+
// create-on-first-save form only when `create` is actually permitted.
|
|
66
|
+
const canCreate = await isOperationPotentiallyAllowed(listConfig.access?.operation, 'create', accessArgs);
|
|
67
|
+
if (!canCreate) {
|
|
68
|
+
return (_jsxs("div", { className: "p-8 max-w-4xl", children: [_jsx("div", { className: "mb-8", children: _jsx("h1", { className: "text-3xl font-bold", children: formatListName(listKey) }) }), _jsx("div", { className: "bg-muted/50 border border-border rounded-lg p-6", children: _jsxs("p", { className: "text-muted-foreground", children: ["There is no ", formatListName(listKey), " record yet."] }) })] }));
|
|
69
|
+
}
|
|
70
|
+
// Create-on-first-save: render the form in create mode with an empty record.
|
|
71
|
+
// The save goes through the existing `serverAction` create path; core
|
|
72
|
+
// assigns the singleton `id` (always `1`) and enforces the single-record
|
|
73
|
+
// constraint, so the form sends only the user-entered field data.
|
|
74
|
+
const { serializableFields: createFields, initialData: createInitialData, relationshipData: createRelationshipData, } = await prepareItemForm(context, config, listConfig, {});
|
|
75
|
+
return (_jsxs("div", { className: "p-8 max-w-4xl", children: [_jsxs("div", { className: "mb-8", children: [_jsxs(Link, { href: basePath, className: "inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4", children: [_jsx("svg", { className: "w-4 h-4 mr-1", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }), "Back to dashboard"] }), _jsxs("h1", { className: "text-3xl font-bold", children: ["Create ", formatListName(listKey)] })] }), _jsx("div", { className: "bg-card border border-border rounded-lg p-6", children: _jsx(ItemFormClient, { listKey: listKey, urlKey: urlKey, mode: "create", fields: createFields, initialData: createInitialData, basePath: basePath, serverAction: serverAction, relationshipData: createRelationshipData, canDelete: false }) })] }));
|
|
76
|
+
}
|
|
77
|
+
// Reuse the shared field-serialization + relationship-data logic so the
|
|
78
|
+
// singleton editor stays in lockstep with the regular item form.
|
|
79
|
+
const { serializableFields, initialData, relationshipData } = await prepareItemForm(context, config, listConfig, record);
|
|
80
|
+
const itemId = record.id;
|
|
81
|
+
return (_jsxs("div", { className: "p-8 max-w-4xl", children: [_jsxs("div", { className: "mb-8", children: [_jsxs(Link, { href: basePath, className: "inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4", children: [_jsx("svg", { className: "w-4 h-4 mr-1", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }), "Back to dashboard"] }), _jsxs("h1", { className: "text-3xl font-bold", children: ["Edit ", formatListName(listKey)] })] }), _jsx("div", { className: "bg-card border border-border rounded-lg p-6", children: _jsx(ItemFormClient, { listKey: listKey, urlKey: urlKey, mode: "edit", fields: serializableFields, initialData: initialData, itemId: itemId, basePath: basePath, serverAction: serverAction, relationshipData: relationshipData, canDelete: false }) })] }));
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { ListView } from './components/ListView.js';
|
|
|
6
6
|
export { ListViewClient } from './components/ListViewClient.js';
|
|
7
7
|
export { ItemForm } from './components/ItemForm.js';
|
|
8
8
|
export { ItemFormClient } from './components/ItemFormClient.js';
|
|
9
|
+
export { SingletonView } from './components/SingletonView.js';
|
|
9
10
|
export { ConfirmDialog } from './components/ConfirmDialog.js';
|
|
10
11
|
export { LoadingSpinner } from './components/LoadingSpinner.js';
|
|
11
12
|
export { SkeletonLoader, TableSkeleton, FormSkeleton } from './components/SkeletonLoader.js';
|
|
@@ -18,6 +19,7 @@ export type { ListViewProps } from './components/ListView.js';
|
|
|
18
19
|
export type { ListViewClientProps } from './components/ListViewClient.js';
|
|
19
20
|
export type { ItemFormProps } from './components/ItemForm.js';
|
|
20
21
|
export type { ItemFormClientProps } from './components/ItemFormClient.js';
|
|
22
|
+
export type { SingletonViewProps } from './components/SingletonView.js';
|
|
21
23
|
export type { ConfirmDialogProps } from './components/ConfirmDialog.js';
|
|
22
24
|
export type { LoadingSpinnerProps } from './components/LoadingSpinner.js';
|
|
23
25
|
export type { SkeletonLoaderProps } from './components/SkeletonLoader.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAA;AAG5F,OAAO,EACL,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,sBAAsB,EACtB,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,8BAA8B,CAAA;AAGrC,YAAY,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAC/D,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AACjE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AACvE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAEzE,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,mBAAmB,GACpB,MAAM,8BAA8B,CAAA;AAGrC,OAAO,EACL,cAAc,EACd,YAAY,EACZ,SAAS,EACT,SAAS,EACT,YAAY,GACb,MAAM,kCAAkC,CAAA;AAEzC,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,iBAAiB,GAClB,MAAM,kCAAkC,CAAA;AAGzC,OAAO,EAAE,EAAE,EAAE,cAAc,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAG1F,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAA;AAG5F,OAAO,EACL,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,sBAAsB,EACtB,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,8BAA8B,CAAA;AAGrC,YAAY,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAC/D,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AACjE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AACvE,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AACvE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AACzE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAEzE,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,mBAAmB,GACpB,MAAM,8BAA8B,CAAA;AAGrC,OAAO,EACL,cAAc,EACd,YAAY,EACZ,SAAS,EACT,SAAS,EACT,YAAY,GACb,MAAM,kCAAkC,CAAA;AAEzC,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,iBAAiB,GAClB,MAAM,kCAAkC,CAAA;AAGzC,OAAO,EAAE,EAAE,EAAE,cAAc,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAG1F,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { ListView } from './components/ListView.js';
|
|
|
7
7
|
export { ListViewClient } from './components/ListViewClient.js';
|
|
8
8
|
export { ItemForm } from './components/ItemForm.js';
|
|
9
9
|
export { ItemFormClient } from './components/ItemFormClient.js';
|
|
10
|
+
export { SingletonView } from './components/SingletonView.js';
|
|
10
11
|
export { ConfirmDialog } from './components/ConfirmDialog.js';
|
|
11
12
|
export { LoadingSpinner } from './components/LoadingSpinner.js';
|
|
12
13
|
export { SkeletonLoader, TableSkeleton, FormSkeleton } from './components/SkeletonLoader.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AccessContext, OperationAccess, Session } from '@opensaas/stack-core';
|
|
2
|
+
/**
|
|
3
|
+
* The names of the operation-level access checks we evaluate in the UI.
|
|
4
|
+
*/
|
|
5
|
+
export type OperationAccessName = 'query' | 'create' | 'update' | 'delete';
|
|
6
|
+
/**
|
|
7
|
+
* Evaluate a list's operation-level access control for the current session,
|
|
8
|
+
* coercing the result to a single "is this operation potentially permitted?"
|
|
9
|
+
* boolean.
|
|
10
|
+
*
|
|
11
|
+
* This mirrors how the core access engine treats a result:
|
|
12
|
+
* - `false` → denied
|
|
13
|
+
* - `true` → permitted
|
|
14
|
+
* - a filter object → permitted, but scoped to matching rows
|
|
15
|
+
*
|
|
16
|
+
* Because the UI cannot know whether a returned filter would match the
|
|
17
|
+
* (possibly not-yet-created) singleton row, a filter is treated as "potentially
|
|
18
|
+
* permitted" — the actual operation still runs through the access engine, which
|
|
19
|
+
* re-applies the filter. This helper only decides which affordance to render.
|
|
20
|
+
*
|
|
21
|
+
* Access functions are user-defined and may throw (e.g. they assume a session
|
|
22
|
+
* shape that anonymous requests don't have). A throw is treated as **denied** —
|
|
23
|
+
* the safest outcome, so a misbehaving access function never exposes an
|
|
24
|
+
* editable/create form to a session that might not be allowed.
|
|
25
|
+
*
|
|
26
|
+
* No access function configured for the operation is also treated as denied,
|
|
27
|
+
* matching the core engine's deny-by-default (`checkAccess` returns `false`
|
|
28
|
+
* when `accessControl` is undefined).
|
|
29
|
+
*/
|
|
30
|
+
export declare function isOperationPotentiallyAllowed(access: OperationAccess | undefined, operation: OperationAccessName, args: {
|
|
31
|
+
session: Session | null;
|
|
32
|
+
context: AccessContext<unknown>;
|
|
33
|
+
}): Promise<boolean>;
|
|
34
|
+
//# sourceMappingURL=operationAccess.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operationAccess.d.ts","sourceRoot":"","sources":["../../src/lib/operationAccess.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAEnF;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE1E;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,6BAA6B,CACjD,MAAM,EAAE,eAAe,GAAG,SAAS,EACnC,SAAS,EAAE,mBAAmB,EAC9B,IAAI,EAAE;IAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAAE,GACjE,OAAO,CAAC,OAAO,CAAC,CAiBlB"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate a list's operation-level access control for the current session,
|
|
3
|
+
* coercing the result to a single "is this operation potentially permitted?"
|
|
4
|
+
* boolean.
|
|
5
|
+
*
|
|
6
|
+
* This mirrors how the core access engine treats a result:
|
|
7
|
+
* - `false` → denied
|
|
8
|
+
* - `true` → permitted
|
|
9
|
+
* - a filter object → permitted, but scoped to matching rows
|
|
10
|
+
*
|
|
11
|
+
* Because the UI cannot know whether a returned filter would match the
|
|
12
|
+
* (possibly not-yet-created) singleton row, a filter is treated as "potentially
|
|
13
|
+
* permitted" — the actual operation still runs through the access engine, which
|
|
14
|
+
* re-applies the filter. This helper only decides which affordance to render.
|
|
15
|
+
*
|
|
16
|
+
* Access functions are user-defined and may throw (e.g. they assume a session
|
|
17
|
+
* shape that anonymous requests don't have). A throw is treated as **denied** —
|
|
18
|
+
* the safest outcome, so a misbehaving access function never exposes an
|
|
19
|
+
* editable/create form to a session that might not be allowed.
|
|
20
|
+
*
|
|
21
|
+
* No access function configured for the operation is also treated as denied,
|
|
22
|
+
* matching the core engine's deny-by-default (`checkAccess` returns `false`
|
|
23
|
+
* when `accessControl` is undefined).
|
|
24
|
+
*/
|
|
25
|
+
export async function isOperationPotentiallyAllowed(access, operation, args) {
|
|
26
|
+
const accessControl = access?.[operation];
|
|
27
|
+
// Deny by default — no rule means no access (matches the core engine).
|
|
28
|
+
if (!accessControl)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const result = await accessControl({
|
|
32
|
+
session: args.session,
|
|
33
|
+
// The access engine passes the same context through to the function.
|
|
34
|
+
context: args.context,
|
|
35
|
+
});
|
|
36
|
+
// `false` denies; `true` or a filter object both mean "potentially allowed".
|
|
37
|
+
return result !== false;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A throwing access function is treated as denied — never widen access on error.
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AccessContext, OpenSaasConfig } from '@opensaas/stack-core';
|
|
2
|
+
import type { ListConfig } from '@opensaas/stack-core';
|
|
3
|
+
import { type SerializableFieldConfig } from './serializeFieldConfig.js';
|
|
4
|
+
/**
|
|
5
|
+
* Data prepared on the server for the client item form.
|
|
6
|
+
*
|
|
7
|
+
* Everything here is JSON-serializable and contains only what `ItemFormClient`
|
|
8
|
+
* needs to render — see the repo rule on minimal, serializable client props.
|
|
9
|
+
*/
|
|
10
|
+
export interface PreparedItemForm {
|
|
11
|
+
/** Field configs stripped of functions/non-serializable props. */
|
|
12
|
+
serializableFields: Record<string, SerializableFieldConfig>;
|
|
13
|
+
/** Initial form values (relationships reduced to ids, client transforms applied). */
|
|
14
|
+
initialData: Record<string, unknown>;
|
|
15
|
+
/** Relationship options keyed by field name. */
|
|
16
|
+
relationshipData: Record<string, Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
}>>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the `include` object needed to hydrate relationship fields when
|
|
23
|
+
* fetching an item for editing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildRelationshipInclude(listConfig: ListConfig<any>): Record<string, boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Prepare the serializable props for `ItemFormClient` from an already-fetched
|
|
28
|
+
* record (or an empty object for create).
|
|
29
|
+
*
|
|
30
|
+
* This is shared by `ItemForm` (which fetches via `findUnique`) and
|
|
31
|
+
* `SingletonView` (which resolves via the singleton `get()`), so the
|
|
32
|
+
* relationship/serialization logic lives in exactly one place.
|
|
33
|
+
*/
|
|
34
|
+
export declare function prepareItemForm(context: AccessContext<unknown>, config: OpenSaasConfig, listConfig: ListConfig<any>, itemData: Record<string, unknown>): Promise<PreparedItemForm>;
|
|
35
|
+
//# sourceMappingURL=prepareItemForm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prepareItemForm.d.ts","sourceRoot":"","sources":["../../src/lib/prepareItemForm.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAY,cAAc,EAAE,MAAM,sBAAsB,CAAA;AACnF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAyB,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAA;AAE/F;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kEAAkE;IAClE,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC3D,qFAAqF;IACrF,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACpC,gDAAgD;IAChD,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACvE;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAEtC,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,GAC1B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,EAC/B,MAAM,EAAE,cAAc,EAEtB,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,EAC3B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAsE3B"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getDbKey } from '@opensaas/stack-core';
|
|
2
|
+
import { serializeFieldConfigs } from './serializeFieldConfig.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build the `include` object needed to hydrate relationship fields when
|
|
5
|
+
* fetching an item for editing.
|
|
6
|
+
*/
|
|
7
|
+
export function buildRelationshipInclude(
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig is generic over TypeInfo
|
|
9
|
+
listConfig) {
|
|
10
|
+
const includeRelationships = {};
|
|
11
|
+
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
12
|
+
if (fieldConfig.type === 'relationship') {
|
|
13
|
+
includeRelationships[fieldName] = true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return includeRelationships;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Prepare the serializable props for `ItemFormClient` from an already-fetched
|
|
20
|
+
* record (or an empty object for create).
|
|
21
|
+
*
|
|
22
|
+
* This is shared by `ItemForm` (which fetches via `findUnique`) and
|
|
23
|
+
* `SingletonView` (which resolves via the singleton `get()`), so the
|
|
24
|
+
* relationship/serialization logic lives in exactly one place.
|
|
25
|
+
*/
|
|
26
|
+
export async function prepareItemForm(context, config,
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig is generic over TypeInfo
|
|
28
|
+
listConfig, itemData) {
|
|
29
|
+
// Fetch relationship options for all relationship fields
|
|
30
|
+
const relationshipData = {};
|
|
31
|
+
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
32
|
+
const fieldConfigAny = fieldConfig;
|
|
33
|
+
if (fieldConfigAny.type === 'relationship') {
|
|
34
|
+
const ref = fieldConfigAny.ref;
|
|
35
|
+
if (ref) {
|
|
36
|
+
// Parse ref format: "ListName.fieldName"
|
|
37
|
+
const relatedListName = ref.split('.')[0];
|
|
38
|
+
const relatedListConfig = config.lists[relatedListName];
|
|
39
|
+
if (relatedListConfig) {
|
|
40
|
+
try {
|
|
41
|
+
const delegate = context.db[getDbKey(relatedListName)];
|
|
42
|
+
const relatedItems = delegate?.findMany ? await delegate.findMany({}) : [];
|
|
43
|
+
// Use 'name' field as label if it exists, otherwise use 'id'
|
|
44
|
+
relationshipData[fieldName] = relatedItems.map((item) => ({
|
|
45
|
+
id: item.id,
|
|
46
|
+
label: (item.name || item.title || item.id) || '',
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error(`Failed to fetch relationship items for ${fieldName}:`, error);
|
|
51
|
+
relationshipData[fieldName] = [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Serialize field configs to remove non-serializable properties
|
|
58
|
+
const serializableFields = serializeFieldConfigs(listConfig.fields);
|
|
59
|
+
// Transform relationship data in itemData from objects to IDs for form
|
|
60
|
+
// Also apply valueForClientSerialization transformation
|
|
61
|
+
const formData = { ...itemData };
|
|
62
|
+
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
63
|
+
const fieldConfigAny = fieldConfig;
|
|
64
|
+
if (fieldConfigAny.type === 'relationship' && formData[fieldName]) {
|
|
65
|
+
const value = formData[fieldName];
|
|
66
|
+
if (fieldConfigAny.many && Array.isArray(value)) {
|
|
67
|
+
// Many relationship: extract IDs from array of objects
|
|
68
|
+
formData[fieldName] = value.map((item) => item.id);
|
|
69
|
+
}
|
|
70
|
+
else if (value && typeof value === 'object' && 'id' in value) {
|
|
71
|
+
// Single relationship: extract ID from object
|
|
72
|
+
formData[fieldName] = value.id;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Apply valueForClientSerialization if defined
|
|
76
|
+
if (fieldConfigAny.ui?.valueForClientSerialization &&
|
|
77
|
+
typeof fieldConfigAny.ui.valueForClientSerialization === 'function') {
|
|
78
|
+
const transformer = fieldConfigAny.ui.valueForClientSerialization;
|
|
79
|
+
formData[fieldName] = transformer({ value: formData[fieldName] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// JSON round-trip ensures only serializable data crosses the client boundary
|
|
83
|
+
const initialData = JSON.parse(JSON.stringify(formData));
|
|
84
|
+
return { serializableFields, initialData, relationshipData };
|
|
85
|
+
}
|
package/dist/styles/globals.css
CHANGED
|
@@ -384,6 +384,9 @@
|
|
|
384
384
|
.grid {
|
|
385
385
|
display: grid;
|
|
386
386
|
}
|
|
387
|
+
.hidden {
|
|
388
|
+
display: none;
|
|
389
|
+
}
|
|
387
390
|
.inline-block {
|
|
388
391
|
display: inline-block;
|
|
389
392
|
}
|
|
@@ -402,6 +405,9 @@
|
|
|
402
405
|
.h-4 {
|
|
403
406
|
height: calc(var(--spacing) * 4);
|
|
404
407
|
}
|
|
408
|
+
.h-5 {
|
|
409
|
+
height: calc(var(--spacing) * 5);
|
|
410
|
+
}
|
|
405
411
|
.h-7 {
|
|
406
412
|
height: calc(var(--spacing) * 7);
|
|
407
413
|
}
|
|
@@ -459,6 +465,12 @@
|
|
|
459
465
|
.w-4 {
|
|
460
466
|
width: calc(var(--spacing) * 4);
|
|
461
467
|
}
|
|
468
|
+
.w-5 {
|
|
469
|
+
width: calc(var(--spacing) * 5);
|
|
470
|
+
}
|
|
471
|
+
.w-7 {
|
|
472
|
+
width: calc(var(--spacing) * 7);
|
|
473
|
+
}
|
|
462
474
|
.w-8 {
|
|
463
475
|
width: calc(var(--spacing) * 8);
|
|
464
476
|
}
|
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",
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
+
import { redirect } from 'next/navigation.js'
|
|
2
3
|
import { Navigation } from './Navigation.js'
|
|
3
4
|
import { Dashboard } from './Dashboard.js'
|
|
4
5
|
import { ListView } from './ListView.js'
|
|
5
6
|
import { ItemForm } from './ItemForm.js'
|
|
7
|
+
import { SingletonView } from './SingletonView.js'
|
|
6
8
|
import type { ServerActionInput } from '../server/types.js'
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type AccessContext,
|
|
11
|
+
getListKeyFromUrl,
|
|
12
|
+
getUrlKey,
|
|
13
|
+
OpenSaasConfig,
|
|
14
|
+
} from '@opensaas/stack-core'
|
|
8
15
|
import { generateThemeCSS } from '../lib/theme.js'
|
|
9
16
|
|
|
10
17
|
export interface AdminUIProps {
|
|
@@ -24,7 +31,7 @@ export interface AdminUIProps {
|
|
|
24
31
|
*
|
|
25
32
|
* Handles routing based on params array:
|
|
26
33
|
* - [] → Dashboard
|
|
27
|
-
* - [list] → ListView
|
|
34
|
+
* - [list] → ListView (or SingletonView when the list is `isSingleton`)
|
|
28
35
|
* - [list, 'create'] → ItemForm (create)
|
|
29
36
|
* - [list, id] → ItemForm (edit)
|
|
30
37
|
*/
|
|
@@ -52,6 +59,13 @@ export function AdminUI({
|
|
|
52
59
|
if (!listKey) {
|
|
53
60
|
// Dashboard
|
|
54
61
|
content = <Dashboard context={context} config={config} basePath={basePath} />
|
|
62
|
+
} else if (config.lists[listKey]?.isSingleton && action) {
|
|
63
|
+
// A singleton has a single record edited at its bare [list] route, so the
|
|
64
|
+
// create/id sub-routes (`[list, 'create']` / `[list, id]`) don't apply.
|
|
65
|
+
// Redirect them to the bare editor so old links keep working. This runs
|
|
66
|
+
// before the create/edit ItemForm branches; non-singleton routing below is
|
|
67
|
+
// unchanged.
|
|
68
|
+
redirect(`${basePath}/${getUrlKey(listKey)}`)
|
|
55
69
|
} else if (action === 'create') {
|
|
56
70
|
// Create form
|
|
57
71
|
content = (
|
|
@@ -77,11 +91,29 @@ export function AdminUI({
|
|
|
77
91
|
serverAction={serverAction}
|
|
78
92
|
/>
|
|
79
93
|
)
|
|
94
|
+
} else if (config.lists[listKey]?.isSingleton) {
|
|
95
|
+
// Singleton editor: a singleton has a single record, so its bare [list]
|
|
96
|
+
// route renders a single-record editor instead of a list table.
|
|
97
|
+
content = (
|
|
98
|
+
<SingletonView
|
|
99
|
+
context={context}
|
|
100
|
+
config={config}
|
|
101
|
+
listKey={listKey}
|
|
102
|
+
basePath={basePath}
|
|
103
|
+
serverAction={serverAction}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
80
106
|
} else {
|
|
81
107
|
// List view
|
|
82
108
|
const search = typeof searchParams.search === 'string' ? searchParams.search : undefined
|
|
83
109
|
const page = typeof searchParams.page === 'string' ? parseInt(searchParams.page, 10) : 1
|
|
84
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
|
+
|
|
85
117
|
content = (
|
|
86
118
|
<ListView
|
|
87
119
|
context={context}
|
|
@@ -90,6 +122,8 @@ export function AdminUI({
|
|
|
90
122
|
basePath={basePath}
|
|
91
123
|
search={search}
|
|
92
124
|
page={page}
|
|
125
|
+
columns={listView?.initialColumns}
|
|
126
|
+
initialSort={listView?.initialSort}
|
|
93
127
|
/>
|
|
94
128
|
)
|
|
95
129
|
}
|