@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,11 +1,11 @@
1
1
 
2
- > @opensaas/stack-ui@0.20.1 build /home/runner/work/stack/stack/packages/ui
2
+ > @opensaas/stack-ui@0.22.0 build /home/runner/work/stack/stack/packages/ui
3
3
  > tsc && npm run build:css
4
4
 
5
- npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm.
6
- npm warn Unknown env config "npm-globalconfig". This will stop working in the next major version of npm.
7
- npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm.
5
+ npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
6
+ npm warn Unknown env config "npm-globalconfig". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
7
+ npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
8
8
 
9
- > @opensaas/stack-ui@0.20.1 build:css
9
+ > @opensaas/stack-ui@0.22.0 build:css
10
10
  > mkdir -p dist/styles && postcss ./src/styles/globals.css -o ./dist/styles/globals.css
11
11
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # @opensaas/stack-ui
2
2
 
3
+ ## 0.22.0
4
+
5
+ ## 0.21.0
6
+
7
+ ### Minor Changes
8
+
9
+ - [#417](https://github.com/OpenSaasAU/stack/pull/417) [`ed1c9f5`](https://github.com/OpenSaasAU/stack/commit/ed1c9f532b77ef59d7a845731e6a6116904a859e) Thanks [@borisno2](https://github.com/borisno2)! - Unify the item-form logic behind a shared `useItemForm` engine
10
+
11
+ The AdminUI form (`ItemFormClient`) and the standalone `ItemCreateForm`/
12
+ `ItemEditForm` each carried their own near-identical copy of the form state,
13
+ the relationship-to-`connect` submit transform, the clear-error-on-change
14
+ behaviour, and the error/pending handling. That logic now lives once in a
15
+ `useItemForm` hook (with pure, exported `transformItemFormData`,
16
+ `transformInitialData`, and `getEditableFields` helpers); each form supplies
17
+ only an `onSubmit` adapter and renders the returned state.
18
+
19
+ Behaviour is unified to the superset: every form now applies the relationship
20
+ transform, the password `{ isSet }` skip for unchanged passwords, and
21
+ system-field filtering. The transform logic is covered by unit tests for the
22
+ first time.
23
+
24
+ No public API change — `ItemCreateForm`, `ItemEditForm`, and the AdminUI form
25
+ keep their existing props.
26
+
27
+ - [#415](https://github.com/OpenSaasAU/stack/pull/415) [`8980ff3`](https://github.com/OpenSaasAU/stack/commit/8980ff36ffb0879d8f4409740493dd940572cc9d) Thanks [@borisno2](https://github.com/borisno2)! - Curate the `@opensaas/stack-core` public surface into clearly-scoped entry points
28
+
29
+ The root entry point now exposes only the everyday consumer surface — `config`,
30
+ `list`, `getContext`, the naming helpers (`getDbKey`, `getUrlKey`,
31
+ `getListKeyFromUrl`), `ValidationError`, and the config/access types you annotate
32
+ with. Plugin and field authoring contracts move to a new `/extend` path, and the
33
+ plumbing shared with sibling packages and generated code moves to `/internal`.
34
+
35
+ ```typescript
36
+ // Everyday usage (unchanged)
37
+ import { config, list, getContext } from '@opensaas/stack-core'
38
+
39
+ // Authoring a plugin or a third-party field package
40
+ import type { Plugin, BaseFieldConfig, TypeInfo } from '@opensaas/stack-core/extend'
41
+ ```
42
+
43
+ `@opensaas/stack-core/internal` carries no semver guarantees; application code
44
+ should never import from it. `Session` stays on the root entry point because it is
45
+ the module-augmentation target.
46
+
47
+ Removed from the public surface (zero callers): the nine `*HookArgs` types and the
48
+ callerless typed-query runtime types. The other `@opensaas/*` packages and the CLI
49
+ generator are updated to import from the new paths.
50
+
51
+ ### Patch Changes
52
+
53
+ - [#412](https://github.com/OpenSaasAU/stack/pull/412) [`9696f98`](https://github.com/OpenSaasAU/stack/commit/9696f9800284f94e21e14c31a716de4b48d736e5) Thanks [@borisno2](https://github.com/borisno2)! - Refactor `FieldRenderer` to use data-presence checks instead of `fieldConfig.type` comparisons
54
+
55
+ `FieldRenderer` no longer checks `fieldConfig.type` to decide which props to pass to field
56
+ components. Field-specific UI props (select options, relationship items/key/many) are now derived
57
+ from the serialised field config using data-presence checks (`fieldConfig.options`, `fieldConfig.ref`)
58
+ — the same self-contained pattern used for Prisma and TypeScript generation.
59
+
60
+ **For users:** no changes required. Field rendering behaviour is unchanged.
61
+
3
62
  ## 0.20.1
4
63
 
5
64
  ## 0.20.0
@@ -15,8 +15,11 @@ export interface ItemFormClientProps {
15
15
  }>>;
16
16
  }
17
17
  /**
18
- * Client component for interactive form
19
- * Handles form state, validation, and submission
18
+ * Client component for the AdminUI item form.
19
+ *
20
+ * Shares the form state/transform/error/pending logic with the standalone
21
+ * forms via `useItemForm`; this component only adapts submission to the
22
+ * AdminUI server action + router navigation, and adds the delete flow.
20
23
  */
21
24
  export declare function ItemFormClient({ listKey, urlKey, mode, fields, initialData, itemId, basePath, serverAction, relationshipData, }: ItemFormClientProps): import("react/jsx-runtime").JSX.Element;
22
25
  //# sourceMappingURL=ItemFormClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ItemFormClient.d.ts","sourceRoot":"","sources":["../../src/components/ItemFormClient.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAE7E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACxE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,WAAgB,EAChB,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,gBAAqB,GACtB,EAAE,mBAAmB,2CA8NrB"}
1
+ {"version":3,"file":"ItemFormClient.d.ts","sourceRoot":"","sources":["../../src/components/ItemFormClient.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAG7E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACxE;AAoBD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,WAAgB,EAChB,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,gBAAqB,GACtB,EAAE,mBAAmB,2CAkIrB"}
@@ -6,142 +6,69 @@ import { FieldRenderer } from './fields/FieldRenderer.js';
6
6
  import { ConfirmDialog } from './ConfirmDialog.js';
7
7
  import { LoadingSpinner } from './LoadingSpinner.js';
8
8
  import { Button } from '../primitives/button.js';
9
+ import { useItemForm } from '../lib/useItemForm.js';
9
10
  /**
10
- * Client component for interactive form
11
- * Handles form state, validation, and submission
11
+ * Normalise a server action's response into the form engine's result shape.
12
+ * Supports both the `{ success, error, fieldErrors }` envelope and the legacy
13
+ * "truthy data = success, null = access denied" convention.
14
+ */
15
+ function toSubmitResult(result, deniedMessage) {
16
+ if (result && typeof result === 'object' && 'success' in result) {
17
+ const r = result;
18
+ return r.success
19
+ ? { success: true }
20
+ : { success: false, error: r.error, fieldErrors: r.fieldErrors };
21
+ }
22
+ if (result)
23
+ return { success: true };
24
+ return { success: false, error: deniedMessage };
25
+ }
26
+ /**
27
+ * Client component for the AdminUI item form.
28
+ *
29
+ * Shares the form state/transform/error/pending logic with the standalone
30
+ * forms via `useItemForm`; this component only adapts submission to the
31
+ * AdminUI server action + router navigation, and adds the delete flow.
12
32
  */
13
33
  export function ItemFormClient({ listKey, urlKey, mode, fields, initialData = {}, itemId, basePath, serverAction, relationshipData = {}, }) {
14
34
  const router = useRouter();
15
- const [isPending, startTransition] = useTransition();
16
- const [formData, setFormData] = useState(initialData);
17
- const [errors, setErrors] = useState({});
18
- const [generalError, setGeneralError] = useState(null);
19
35
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
20
- const handleFieldChange = (fieldName, value) => {
21
- setFormData((prev) => ({ ...prev, [fieldName]: value }));
22
- // Clear error for this field when user starts typing
23
- if (errors[fieldName]) {
24
- setErrors((prev) => {
25
- const newErrors = { ...prev };
26
- delete newErrors[fieldName];
27
- return newErrors;
28
- });
29
- }
30
- };
31
- const handleSubmit = async (e) => {
32
- e.preventDefault();
33
- setErrors({});
34
- setGeneralError(null);
35
- startTransition(async () => {
36
- // Transform relationship fields to Prisma format
37
- // Filter out password fields with isSet objects (unchanged passwords)
38
- // File/Image fields: pass File objects through (Next.js will serialize them)
39
- const transformedData = {};
40
- for (const [fieldName, value] of Object.entries(formData)) {
41
- const fieldConfig = fields[fieldName];
42
- // Skip password fields that have { isSet: boolean } value (not being changed)
43
- if (typeof value === 'object' && value !== null && 'isSet' in value) {
44
- continue;
45
- }
46
- // Transform relationship fields - check discriminated union type
47
- const fieldAny = fieldConfig;
48
- if (fieldAny?.type === 'relationship') {
49
- if (fieldAny.many) {
50
- // Many relationship: use connect format
51
- if (Array.isArray(value) && value.length > 0) {
52
- transformedData[fieldName] = {
53
- connect: value.map((id) => ({ id })),
54
- };
55
- }
56
- }
57
- else {
58
- // Single relationship: use connect format
59
- if (value) {
60
- transformedData[fieldName] = {
61
- connect: { id: value },
62
- };
63
- }
64
- }
65
- }
66
- else {
67
- // Non-relationship field: pass through (including File objects for file/image fields)
68
- // File objects will be serialized by Next.js server action
69
- transformedData[fieldName] = value;
70
- }
71
- }
72
- const result = mode === 'create'
73
- ? await serverAction({
74
- listKey,
75
- action: 'create',
76
- data: transformedData,
77
- })
78
- : await serverAction({
79
- listKey,
80
- action: 'update',
81
- id: itemId,
82
- data: transformedData,
83
- });
84
- // Check if result has the new format with success/error fields
85
- if (result && typeof result === 'object' && 'success' in result) {
86
- const actionResult = result;
87
- if (actionResult.success) {
88
- // Navigate back to list view
89
- router.push(`${basePath}/${urlKey}`);
90
- router.refresh();
91
- }
92
- else {
93
- // Handle error response
94
- if (actionResult.fieldErrors) {
95
- setErrors(actionResult.fieldErrors);
96
- }
97
- setGeneralError(actionResult.error);
98
- }
99
- }
100
- else if (result) {
101
- // Legacy format: result is the data itself
36
+ // Separate transition for delete (not a form submit, so outside the engine).
37
+ const [isDeleting, startDeleteTransition] = useTransition();
38
+ const { formData, errors, generalError, isPending, editableFields, handleFieldChange, handleSubmit, setGeneralError, } = useItemForm({
39
+ fields,
40
+ initialData,
41
+ mode: mode === 'create' ? 'create' : 'update',
42
+ errorFallback: 'Access denied or operation failed',
43
+ onSubmit: async (data, action) => {
44
+ const result = action === 'create'
45
+ ? await serverAction({ listKey, action: 'create', data })
46
+ : await serverAction({ listKey, action: 'update', id: itemId, data });
47
+ const normalized = toSubmitResult(result, 'Access denied or operation failed');
48
+ if (normalized.success) {
102
49
  router.push(`${basePath}/${urlKey}`);
103
50
  router.refresh();
104
51
  }
105
- else {
106
- // null result means access denied
107
- setGeneralError('Access denied or operation failed');
108
- }
109
- });
110
- };
111
- const handleDelete = async () => {
52
+ return normalized;
53
+ },
54
+ });
55
+ const handleDelete = () => {
112
56
  if (!itemId)
113
57
  return;
114
58
  setGeneralError(null);
115
59
  setShowDeleteConfirm(false);
116
- startTransition(async () => {
117
- const result = await serverAction({
118
- listKey,
119
- action: 'delete',
120
- id: itemId,
121
- });
122
- // Check if result has the new format with success/error fields
123
- if (result && typeof result === 'object' && 'success' in result) {
124
- const actionResult = result;
125
- if (actionResult.success) {
126
- router.push(`${basePath}/${urlKey}`);
127
- router.refresh();
128
- }
129
- else {
130
- setGeneralError(actionResult.error);
131
- }
132
- }
133
- else if (result) {
134
- // Legacy format: result is the data itself
60
+ startDeleteTransition(async () => {
61
+ const result = await serverAction({ listKey, action: 'delete', id: itemId });
62
+ const normalized = toSubmitResult(result, 'Access denied or failed to delete item');
63
+ if (normalized.success) {
135
64
  router.push(`${basePath}/${urlKey}`);
136
65
  router.refresh();
137
66
  }
138
67
  else {
139
- // null result means access denied
140
- setGeneralError('Access denied or failed to delete item');
68
+ setGeneralError(normalized.error);
141
69
  }
142
70
  });
143
71
  };
144
- // Filter out system fields
145
- const editableFields = Object.entries(fields).filter(([key]) => !['id', 'createdAt', 'updatedAt'].includes(key));
146
- return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-6", children: [generalError && (_jsx("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-4", children: _jsx("p", { className: "text-sm font-medium", children: generalError }) })), _jsx("div", { className: "space-y-6", children: editableFields.map(([fieldName, fieldConfig]) => (_jsx(FieldRenderer, { fieldName: fieldName, fieldConfig: fieldConfig, value: formData[fieldName], onChange: (value) => handleFieldChange(fieldName, value), error: errors[fieldName], disabled: isPending, mode: "edit", relationshipItems: relationshipData[fieldName] || [], relationshipLoading: false, basePath: basePath }, fieldName))) }), _jsxs("div", { className: "flex items-center justify-between pt-6 border-t border-border", children: [_jsxs("div", { className: "flex gap-3", children: [_jsxs(Button, { type: "submit", disabled: isPending, className: "gap-2", children: [isPending && (_jsx(LoadingSpinner, { size: "sm", className: "border-primary-foreground border-t-transparent" })), isPending ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'] }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => router.push(`${basePath}/${urlKey}`), disabled: isPending, children: "Cancel" })] }), mode === 'edit' && itemId && (_jsx(Button, { type: "button", variant: "destructive", onClick: () => setShowDeleteConfirm(true), disabled: isPending, children: "Delete" }))] }), _jsx(ConfirmDialog, { isOpen: showDeleteConfirm, title: "Delete Item", message: "Are you sure you want to delete this item? This action cannot be undone.", confirmLabel: "Delete", cancelLabel: "Cancel", variant: "danger", onConfirm: handleDelete, onCancel: () => setShowDeleteConfirm(false) })] }));
72
+ const busy = isPending || isDeleting;
73
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-6", children: [generalError && (_jsx("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-4", children: _jsx("p", { className: "text-sm font-medium", children: generalError }) })), _jsx("div", { className: "space-y-6", children: editableFields.map(([fieldName, fieldConfig]) => (_jsx(FieldRenderer, { fieldName: fieldName, fieldConfig: fieldConfig, value: formData[fieldName], onChange: (value) => handleFieldChange(fieldName, value), error: errors[fieldName], disabled: busy, mode: "edit", relationshipItems: relationshipData[fieldName] || [], relationshipLoading: false, basePath: basePath }, fieldName))) }), _jsxs("div", { className: "flex items-center justify-between pt-6 border-t border-border", children: [_jsxs("div", { className: "flex gap-3", children: [_jsxs(Button, { type: "submit", disabled: busy, className: "gap-2", children: [isPending && (_jsx(LoadingSpinner, { size: "sm", className: "border-primary-foreground border-t-transparent" })), isPending ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'] }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => router.push(`${basePath}/${urlKey}`), disabled: busy, children: "Cancel" })] }), mode === 'edit' && itemId && (_jsx(Button, { type: "button", variant: "destructive", onClick: () => setShowDeleteConfirm(true), disabled: busy, children: "Delete" }))] }), _jsx(ConfirmDialog, { isOpen: showDeleteConfirm, title: "Delete Item", message: "Are you sure you want to delete this item? This action cannot be undone.", confirmLabel: "Delete", cancelLabel: "Cancel", variant: "danger", onConfirm: handleDelete, onCancel: () => setShowDeleteConfirm(false) })] }));
147
74
  }
@@ -1 +1 @@
1
- {"version":3,"file":"FieldRenderer.d.ts","sourceRoot":"","sources":["../../../src/components/fields/FieldRenderer.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAA;AAEhF,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,uBAAuB,CAAA;IACpC,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxD,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AA6ED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,kDA+BtD"}
1
+ {"version":3,"file":"FieldRenderer.d.ts","sourceRoot":"","sources":["../../../src/components/fields/FieldRenderer.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAA;AAEhF,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,uBAAuB,CAAA;IACpC,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxD,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAwFD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,kDA+BtD"}
@@ -27,33 +27,37 @@ function FieldRendererInner({ Component, fieldName, fieldConfig, value, onChange
27
27
  required: isRequired,
28
28
  mode,
29
29
  };
30
- // Add field-type-specific props
31
- const specificProps = {};
32
- if (fieldConfig.type === 'select' && 'options' in fieldConfig && fieldConfig.options) {
33
- specificProps.options = fieldConfig.options.map((opt) => typeof opt === 'string' ? { label: opt, value: opt } : opt);
34
- }
35
- if (fieldConfig.type === 'password') {
36
- specificProps.showConfirm = mode === 'edit';
37
- }
38
- if (fieldConfig.type === 'relationship') {
39
- specificProps.items = relationshipItems;
40
- specificProps.isLoading = relationshipLoading;
41
- specificProps.many = fieldConfig.many || false;
42
- // Extract related list key from ref (format: 'ListName.fieldName')
43
- if (fieldConfig.ref) {
44
- const [relatedListName] = fieldConfig.ref.split('.');
45
- specificProps.relatedListKey = getUrlKey(relatedListName);
46
- specificProps.basePath = basePath;
47
- }
48
- }
30
+ // Derive field-type-specific props from data-presence checks — no branching on fieldConfig.type.
31
+ const specificProps = buildFallbackUIProps(fieldConfig, relationshipItems, relationshipLoading, basePath);
49
32
  // Pass through any UI options from fieldConfig.ui (excluding component and fieldType)
50
33
  if (fieldConfig.ui) {
51
- const { _component, _fieldType, ...uiOptions } = fieldConfig.ui;
34
+ const { component: _component, fieldType: _fieldType, ...uiOptions } = fieldConfig.ui;
52
35
  Object.assign(specificProps, uiOptions);
53
36
  }
54
37
  const allProps = { ...baseProps, ...specificProps };
55
38
  return _jsx(Component, { ...allProps });
56
39
  }
40
+ /**
41
+ * Derive field-specific UI props from the serialised field config using
42
+ * data-presence checks rather than `fieldConfig.type` comparisons.
43
+ */
44
+ function buildFallbackUIProps(fieldConfig, relationshipItems, relationshipLoading, basePath) {
45
+ const props = {};
46
+ // Select options — only present on select fields
47
+ if (fieldConfig.options) {
48
+ props.options = fieldConfig.options;
49
+ }
50
+ // Relationship props — only present on relationship fields
51
+ if (fieldConfig.ref) {
52
+ const [relatedListName] = fieldConfig.ref.split('.');
53
+ props.relatedListKey = getUrlKey(relatedListName ?? '');
54
+ props.many = fieldConfig.many || false;
55
+ props.items = relationshipItems;
56
+ props.isLoading = relationshipLoading;
57
+ props.basePath = basePath;
58
+ }
59
+ return props;
60
+ }
57
61
  /**
58
62
  * Factory component that renders the appropriate field type
59
63
  * based on the field configuration and component registry
@@ -1,4 +1,4 @@
1
- import type { FileMetadata } from '@opensaas/stack-core';
1
+ import type { FileMetadata } from '@opensaas/stack-core/internal';
2
2
  export interface FileFieldProps {
3
3
  name: string;
4
4
  value: File | FileMetadata | null;
@@ -1 +1 @@
1
- {"version":3,"file":"FileField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/FileField.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAMxD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,CAAA;IACjC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,KAAK,IAAI,CAAA;IACrD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,QAAQ,EACR,WAA8C,GAC/C,EAAE,cAAc,2CA+KhB"}
1
+ {"version":3,"file":"FileField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/FileField.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAA;AAMjE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,CAAA;IACjC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,KAAK,IAAI,CAAA;IACrD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,QAAQ,EACR,WAA8C,GAC/C,EAAE,cAAc,2CA+KhB"}
@@ -1,4 +1,4 @@
1
- import type { ImageMetadata } from '@opensaas/stack-core';
1
+ import type { ImageMetadata } from '@opensaas/stack-core/internal';
2
2
  export interface ImageFieldProps {
3
3
  name: string;
4
4
  value: File | ImageMetadata | null;
@@ -1 +1 @@
1
- {"version":3,"file":"ImageField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/ImageField.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAOzD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,IAAI,GAAG,aAAa,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,GAAG,aAAa,GAAG,IAAI,KAAK,IAAI,CAAA;IACtD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,QAAQ,EACR,WAAgD,EAChD,WAAkB,EAClB,WAAiB,GAClB,EAAE,eAAe,2CAmRjB"}
1
+ {"version":3,"file":"ImageField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/ImageField.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAOlE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,IAAI,GAAG,aAAa,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,GAAG,aAAa,GAAG,IAAI,KAAK,IAAI,CAAA;IACtD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,QAAQ,EACR,WAAgD,EAChD,WAAkB,EAClB,WAAiB,GAClB,EAAE,eAAe,2CAmRjB"}
@@ -1 +1 @@
1
- {"version":3,"file":"ItemCreateForm.d.ts","sourceRoot":"","sources":["../../../src/components/standalone/ItemCreateForm.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGvD,MAAM,WAAW,mBAAmB,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAClE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;IACvE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC9D,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,gBAAqB,EACrB,WAAsB,EACtB,WAAsB,EACtB,SAAS,GACV,EAAE,mBAAmB,CAAC,KAAK,CAAC,2CAoH5B"}
1
+ {"version":3,"file":"ItemCreateForm.d.ts","sourceRoot":"","sources":["../../../src/components/standalone/ItemCreateForm.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAIvD,MAAM,WAAW,mBAAmB,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAClE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;IACvE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC9D,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,gBAAqB,EACrB,WAAsB,EACtB,WAAsB,EACtB,SAAS,GACV,EAAE,mBAAmB,CAAC,KAAK,CAAC,2CAmE5B"}
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useState, useMemo } from 'react';
3
+ import { useMemo } from 'react';
4
4
  import { FieldRenderer } from '../fields/FieldRenderer.js';
5
5
  import { LoadingSpinner } from '../LoadingSpinner.js';
6
6
  import { Button } from '../../primitives/button.js';
7
7
  import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js';
8
+ import { useItemForm } from '../../lib/useItemForm.js';
8
9
  /**
9
10
  * Standalone form component for creating items
10
11
  * Can be embedded in any custom page
@@ -24,68 +25,16 @@ import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js';
24
25
  export function ItemCreateForm({ fields, onSubmit, onCancel, relationshipData = {}, submitLabel = 'Create', cancelLabel = 'Cancel', className, }) {
25
26
  // Serialize field configs to remove non-serializable properties
26
27
  const serializedFields = useMemo(() => serializeFieldConfigs(fields), [fields]);
27
- const [isPending, setIsPending] = useState(false);
28
- const [formData, setFormData] = useState({});
29
- const [errors, setErrors] = useState({});
30
- const [generalError, setGeneralError] = useState(null);
31
- const handleFieldChange = (fieldName, value) => {
32
- setFormData((prev) => ({ ...prev, [fieldName]: value }));
33
- // Clear error for this field when user starts typing
34
- if (errors[fieldName]) {
35
- setErrors((prev) => {
36
- const newErrors = { ...prev };
37
- delete newErrors[fieldName];
38
- return newErrors;
39
- });
40
- }
41
- };
42
- const handleSubmit = async (e) => {
43
- e.preventDefault();
44
- setErrors({});
45
- setGeneralError(null);
46
- setIsPending(true);
47
- try {
48
- // Transform relationship fields to Prisma format
49
- const transformedData = {};
50
- for (const [fieldName, value] of Object.entries(formData)) {
51
- const fieldConfig = serializedFields[fieldName];
52
- // Transform relationship fields
53
- if (fieldConfig?.type === 'relationship') {
54
- if (fieldConfig.many) {
55
- // Many relationship: use connect format
56
- if (Array.isArray(value) && value.length > 0) {
57
- transformedData[fieldName] = {
58
- connect: value.map((id) => ({ id })),
59
- };
60
- }
61
- }
62
- else {
63
- // Single relationship: use connect format
64
- if (value) {
65
- transformedData[fieldName] = {
66
- connect: { id: value },
67
- };
68
- }
69
- }
70
- }
71
- else {
72
- // Non-relationship field: pass through
73
- transformedData[fieldName] = value;
74
- }
75
- }
76
- const result = await onSubmit(transformedData);
77
- if (!result.success) {
78
- setGeneralError(result.error || 'Failed to create item');
79
- }
80
- }
81
- catch (error) {
82
- setGeneralError(error.message || 'Failed to create item');
83
- }
84
- finally {
85
- setIsPending(false);
86
- }
87
- };
88
- // Filter out system fields
89
- const editableFields = Object.entries(serializedFields).filter(([key]) => !['id', 'createdAt', 'updatedAt'].includes(key));
28
+ const { formData, errors, generalError, isPending, editableFields, handleFieldChange, handleSubmit, } = useItemForm({
29
+ fields: serializedFields,
30
+ mode: 'create',
31
+ errorFallback: 'Failed to create item',
32
+ onSubmit: async (data) => {
33
+ const result = await onSubmit(data);
34
+ return result.success
35
+ ? { success: true }
36
+ : { success: false, error: result.error || 'Failed to create item' };
37
+ },
38
+ });
90
39
  return (_jsxs("form", { onSubmit: handleSubmit, className: className, children: [generalError && (_jsx("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-4 mb-6", children: _jsx("p", { className: "text-sm font-medium", children: generalError }) })), _jsx("div", { className: "space-y-6", children: editableFields.map(([fieldName, fieldConfig]) => (_jsx(FieldRenderer, { fieldName: fieldName, fieldConfig: fieldConfig, value: formData[fieldName], onChange: (value) => handleFieldChange(fieldName, value), error: errors[fieldName], disabled: isPending, mode: "edit", relationshipItems: relationshipData[fieldName] || [], relationshipLoading: false }, fieldName))) }), _jsxs("div", { className: "flex gap-3 pt-6 mt-6 border-t border-border", children: [_jsxs(Button, { type: "submit", disabled: isPending, className: "gap-2", children: [isPending && (_jsx(LoadingSpinner, { size: "sm", className: "border-primary-foreground border-t-transparent" })), isPending ? 'Creating...' : submitLabel] }), onCancel && (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: isPending, children: cancelLabel }))] })] }));
91
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ItemEditForm.d.ts","sourceRoot":"","sources":["../../../src/components/standalone/ItemEditForm.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGvD,MAAM,WAAW,iBAAiB,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAChE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IACnC,WAAW,EAAE,KAAK,CAAA;IAClB,QAAQ,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;IACvE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC5D,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,gBAAqB,EACrB,WAAoB,EACpB,WAAsB,EACtB,SAAS,EACT,QAAmB,GACpB,EAAE,iBAAiB,CAAC,KAAK,CAAC,2CA+I1B"}
1
+ {"version":3,"file":"ItemEditForm.d.ts","sourceRoot":"","sources":["../../../src/components/standalone/ItemEditForm.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAIvD,MAAM,WAAW,iBAAiB,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAChE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IACnC,WAAW,EAAE,KAAK,CAAA;IAClB,QAAQ,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;IACvE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC5D,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,gBAAqB,EACrB,WAAoB,EACpB,WAAsB,EACtB,SAAS,EACT,QAAmB,GACpB,EAAE,iBAAiB,CAAC,KAAK,CAAC,2CA2E1B"}
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useState, useMemo } from 'react';
3
+ import { useMemo } from 'react';
4
4
  import { FieldRenderer } from '../fields/FieldRenderer.js';
5
5
  import { LoadingSpinner } from '../LoadingSpinner.js';
6
6
  import { Button } from '../../primitives/button.js';
7
7
  import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js';
8
+ import { useItemForm, transformInitialData } from '../../lib/useItemForm.js';
8
9
  /**
9
10
  * Standalone form component for editing items
10
11
  * Can be embedded in any custom page
@@ -26,87 +27,18 @@ export function ItemEditForm({ fields, initialData, onSubmit, onCancel, relation
26
27
  // Serialize field configs to remove non-serializable properties
27
28
  const serializedFields = useMemo(() => serializeFieldConfigs(fields), [fields]);
28
29
  // Apply valueForClientSerialization transformations to initial data
29
- const transformedInitialData = useMemo(() => {
30
- const transformed = { ...initialData };
31
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
32
- const fieldConfigAny = fieldConfig;
33
- if (fieldConfigAny.ui?.valueForClientSerialization &&
34
- typeof fieldConfigAny.ui.valueForClientSerialization === 'function') {
35
- const transformer = fieldConfigAny.ui.valueForClientSerialization;
36
- transformed[fieldName] = transformer({
37
- value: transformed[fieldName],
38
- });
39
- }
40
- }
41
- return transformed;
42
- }, [initialData, fields]);
43
- const [isPending, setIsPending] = useState(false);
44
- const [formData, setFormData] = useState(transformedInitialData);
45
- const [errors, setErrors] = useState({});
46
- const [generalError, setGeneralError] = useState(null);
47
- const handleFieldChange = (fieldName, value) => {
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
- const handleSubmit = async (e) => {
59
- e.preventDefault();
60
- setErrors({});
61
- setGeneralError(null);
62
- setIsPending(true);
63
- try {
64
- // Transform relationship fields to Prisma format
65
- // Filter out password fields with isSet objects (unchanged passwords)
66
- const transformedData = {};
67
- for (const [fieldName, value] of Object.entries(formData)) {
68
- const fieldConfig = serializedFields[fieldName];
69
- // Skip password fields that have { isSet: boolean } value (not being changed)
70
- if (typeof value === 'object' && value !== null && 'isSet' in value) {
71
- continue;
72
- }
73
- // Transform relationship fields
74
- if (fieldConfig?.type === 'relationship') {
75
- if (fieldConfig.many) {
76
- // Many relationship: use connect format
77
- if (Array.isArray(value) && value.length > 0) {
78
- transformedData[fieldName] = {
79
- connect: value.map((id) => ({ id })),
80
- };
81
- }
82
- }
83
- else {
84
- // Single relationship: use connect format
85
- if (value) {
86
- transformedData[fieldName] = {
87
- connect: { id: value },
88
- };
89
- }
90
- }
91
- }
92
- else {
93
- // Non-relationship field: pass through
94
- transformedData[fieldName] = value;
95
- }
96
- }
97
- const result = await onSubmit(transformedData);
98
- if (!result.success) {
99
- setGeneralError(result.error || 'Failed to update item');
100
- }
101
- }
102
- catch (error) {
103
- setGeneralError(error.message || 'Failed to update item');
104
- }
105
- finally {
106
- setIsPending(false);
107
- }
108
- };
109
- // Filter out system fields
110
- const editableFields = Object.entries(serializedFields).filter(([key]) => !['id', 'createdAt', 'updatedAt'].includes(key));
30
+ const transformedInitialData = useMemo(() => transformInitialData(fields, initialData), [initialData, fields]);
31
+ const { formData, errors, generalError, isPending, editableFields, handleFieldChange, handleSubmit, } = useItemForm({
32
+ fields: serializedFields,
33
+ initialData: transformedInitialData,
34
+ mode: 'update',
35
+ errorFallback: 'Failed to update item',
36
+ onSubmit: async (data) => {
37
+ const result = await onSubmit(data);
38
+ return result.success
39
+ ? { success: true }
40
+ : { success: false, error: result.error || 'Failed to update item' };
41
+ },
42
+ });
111
43
  return (_jsxs("form", { onSubmit: handleSubmit, className: className, children: [generalError && (_jsx("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-4 mb-6", children: _jsx("p", { className: "text-sm font-medium", children: generalError }) })), _jsx("div", { className: "space-y-6", children: editableFields.map(([fieldName, fieldConfig]) => (_jsx(FieldRenderer, { fieldName: fieldName, fieldConfig: fieldConfig, value: formData[fieldName], onChange: (value) => handleFieldChange(fieldName, value), error: errors[fieldName], disabled: isPending, mode: "edit", relationshipItems: relationshipData[fieldName] || [], relationshipLoading: false, basePath: basePath }, fieldName))) }), _jsxs("div", { className: "flex gap-3 pt-6 mt-6 border-t border-border", children: [_jsxs(Button, { type: "submit", disabled: isPending, className: "gap-2", children: [isPending && (_jsx(LoadingSpinner, { size: "sm", className: "border-primary-foreground border-t-transparent" })), isPending ? 'Saving...' : submitLabel] }), onCancel && (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: isPending, children: cancelLabel }))] })] }));
112
44
  }
@@ -1,4 +1,4 @@
1
- import type { ThemeColors, ThemeConfig, ThemePreset } from '@opensaas/stack-core';
1
+ import type { ThemeColors, ThemeConfig, ThemePreset } from '@opensaas/stack-core/internal';
2
2
  /**
3
3
  * Preset theme definitions
4
4
  */