@magnet-cms/plugin-playground 2.0.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.
@@ -0,0 +1,652 @@
1
+ import { useAdmin } from '@magnet-cms/admin'
2
+ import {
3
+ Accordion,
4
+ AccordionContent,
5
+ AccordionItem,
6
+ AccordionTrigger,
7
+ Button,
8
+ Input,
9
+ Label,
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ Switch,
16
+ Textarea,
17
+ } from '@magnet-cms/ui/components'
18
+ import { ChevronRight, Lock, Plus, Trash2, X } from 'lucide-react'
19
+ import { useState } from 'react'
20
+ import {
21
+ FIELD_TYPES,
22
+ RELATION_TYPES,
23
+ UI_SUBTYPES,
24
+ VALIDATION_RULES_BY_TYPE,
25
+ getValidationRuleDefinition,
26
+ } from '../constants/field-types'
27
+ import { useSchemaBuilder } from '../hooks/useSchemaBuilder'
28
+ import type {
29
+ FieldType,
30
+ RelationConfig,
31
+ ValidationRule,
32
+ } from '../types/builder.types'
33
+ import { RelationConfigModal } from './RelationConfigModal'
34
+
35
+ export function FieldSettingsPanel() {
36
+ const { selectedField, updateField, selectField, state } = useSchemaBuilder()
37
+ const { schemas } = useAdmin()
38
+ const [newOptionKey, setNewOptionKey] = useState('')
39
+ const [newOptionValue, setNewOptionValue] = useState('')
40
+ const [relationModalOpen, setRelationModalOpen] = useState(false)
41
+
42
+ if (!selectedField) {
43
+ return (
44
+ <div className="h-full flex items-center justify-center text-muted-foreground p-8 text-center">
45
+ <div>
46
+ <p className="text-sm font-medium mb-1">No field selected</p>
47
+ <p className="text-xs">Select a field to edit its settings</p>
48
+ </div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ const fieldTypeDef = FIELD_TYPES.find((t) => t.id === selectedField.type)
54
+ const availableValidations =
55
+ VALIDATION_RULES_BY_TYPE[selectedField.type] || []
56
+ const uiSubtypes = UI_SUBTYPES[selectedField.type] || []
57
+
58
+ const handleAddOption = () => {
59
+ if (!newOptionKey.trim() || !newOptionValue.trim()) return
60
+ const currentOptions = selectedField.ui.options || []
61
+ updateField(selectedField.id, {
62
+ ui: {
63
+ ...selectedField.ui,
64
+ options: [
65
+ ...currentOptions,
66
+ { key: newOptionKey.trim(), value: newOptionValue.trim() },
67
+ ],
68
+ },
69
+ })
70
+ setNewOptionKey('')
71
+ setNewOptionValue('')
72
+ }
73
+
74
+ const handleRemoveOption = (index: number) => {
75
+ const currentOptions = selectedField.ui.options || []
76
+ updateField(selectedField.id, {
77
+ ui: {
78
+ ...selectedField.ui,
79
+ options: currentOptions.filter((_, i) => i !== index),
80
+ },
81
+ })
82
+ }
83
+
84
+ const handleAddValidation = (type: string) => {
85
+ const currentValidations = selectedField.validations || []
86
+ if (currentValidations.some((v) => v.type === type)) return
87
+
88
+ const ruleDef = getValidationRuleDefinition(type)
89
+ const newRule: ValidationRule = {
90
+ type,
91
+ constraints: ruleDef?.constraintCount ? [] : undefined,
92
+ }
93
+ updateField(selectedField.id, {
94
+ validations: [...currentValidations, newRule],
95
+ })
96
+ }
97
+
98
+ const handleRemoveValidation = (type: string) => {
99
+ updateField(selectedField.id, {
100
+ validations: selectedField.validations.filter((v) => v.type !== type),
101
+ })
102
+ }
103
+
104
+ const handleUpdateValidationConstraints = (
105
+ type: string,
106
+ constraints: (string | number)[],
107
+ ) => {
108
+ updateField(selectedField.id, {
109
+ validations: selectedField.validations.map((v) =>
110
+ v.type === type ? { ...v, constraints } : v,
111
+ ),
112
+ })
113
+ }
114
+
115
+ const getRelationTypeLabel = (type: string | undefined) => {
116
+ return RELATION_TYPES.find((t) => t.value === type)?.label || 'Not Set'
117
+ }
118
+
119
+ return (
120
+ <div className="h-full flex flex-col">
121
+ {/* Header */}
122
+ <div className="p-4 border-b flex items-center justify-between">
123
+ <h2 className="text-xs font-semibold uppercase tracking-wide">
124
+ Field Settings
125
+ </h2>
126
+ <Button
127
+ variant="ghost"
128
+ size="icon"
129
+ className="h-8 w-8"
130
+ onClick={() => selectField(null)}
131
+ >
132
+ <X className="h-4 w-4" />
133
+ </Button>
134
+ </div>
135
+
136
+ {/* Content */}
137
+ <div className="flex-1 overflow-y-auto">
138
+ <Accordion
139
+ type="multiple"
140
+ defaultValue={['basic-info', 'constraints']}
141
+ className="w-full"
142
+ >
143
+ {/* Basic Info */}
144
+ <AccordionItem value="basic-info" className="px-5">
145
+ <AccordionTrigger className="text-xs font-semibold uppercase tracking-wide">
146
+ Basic Info
147
+ </AccordionTrigger>
148
+ <AccordionContent className="space-y-4">
149
+ <div className="space-y-1.5">
150
+ <Label className="text-xs">Display Name</Label>
151
+ <Input
152
+ value={selectedField.displayName}
153
+ onChange={(e) =>
154
+ updateField(selectedField.id, {
155
+ displayName: e.target.value,
156
+ })
157
+ }
158
+ className="font-medium"
159
+ />
160
+ </div>
161
+
162
+ <div className="space-y-1.5">
163
+ <Label className="text-xs">API ID</Label>
164
+ <div className="relative">
165
+ <Input
166
+ value={selectedField.name}
167
+ onChange={(e) =>
168
+ updateField(selectedField.id, { name: e.target.value })
169
+ }
170
+ className="font-mono text-muted-foreground pr-8"
171
+ />
172
+ <Lock className="absolute right-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground/50" />
173
+ </div>
174
+ </div>
175
+
176
+ <div className="space-y-1.5">
177
+ <Label className="text-xs">Field Type</Label>
178
+ <Select
179
+ value={selectedField.type}
180
+ onValueChange={(value) =>
181
+ updateField(selectedField.id, { type: value as FieldType })
182
+ }
183
+ >
184
+ <SelectTrigger>
185
+ <SelectValue />
186
+ </SelectTrigger>
187
+ <SelectContent>
188
+ {FIELD_TYPES.map((type) => (
189
+ <SelectItem key={type.id} value={type.id}>
190
+ <div className="flex items-center gap-2">
191
+ <type.icon className="h-4 w-4" />
192
+ {type.label}
193
+ </div>
194
+ </SelectItem>
195
+ ))}
196
+ </SelectContent>
197
+ </Select>
198
+ </div>
199
+
200
+ {/* UI Subtype Selector */}
201
+ {uiSubtypes.length > 1 && (
202
+ <div className="space-y-1.5">
203
+ <Label className="text-xs">UI Type</Label>
204
+ <Select
205
+ value={selectedField.ui.type || uiSubtypes[0]?.id}
206
+ onValueChange={(value) =>
207
+ updateField(selectedField.id, {
208
+ ui: { ...selectedField.ui, type: value },
209
+ })
210
+ }
211
+ >
212
+ <SelectTrigger>
213
+ <SelectValue />
214
+ </SelectTrigger>
215
+ <SelectContent>
216
+ {uiSubtypes.map((subtype) => (
217
+ <SelectItem key={subtype.id} value={subtype.id}>
218
+ <div className="flex flex-col">
219
+ <span>{subtype.label}</span>
220
+ </div>
221
+ </SelectItem>
222
+ ))}
223
+ </SelectContent>
224
+ </Select>
225
+ <p className="text-xs text-muted-foreground">
226
+ {uiSubtypes.find((s) => s.id === selectedField.ui.type)
227
+ ?.description || uiSubtypes[0]?.description}
228
+ </p>
229
+ </div>
230
+ )}
231
+ </AccordionContent>
232
+ </AccordionItem>
233
+
234
+ {/* UI Settings */}
235
+ <AccordionItem value="ui-settings" className="px-5">
236
+ <AccordionTrigger className="text-xs font-semibold uppercase tracking-wide">
237
+ UI Settings
238
+ </AccordionTrigger>
239
+ <AccordionContent className="space-y-4">
240
+ <div className="space-y-1.5">
241
+ <Label className="text-xs">Tab</Label>
242
+ <Input
243
+ value={selectedField.ui.tab || ''}
244
+ onChange={(e) =>
245
+ updateField(selectedField.id, {
246
+ ui: { ...selectedField.ui, tab: e.target.value },
247
+ })
248
+ }
249
+ placeholder="General"
250
+ />
251
+ </div>
252
+
253
+ <div className="space-y-1.5">
254
+ <Label className="text-xs">Description</Label>
255
+ <Textarea
256
+ value={selectedField.ui.description || ''}
257
+ onChange={(e) =>
258
+ updateField(selectedField.id, {
259
+ ui: { ...selectedField.ui, description: e.target.value },
260
+ })
261
+ }
262
+ placeholder="Help text for this field"
263
+ rows={2}
264
+ />
265
+ </div>
266
+
267
+ <div className="flex items-center justify-between">
268
+ <Label className="text-xs">Show in Side Panel</Label>
269
+ <Switch
270
+ checked={selectedField.ui.side || false}
271
+ onCheckedChange={(checked) =>
272
+ updateField(selectedField.id, {
273
+ ui: { ...selectedField.ui, side: checked },
274
+ })
275
+ }
276
+ />
277
+ </div>
278
+
279
+ <div className="flex items-center justify-between">
280
+ <Label className="text-xs">Half Width (Row)</Label>
281
+ <Switch
282
+ checked={selectedField.ui.row || false}
283
+ onCheckedChange={(checked) =>
284
+ updateField(selectedField.id, {
285
+ ui: { ...selectedField.ui, row: checked },
286
+ })
287
+ }
288
+ />
289
+ </div>
290
+ </AccordionContent>
291
+ </AccordionItem>
292
+
293
+ {/* Constraints */}
294
+ <AccordionItem value="constraints" className="px-5">
295
+ <AccordionTrigger className="text-xs font-semibold uppercase tracking-wide">
296
+ Constraints
297
+ </AccordionTrigger>
298
+ <AccordionContent className="space-y-4">
299
+ <div className="flex items-center justify-between">
300
+ <Label className="text-xs">Required</Label>
301
+ <Switch
302
+ checked={selectedField.prop.required || false}
303
+ onCheckedChange={(checked) =>
304
+ updateField(selectedField.id, {
305
+ prop: { ...selectedField.prop, required: checked },
306
+ })
307
+ }
308
+ />
309
+ </div>
310
+
311
+ <div className="flex items-center justify-between">
312
+ <Label className="text-xs">Unique</Label>
313
+ <Switch
314
+ checked={selectedField.prop.unique || false}
315
+ onCheckedChange={(checked) =>
316
+ updateField(selectedField.id, {
317
+ prop: { ...selectedField.prop, unique: checked },
318
+ })
319
+ }
320
+ />
321
+ </div>
322
+
323
+ <div className="flex items-center justify-between">
324
+ <Label className="text-xs">Hidden</Label>
325
+ <Switch
326
+ checked={selectedField.prop.hidden || false}
327
+ onCheckedChange={(checked) =>
328
+ updateField(selectedField.id, {
329
+ prop: { ...selectedField.prop, hidden: checked },
330
+ })
331
+ }
332
+ />
333
+ </div>
334
+
335
+ <div className="flex items-center justify-between">
336
+ <Label className="text-xs">Read Only</Label>
337
+ <Switch
338
+ checked={selectedField.prop.readonly || false}
339
+ onCheckedChange={(checked) =>
340
+ updateField(selectedField.id, {
341
+ prop: { ...selectedField.prop, readonly: checked },
342
+ })
343
+ }
344
+ />
345
+ </div>
346
+
347
+ <div className="flex items-center justify-between">
348
+ <Label className="text-xs">Enable i18n</Label>
349
+ <Switch
350
+ checked={selectedField.prop.intl || false}
351
+ onCheckedChange={(checked) =>
352
+ updateField(selectedField.id, {
353
+ prop: { ...selectedField.prop, intl: checked },
354
+ })
355
+ }
356
+ />
357
+ </div>
358
+ </AccordionContent>
359
+ </AccordionItem>
360
+
361
+ {/* Select Options */}
362
+ {fieldTypeDef?.hasOptions && (
363
+ <AccordionItem value="select-options" className="px-5">
364
+ <AccordionTrigger className="text-xs font-semibold uppercase tracking-wide">
365
+ Select Options
366
+ </AccordionTrigger>
367
+ <AccordionContent className="space-y-4">
368
+ {(selectedField.ui.options || []).map((option, index) => (
369
+ <div
370
+ key={option.key || `option-${index}`}
371
+ className="flex items-center gap-2"
372
+ >
373
+ <Input
374
+ value={option.key}
375
+ onChange={(e) => {
376
+ const newOptions = [...(selectedField.ui.options || [])]
377
+ newOptions[index] = { ...option, key: e.target.value }
378
+ updateField(selectedField.id, {
379
+ ui: { ...selectedField.ui, options: newOptions },
380
+ })
381
+ }}
382
+ placeholder="Key"
383
+ className="flex-1"
384
+ />
385
+ <Input
386
+ value={option.value}
387
+ onChange={(e) => {
388
+ const newOptions = [...(selectedField.ui.options || [])]
389
+ newOptions[index] = { ...option, value: e.target.value }
390
+ updateField(selectedField.id, {
391
+ ui: { ...selectedField.ui, options: newOptions },
392
+ })
393
+ }}
394
+ placeholder="Label"
395
+ className="flex-1"
396
+ />
397
+ <Button
398
+ variant="ghost"
399
+ size="icon"
400
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
401
+ onClick={() => handleRemoveOption(index)}
402
+ >
403
+ <Trash2 className="h-3.5 w-3.5" />
404
+ </Button>
405
+ </div>
406
+ ))}
407
+
408
+ <div className="flex items-center gap-2">
409
+ <Input
410
+ value={newOptionKey}
411
+ onChange={(e) => setNewOptionKey(e.target.value)}
412
+ placeholder="Key"
413
+ className="flex-1"
414
+ />
415
+ <Input
416
+ value={newOptionValue}
417
+ onChange={(e) => setNewOptionValue(e.target.value)}
418
+ placeholder="Label"
419
+ className="flex-1"
420
+ />
421
+ <Button
422
+ variant="outline"
423
+ size="icon"
424
+ className="h-8 w-8"
425
+ onClick={handleAddOption}
426
+ disabled={!newOptionKey.trim() || !newOptionValue.trim()}
427
+ >
428
+ <Plus className="h-3.5 w-3.5" />
429
+ </Button>
430
+ </div>
431
+ </AccordionContent>
432
+ </AccordionItem>
433
+ )}
434
+
435
+ {/* Relation Config */}
436
+ {fieldTypeDef?.hasRelationConfig && (
437
+ <AccordionItem value="relation-config" className="px-5">
438
+ <AccordionTrigger className="text-xs font-semibold uppercase tracking-wide">
439
+ Relation Configuration
440
+ </AccordionTrigger>
441
+ <AccordionContent className="space-y-4">
442
+ {/* Visual Relation Preview */}
443
+ <div className="p-3 bg-muted/50 rounded-lg border space-y-3">
444
+ <div className="flex items-center justify-between text-xs">
445
+ <span className="text-muted-foreground">Relation Type</span>
446
+ <span className="font-medium">
447
+ {getRelationTypeLabel(
448
+ selectedField.relationConfig?.relationType,
449
+ )}
450
+ </span>
451
+ </div>
452
+
453
+ {/* Visual Diagram */}
454
+ <div className="flex items-center justify-center py-3 gap-3">
455
+ <div className="px-2 py-1 bg-background border rounded text-xs font-medium shadow-sm">
456
+ {state.schema.name || 'Current'}
457
+ </div>
458
+ <div className="flex items-center gap-0.5">
459
+ <div className="w-2 h-2 bg-muted-foreground/60 rounded-full" />
460
+ <div className="w-8 h-px bg-muted-foreground/60" />
461
+ <ChevronRight className="w-3 h-3 text-muted-foreground/60" />
462
+ </div>
463
+ <div className="px-2 py-1 bg-background border rounded text-xs font-medium shadow-sm">
464
+ {selectedField.relationConfig?.targetSchema ||
465
+ 'Select...'}
466
+ </div>
467
+ </div>
468
+
469
+ <Button
470
+ variant="secondary"
471
+ size="sm"
472
+ className="w-full"
473
+ onClick={() => setRelationModalOpen(true)}
474
+ >
475
+ Change Relation
476
+ </Button>
477
+ </div>
478
+
479
+ <div className="space-y-1.5">
480
+ <Label className="text-xs">Related Collection</Label>
481
+ <Select
482
+ value={selectedField.relationConfig?.targetSchema || ''}
483
+ onValueChange={(value) =>
484
+ updateField(selectedField.id, {
485
+ relationConfig: {
486
+ ...selectedField.relationConfig,
487
+ targetSchema: value,
488
+ relationType:
489
+ selectedField.relationConfig?.relationType ||
490
+ 'manyToOne',
491
+ },
492
+ })
493
+ }
494
+ >
495
+ <SelectTrigger>
496
+ <SelectValue placeholder="Select schema..." />
497
+ </SelectTrigger>
498
+ <SelectContent>
499
+ {schemas?.map((schema) => (
500
+ <SelectItem key={schema} value={schema}>
501
+ {schema}
502
+ </SelectItem>
503
+ ))}
504
+ </SelectContent>
505
+ </Select>
506
+ </div>
507
+
508
+ <div className="space-y-1.5">
509
+ <Label className="text-xs">Relation Type</Label>
510
+ <Select
511
+ value={
512
+ selectedField.relationConfig?.relationType || 'manyToOne'
513
+ }
514
+ onValueChange={(value) =>
515
+ updateField(selectedField.id, {
516
+ relationConfig: {
517
+ ...selectedField.relationConfig,
518
+ targetSchema:
519
+ selectedField.relationConfig?.targetSchema || '',
520
+ relationType: value as any,
521
+ },
522
+ })
523
+ }
524
+ >
525
+ <SelectTrigger>
526
+ <SelectValue />
527
+ </SelectTrigger>
528
+ <SelectContent>
529
+ {RELATION_TYPES.map((type) => (
530
+ <SelectItem key={type.value} value={type.value}>
531
+ {type.label}
532
+ </SelectItem>
533
+ ))}
534
+ </SelectContent>
535
+ </Select>
536
+ </div>
537
+ </AccordionContent>
538
+ </AccordionItem>
539
+ )}
540
+
541
+ {/* Validations */}
542
+ <AccordionItem value="validations" className="px-5">
543
+ <AccordionTrigger className="text-xs font-semibold uppercase tracking-wide">
544
+ Validations
545
+ </AccordionTrigger>
546
+ <AccordionContent className="space-y-4">
547
+ {selectedField.validations.map((validation) => {
548
+ const ruleDef = getValidationRuleDefinition(validation.type)
549
+ return (
550
+ <div
551
+ key={validation.type}
552
+ className="p-3 bg-muted/50 rounded-lg border space-y-2"
553
+ >
554
+ <div className="flex items-center justify-between">
555
+ <span className="text-sm font-medium">
556
+ {ruleDef?.label || validation.type}
557
+ </span>
558
+ <Button
559
+ variant="ghost"
560
+ size="icon"
561
+ className="h-6 w-6 text-muted-foreground hover:text-destructive"
562
+ onClick={() => handleRemoveValidation(validation.type)}
563
+ >
564
+ <Trash2 className="h-3 w-3" />
565
+ </Button>
566
+ </div>
567
+ {ruleDef?.constraintCount &&
568
+ ruleDef.constraintCount > 0 && (
569
+ <div className="flex gap-2">
570
+ {Array.from({ length: ruleDef.constraintCount }).map(
571
+ (_, i) => (
572
+ <Input
573
+ key={`${validation.type}-constraint-${i}`}
574
+ type={
575
+ validation.type.includes('Length') ||
576
+ validation.type.includes('Min') ||
577
+ validation.type.includes('Max')
578
+ ? 'number'
579
+ : 'text'
580
+ }
581
+ placeholder={
582
+ ruleDef.constraintLabels?.[i] ||
583
+ `Arg ${i + 1}`
584
+ }
585
+ value={validation.constraints?.[i] ?? ''}
586
+ onChange={(e) => {
587
+ const newConstraints = [
588
+ ...(validation.constraints || []),
589
+ ]
590
+ newConstraints[i] =
591
+ validation.type.includes('Length') ||
592
+ validation.type.includes('Min') ||
593
+ validation.type.includes('Max')
594
+ ? Number(e.target.value)
595
+ : e.target.value
596
+ handleUpdateValidationConstraints(
597
+ validation.type,
598
+ newConstraints,
599
+ )
600
+ }}
601
+ className="flex-1"
602
+ />
603
+ ),
604
+ )}
605
+ </div>
606
+ )}
607
+ </div>
608
+ )
609
+ })}
610
+
611
+ <Select onValueChange={handleAddValidation}>
612
+ <SelectTrigger>
613
+ <SelectValue placeholder="Add validation..." />
614
+ </SelectTrigger>
615
+ <SelectContent>
616
+ {availableValidations
617
+ .filter(
618
+ (v) =>
619
+ !selectedField.validations.some((sv) => sv.type === v),
620
+ )
621
+ .map((validation) => {
622
+ const ruleDef = getValidationRuleDefinition(validation)
623
+ return (
624
+ <SelectItem key={validation} value={validation}>
625
+ {ruleDef?.label || validation}
626
+ </SelectItem>
627
+ )
628
+ })}
629
+ </SelectContent>
630
+ </Select>
631
+ </AccordionContent>
632
+ </AccordionItem>
633
+ </Accordion>
634
+ </div>
635
+
636
+ {/* Relation Config Modal */}
637
+ {fieldTypeDef?.hasRelationConfig && (
638
+ <RelationConfigModal
639
+ open={relationModalOpen}
640
+ onOpenChange={setRelationModalOpen}
641
+ currentSchema={state.schema.name}
642
+ relationConfig={selectedField.relationConfig}
643
+ onSave={(config: RelationConfig) => {
644
+ updateField(selectedField.id, {
645
+ relationConfig: config,
646
+ })
647
+ }}
648
+ />
649
+ )}
650
+ </div>
651
+ )
652
+ }