@opensaas/stack-ui 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +57 -0
- package/dist/components/ItemFormClient.d.ts +5 -2
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +46 -119
- package/dist/components/fields/FieldRenderer.d.ts.map +1 -1
- package/dist/components/fields/FieldRenderer.js +24 -20
- package/dist/components/fields/FileField.d.ts +1 -1
- package/dist/components/fields/FileField.d.ts.map +1 -1
- package/dist/components/fields/ImageField.d.ts +1 -1
- package/dist/components/fields/ImageField.d.ts.map +1 -1
- package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -1
- package/dist/components/standalone/ItemCreateForm.js +13 -64
- package/dist/components/standalone/ItemEditForm.d.ts.map +1 -1
- package/dist/components/standalone/ItemEditForm.js +15 -83
- package/dist/lib/theme.d.ts +1 -1
- package/dist/lib/theme.d.ts.map +1 -1
- package/dist/lib/useItemForm.d.ts +85 -0
- package/dist/lib/useItemForm.d.ts.map +1 -0
- package/dist/lib/useItemForm.js +122 -0
- package/dist/server/types.d.ts +1 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/styles/globals.css +5 -0
- package/package.json +5 -5
- package/src/components/ItemFormClient.tsx +61 -131
- package/src/components/fields/FieldRenderer.tsx +38 -27
- package/src/components/fields/FileField.tsx +1 -1
- package/src/components/fields/ImageField.tsx +1 -1
- package/src/components/standalone/ItemCreateForm.tsx +21 -69
- package/src/components/standalone/ItemEditForm.tsx +26 -93
- package/src/lib/theme.ts +1 -1
- package/src/lib/useItemForm.ts +194 -0
- package/src/server/types.ts +1 -1
- package/tests/lib/useItemForm.test.ts +107 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
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
|
@@ -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
|
+
}
|
package/src/server/types.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Input for the generic server action
|
|
3
3
|
* Re-exported from @opensaas/stack-core for convenience
|
|
4
4
|
*/
|
|
5
|
-
export type { ServerActionProps as ServerActionInput } from '@opensaas/stack-core'
|
|
5
|
+
export type { ServerActionProps as ServerActionInput } from '@opensaas/stack-core/internal'
|
|
6
6
|
|
|
7
7
|
/**
|
|
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
|
+
})
|