@opensaas/stack-ui 0.20.0 → 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.
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 +7 -7
  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,12 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { useState, useMemo } from 'react'
4
+ import { useMemo } from 'react'
5
5
  import { FieldRenderer } from '../fields/FieldRenderer.js'
6
6
  import { LoadingSpinner } from '../LoadingSpinner.js'
7
7
  import { Button } from '../../primitives/button.js'
8
8
  import type { FieldConfig } from '@opensaas/stack-core'
9
9
  import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js'
10
+ import { useItemForm } from '../../lib/useItemForm.js'
10
11
 
11
12
  export interface ItemCreateFormProps<TData = Record<string, unknown>> {
12
13
  fields: Record<string, FieldConfig>
@@ -46,74 +47,25 @@ export function ItemCreateForm<TData = Record<string, unknown>>({
46
47
  // Serialize field configs to remove non-serializable properties
47
48
  const serializedFields = useMemo(() => serializeFieldConfigs(fields), [fields])
48
49
 
49
- const [isPending, setIsPending] = useState(false)
50
- const [formData, setFormData] = useState<Partial<TData>>({} as Partial<TData>)
51
- const [errors, setErrors] = useState<Record<string, string>>({})
52
- const [generalError, setGeneralError] = useState<string | null>(null)
53
-
54
- const handleFieldChange = (fieldName: string, value: unknown) => {
55
- setFormData((prev) => ({ ...prev, [fieldName]: value }) as Partial<TData>)
56
- // Clear error for this field when user starts typing
57
- if (errors[fieldName]) {
58
- setErrors((prev) => {
59
- const newErrors = { ...prev }
60
- delete newErrors[fieldName]
61
- return newErrors
62
- })
63
- }
64
- }
65
-
66
- const handleSubmit = async (e: React.FormEvent) => {
67
- e.preventDefault()
68
- setErrors({})
69
- setGeneralError(null)
70
- setIsPending(true)
71
-
72
- try {
73
- // Transform relationship fields to Prisma format
74
- const transformedData: Record<string, unknown> = {}
75
- for (const [fieldName, value] of Object.entries(formData as Record<string, unknown>)) {
76
- const fieldConfig = serializedFields[fieldName]
77
-
78
- // Transform relationship fields
79
- if (fieldConfig?.type === 'relationship') {
80
- if (fieldConfig.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
97
- transformedData[fieldName] = value
98
- }
99
- }
100
-
101
- const result = await onSubmit(transformedData as TData)
102
-
103
- if (!result.success) {
104
- setGeneralError(result.error || 'Failed to create item')
105
- }
106
- } catch (error: unknown) {
107
- setGeneralError((error as Error).message || 'Failed to create item')
108
- } finally {
109
- setIsPending(false)
110
- }
111
- }
112
-
113
- // Filter out system fields
114
- const editableFields = Object.entries(serializedFields).filter(
115
- ([key]) => !['id', 'createdAt', 'updatedAt'].includes(key),
116
- )
50
+ const {
51
+ formData,
52
+ errors,
53
+ generalError,
54
+ isPending,
55
+ editableFields,
56
+ handleFieldChange,
57
+ handleSubmit,
58
+ } = useItemForm({
59
+ fields: serializedFields,
60
+ mode: 'create',
61
+ errorFallback: 'Failed to create item',
62
+ onSubmit: async (data) => {
63
+ const result = await onSubmit(data as TData)
64
+ return result.success
65
+ ? { success: true }
66
+ : { success: false, error: result.error || 'Failed to create item' }
67
+ },
68
+ })
117
69
 
118
70
  return (
119
71
  <form onSubmit={handleSubmit} className={className}>
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { useState, useMemo } from 'react'
4
+ import { useMemo } from 'react'
5
5
  import { FieldRenderer } from '../fields/FieldRenderer.js'
6
6
  import { LoadingSpinner } from '../LoadingSpinner.js'
7
7
  import { Button } from '../../primitives/button.js'
8
8
  import type { FieldConfig } from '@opensaas/stack-core'
9
9
  import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js'
10
+ import { useItemForm, transformInitialData } from '../../lib/useItemForm.js'
10
11
 
11
12
  export interface ItemEditFormProps<TData = Record<string, unknown>> {
12
13
  fields: Record<string, FieldConfig>
@@ -52,100 +53,32 @@ export function ItemEditForm<TData = Record<string, unknown>>({
52
53
  const serializedFields = useMemo(() => serializeFieldConfigs(fields), [fields])
53
54
 
54
55
  // Apply valueForClientSerialization transformations to initial data
55
- const transformedInitialData = useMemo(() => {
56
- const transformed = { ...initialData }
57
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
58
- const fieldConfigAny = fieldConfig as { ui?: Record<string, unknown> }
59
- if (
60
- fieldConfigAny.ui?.valueForClientSerialization &&
61
- typeof fieldConfigAny.ui.valueForClientSerialization === 'function'
62
- ) {
63
- const transformer = fieldConfigAny.ui.valueForClientSerialization as (args: {
64
- value: unknown
65
- }) => unknown
66
- transformed[fieldName as keyof TData] = transformer({
67
- value: transformed[fieldName as keyof TData],
68
- }) as TData[keyof TData]
69
- }
70
- }
71
- return transformed
72
- }, [initialData, fields])
73
-
74
- const [isPending, setIsPending] = useState(false)
75
- const [formData, setFormData] = useState<TData>(transformedInitialData)
76
- const [errors, setErrors] = useState<Record<string, string>>({})
77
- const [generalError, setGeneralError] = useState<string | null>(null)
78
-
79
- const handleFieldChange = (fieldName: string, value: unknown) => {
80
- setFormData((prev) => ({ ...prev, [fieldName]: value }) as TData)
81
- // Clear error for this field when user starts typing
82
- if (errors[fieldName]) {
83
- setErrors((prev) => {
84
- const newErrors = { ...prev }
85
- delete newErrors[fieldName]
86
- return newErrors
87
- })
88
- }
89
- }
90
-
91
- const handleSubmit = async (e: React.FormEvent) => {
92
- e.preventDefault()
93
- setErrors({})
94
- setGeneralError(null)
95
- setIsPending(true)
96
-
97
- try {
98
- // Transform relationship fields to Prisma format
99
- // Filter out password fields with isSet objects (unchanged passwords)
100
- const transformedData: Record<string, unknown> = {}
101
- for (const [fieldName, value] of Object.entries(formData as Record<string, unknown>)) {
102
- const fieldConfig = serializedFields[fieldName]
103
-
104
- // Skip password fields that have { isSet: boolean } value (not being changed)
105
- if (typeof value === 'object' && value !== null && 'isSet' in value) {
106
- continue
107
- }
108
-
109
- // Transform relationship fields
110
- if (fieldConfig?.type === 'relationship') {
111
- if (fieldConfig.many) {
112
- // Many relationship: use connect format
113
- if (Array.isArray(value) && value.length > 0) {
114
- transformedData[fieldName] = {
115
- connect: value.map((id: string) => ({ id })),
116
- }
117
- }
118
- } else {
119
- // Single relationship: use connect format
120
- if (value) {
121
- transformedData[fieldName] = {
122
- connect: { id: value },
123
- }
124
- }
125
- }
126
- } else {
127
- // Non-relationship field: pass through
128
- transformedData[fieldName] = value
129
- }
130
- }
131
-
132
- const result = await onSubmit(transformedData as TData)
133
-
134
- if (!result.success) {
135
- setGeneralError(result.error || 'Failed to update item')
136
- }
137
- } catch (error: unknown) {
138
- setGeneralError((error as Error).message || 'Failed to update item')
139
- } finally {
140
- setIsPending(false)
141
- }
142
- }
143
-
144
- // Filter out system fields
145
- const editableFields = Object.entries(serializedFields).filter(
146
- ([key]) => !['id', 'createdAt', 'updatedAt'].includes(key),
56
+ const transformedInitialData = useMemo(
57
+ () => transformInitialData(fields, initialData as Record<string, unknown>),
58
+ [initialData, fields],
147
59
  )
148
60
 
61
+ const {
62
+ formData,
63
+ errors,
64
+ generalError,
65
+ isPending,
66
+ editableFields,
67
+ handleFieldChange,
68
+ handleSubmit,
69
+ } = useItemForm({
70
+ fields: serializedFields,
71
+ initialData: transformedInitialData,
72
+ mode: 'update',
73
+ errorFallback: 'Failed to update item',
74
+ onSubmit: async (data) => {
75
+ const result = await onSubmit(data as TData)
76
+ return result.success
77
+ ? { success: true }
78
+ : { success: false, error: result.error || 'Failed to update item' }
79
+ },
80
+ })
81
+
149
82
  return (
150
83
  <form onSubmit={handleSubmit} className={className}>
151
84
  {/* General Error */}
package/src/lib/theme.ts CHANGED
@@ -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
  /**
4
4
  * Preset theme definitions
@@ -0,0 +1,194 @@
1
+ 'use client'
2
+
3
+ import { useState, useTransition } from 'react'
4
+ import type { SerializableFieldConfig } from './serializeFieldConfig.js'
5
+
6
+ /**
7
+ * The action an item form submission represents.
8
+ */
9
+ export type ItemFormAction = 'create' | 'update'
10
+
11
+ const SYSTEM_FIELDS = ['id', 'createdAt', 'updatedAt']
12
+
13
+ /**
14
+ * Transform raw form state into the data shape expected by the server/Prisma.
15
+ *
16
+ * This is the shared submit transform used by every item form (the AdminUI
17
+ * form and the standalone create/edit forms). Keeping it a pure function makes
18
+ * it testable without rendering a component.
19
+ *
20
+ * Behaviour (the superset applied by all forms):
21
+ * - Relationship fields are converted to Prisma `connect` shape (single or many).
22
+ * Empty single relationships and empty many-arrays are omitted.
23
+ * - Password fields whose value is an `{ isSet }` sentinel (an unchanged password
24
+ * read back from the server) are skipped, so they are not re-submitted.
25
+ * - All other fields pass through unchanged (including `File` objects, which the
26
+ * Next.js server action serialises).
27
+ */
28
+ export function transformItemFormData(
29
+ fields: Record<string, SerializableFieldConfig>,
30
+ formData: Record<string, unknown>,
31
+ ): Record<string, unknown> {
32
+ const transformed: Record<string, unknown> = {}
33
+
34
+ for (const [fieldName, value] of Object.entries(formData)) {
35
+ const fieldConfig = fields[fieldName]
36
+
37
+ // Skip password fields carrying an { isSet } sentinel (unchanged password).
38
+ if (typeof value === 'object' && value !== null && 'isSet' in value) {
39
+ continue
40
+ }
41
+
42
+ if (fieldConfig?.type === 'relationship') {
43
+ if (fieldConfig.many) {
44
+ if (Array.isArray(value) && value.length > 0) {
45
+ transformed[fieldName] = { connect: value.map((id: string) => ({ id })) }
46
+ }
47
+ } else if (value) {
48
+ transformed[fieldName] = { connect: { id: value } }
49
+ }
50
+ } else {
51
+ transformed[fieldName] = value
52
+ }
53
+ }
54
+
55
+ return transformed
56
+ }
57
+
58
+ /**
59
+ * Apply each field's `valueForClientSerialization` transform to initial data,
60
+ * so values read from the server are shaped for the client form inputs.
61
+ *
62
+ * Operates on the original (non-serialized) field configs because the transform
63
+ * function is stripped during serialization.
64
+ */
65
+ export function transformInitialData<TData extends Record<string, unknown>>(
66
+ fields: Record<string, unknown>,
67
+ initialData: TData,
68
+ ): TData {
69
+ const transformed = { ...initialData }
70
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
71
+ const ui = (fieldConfig as { ui?: Record<string, unknown> }).ui
72
+ const transformer = ui?.valueForClientSerialization
73
+ if (typeof transformer === 'function') {
74
+ transformed[fieldName as keyof TData] = (
75
+ transformer as (args: { value: unknown }) => unknown
76
+ )({ value: transformed[fieldName as keyof TData] }) as TData[keyof TData]
77
+ }
78
+ }
79
+ return transformed
80
+ }
81
+
82
+ /**
83
+ * Drop system fields (id, createdAt, updatedAt) from a field-config map,
84
+ * returning the editable entries in declaration order.
85
+ */
86
+ export function getEditableFields(
87
+ fields: Record<string, SerializableFieldConfig>,
88
+ ): Array<[string, SerializableFieldConfig]> {
89
+ return Object.entries(fields).filter(([key]) => !SYSTEM_FIELDS.includes(key))
90
+ }
91
+
92
+ /**
93
+ * Result of a form submission adapter. `false`/error means the submission
94
+ * failed and the form should surface `error` (and optional per-field errors).
95
+ */
96
+ export type ItemFormSubmitResult =
97
+ | { success: true }
98
+ | { success: false; error: string; fieldErrors?: Record<string, string> }
99
+
100
+ export interface UseItemFormOptions {
101
+ /** Serialized field configs (drives rendering + the submit transform). */
102
+ fields: Record<string, SerializableFieldConfig>
103
+ /** Initial form values (already client-serialized). */
104
+ initialData?: Record<string, unknown>
105
+ /** Whether this form creates or updates. Selects the submit action. */
106
+ mode: ItemFormAction
107
+ /**
108
+ * Submit adapter. Receives the transformed data and the action; each caller
109
+ * wires this to its own mechanism (AdminUI server action + navigation, or a
110
+ * standalone `onSubmit` callback). May return void for the legacy/standalone
111
+ * "throw on failure" style.
112
+ */
113
+ onSubmit: (
114
+ data: Record<string, unknown>,
115
+ action: ItemFormAction,
116
+ ) => Promise<ItemFormSubmitResult | void>
117
+ /** Optional fallback message when a submit throws without a message. */
118
+ errorFallback?: string
119
+ }
120
+
121
+ export interface UseItemFormResult {
122
+ formData: Record<string, unknown>
123
+ errors: Record<string, string>
124
+ generalError: string | null
125
+ isPending: boolean
126
+ editableFields: Array<[string, SerializableFieldConfig]>
127
+ handleFieldChange: (fieldName: string, value: unknown) => void
128
+ handleSubmit: (e: { preventDefault: () => void }) => void
129
+ setGeneralError: (message: string | null) => void
130
+ }
131
+
132
+ /**
133
+ * The shared item-form engine.
134
+ *
135
+ * Holds the form state, the clear-error-on-change behaviour, the submit
136
+ * transform, and the pending state (via `useTransition`) that every item form
137
+ * needs. Callers supply only an `onSubmit` adapter and render the returned
138
+ * fields/handlers — so the AdminUI form and the standalone create/edit forms
139
+ * stay thin and share one tested code path.
140
+ */
141
+ export function useItemForm({
142
+ fields,
143
+ initialData = {},
144
+ mode,
145
+ onSubmit,
146
+ errorFallback = 'Operation failed',
147
+ }: UseItemFormOptions): UseItemFormResult {
148
+ const [isPending, startTransition] = useTransition()
149
+ const [formData, setFormData] = useState<Record<string, unknown>>(initialData)
150
+ const [errors, setErrors] = useState<Record<string, string>>({})
151
+ const [generalError, setGeneralError] = useState<string | null>(null)
152
+
153
+ const handleFieldChange = (fieldName: string, value: unknown) => {
154
+ setFormData((prev) => ({ ...prev, [fieldName]: value }))
155
+ if (errors[fieldName]) {
156
+ setErrors((prev) => {
157
+ const next = { ...prev }
158
+ delete next[fieldName]
159
+ return next
160
+ })
161
+ }
162
+ }
163
+
164
+ const handleSubmit = (e: { preventDefault: () => void }) => {
165
+ e.preventDefault()
166
+ setErrors({})
167
+ setGeneralError(null)
168
+
169
+ startTransition(async () => {
170
+ const data = transformItemFormData(fields, formData)
171
+ try {
172
+ const result = await onSubmit(data, mode)
173
+ // void result → adapter handles its own success/navigation.
174
+ if (result && result.success === false) {
175
+ if (result.fieldErrors) setErrors(result.fieldErrors)
176
+ setGeneralError(result.error || errorFallback)
177
+ }
178
+ } catch (error: unknown) {
179
+ setGeneralError((error as Error)?.message || errorFallback)
180
+ }
181
+ })
182
+ }
183
+
184
+ return {
185
+ formData,
186
+ errors,
187
+ generalError,
188
+ isPending,
189
+ editableFields: getEditableFields(fields),
190
+ handleFieldChange,
191
+ handleSubmit,
192
+ setGeneralError,
193
+ }
194
+ }
@@ -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
  /**
8
8
  * Result of a server action
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ transformItemFormData,
4
+ transformInitialData,
5
+ getEditableFields,
6
+ } from '../../src/lib/useItemForm.js'
7
+ import type { SerializableFieldConfig } from '../../src/lib/serializeFieldConfig.js'
8
+
9
+ const text = (): SerializableFieldConfig => ({ type: 'text' })
10
+ const singleRel = (): SerializableFieldConfig => ({ type: 'relationship', many: false })
11
+ const manyRel = (): SerializableFieldConfig => ({ type: 'relationship', many: true })
12
+ const password = (): SerializableFieldConfig => ({ type: 'password' })
13
+
14
+ describe('transformItemFormData', () => {
15
+ it('passes scalar fields through unchanged', () => {
16
+ const fields = { title: text(), views: text() }
17
+ const out = transformItemFormData(fields, { title: 'Hi', views: 3 })
18
+ expect(out).toEqual({ title: 'Hi', views: 3 })
19
+ })
20
+
21
+ it('converts a single relationship to connect shape', () => {
22
+ const fields = { author: singleRel() }
23
+ expect(transformItemFormData(fields, { author: 'u1' })).toEqual({
24
+ author: { connect: { id: 'u1' } },
25
+ })
26
+ })
27
+
28
+ it('omits an empty single relationship', () => {
29
+ const fields = { author: singleRel() }
30
+ expect(transformItemFormData(fields, { author: '' })).toEqual({})
31
+ expect(transformItemFormData(fields, { author: null })).toEqual({})
32
+ })
33
+
34
+ it('converts a many relationship to connect-array shape', () => {
35
+ const fields = { tags: manyRel() }
36
+ expect(transformItemFormData(fields, { tags: ['a', 'b'] })).toEqual({
37
+ tags: { connect: [{ id: 'a' }, { id: 'b' }] },
38
+ })
39
+ })
40
+
41
+ it('omits an empty many relationship', () => {
42
+ const fields = { tags: manyRel() }
43
+ expect(transformItemFormData(fields, { tags: [] })).toEqual({})
44
+ })
45
+
46
+ it('skips password fields carrying an { isSet } sentinel', () => {
47
+ const fields = { password: password() }
48
+ expect(transformItemFormData(fields, { password: { isSet: true } })).toEqual({})
49
+ })
50
+
51
+ it('submits a password when a new plaintext value is provided', () => {
52
+ const fields = { password: password() }
53
+ expect(transformItemFormData(fields, { password: 'secret' })).toEqual({ password: 'secret' })
54
+ })
55
+
56
+ it('handles a mixed payload end-to-end', () => {
57
+ const fields = {
58
+ title: text(),
59
+ author: singleRel(),
60
+ tags: manyRel(),
61
+ password: password(),
62
+ }
63
+ const out = transformItemFormData(fields, {
64
+ title: 'Post',
65
+ author: 'u1',
66
+ tags: ['t1'],
67
+ password: { isSet: true },
68
+ })
69
+ expect(out).toEqual({
70
+ title: 'Post',
71
+ author: { connect: { id: 'u1' } },
72
+ tags: { connect: [{ id: 't1' }] },
73
+ })
74
+ })
75
+ })
76
+
77
+ describe('transformInitialData', () => {
78
+ it('applies a field valueForClientSerialization transform', () => {
79
+ const fields = {
80
+ when: {
81
+ type: 'timestamp',
82
+ ui: { valueForClientSerialization: ({ value }: { value: unknown }) => `iso:${value}` },
83
+ },
84
+ title: { type: 'text' },
85
+ }
86
+ const out = transformInitialData(fields, { when: 123, title: 'x' })
87
+ expect(out).toEqual({ when: 'iso:123', title: 'x' })
88
+ })
89
+
90
+ it('leaves data unchanged when no transform is defined', () => {
91
+ const fields = { title: { type: 'text' } }
92
+ expect(transformInitialData(fields, { title: 'x' })).toEqual({ title: 'x' })
93
+ })
94
+ })
95
+
96
+ describe('getEditableFields', () => {
97
+ it('drops system fields and preserves declaration order', () => {
98
+ const fields = {
99
+ id: text(),
100
+ title: text(),
101
+ createdAt: text(),
102
+ body: text(),
103
+ updatedAt: text(),
104
+ }
105
+ expect(getEditableFields(fields).map(([k]) => k)).toEqual(['title', 'body'])
106
+ })
107
+ })