@opensaas/stack-ui 0.20.1 → 0.21.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 +5 -5
- package/CHANGELOG.md +57 -0
- package/dist/components/ItemFormClient.d.ts +5 -2
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +46 -119
- package/dist/components/fields/FieldRenderer.d.ts.map +1 -1
- package/dist/components/fields/FieldRenderer.js +24 -20
- package/dist/components/fields/FileField.d.ts +1 -1
- package/dist/components/fields/FileField.d.ts.map +1 -1
- package/dist/components/fields/ImageField.d.ts +1 -1
- package/dist/components/fields/ImageField.d.ts.map +1 -1
- package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -1
- package/dist/components/standalone/ItemCreateForm.js +13 -64
- package/dist/components/standalone/ItemEditForm.d.ts.map +1 -1
- package/dist/components/standalone/ItemEditForm.js +15 -83
- package/dist/lib/theme.d.ts +1 -1
- package/dist/lib/theme.d.ts.map +1 -1
- package/dist/lib/useItemForm.d.ts +85 -0
- package/dist/lib/useItemForm.d.ts.map +1 -0
- package/dist/lib/useItemForm.js +122 -0
- package/dist/server/types.d.ts +1 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/styles/globals.css +5 -0
- package/package.json +5 -5
- package/src/components/ItemFormClient.tsx +61 -131
- package/src/components/fields/FieldRenderer.tsx +38 -27
- package/src/components/fields/FileField.tsx +1 -1
- package/src/components/fields/ImageField.tsx +1 -1
- package/src/components/standalone/ItemCreateForm.tsx +21 -69
- package/src/components/standalone/ItemEditForm.tsx +26 -93
- package/src/lib/theme.ts +1 -1
- package/src/lib/useItemForm.ts +194 -0
- package/src/server/types.ts +1 -1
- package/tests/lib/useItemForm.test.ts +107 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SerializableFieldConfig } from './serializeFieldConfig.js';
|
|
2
|
+
/**
|
|
3
|
+
* The action an item form submission represents.
|
|
4
|
+
*/
|
|
5
|
+
export type ItemFormAction = 'create' | 'update';
|
|
6
|
+
/**
|
|
7
|
+
* Transform raw form state into the data shape expected by the server/Prisma.
|
|
8
|
+
*
|
|
9
|
+
* This is the shared submit transform used by every item form (the AdminUI
|
|
10
|
+
* form and the standalone create/edit forms). Keeping it a pure function makes
|
|
11
|
+
* it testable without rendering a component.
|
|
12
|
+
*
|
|
13
|
+
* Behaviour (the superset applied by all forms):
|
|
14
|
+
* - Relationship fields are converted to Prisma `connect` shape (single or many).
|
|
15
|
+
* Empty single relationships and empty many-arrays are omitted.
|
|
16
|
+
* - Password fields whose value is an `{ isSet }` sentinel (an unchanged password
|
|
17
|
+
* read back from the server) are skipped, so they are not re-submitted.
|
|
18
|
+
* - All other fields pass through unchanged (including `File` objects, which the
|
|
19
|
+
* Next.js server action serialises).
|
|
20
|
+
*/
|
|
21
|
+
export declare function transformItemFormData(fields: Record<string, SerializableFieldConfig>, formData: Record<string, unknown>): Record<string, unknown>;
|
|
22
|
+
/**
|
|
23
|
+
* Apply each field's `valueForClientSerialization` transform to initial data,
|
|
24
|
+
* so values read from the server are shaped for the client form inputs.
|
|
25
|
+
*
|
|
26
|
+
* Operates on the original (non-serialized) field configs because the transform
|
|
27
|
+
* function is stripped during serialization.
|
|
28
|
+
*/
|
|
29
|
+
export declare function transformInitialData<TData extends Record<string, unknown>>(fields: Record<string, unknown>, initialData: TData): TData;
|
|
30
|
+
/**
|
|
31
|
+
* Drop system fields (id, createdAt, updatedAt) from a field-config map,
|
|
32
|
+
* returning the editable entries in declaration order.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getEditableFields(fields: Record<string, SerializableFieldConfig>): Array<[string, SerializableFieldConfig]>;
|
|
35
|
+
/**
|
|
36
|
+
* Result of a form submission adapter. `false`/error means the submission
|
|
37
|
+
* failed and the form should surface `error` (and optional per-field errors).
|
|
38
|
+
*/
|
|
39
|
+
export type ItemFormSubmitResult = {
|
|
40
|
+
success: true;
|
|
41
|
+
} | {
|
|
42
|
+
success: false;
|
|
43
|
+
error: string;
|
|
44
|
+
fieldErrors?: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
export interface UseItemFormOptions {
|
|
47
|
+
/** Serialized field configs (drives rendering + the submit transform). */
|
|
48
|
+
fields: Record<string, SerializableFieldConfig>;
|
|
49
|
+
/** Initial form values (already client-serialized). */
|
|
50
|
+
initialData?: Record<string, unknown>;
|
|
51
|
+
/** Whether this form creates or updates. Selects the submit action. */
|
|
52
|
+
mode: ItemFormAction;
|
|
53
|
+
/**
|
|
54
|
+
* Submit adapter. Receives the transformed data and the action; each caller
|
|
55
|
+
* wires this to its own mechanism (AdminUI server action + navigation, or a
|
|
56
|
+
* standalone `onSubmit` callback). May return void for the legacy/standalone
|
|
57
|
+
* "throw on failure" style.
|
|
58
|
+
*/
|
|
59
|
+
onSubmit: (data: Record<string, unknown>, action: ItemFormAction) => Promise<ItemFormSubmitResult | void>;
|
|
60
|
+
/** Optional fallback message when a submit throws without a message. */
|
|
61
|
+
errorFallback?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface UseItemFormResult {
|
|
64
|
+
formData: Record<string, unknown>;
|
|
65
|
+
errors: Record<string, string>;
|
|
66
|
+
generalError: string | null;
|
|
67
|
+
isPending: boolean;
|
|
68
|
+
editableFields: Array<[string, SerializableFieldConfig]>;
|
|
69
|
+
handleFieldChange: (fieldName: string, value: unknown) => void;
|
|
70
|
+
handleSubmit: (e: {
|
|
71
|
+
preventDefault: () => void;
|
|
72
|
+
}) => void;
|
|
73
|
+
setGeneralError: (message: string | null) => void;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The shared item-form engine.
|
|
77
|
+
*
|
|
78
|
+
* Holds the form state, the clear-error-on-change behaviour, the submit
|
|
79
|
+
* transform, and the pending state (via `useTransition`) that every item form
|
|
80
|
+
* needs. Callers supply only an `onSubmit` adapter and render the returned
|
|
81
|
+
* fields/handlers — so the AdminUI form and the standalone create/edit forms
|
|
82
|
+
* stay thin and share one tested code path.
|
|
83
|
+
*/
|
|
84
|
+
export declare function useItemForm({ fields, initialData, mode, onSubmit, errorFallback, }: UseItemFormOptions): UseItemFormResult;
|
|
85
|
+
//# sourceMappingURL=useItemForm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useItemForm.d.ts","sourceRoot":"","sources":["../../src/lib/useItemForm.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAA;AAExE;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAIhD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,EAC/C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAyBzB;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,WAAW,EAAE,KAAK,GACjB,KAAK,CAYP;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,GAC9C,KAAK,CAAC,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC,CAE1C;AAED;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAC5B;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GACjB;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAA;AAE3E,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,uEAAuE;IACvE,IAAI,EAAE,cAAc,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,EAAE,CACR,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,MAAM,EAAE,cAAc,KACnB,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAA;IACzC,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,SAAS,EAAE,OAAO,CAAA;IAClB,cAAc,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC,CAAA;IACxD,iBAAiB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAC9D,YAAY,EAAE,CAAC,CAAC,EAAE;QAAE,cAAc,EAAE,MAAM,IAAI,CAAA;KAAE,KAAK,IAAI,CAAA;IACzD,eAAe,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;CAClD;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,WAAgB,EAChB,IAAI,EACJ,QAAQ,EACR,aAAkC,GACnC,EAAE,kBAAkB,GAAG,iBAAiB,CA+CxC"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useTransition } from 'react';
|
|
3
|
+
const SYSTEM_FIELDS = ['id', 'createdAt', 'updatedAt'];
|
|
4
|
+
/**
|
|
5
|
+
* Transform raw form state into the data shape expected by the server/Prisma.
|
|
6
|
+
*
|
|
7
|
+
* This is the shared submit transform used by every item form (the AdminUI
|
|
8
|
+
* form and the standalone create/edit forms). Keeping it a pure function makes
|
|
9
|
+
* it testable without rendering a component.
|
|
10
|
+
*
|
|
11
|
+
* Behaviour (the superset applied by all forms):
|
|
12
|
+
* - Relationship fields are converted to Prisma `connect` shape (single or many).
|
|
13
|
+
* Empty single relationships and empty many-arrays are omitted.
|
|
14
|
+
* - Password fields whose value is an `{ isSet }` sentinel (an unchanged password
|
|
15
|
+
* read back from the server) are skipped, so they are not re-submitted.
|
|
16
|
+
* - All other fields pass through unchanged (including `File` objects, which the
|
|
17
|
+
* Next.js server action serialises).
|
|
18
|
+
*/
|
|
19
|
+
export function transformItemFormData(fields, formData) {
|
|
20
|
+
const transformed = {};
|
|
21
|
+
for (const [fieldName, value] of Object.entries(formData)) {
|
|
22
|
+
const fieldConfig = fields[fieldName];
|
|
23
|
+
// Skip password fields carrying an { isSet } sentinel (unchanged password).
|
|
24
|
+
if (typeof value === 'object' && value !== null && 'isSet' in value) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (fieldConfig?.type === 'relationship') {
|
|
28
|
+
if (fieldConfig.many) {
|
|
29
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
30
|
+
transformed[fieldName] = { connect: value.map((id) => ({ id })) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else if (value) {
|
|
34
|
+
transformed[fieldName] = { connect: { id: value } };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
transformed[fieldName] = value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return transformed;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Apply each field's `valueForClientSerialization` transform to initial data,
|
|
45
|
+
* so values read from the server are shaped for the client form inputs.
|
|
46
|
+
*
|
|
47
|
+
* Operates on the original (non-serialized) field configs because the transform
|
|
48
|
+
* function is stripped during serialization.
|
|
49
|
+
*/
|
|
50
|
+
export function transformInitialData(fields, initialData) {
|
|
51
|
+
const transformed = { ...initialData };
|
|
52
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
53
|
+
const ui = fieldConfig.ui;
|
|
54
|
+
const transformer = ui?.valueForClientSerialization;
|
|
55
|
+
if (typeof transformer === 'function') {
|
|
56
|
+
transformed[fieldName] = transformer({ value: transformed[fieldName] });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return transformed;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Drop system fields (id, createdAt, updatedAt) from a field-config map,
|
|
63
|
+
* returning the editable entries in declaration order.
|
|
64
|
+
*/
|
|
65
|
+
export function getEditableFields(fields) {
|
|
66
|
+
return Object.entries(fields).filter(([key]) => !SYSTEM_FIELDS.includes(key));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The shared item-form engine.
|
|
70
|
+
*
|
|
71
|
+
* Holds the form state, the clear-error-on-change behaviour, the submit
|
|
72
|
+
* transform, and the pending state (via `useTransition`) that every item form
|
|
73
|
+
* needs. Callers supply only an `onSubmit` adapter and render the returned
|
|
74
|
+
* fields/handlers — so the AdminUI form and the standalone create/edit forms
|
|
75
|
+
* stay thin and share one tested code path.
|
|
76
|
+
*/
|
|
77
|
+
export function useItemForm({ fields, initialData = {}, mode, onSubmit, errorFallback = 'Operation failed', }) {
|
|
78
|
+
const [isPending, startTransition] = useTransition();
|
|
79
|
+
const [formData, setFormData] = useState(initialData);
|
|
80
|
+
const [errors, setErrors] = useState({});
|
|
81
|
+
const [generalError, setGeneralError] = useState(null);
|
|
82
|
+
const handleFieldChange = (fieldName, value) => {
|
|
83
|
+
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
84
|
+
if (errors[fieldName]) {
|
|
85
|
+
setErrors((prev) => {
|
|
86
|
+
const next = { ...prev };
|
|
87
|
+
delete next[fieldName];
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const handleSubmit = (e) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
setErrors({});
|
|
95
|
+
setGeneralError(null);
|
|
96
|
+
startTransition(async () => {
|
|
97
|
+
const data = transformItemFormData(fields, formData);
|
|
98
|
+
try {
|
|
99
|
+
const result = await onSubmit(data, mode);
|
|
100
|
+
// void result → adapter handles its own success/navigation.
|
|
101
|
+
if (result && result.success === false) {
|
|
102
|
+
if (result.fieldErrors)
|
|
103
|
+
setErrors(result.fieldErrors);
|
|
104
|
+
setGeneralError(result.error || errorFallback);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
setGeneralError(error?.message || errorFallback);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
formData,
|
|
114
|
+
errors,
|
|
115
|
+
generalError,
|
|
116
|
+
isPending,
|
|
117
|
+
editableFields: getEditableFields(fields),
|
|
118
|
+
handleFieldChange,
|
|
119
|
+
handleSubmit,
|
|
120
|
+
setGeneralError,
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Input for the generic server action
|
|
3
3
|
* Re-exported from @opensaas/stack-core for convenience
|
|
4
4
|
*/
|
|
5
|
-
export type { ServerActionProps as ServerActionInput } from '@opensaas/stack-core';
|
|
5
|
+
export type { ServerActionProps as ServerActionInput } from '@opensaas/stack-core/internal';
|
|
6
6
|
/**
|
|
7
7
|
* Result of a server action
|
|
8
8
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,YAAY,EAAE,iBAAiB,IAAI,iBAAiB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,YAAY,EAAE,iBAAiB,IAAI,iBAAiB,EAAE,MAAM,+BAA+B,CAAA;AAE3F;;GAEG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACvD,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,CAAC,CAAA;IACR,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACrC"}
|
package/dist/styles/globals.css
CHANGED
|
@@ -1208,6 +1208,11 @@
|
|
|
1208
1208
|
.filter {
|
|
1209
1209
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
|
1210
1210
|
}
|
|
1211
|
+
.transition {
|
|
1212
|
+
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
|
1213
|
+
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
|
1214
|
+
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
|
1215
|
+
}
|
|
1211
1216
|
.transition-all {
|
|
1212
1217
|
transition-property: all;
|
|
1213
1218
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Composable React UI components for OpenSaas Stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -77,16 +77,16 @@
|
|
|
77
77
|
"@testing-library/jest-dom": "^6.9.1",
|
|
78
78
|
"@testing-library/react": "^16.3.2",
|
|
79
79
|
"@testing-library/user-event": "^14.6.1",
|
|
80
|
-
"@types/node": "^
|
|
80
|
+
"@types/node": "^25.9.1",
|
|
81
81
|
"@types/react": "^19.2.14",
|
|
82
82
|
"@types/react-dom": "^19.2.3",
|
|
83
83
|
"@vitejs/plugin-react": "^5.1.4",
|
|
84
|
-
"@vitest/browser": "^4.
|
|
84
|
+
"@vitest/browser": "^4.1.8",
|
|
85
85
|
"@vitest/browser-playwright": "^4.1.0",
|
|
86
86
|
"@vitest/coverage-v8": "^4.0.18",
|
|
87
87
|
"happy-dom": "^20.8.3",
|
|
88
88
|
"next": "^16.1.6",
|
|
89
|
-
"playwright": "^1.
|
|
89
|
+
"playwright": "^1.60.0",
|
|
90
90
|
"postcss": "^8.5.8",
|
|
91
91
|
"postcss-cli": "^11.0.1",
|
|
92
92
|
"react": "^19.2.4",
|
|
@@ -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.21.0"
|
|
98
98
|
},
|
|
99
99
|
"scripts": {
|
|
100
100
|
"build": "tsc && npm run build:css",
|
|
@@ -9,6 +9,7 @@ import { LoadingSpinner } from './LoadingSpinner.js'
|
|
|
9
9
|
import { Button } from '../primitives/button.js'
|
|
10
10
|
import type { ServerActionInput } from '../server/types.js'
|
|
11
11
|
import type { SerializableFieldConfig } from '../lib/serializeFieldConfig.js'
|
|
12
|
+
import { useItemForm, type ItemFormSubmitResult } from '../lib/useItemForm.js'
|
|
12
13
|
|
|
13
14
|
export interface ItemFormClientProps {
|
|
14
15
|
listKey: string
|
|
@@ -23,8 +24,29 @@ export interface ItemFormClientProps {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* Normalise a server action's response into the form engine's result shape.
|
|
28
|
+
* Supports both the `{ success, error, fieldErrors }` envelope and the legacy
|
|
29
|
+
* "truthy data = success, null = access denied" convention.
|
|
30
|
+
*/
|
|
31
|
+
function toSubmitResult(result: unknown, deniedMessage: string): ItemFormSubmitResult {
|
|
32
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
33
|
+
const r = result as
|
|
34
|
+
| { success: true; data: unknown }
|
|
35
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
36
|
+
return r.success
|
|
37
|
+
? { success: true }
|
|
38
|
+
: { success: false, error: r.error, fieldErrors: r.fieldErrors }
|
|
39
|
+
}
|
|
40
|
+
if (result) return { success: true }
|
|
41
|
+
return { success: false, error: deniedMessage }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Client component for the AdminUI item form.
|
|
46
|
+
*
|
|
47
|
+
* Shares the form state/transform/error/pending logic with the standalone
|
|
48
|
+
* forms via `useItemForm`; this component only adapts submission to the
|
|
49
|
+
* AdminUI server action + router navigation, and adds the delete flow.
|
|
28
50
|
*/
|
|
29
51
|
export function ItemFormClient({
|
|
30
52
|
listKey,
|
|
@@ -38,149 +60,57 @@ export function ItemFormClient({
|
|
|
38
60
|
relationshipData = {},
|
|
39
61
|
}: ItemFormClientProps) {
|
|
40
62
|
const router = useRouter()
|
|
41
|
-
const [isPending, startTransition] = useTransition()
|
|
42
|
-
const [formData, setFormData] = useState<Record<string, unknown>>(initialData)
|
|
43
|
-
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
44
|
-
const [generalError, setGeneralError] = useState<string | null>(null)
|
|
45
63
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
startTransition(async () => {
|
|
65
|
-
// Transform relationship fields to Prisma format
|
|
66
|
-
// Filter out password fields with isSet objects (unchanged passwords)
|
|
67
|
-
// File/Image fields: pass File objects through (Next.js will serialize them)
|
|
68
|
-
const transformedData: Record<string, unknown> = {}
|
|
69
|
-
for (const [fieldName, value] of Object.entries(formData)) {
|
|
70
|
-
const fieldConfig = fields[fieldName]
|
|
71
|
-
|
|
72
|
-
// Skip password fields that have { isSet: boolean } value (not being changed)
|
|
73
|
-
if (typeof value === 'object' && value !== null && 'isSet' in value) {
|
|
74
|
-
continue
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Transform relationship fields - check discriminated union type
|
|
78
|
-
const fieldAny = fieldConfig as { type: string; many?: boolean }
|
|
79
|
-
if (fieldAny?.type === 'relationship') {
|
|
80
|
-
if (fieldAny.many) {
|
|
81
|
-
// Many relationship: use connect format
|
|
82
|
-
if (Array.isArray(value) && value.length > 0) {
|
|
83
|
-
transformedData[fieldName] = {
|
|
84
|
-
connect: value.map((id: string) => ({ id })),
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
} else {
|
|
88
|
-
// Single relationship: use connect format
|
|
89
|
-
if (value) {
|
|
90
|
-
transformedData[fieldName] = {
|
|
91
|
-
connect: { id: value },
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
} else {
|
|
96
|
-
// Non-relationship field: pass through (including File objects for file/image fields)
|
|
97
|
-
// File objects will be serialized by Next.js server action
|
|
98
|
-
transformedData[fieldName] = value
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
64
|
+
// Separate transition for delete (not a form submit, so outside the engine).
|
|
65
|
+
const [isDeleting, startDeleteTransition] = useTransition()
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
formData,
|
|
69
|
+
errors,
|
|
70
|
+
generalError,
|
|
71
|
+
isPending,
|
|
72
|
+
editableFields,
|
|
73
|
+
handleFieldChange,
|
|
74
|
+
handleSubmit,
|
|
75
|
+
setGeneralError,
|
|
76
|
+
} = useItemForm({
|
|
77
|
+
fields,
|
|
78
|
+
initialData,
|
|
79
|
+
mode: mode === 'create' ? 'create' : 'update',
|
|
80
|
+
errorFallback: 'Access denied or operation failed',
|
|
81
|
+
onSubmit: async (data, action) => {
|
|
102
82
|
const result =
|
|
103
|
-
|
|
104
|
-
? await serverAction({
|
|
105
|
-
|
|
106
|
-
action: 'create',
|
|
107
|
-
data: transformedData,
|
|
108
|
-
})
|
|
109
|
-
: await serverAction({
|
|
110
|
-
listKey,
|
|
111
|
-
action: 'update',
|
|
112
|
-
id: itemId!,
|
|
113
|
-
data: transformedData,
|
|
114
|
-
})
|
|
83
|
+
action === 'create'
|
|
84
|
+
? await serverAction({ listKey, action: 'create', data })
|
|
85
|
+
: await serverAction({ listKey, action: 'update', id: itemId!, data })
|
|
115
86
|
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
const actionResult = result as
|
|
119
|
-
| { success: true; data: unknown }
|
|
120
|
-
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
121
|
-
|
|
122
|
-
if (actionResult.success) {
|
|
123
|
-
// Navigate back to list view
|
|
124
|
-
router.push(`${basePath}/${urlKey}`)
|
|
125
|
-
router.refresh()
|
|
126
|
-
} else {
|
|
127
|
-
// Handle error response
|
|
128
|
-
if (actionResult.fieldErrors) {
|
|
129
|
-
setErrors(actionResult.fieldErrors)
|
|
130
|
-
}
|
|
131
|
-
setGeneralError(actionResult.error)
|
|
132
|
-
}
|
|
133
|
-
} else if (result) {
|
|
134
|
-
// Legacy format: result is the data itself
|
|
87
|
+
const normalized = toSubmitResult(result, 'Access denied or operation failed')
|
|
88
|
+
if (normalized.success) {
|
|
135
89
|
router.push(`${basePath}/${urlKey}`)
|
|
136
90
|
router.refresh()
|
|
137
|
-
} else {
|
|
138
|
-
// null result means access denied
|
|
139
|
-
setGeneralError('Access denied or operation failed')
|
|
140
91
|
}
|
|
141
|
-
|
|
142
|
-
|
|
92
|
+
return normalized
|
|
93
|
+
},
|
|
94
|
+
})
|
|
143
95
|
|
|
144
|
-
const handleDelete =
|
|
96
|
+
const handleDelete = () => {
|
|
145
97
|
if (!itemId) return
|
|
146
|
-
|
|
147
98
|
setGeneralError(null)
|
|
148
99
|
setShowDeleteConfirm(false)
|
|
149
100
|
|
|
150
|
-
|
|
151
|
-
const result = await serverAction({
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
id: itemId,
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
// Check if result has the new format with success/error fields
|
|
158
|
-
if (result && typeof result === 'object' && 'success' in result) {
|
|
159
|
-
const actionResult = result as
|
|
160
|
-
| { success: true; data: unknown }
|
|
161
|
-
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
162
|
-
|
|
163
|
-
if (actionResult.success) {
|
|
164
|
-
router.push(`${basePath}/${urlKey}`)
|
|
165
|
-
router.refresh()
|
|
166
|
-
} else {
|
|
167
|
-
setGeneralError(actionResult.error)
|
|
168
|
-
}
|
|
169
|
-
} else if (result) {
|
|
170
|
-
// Legacy format: result is the data itself
|
|
101
|
+
startDeleteTransition(async () => {
|
|
102
|
+
const result = await serverAction({ listKey, action: 'delete', id: itemId })
|
|
103
|
+
const normalized = toSubmitResult(result, 'Access denied or failed to delete item')
|
|
104
|
+
if (normalized.success) {
|
|
171
105
|
router.push(`${basePath}/${urlKey}`)
|
|
172
106
|
router.refresh()
|
|
173
107
|
} else {
|
|
174
|
-
|
|
175
|
-
setGeneralError('Access denied or failed to delete item')
|
|
108
|
+
setGeneralError(normalized.error)
|
|
176
109
|
}
|
|
177
110
|
})
|
|
178
111
|
}
|
|
179
112
|
|
|
180
|
-
|
|
181
|
-
const editableFields = Object.entries(fields).filter(
|
|
182
|
-
([key]) => !['id', 'createdAt', 'updatedAt'].includes(key),
|
|
183
|
-
)
|
|
113
|
+
const busy = isPending || isDeleting
|
|
184
114
|
|
|
185
115
|
return (
|
|
186
116
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
@@ -201,7 +131,7 @@ export function ItemFormClient({
|
|
|
201
131
|
value={formData[fieldName]}
|
|
202
132
|
onChange={(value) => handleFieldChange(fieldName, value)}
|
|
203
133
|
error={errors[fieldName]}
|
|
204
|
-
disabled={
|
|
134
|
+
disabled={busy}
|
|
205
135
|
mode="edit"
|
|
206
136
|
relationshipItems={relationshipData[fieldName] || []}
|
|
207
137
|
relationshipLoading={false}
|
|
@@ -213,7 +143,7 @@ export function ItemFormClient({
|
|
|
213
143
|
{/* Form Actions */}
|
|
214
144
|
<div className="flex items-center justify-between pt-6 border-t border-border">
|
|
215
145
|
<div className="flex gap-3">
|
|
216
|
-
<Button type="submit" disabled={
|
|
146
|
+
<Button type="submit" disabled={busy} className="gap-2">
|
|
217
147
|
{isPending && (
|
|
218
148
|
<LoadingSpinner
|
|
219
149
|
size="sm"
|
|
@@ -226,7 +156,7 @@ export function ItemFormClient({
|
|
|
226
156
|
type="button"
|
|
227
157
|
variant="secondary"
|
|
228
158
|
onClick={() => router.push(`${basePath}/${urlKey}`)}
|
|
229
|
-
disabled={
|
|
159
|
+
disabled={busy}
|
|
230
160
|
>
|
|
231
161
|
Cancel
|
|
232
162
|
</Button>
|
|
@@ -238,7 +168,7 @@ export function ItemFormClient({
|
|
|
238
168
|
type="button"
|
|
239
169
|
variant="destructive"
|
|
240
170
|
onClick={() => setShowDeleteConfirm(true)}
|
|
241
|
-
disabled={
|
|
171
|
+
disabled={busy}
|
|
242
172
|
>
|
|
243
173
|
Delete
|
|
244
174
|
</Button>
|
|
@@ -57,36 +57,17 @@ function FieldRendererInner({
|
|
|
57
57
|
mode,
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
const specificProps: Record<string, unknown> =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (fieldConfig.type === 'password') {
|
|
71
|
-
specificProps.showConfirm = mode === 'edit'
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (fieldConfig.type === 'relationship') {
|
|
75
|
-
specificProps.items = relationshipItems
|
|
76
|
-
specificProps.isLoading = relationshipLoading
|
|
77
|
-
specificProps.many = fieldConfig.many || false
|
|
78
|
-
|
|
79
|
-
// Extract related list key from ref (format: 'ListName.fieldName')
|
|
80
|
-
if (fieldConfig.ref) {
|
|
81
|
-
const [relatedListName] = fieldConfig.ref.split('.')
|
|
82
|
-
specificProps.relatedListKey = getUrlKey(relatedListName)
|
|
83
|
-
specificProps.basePath = basePath
|
|
84
|
-
}
|
|
85
|
-
}
|
|
60
|
+
// Derive field-type-specific props from data-presence checks — no branching on fieldConfig.type.
|
|
61
|
+
const specificProps: Record<string, unknown> = buildFallbackUIProps(
|
|
62
|
+
fieldConfig,
|
|
63
|
+
relationshipItems,
|
|
64
|
+
relationshipLoading,
|
|
65
|
+
basePath,
|
|
66
|
+
)
|
|
86
67
|
|
|
87
68
|
// Pass through any UI options from fieldConfig.ui (excluding component and fieldType)
|
|
88
69
|
if (fieldConfig.ui) {
|
|
89
|
-
const { _component, _fieldType, ...uiOptions } = fieldConfig.ui
|
|
70
|
+
const { component: _component, fieldType: _fieldType, ...uiOptions } = fieldConfig.ui
|
|
90
71
|
Object.assign(specificProps, uiOptions)
|
|
91
72
|
}
|
|
92
73
|
|
|
@@ -94,6 +75,36 @@ function FieldRendererInner({
|
|
|
94
75
|
return <Component {...allProps} />
|
|
95
76
|
}
|
|
96
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Derive field-specific UI props from the serialised field config using
|
|
80
|
+
* data-presence checks rather than `fieldConfig.type` comparisons.
|
|
81
|
+
*/
|
|
82
|
+
function buildFallbackUIProps(
|
|
83
|
+
fieldConfig: SerializableFieldConfig,
|
|
84
|
+
relationshipItems: Array<{ id: string; label: string }> | undefined,
|
|
85
|
+
relationshipLoading: boolean | undefined,
|
|
86
|
+
basePath: string | undefined,
|
|
87
|
+
): Record<string, unknown> {
|
|
88
|
+
const props: Record<string, unknown> = {}
|
|
89
|
+
|
|
90
|
+
// Select options — only present on select fields
|
|
91
|
+
if (fieldConfig.options) {
|
|
92
|
+
props.options = fieldConfig.options
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Relationship props — only present on relationship fields
|
|
96
|
+
if (fieldConfig.ref) {
|
|
97
|
+
const [relatedListName] = fieldConfig.ref.split('.')
|
|
98
|
+
props.relatedListKey = getUrlKey(relatedListName ?? '')
|
|
99
|
+
props.many = fieldConfig.many || false
|
|
100
|
+
props.items = relationshipItems
|
|
101
|
+
props.isLoading = relationshipLoading
|
|
102
|
+
props.basePath = basePath
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return props
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
/**
|
|
98
109
|
* Factory component that renders the appropriate field type
|
|
99
110
|
* based on the field configuration and component registry
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React, { useCallback, useState } from 'react'
|
|
4
|
-
import type { FileMetadata } from '@opensaas/stack-core'
|
|
4
|
+
import type { FileMetadata } from '@opensaas/stack-core/internal'
|
|
5
5
|
import { Button } from '../../primitives/button.js'
|
|
6
6
|
import { Input } from '../../primitives/input.js'
|
|
7
7
|
import { Label } from '../../primitives/label.js'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React, { useCallback, useState } from 'react'
|
|
4
|
-
import type { ImageMetadata } from '@opensaas/stack-core'
|
|
4
|
+
import type { ImageMetadata } from '@opensaas/stack-core/internal'
|
|
5
5
|
import { Button } from '../../primitives/button.js'
|
|
6
6
|
import { Input } from '../../primitives/input.js'
|
|
7
7
|
import { Label } from '../../primitives/label.js'
|