@opensaas/stack-ui 0.20.1 → 0.22.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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +5 -5
  2. package/CHANGELOG.md +59 -0
  3. package/dist/components/ItemFormClient.d.ts +5 -2
  4. package/dist/components/ItemFormClient.d.ts.map +1 -1
  5. package/dist/components/ItemFormClient.js +46 -119
  6. package/dist/components/fields/FieldRenderer.d.ts.map +1 -1
  7. package/dist/components/fields/FieldRenderer.js +24 -20
  8. package/dist/components/fields/FileField.d.ts +1 -1
  9. package/dist/components/fields/FileField.d.ts.map +1 -1
  10. package/dist/components/fields/ImageField.d.ts +1 -1
  11. package/dist/components/fields/ImageField.d.ts.map +1 -1
  12. package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -1
  13. package/dist/components/standalone/ItemCreateForm.js +13 -64
  14. package/dist/components/standalone/ItemEditForm.d.ts.map +1 -1
  15. package/dist/components/standalone/ItemEditForm.js +15 -83
  16. package/dist/lib/theme.d.ts +1 -1
  17. package/dist/lib/theme.d.ts.map +1 -1
  18. package/dist/lib/useItemForm.d.ts +85 -0
  19. package/dist/lib/useItemForm.d.ts.map +1 -0
  20. package/dist/lib/useItemForm.js +122 -0
  21. package/dist/server/types.d.ts +1 -1
  22. package/dist/server/types.d.ts.map +1 -1
  23. package/dist/styles/globals.css +5 -0
  24. package/package.json +5 -5
  25. package/src/components/ItemFormClient.tsx +61 -131
  26. package/src/components/fields/FieldRenderer.tsx +38 -27
  27. package/src/components/fields/FileField.tsx +1 -1
  28. package/src/components/fields/ImageField.tsx +1 -1
  29. package/src/components/standalone/ItemCreateForm.tsx +21 -69
  30. package/src/components/standalone/ItemEditForm.tsx +26 -93
  31. package/src/lib/theme.ts +1 -1
  32. package/src/lib/useItemForm.ts +194 -0
  33. package/src/server/types.ts +1 -1
  34. package/tests/lib/useItemForm.test.ts +107 -0
@@ -1 +1 @@
1
- {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/lib/theme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAEjF;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,WAAW,EAAE;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAAE,CAiJvF,CAAA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,MAAM,CAuC7D;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,MAAM,CAE7D"}
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/lib/theme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAA;AAE1F;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,WAAW,EAAE;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAAE,CAiJvF,CAAA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,MAAM,CAuC7D;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,MAAM,CAE7D"}
@@ -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
+ }
@@ -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,sBAAsB,CAAA;AAElF;;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"}
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"}
@@ -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.20.1",
3
+ "version": "0.22.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": "^24.12.0",
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.0.18",
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.58.2",
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.20.1"
97
+ "@opensaas/stack-core": "0.22.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
- * Client component for interactive form
27
- * Handles form state, validation, and submission
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 handleFieldChange = (fieldName: string, value: unknown) => {
48
- setFormData((prev) => ({ ...prev, [fieldName]: value }))
49
- // Clear error for this field when user starts typing
50
- if (errors[fieldName]) {
51
- setErrors((prev) => {
52
- const newErrors = { ...prev }
53
- delete newErrors[fieldName]
54
- return newErrors
55
- })
56
- }
57
- }
58
-
59
- const handleSubmit = async (e: React.FormEvent) => {
60
- e.preventDefault()
61
- setErrors({})
62
- setGeneralError(null)
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
- mode === 'create'
104
- ? await serverAction({
105
- listKey,
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
- // Check if result has the new format with success/error fields
117
- if (result && typeof result === 'object' && 'success' in result) {
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 = async () => {
96
+ const handleDelete = () => {
145
97
  if (!itemId) return
146
-
147
98
  setGeneralError(null)
148
99
  setShowDeleteConfirm(false)
149
100
 
150
- startTransition(async () => {
151
- const result = await serverAction({
152
- listKey,
153
- action: 'delete',
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
- // null result means access denied
175
- setGeneralError('Access denied or failed to delete item')
108
+ setGeneralError(normalized.error)
176
109
  }
177
110
  })
178
111
  }
179
112
 
180
- // Filter out system fields
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={isPending}
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={isPending} className="gap-2">
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={isPending}
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={isPending}
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
- // Add field-type-specific props
61
- const specificProps: Record<string, unknown> = {}
62
-
63
- if (fieldConfig.type === 'select' && 'options' in fieldConfig && fieldConfig.options) {
64
- specificProps.options = fieldConfig.options.map(
65
- (opt: string | { label: string; value: string }) =>
66
- typeof opt === 'string' ? { label: opt, value: opt } : opt,
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'