@dyrected/admin 1.0.1
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/CHANGELOG.md +40 -0
- package/LICENSE.md +50 -0
- package/README.md +73 -0
- package/components.json +17 -0
- package/eslint.config.js +22 -0
- package/index.html +13 -0
- package/package.json +99 -0
- package/postcss.config.js +6 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +184 -0
- package/src/App.tsx +25 -0
- package/src/assets/dyrected.svg +155 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/auth/auth-gate.tsx +64 -0
- package/src/components/error-boundary.tsx +45 -0
- package/src/components/forms/field-renderer.tsx +111 -0
- package/src/components/forms/fields/block-builder.tsx +213 -0
- package/src/components/forms/fields/date-picker.tsx +60 -0
- package/src/components/forms/fields/json-editor.tsx +62 -0
- package/src/components/forms/fields/media-picker.tsx +286 -0
- package/src/components/forms/fields/multi-select.tsx +145 -0
- package/src/components/forms/fields/radio-field.tsx +51 -0
- package/src/components/forms/fields/relationship-picker.tsx +143 -0
- package/src/components/forms/fields/rich-text-editor.tsx +224 -0
- package/src/components/forms/fields/select-field.tsx +35 -0
- package/src/components/forms/fields/switch-field.tsx +16 -0
- package/src/components/forms/fields/text-area-field.tsx +15 -0
- package/src/components/forms/fields/text-field.tsx +24 -0
- package/src/components/forms/form-engine.tsx +87 -0
- package/src/components/forms/form-field-renderer.tsx +269 -0
- package/src/components/forms/utils.ts +97 -0
- package/src/components/layout/admin-shell.tsx +479 -0
- package/src/components/layout/branding-provider.tsx +112 -0
- package/src/components/live-preview/LivePreviewPane.tsx +128 -0
- package/src/components/media/focal-point-picker.tsx +66 -0
- package/src/components/media/media-card.tsx +44 -0
- package/src/components/media/media-grid.tsx +32 -0
- package/src/components/media/media-library-dialog.tsx +465 -0
- package/src/components/ui/aspect-ratio.tsx +7 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +214 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/command.tsx +151 -0
- package/src/components/ui/data-table.tsx +219 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/form.tsx +178 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/page-header.tsx +30 -0
- package/src/components/ui/pagination.tsx +57 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/radio-group.tsx +42 -0
- package/src/components/ui/render-cell.tsx +110 -0
- package/src/components/ui/scroll-area.tsx +46 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +771 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +27 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-preferences.ts +56 -0
- package/src/index.css +111 -0
- package/src/index.tsx +198 -0
- package/src/lib/utils.ts +32 -0
- package/src/main.tsx +10 -0
- package/src/pages/auth/first-user-page.tsx +115 -0
- package/src/pages/auth/login-page.tsx +91 -0
- package/src/pages/collections/edit-page.tsx +280 -0
- package/src/pages/collections/list-page.tsx +343 -0
- package/src/pages/dashboard/dashboard.tsx +150 -0
- package/src/pages/globals/editor-page.tsx +122 -0
- package/src/pages/media/media-page.tsx +564 -0
- package/src/pages/setup/setup-prompt.tsx +152 -0
- package/src/providers/dyrected-provider.tsx +122 -0
- package/src/providers/query-provider.tsx +19 -0
- package/src/types/jexl.d.ts +11 -0
- package/tailwind.config.ts +102 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +12 -0
- package/tsconfig.node.json +27 -0
- package/vite.config.ts +36 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect } from "react"
|
|
2
|
+
import { useForm, useWatch } from "react-hook-form"
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod"
|
|
4
|
+
import * as z from "zod"
|
|
5
|
+
import { Form } from "../ui/form"
|
|
6
|
+
import { Button } from "../ui/button"
|
|
7
|
+
import type { Field as FieldSchema, Block as BlockSchema } from "@dyrected/sdk"
|
|
8
|
+
import { buildSchemaShape, buildDefaultValues } from "./utils"
|
|
9
|
+
import { FormFieldRenderer } from "./form-field-renderer"
|
|
10
|
+
|
|
11
|
+
export type { FieldSchema, BlockSchema }
|
|
12
|
+
|
|
13
|
+
interface FormEngineProps {
|
|
14
|
+
collection: string
|
|
15
|
+
fields: FieldSchema[]
|
|
16
|
+
defaultValues?: Record<string, any>
|
|
17
|
+
onSubmit: (data: any) => void
|
|
18
|
+
onChange?: (isDirty: boolean) => void
|
|
19
|
+
isLoading?: boolean
|
|
20
|
+
submitLabel?: string
|
|
21
|
+
readOnly?: boolean
|
|
22
|
+
onDataChange?: (data: any) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function FormEngine({
|
|
26
|
+
collection,
|
|
27
|
+
fields,
|
|
28
|
+
defaultValues = {},
|
|
29
|
+
onSubmit,
|
|
30
|
+
onChange,
|
|
31
|
+
isLoading,
|
|
32
|
+
submitLabel = "Save",
|
|
33
|
+
readOnly,
|
|
34
|
+
onDataChange
|
|
35
|
+
}: FormEngineProps) {
|
|
36
|
+
const schemaShape = buildSchemaShape(fields)
|
|
37
|
+
const formSchema = z.object(schemaShape)
|
|
38
|
+
|
|
39
|
+
const form = useForm<z.infer<typeof formSchema>>({
|
|
40
|
+
resolver: zodResolver(formSchema),
|
|
41
|
+
defaultValues: buildDefaultValues(fields, defaultValues),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const { isDirty } = form.formState
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
onChange?.(isDirty)
|
|
48
|
+
}, [isDirty, onChange])
|
|
49
|
+
|
|
50
|
+
const watchedValues = useWatch({
|
|
51
|
+
control: form.control,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (onDataChange) {
|
|
56
|
+
const handler = setTimeout(() => {
|
|
57
|
+
onDataChange(watchedValues)
|
|
58
|
+
}, 100)
|
|
59
|
+
return () => clearTimeout(handler)
|
|
60
|
+
}
|
|
61
|
+
}, [watchedValues, onDataChange])
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Form {...form}>
|
|
65
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
|
66
|
+
<div className="grid gap-6">
|
|
67
|
+
{fields.filter(f => !f.admin?.hidden).map((field) => (
|
|
68
|
+
<FormFieldRenderer
|
|
69
|
+
key={field.name}
|
|
70
|
+
schema={field}
|
|
71
|
+
basePath=""
|
|
72
|
+
control={form.control}
|
|
73
|
+
collection={collection}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex justify-end gap-4">
|
|
78
|
+
{!readOnly && (
|
|
79
|
+
<Button type="submit" disabled={isLoading}>
|
|
80
|
+
{isLoading ? "Saving..." : submitLabel}
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</form>
|
|
85
|
+
</Form>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { useWatch, useFieldArray } from "react-hook-form"
|
|
3
|
+
import { useDyrected } from "../../providers/dyrected-provider"
|
|
4
|
+
import {
|
|
5
|
+
FormControl,
|
|
6
|
+
FormField,
|
|
7
|
+
FormItem,
|
|
8
|
+
FormLabel,
|
|
9
|
+
FormMessage,
|
|
10
|
+
} from "../ui/form"
|
|
11
|
+
import { Button } from "../ui/button"
|
|
12
|
+
import { Plus, Trash2, Layers } from "lucide-react"
|
|
13
|
+
import { MediaLibraryDialog } from "../media/media-library-dialog"
|
|
14
|
+
import { cn } from "../../lib/utils"
|
|
15
|
+
import jexl from 'jexl'
|
|
16
|
+
import type { Field as FieldSchema } from "@dyrected/sdk"
|
|
17
|
+
import { FieldRenderer } from "./field-renderer"
|
|
18
|
+
import { BlockBuilder } from "./fields/block-builder"
|
|
19
|
+
import { buildDefaultValues } from "./utils"
|
|
20
|
+
|
|
21
|
+
interface FormFieldRendererProps {
|
|
22
|
+
schema: FieldSchema
|
|
23
|
+
basePath: string
|
|
24
|
+
control: any
|
|
25
|
+
collection: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* FormFieldRenderer (Field Orchestrator)
|
|
30
|
+
*
|
|
31
|
+
* This component handles the high-level logic for a single form field, including:
|
|
32
|
+
* - Field Lifecycle: Manages registration and state via react-hook-form Controller.
|
|
33
|
+
* - Conditional Visibility: Evaluates 'admin.condition' to show/hide fields dynamically.
|
|
34
|
+
* - Layout & Presentation: Renders labels, descriptions, and error states.
|
|
35
|
+
* - Validation: Displays Zod validation errors.
|
|
36
|
+
*
|
|
37
|
+
* It delegates the actual rendering of the input UI to the FieldRenderer.
|
|
38
|
+
*/
|
|
39
|
+
export function FormFieldRenderer({ schema, basePath, control, collection }: FormFieldRendererProps) {
|
|
40
|
+
const { user, schemas } = useDyrected()
|
|
41
|
+
|
|
42
|
+
if (schema.admin?.hidden) return null
|
|
43
|
+
|
|
44
|
+
const formValues = useWatch({ control })
|
|
45
|
+
const siblingData = useWatch({ control, name: (basePath || undefined) as any }) || {}
|
|
46
|
+
const conditionData = basePath ? { ...formValues, ...siblingData } : formValues
|
|
47
|
+
|
|
48
|
+
let isVisible = true
|
|
49
|
+
const condition = schema.admin?.condition
|
|
50
|
+
|
|
51
|
+
if (typeof condition === 'function') {
|
|
52
|
+
isVisible = condition(conditionData, siblingData)
|
|
53
|
+
} else if (typeof condition === 'string') {
|
|
54
|
+
try {
|
|
55
|
+
const sanitizedCondition = condition.replace(/===/g, '==').replace(/!==/g, '!=')
|
|
56
|
+
isVisible = jexl.evalSync(sanitizedCondition, conditionData)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn("Jexl eval failed:", e)
|
|
59
|
+
isVisible = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!isVisible) return null
|
|
64
|
+
|
|
65
|
+
const readAccess = (schema.access as any)?.read
|
|
66
|
+
let canRead = true
|
|
67
|
+
if (readAccess === false) {
|
|
68
|
+
canRead = false
|
|
69
|
+
} else if (typeof readAccess === 'string') {
|
|
70
|
+
try {
|
|
71
|
+
canRead = jexl.evalSync(readAccess, { user, ...conditionData })
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.warn("Read access eval failed:", e)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!canRead) return null
|
|
78
|
+
|
|
79
|
+
const fullPath = basePath ? `${basePath}.${schema.name}` : schema.name
|
|
80
|
+
|
|
81
|
+
if (schema.type === "object") {
|
|
82
|
+
return (
|
|
83
|
+
<div className="left-accent space-y-6">
|
|
84
|
+
<div className="flex items-center gap-2 mb-2">
|
|
85
|
+
<h4 className="font-bold text-sm text-foreground/80 tracking-tight">{schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)}</h4>
|
|
86
|
+
{schema.admin?.description && (
|
|
87
|
+
<p className="text-[10px] text-muted-foreground/50 italic">{schema.admin.description}</p>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="space-y-6">
|
|
91
|
+
{schema.fields?.map(subField => (
|
|
92
|
+
<FormFieldRenderer key={subField.name} schema={subField} basePath={fullPath} control={control} collection={collection} />
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (schema.type === "array") {
|
|
100
|
+
return <ArrayFieldRenderer schema={schema} basePath={fullPath} control={control} collection={collection} />
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (schema.type === "blocks" && schema.blocks) {
|
|
104
|
+
return <BlockBuilder schema={schema} basePath={fullPath} control={control} collection={collection} />
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isBoolean = schema.type === "boolean"
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<FormField
|
|
111
|
+
control={control}
|
|
112
|
+
name={fullPath}
|
|
113
|
+
render={({ field: formField }: { field: any }) => (
|
|
114
|
+
<FormItem className={cn(
|
|
115
|
+
isBoolean
|
|
116
|
+
? "flex flex-row items-center justify-between rounded-xl border border-border/40 p-4 bg-white/50 shadow-sm space-y-0"
|
|
117
|
+
: "space-y-3"
|
|
118
|
+
)}>
|
|
119
|
+
<div className={cn(isBoolean ? "space-y-1" : "flex items-center gap-2 mb-1")}>
|
|
120
|
+
<FormLabel className="text-sm font-semibold text-foreground/80 cursor-pointer">
|
|
121
|
+
{schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)}
|
|
122
|
+
{schema.required && <span className="text-destructive ml-1">*</span>}
|
|
123
|
+
</FormLabel>
|
|
124
|
+
{schema.admin?.description && (
|
|
125
|
+
<p className={cn(
|
|
126
|
+
"text-muted-foreground/60 italic",
|
|
127
|
+
isBoolean ? "text-[11px] leading-tight" : "text-[11px] leading-relaxed"
|
|
128
|
+
)}>
|
|
129
|
+
{schema.admin.description}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
{!isBoolean && schema.unique && (
|
|
133
|
+
<span className="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary ring-1 ring-inset ring-primary/10">
|
|
134
|
+
Unique
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
<FormControl>
|
|
139
|
+
<FieldRenderer schema={schema} field={formField} collection={collection} context={{ user, schemas, siblingData: conditionData }} />
|
|
140
|
+
</FormControl>
|
|
141
|
+
{!isBoolean && schema.admin?.description && (
|
|
142
|
+
<p className="text-[11px] text-muted-foreground/70 leading-relaxed italic">{schema.admin.description}</p>
|
|
143
|
+
)}
|
|
144
|
+
<FormMessage className="text-xs font-medium" />
|
|
145
|
+
</FormItem>
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ArrayFieldRenderer({ schema, basePath, control, collection }: { schema: FieldSchema, basePath: string, control: any, collection: string }) {
|
|
152
|
+
const { fields, append, remove } = useFieldArray({ control, name: basePath })
|
|
153
|
+
const { schemas } = useDyrected()
|
|
154
|
+
const [isBulkOpen, setIsBulkOpen] = React.useState(false)
|
|
155
|
+
|
|
156
|
+
// Find if there is an image field or a relationship to an upload collection
|
|
157
|
+
const imageField = React.useMemo(() => {
|
|
158
|
+
return schema.fields?.find(f => {
|
|
159
|
+
if (f.type === 'image') return true
|
|
160
|
+
if (f.type === 'relationship' && f.relationTo) {
|
|
161
|
+
const relatedSchema = schemas?.collections?.find(s => s.slug === f.relationTo)
|
|
162
|
+
return relatedSchema?.upload === true
|
|
163
|
+
}
|
|
164
|
+
return false
|
|
165
|
+
})
|
|
166
|
+
}, [schema.fields, schemas])
|
|
167
|
+
|
|
168
|
+
const bulkCollection = (imageField?.type === 'relationship' ? imageField.relationTo : 'media') || 'media'
|
|
169
|
+
|
|
170
|
+
const handleBulkAdd = (ids: string[]) => {
|
|
171
|
+
ids.forEach(id => {
|
|
172
|
+
const newItem = buildDefaultValues(schema.fields || [], {})
|
|
173
|
+
if (imageField) {
|
|
174
|
+
newItem[imageField.name] = id
|
|
175
|
+
}
|
|
176
|
+
append(newItem)
|
|
177
|
+
})
|
|
178
|
+
setIsBulkOpen(false)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="space-y-6 transition-all py-6">
|
|
183
|
+
<div className="flex justify-between items-end pb-2">
|
|
184
|
+
<div className="space-y-1">
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<Layers className="h-4 w-4 text-primary" />
|
|
187
|
+
<h4 className="font-serif font-bold text-base text-foreground tracking-tight">{schema.label || schema.name.charAt(0).toUpperCase() + schema.name.slice(1)}</h4>
|
|
188
|
+
</div>
|
|
189
|
+
{schema.admin?.description && (
|
|
190
|
+
<p className="text-[11px] text-muted-foreground/60 italic leading-relaxed">{schema.admin.description}</p>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
<Button
|
|
195
|
+
type="button"
|
|
196
|
+
variant="outline"
|
|
197
|
+
size="sm"
|
|
198
|
+
className="h-9 text-[11px] font-bold rounded-xl border-primary/20 hover:bg-primary/5 hover:text-primary transition-all shadow-sm"
|
|
199
|
+
onClick={() => append(buildDefaultValues(schema.fields || [], {}))}
|
|
200
|
+
>
|
|
201
|
+
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
|
202
|
+
Add Item
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div className="space-y-8 pl-0 border-l border-muted/30">
|
|
208
|
+
{fields.map((item, index) => (
|
|
209
|
+
<div key={item.id} className="relative group animate-in slide-in-from-left-2 duration-300">
|
|
210
|
+
<div className="bg-muted/5 left-accent transition-all relative">
|
|
211
|
+
<Button
|
|
212
|
+
type="button"
|
|
213
|
+
variant="ghost"
|
|
214
|
+
size="icon"
|
|
215
|
+
className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/20 hover:text-destructive hover:bg-destructive/10 rounded-xl opacity-0 group-hover:opacity-100 transition-all"
|
|
216
|
+
onClick={() => remove(index)}
|
|
217
|
+
>
|
|
218
|
+
<Trash2 className="w-4 h-4" />
|
|
219
|
+
</Button>
|
|
220
|
+
<div className="space-y-6">
|
|
221
|
+
{schema.fields?.map(subField => (
|
|
222
|
+
<FormFieldRenderer
|
|
223
|
+
key={subField.name}
|
|
224
|
+
schema={subField}
|
|
225
|
+
basePath={`${basePath}.${index}`}
|
|
226
|
+
control={control}
|
|
227
|
+
collection={collection}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
))}
|
|
234
|
+
|
|
235
|
+
{fields.length === 0 && (
|
|
236
|
+
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-3xl bg-muted/5 space-y-3">
|
|
237
|
+
<div className="p-3 bg-muted rounded-full">
|
|
238
|
+
<Layers className="h-6 w-6 text-muted-foreground/40" />
|
|
239
|
+
</div>
|
|
240
|
+
<p className="text-xs font-medium text-muted-foreground/50">No items added yet</p>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
<Button
|
|
245
|
+
type="button"
|
|
246
|
+
variant="outline"
|
|
247
|
+
size="sm"
|
|
248
|
+
className="w-full h-10 text-xs font-bold rounded-2xl border-dashed border-primary/20 hover:bg-primary/5 hover:text-primary transition-all shadow-sm"
|
|
249
|
+
onClick={() => append(buildDefaultValues(schema.fields || [], {}))}
|
|
250
|
+
>
|
|
251
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
252
|
+
Add Item
|
|
253
|
+
</Button>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{imageField && (
|
|
257
|
+
<MediaLibraryDialog
|
|
258
|
+
collection={bulkCollection}
|
|
259
|
+
isOpen={isBulkOpen}
|
|
260
|
+
onOpenChange={setIsBulkOpen}
|
|
261
|
+
selectedValues={[]}
|
|
262
|
+
multiple={true}
|
|
263
|
+
onSelect={() => { }}
|
|
264
|
+
onConfirm={(ids: string[]) => handleBulkAdd(ids)}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as z from "zod"
|
|
2
|
+
import type { Field as FieldSchema } from "@dyrected/sdk"
|
|
3
|
+
|
|
4
|
+
export function normalizeOptions(options: string[] | { label: string; value: string }[] | undefined): { label: string; value: string }[] {
|
|
5
|
+
if (!options) return []
|
|
6
|
+
return options.map(opt => typeof opt === "string" ? { label: opt, value: opt } : opt)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildSchemaShape(fields: FieldSchema[]) {
|
|
10
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
11
|
+
fields.forEach((field) => {
|
|
12
|
+
let validator: any = z.any()
|
|
13
|
+
const label = field.label || field.name.charAt(0).toUpperCase() + field.name.slice(1)
|
|
14
|
+
|
|
15
|
+
if (field.type === "object" && field.fields) {
|
|
16
|
+
validator = z.object(buildSchemaShape(field.fields))
|
|
17
|
+
if (!field.required) validator = validator.optional()
|
|
18
|
+
shape[field.name] = validator
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (field.type === "blocks") {
|
|
23
|
+
validator = z.array(z.any())
|
|
24
|
+
if (!field.required) validator = validator.optional()
|
|
25
|
+
shape[field.name] = validator
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (field.type === "array" && field.fields) {
|
|
30
|
+
validator = z.array(z.object(buildSchemaShape(field.fields)))
|
|
31
|
+
if (!field.required) validator = validator.optional()
|
|
32
|
+
shape[field.name] = validator
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fieldType = field.type as string
|
|
37
|
+
if (fieldType === "text" || fieldType === "textarea" || fieldType === "select" || fieldType === "image" || fieldType === "richText" || fieldType === "relationship" || fieldType === "date") {
|
|
38
|
+
validator = z.string()
|
|
39
|
+
if (field.required) validator = validator.min(1, `${label} is required`)
|
|
40
|
+
} else if (field.type === "email") {
|
|
41
|
+
validator = z.string().email(`${label} must be a valid email`)
|
|
42
|
+
if (field.required) validator = validator.min(1, `${label} is required`)
|
|
43
|
+
} else if (field.type === "url") {
|
|
44
|
+
validator = z.string().url(`${label} must be a valid URL`)
|
|
45
|
+
if (field.required) validator = validator.min(1, `${label} is required`)
|
|
46
|
+
} else if (field.type === "number") {
|
|
47
|
+
validator = z.coerce.number()
|
|
48
|
+
} else if (field.type === "boolean") {
|
|
49
|
+
validator = z.boolean()
|
|
50
|
+
} else if (field.type === "json") {
|
|
51
|
+
validator = z.any()
|
|
52
|
+
} else if (field.type === "multiSelect") {
|
|
53
|
+
validator = z.array(z.string())
|
|
54
|
+
if (field.required) validator = validator.min(1, `${label} requires at least one selection`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!field.required && field.type !== "multiSelect") {
|
|
58
|
+
validator = validator.optional().or(z.literal(""))
|
|
59
|
+
} else if (!field.required && field.type === "multiSelect") {
|
|
60
|
+
validator = validator.optional()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
shape[field.name] = validator
|
|
64
|
+
})
|
|
65
|
+
return shape
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildDefaultValues(fields: FieldSchema[], defaults: any) {
|
|
69
|
+
return fields.reduce((acc, field) => {
|
|
70
|
+
let defaultVal = defaults[field.name] ?? field.defaultValue
|
|
71
|
+
|
|
72
|
+
if (field.type === "object" && field.fields) {
|
|
73
|
+
acc[field.name] = buildDefaultValues(field.fields, defaultVal || {})
|
|
74
|
+
return acc
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (field.type === "array") {
|
|
78
|
+
acc[field.name] = Array.isArray(defaultVal) ? defaultVal : []
|
|
79
|
+
return acc
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (field.type === "blocks") {
|
|
83
|
+
acc[field.name] = Array.isArray(defaultVal) ? defaultVal : []
|
|
84
|
+
return acc
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (defaultVal === undefined) {
|
|
88
|
+
if (field.type === "boolean") defaultVal = false
|
|
89
|
+
else if (field.type === "multiSelect") defaultVal = []
|
|
90
|
+
else if (field.type === "json") defaultVal = {}
|
|
91
|
+
else defaultVal = ""
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
acc[field.name] = defaultVal
|
|
95
|
+
return acc
|
|
96
|
+
}, {} as any)
|
|
97
|
+
}
|