@open-mercato/ui 0.4.2-canary-c02407ff85
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/build.mjs +62 -0
- package/dist/backend/AppShell.js +902 -0
- package/dist/backend/AppShell.js.map +7 -0
- package/dist/backend/ConfirmDialog.js +17 -0
- package/dist/backend/ConfirmDialog.js.map +7 -0
- package/dist/backend/ContextHelp.js +31 -0
- package/dist/backend/ContextHelp.js.map +7 -0
- package/dist/backend/CrudForm.js +2028 -0
- package/dist/backend/CrudForm.js.map +7 -0
- package/dist/backend/DataTable.js +1363 -0
- package/dist/backend/DataTable.js.map +7 -0
- package/dist/backend/EmptyState.js +52 -0
- package/dist/backend/EmptyState.js.map +7 -0
- package/dist/backend/FilterBar.js +140 -0
- package/dist/backend/FilterBar.js.map +7 -0
- package/dist/backend/FilterOverlay.js +279 -0
- package/dist/backend/FilterOverlay.js.map +7 -0
- package/dist/backend/FlashMessages.js +66 -0
- package/dist/backend/FlashMessages.js.map +7 -0
- package/dist/backend/JsonBuilder.js +322 -0
- package/dist/backend/JsonBuilder.js.map +7 -0
- package/dist/backend/JsonDisplay.js +203 -0
- package/dist/backend/JsonDisplay.js.map +7 -0
- package/dist/backend/Page.js +27 -0
- package/dist/backend/Page.js.map +7 -0
- package/dist/backend/PerspectiveSidebar.js +282 -0
- package/dist/backend/PerspectiveSidebar.js.map +7 -0
- package/dist/backend/RowActions.js +148 -0
- package/dist/backend/RowActions.js.map +7 -0
- package/dist/backend/TruncatedCell.js +92 -0
- package/dist/backend/TruncatedCell.js.map +7 -0
- package/dist/backend/UserMenu.js +107 -0
- package/dist/backend/UserMenu.js.map +7 -0
- package/dist/backend/ValueIcons.js +34 -0
- package/dist/backend/ValueIcons.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +578 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
- package/dist/backend/dashboard/index.js +5 -0
- package/dist/backend/dashboard/index.js.map +7 -0
- package/dist/backend/dashboard/widgetRegistry.js +55 -0
- package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +962 -0
- package/dist/backend/detail/ActivitiesSection.js.map +7 -0
- package/dist/backend/detail/AddressEditor.js +413 -0
- package/dist/backend/detail/AddressEditor.js.map +7 -0
- package/dist/backend/detail/AddressTiles.js +437 -0
- package/dist/backend/detail/AddressTiles.js.map +7 -0
- package/dist/backend/detail/AddressesSection.js +264 -0
- package/dist/backend/detail/AddressesSection.js.map +7 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +367 -0
- package/dist/backend/detail/AttachmentsSection.js.map +7 -0
- package/dist/backend/detail/CustomDataSection.js +433 -0
- package/dist/backend/detail/CustomDataSection.js.map +7 -0
- package/dist/backend/detail/DetailFieldsSection.js +75 -0
- package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
- package/dist/backend/detail/ErrorMessage.js +28 -0
- package/dist/backend/detail/ErrorMessage.js.map +7 -0
- package/dist/backend/detail/InlineEditors.js +681 -0
- package/dist/backend/detail/InlineEditors.js.map +7 -0
- package/dist/backend/detail/LoadingMessage.js +14 -0
- package/dist/backend/detail/LoadingMessage.js.map +7 -0
- package/dist/backend/detail/NotesSection.js +1032 -0
- package/dist/backend/detail/NotesSection.js.map +7 -0
- package/dist/backend/detail/TabEmptyState.js +25 -0
- package/dist/backend/detail/TabEmptyState.js.map +7 -0
- package/dist/backend/detail/TagsSection.js +254 -0
- package/dist/backend/detail/TagsSection.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +77 -0
- package/dist/backend/detail/addressFormat.js.map +7 -0
- package/dist/backend/detail/index.js +34 -0
- package/dist/backend/detail/index.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +8 -0
- package/dist/backend/fields/registry.generated.js.map +7 -0
- package/dist/backend/fields/registry.js +29 -0
- package/dist/backend/fields/registry.js.map +7 -0
- package/dist/backend/indexes/PartialIndexBanner.js +58 -0
- package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
- package/dist/backend/indexes/store.js +62 -0
- package/dist/backend/indexes/store.js.map +7 -0
- package/dist/backend/injection/InjectionSpot.js +179 -0
- package/dist/backend/injection/InjectionSpot.js.map +7 -0
- package/dist/backend/injection/PageInjectionBoundary.js +26 -0
- package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
- package/dist/backend/injection/helpers.js +26 -0
- package/dist/backend/injection/helpers.js.map +7 -0
- package/dist/backend/injection/widgetRegistry.js +55 -0
- package/dist/backend/injection/widgetRegistry.js.map +7 -0
- package/dist/backend/inputs/ComboboxInput.js +225 -0
- package/dist/backend/inputs/ComboboxInput.js.map +7 -0
- package/dist/backend/inputs/LookupSelect.js +191 -0
- package/dist/backend/inputs/LookupSelect.js.map +7 -0
- package/dist/backend/inputs/PhoneNumberField.js +100 -0
- package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +222 -0
- package/dist/backend/inputs/TagsInput.js.map +7 -0
- package/dist/backend/inputs/index.js +6 -0
- package/dist/backend/inputs/index.js.map +7 -0
- package/dist/backend/operations/LastOperationBanner.js +80 -0
- package/dist/backend/operations/LastOperationBanner.js.map +7 -0
- package/dist/backend/operations/store.js +183 -0
- package/dist/backend/operations/store.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +107 -0
- package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
- package/dist/backend/schedule/ScheduleGrid.js +107 -0
- package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
- package/dist/backend/schedule/ScheduleToolbar.js +166 -0
- package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
- package/dist/backend/schedule/ScheduleView.js +165 -0
- package/dist/backend/schedule/ScheduleView.js.map +7 -0
- package/dist/backend/schedule/index.js +6 -0
- package/dist/backend/schedule/index.js.map +7 -0
- package/dist/backend/schedule/recurrence.js +83 -0
- package/dist/backend/schedule/recurrence.js.map +7 -0
- package/dist/backend/schedule/types.js +1 -0
- package/dist/backend/schedule/types.js.map +7 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
- package/dist/backend/utils/api.js +127 -0
- package/dist/backend/utils/api.js.map +7 -0
- package/dist/backend/utils/apiCall.js +48 -0
- package/dist/backend/utils/apiCall.js.map +7 -0
- package/dist/backend/utils/crud.js +126 -0
- package/dist/backend/utils/crud.js.map +7 -0
- package/dist/backend/utils/customFieldColumns.js +56 -0
- package/dist/backend/utils/customFieldColumns.js.map +7 -0
- package/dist/backend/utils/customFieldDefs.js +143 -0
- package/dist/backend/utils/customFieldDefs.js.map +7 -0
- package/dist/backend/utils/customFieldFilters.js +126 -0
- package/dist/backend/utils/customFieldFilters.js.map +7 -0
- package/dist/backend/utils/customFieldForms.js +162 -0
- package/dist/backend/utils/customFieldForms.js.map +7 -0
- package/dist/backend/utils/customFieldValues.js +26 -0
- package/dist/backend/utils/customFieldValues.js.map +7 -0
- package/dist/backend/utils/flash.js +16 -0
- package/dist/backend/utils/flash.js.map +7 -0
- package/dist/backend/utils/nav.js +185 -0
- package/dist/backend/utils/nav.js.map +7 -0
- package/dist/backend/utils/serverErrors.js +230 -0
- package/dist/backend/utils/serverErrors.js.map +7 -0
- package/dist/frontend/AuthFooter.js +23 -0
- package/dist/frontend/AuthFooter.js.map +7 -0
- package/dist/frontend/LanguageSwitcher.js +57 -0
- package/dist/frontend/LanguageSwitcher.js.map +7 -0
- package/dist/frontend/Layout.js +14 -0
- package/dist/frontend/Layout.js.map +7 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +7 -0
- package/dist/primitives/DataLoader.js +67 -0
- package/dist/primitives/DataLoader.js.map +7 -0
- package/dist/primitives/ErrorNotice.js +20 -0
- package/dist/primitives/ErrorNotice.js.map +7 -0
- package/dist/primitives/alert.js +38 -0
- package/dist/primitives/alert.js.map +7 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/badge.js.map +7 -0
- package/dist/primitives/button.js +44 -0
- package/dist/primitives/button.js.map +7 -0
- package/dist/primitives/card.js +91 -0
- package/dist/primitives/card.js.map +7 -0
- package/dist/primitives/checkbox.js +28 -0
- package/dist/primitives/checkbox.js.map +7 -0
- package/dist/primitives/dialog.js +90 -0
- package/dist/primitives/dialog.js.map +7 -0
- package/dist/primitives/input.js +22 -0
- package/dist/primitives/input.js.map +7 -0
- package/dist/primitives/label.js +21 -0
- package/dist/primitives/label.js.map +7 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +7 -0
- package/dist/primitives/spinner.js +24 -0
- package/dist/primitives/spinner.js.map +7 -0
- package/dist/primitives/switch.js +80 -0
- package/dist/primitives/switch.js.map +7 -0
- package/dist/primitives/table.js +29 -0
- package/dist/primitives/table.js.map +7 -0
- package/dist/primitives/tabs.js +87 -0
- package/dist/primitives/tabs.js.map +7 -0
- package/dist/primitives/textarea.js +21 -0
- package/dist/primitives/textarea.js.map +7 -0
- package/dist/primitives/tooltip.js +60 -0
- package/dist/primitives/tooltip.js.map +7 -0
- package/dist/theme/QueryProvider.js +44 -0
- package/dist/theme/QueryProvider.js.map +7 -0
- package/dist/theme/ThemeProvider.js +95 -0
- package/dist/theme/ThemeProvider.js.map +7 -0
- package/dist/theme/ThemeToggle.js +88 -0
- package/dist/theme/ThemeToggle.js.map +7 -0
- package/dist/theme/index.js +10 -0
- package/dist/theme/index.js.map +7 -0
- package/dist/types/react-big-calendar.d.js +1 -0
- package/dist/types/react-big-calendar.d.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/jest.setup.ts +55 -0
- package/package.json +105 -0
- package/src/backend/AppShell.tsx +1096 -0
- package/src/backend/ConfirmDialog.tsx +19 -0
- package/src/backend/ContextHelp.tsx +38 -0
- package/src/backend/CrudForm.tsx +2503 -0
- package/src/backend/DataTable.tsx +1730 -0
- package/src/backend/EmptyState.tsx +65 -0
- package/src/backend/FilterBar.tsx +161 -0
- package/src/backend/FilterOverlay.tsx +328 -0
- package/src/backend/FlashMessages.tsx +82 -0
- package/src/backend/JsonBuilder.tsx +362 -0
- package/src/backend/JsonDisplay.tsx +254 -0
- package/src/backend/Page.tsx +30 -0
- package/src/backend/PerspectiveSidebar.tsx +337 -0
- package/src/backend/RowActions.tsx +151 -0
- package/src/backend/TruncatedCell.tsx +133 -0
- package/src/backend/UserMenu.tsx +118 -0
- package/src/backend/ValueIcons.tsx +48 -0
- package/src/backend/__tests__/AppShell.test.tsx +115 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
- package/src/backend/__tests__/serverErrors.test.ts +83 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
- package/src/backend/dashboard/DashboardScreen.tsx +684 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetRegistry.ts +68 -0
- package/src/backend/detail/ActivitiesSection.tsx +1284 -0
- package/src/backend/detail/AddressEditor.tsx +472 -0
- package/src/backend/detail/AddressTiles.tsx +587 -0
- package/src/backend/detail/AddressesSection.tsx +346 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
- package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
- package/src/backend/detail/AttachmentsSection.tsx +414 -0
- package/src/backend/detail/CustomDataSection.tsx +530 -0
- package/src/backend/detail/DetailFieldsSection.tsx +147 -0
- package/src/backend/detail/ErrorMessage.tsx +32 -0
- package/src/backend/detail/InlineEditors.tsx +877 -0
- package/src/backend/detail/LoadingMessage.tsx +14 -0
- package/src/backend/detail/NotesSection.tsx +1275 -0
- package/src/backend/detail/TabEmptyState.tsx +48 -0
- package/src/backend/detail/TagsSection.tsx +314 -0
- package/src/backend/detail/addressFormat.tsx +121 -0
- package/src/backend/detail/index.ts +44 -0
- package/src/backend/fields/registry.generated.ts +8 -0
- package/src/backend/fields/registry.ts +38 -0
- package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
- package/src/backend/indexes/store.ts +88 -0
- package/src/backend/injection/InjectionSpot.tsx +236 -0
- package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
- package/src/backend/injection/helpers.ts +35 -0
- package/src/backend/injection/widgetRegistry.ts +68 -0
- package/src/backend/inputs/ComboboxInput.tsx +269 -0
- package/src/backend/inputs/LookupSelect.tsx +247 -0
- package/src/backend/inputs/PhoneNumberField.tsx +129 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
- package/src/backend/inputs/TagsInput.tsx +259 -0
- package/src/backend/inputs/index.ts +5 -0
- package/src/backend/operations/LastOperationBanner.tsx +85 -0
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
- package/src/backend/operations/store.ts +230 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
- package/src/backend/schedule/ScheduleGrid.tsx +136 -0
- package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
- package/src/backend/schedule/ScheduleView.tsx +198 -0
- package/src/backend/schedule/index.ts +5 -0
- package/src/backend/schedule/recurrence.ts +99 -0
- package/src/backend/schedule/types.ts +26 -0
- package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
- package/src/backend/utils/__tests__/crud.test.ts +87 -0
- package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
- package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
- package/src/backend/utils/api.ts +149 -0
- package/src/backend/utils/apiCall.ts +96 -0
- package/src/backend/utils/crud.ts +174 -0
- package/src/backend/utils/customFieldColumns.ts +71 -0
- package/src/backend/utils/customFieldDefs.ts +245 -0
- package/src/backend/utils/customFieldFilters.ts +145 -0
- package/src/backend/utils/customFieldForms.ts +196 -0
- package/src/backend/utils/customFieldValues.ts +41 -0
- package/src/backend/utils/flash.ts +17 -0
- package/src/backend/utils/nav.ts +238 -0
- package/src/backend/utils/serverErrors.ts +302 -0
- package/src/frontend/AuthFooter.tsx +29 -0
- package/src/frontend/LanguageSwitcher.tsx +66 -0
- package/src/frontend/Layout.tsx +13 -0
- package/src/index.ts +32 -0
- package/src/primitives/DataLoader.tsx +92 -0
- package/src/primitives/ErrorNotice.tsx +26 -0
- package/src/primitives/alert.tsx +52 -0
- package/src/primitives/badge.tsx +31 -0
- package/src/primitives/button.tsx +47 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/checkbox.tsx +28 -0
- package/src/primitives/dialog.tsx +110 -0
- package/src/primitives/input.tsx +20 -0
- package/src/primitives/label.tsx +18 -0
- package/src/primitives/separator.tsx +7 -0
- package/src/primitives/spinner.tsx +27 -0
- package/src/primitives/switch.tsx +86 -0
- package/src/primitives/table.tsx +27 -0
- package/src/primitives/tabs.tsx +128 -0
- package/src/primitives/textarea.tsx +20 -0
- package/src/primitives/tooltip.tsx +85 -0
- package/src/theme/QueryProvider.tsx +46 -0
- package/src/theme/ThemeProvider.tsx +120 -0
- package/src/theme/ThemeToggle.tsx +88 -0
- package/src/theme/index.ts +3 -0
- package/src/types/react-big-calendar.d.ts +16 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Cog, GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
5
|
+
import { CUSTOM_FIELD_KINDS } from '@open-mercato/shared/modules/entities/kinds'
|
|
6
|
+
import { FieldRegistry } from '../fields/registry'
|
|
7
|
+
import { slugify } from '@open-mercato/shared/lib/slugify'
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
} from '../../primitives/dialog'
|
|
16
|
+
import {
|
|
17
|
+
normalizeCustomFieldOptions,
|
|
18
|
+
type CustomFieldOptionDto,
|
|
19
|
+
} from '@open-mercato/shared/modules/entities/options'
|
|
20
|
+
|
|
21
|
+
type FieldsetGroup = { code: string; title?: string; hint?: string }
|
|
22
|
+
type FieldsetConfig = { code: string; label: string; icon?: string; description?: string; groups?: FieldsetGroup[] }
|
|
23
|
+
|
|
24
|
+
export type FieldDefinition = {
|
|
25
|
+
key: string
|
|
26
|
+
kind: string
|
|
27
|
+
configJson?: Record<string, unknown>
|
|
28
|
+
isActive?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type FieldDefinitionError = { key?: string; kind?: string }
|
|
32
|
+
|
|
33
|
+
export type FieldDefinitionsEditorProps = {
|
|
34
|
+
definitions: FieldDefinition[]
|
|
35
|
+
errors?: Record<number, FieldDefinitionError>
|
|
36
|
+
deletedKeys?: string[]
|
|
37
|
+
kindOptions?: Array<{ value: string; label: string }>
|
|
38
|
+
orderNotice?: { dirty: boolean; saving?: boolean; message?: string }
|
|
39
|
+
infoNote?: React.ReactNode
|
|
40
|
+
addButtonLabel?: string
|
|
41
|
+
fieldsets?: FieldsetConfig[]
|
|
42
|
+
activeFieldset?: string | null
|
|
43
|
+
onActiveFieldsetChange?: (code: string | null) => void
|
|
44
|
+
onFieldsetsChange?: (next: FieldsetConfig[]) => void
|
|
45
|
+
onFieldsetCodeChange?: (previousCode: string, nextCode: string) => void
|
|
46
|
+
onFieldsetRemoved?: (code: string) => void
|
|
47
|
+
onAddField: () => void
|
|
48
|
+
onRemoveField: (index: number) => void
|
|
49
|
+
onDefinitionChange: (index: number, next: FieldDefinition) => void
|
|
50
|
+
onRestoreField?: (key: string) => void
|
|
51
|
+
onReorder?: (from: number, to: number) => void
|
|
52
|
+
listRef?: React.Ref<HTMLDivElement>
|
|
53
|
+
listProps?: React.HTMLAttributes<HTMLDivElement>
|
|
54
|
+
singleFieldsetPerRecord?: boolean
|
|
55
|
+
onSingleFieldsetPerRecordChange?: (value: boolean) => void
|
|
56
|
+
translate?: (key: string, fallback: string) => string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_KIND_OPTIONS = CUSTOM_FIELD_KINDS.map((k) => ({
|
|
60
|
+
value: k,
|
|
61
|
+
label: k.charAt(0).toUpperCase() + k.slice(1),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
const FIELDSET_ICON_OPTIONS = [
|
|
65
|
+
{ value: 'layers', label: 'Layers' },
|
|
66
|
+
{ value: 'tag', label: 'Tag' },
|
|
67
|
+
{ value: 'sparkles', label: 'Sparkles' },
|
|
68
|
+
{ value: 'package', label: 'Package' },
|
|
69
|
+
{ value: 'shirt', label: 'Shirt' },
|
|
70
|
+
{ value: 'grid', label: 'Grid' },
|
|
71
|
+
{ value: 'shoppingBag', label: 'Shopping bag' },
|
|
72
|
+
{ value: 'shoppingCart', label: 'Shopping cart' },
|
|
73
|
+
{ value: 'store', label: 'Store' },
|
|
74
|
+
{ value: 'users', label: 'Users' },
|
|
75
|
+
{ value: 'briefcase', label: 'Briefcase' },
|
|
76
|
+
{ value: 'building', label: 'Building' },
|
|
77
|
+
{ value: 'bookOpen', label: 'Book open' },
|
|
78
|
+
{ value: 'bookmark', label: 'Bookmark' },
|
|
79
|
+
{ value: 'camera', label: 'Camera' },
|
|
80
|
+
{ value: 'car', label: 'Car' },
|
|
81
|
+
{ value: 'clock', label: 'Clock' },
|
|
82
|
+
{ value: 'cloud', label: 'Cloud' },
|
|
83
|
+
{ value: 'compass', label: 'Compass' },
|
|
84
|
+
{ value: 'creditCard', label: 'Credit card' },
|
|
85
|
+
{ value: 'database', label: 'Database' },
|
|
86
|
+
{ value: 'flame', label: 'Flame' },
|
|
87
|
+
{ value: 'gift', label: 'Gift' },
|
|
88
|
+
{ value: 'globe', label: 'Globe' },
|
|
89
|
+
{ value: 'heart', label: 'Heart' },
|
|
90
|
+
{ value: 'key', label: 'Key' },
|
|
91
|
+
{ value: 'map', label: 'Map' },
|
|
92
|
+
{ value: 'palette', label: 'Palette' },
|
|
93
|
+
{ value: 'shield', label: 'Shield' },
|
|
94
|
+
{ value: 'star', label: 'Star' },
|
|
95
|
+
{ value: 'truck', label: 'Truck' },
|
|
96
|
+
{ value: 'zap', label: 'Zap' },
|
|
97
|
+
{ value: 'coins', label: 'Coins' },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
function slugifyFieldsetCode(value: string): string {
|
|
101
|
+
return slugify(value, { replacement: '', allowedChars: '_-' })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ensureUniqueFieldsetCode(base: string, existing: FieldsetConfig[]): string {
|
|
105
|
+
const sanitizedBase = slugifyFieldsetCode(base) || 'fieldset'
|
|
106
|
+
let candidate = sanitizedBase
|
|
107
|
+
let counter = 1
|
|
108
|
+
const existingCodes = new Set(existing.map((fs) => fs.code))
|
|
109
|
+
while (existingCodes.has(candidate)) {
|
|
110
|
+
counter += 1
|
|
111
|
+
candidate = `${sanitizedBase}_${counter}`
|
|
112
|
+
}
|
|
113
|
+
return candidate
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeGroupValue(raw: unknown): FieldsetGroup | null {
|
|
117
|
+
if (!raw) return null
|
|
118
|
+
if (typeof raw === 'string') {
|
|
119
|
+
const code = raw.trim()
|
|
120
|
+
return code ? { code } : null
|
|
121
|
+
}
|
|
122
|
+
if (typeof raw !== 'object') return null
|
|
123
|
+
const entry = raw as Record<string, unknown>
|
|
124
|
+
const code = typeof entry.code === 'string' ? entry.code.trim() : ''
|
|
125
|
+
if (!code) return null
|
|
126
|
+
const group: FieldsetGroup = { code }
|
|
127
|
+
if (typeof entry.title === 'string' && entry.title.trim()) group.title = entry.title.trim()
|
|
128
|
+
if (typeof entry.hint === 'string' && entry.hint.trim()) group.hint = entry.hint.trim()
|
|
129
|
+
return group
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function FieldDefinitionsEditor({
|
|
133
|
+
definitions,
|
|
134
|
+
errors,
|
|
135
|
+
deletedKeys,
|
|
136
|
+
kindOptions = DEFAULT_KIND_OPTIONS,
|
|
137
|
+
orderNotice,
|
|
138
|
+
infoNote = (
|
|
139
|
+
<div className="text-xs text-muted-foreground mt-2">
|
|
140
|
+
Supported kinds: text, multiline, integer, float, boolean, select (with options/optionsUrl), currency (fixed currencies list), relation (with related entity and options URL).
|
|
141
|
+
</div>
|
|
142
|
+
),
|
|
143
|
+
addButtonLabel = 'Add Field',
|
|
144
|
+
fieldsets = [],
|
|
145
|
+
activeFieldset = null,
|
|
146
|
+
onActiveFieldsetChange,
|
|
147
|
+
onFieldsetsChange,
|
|
148
|
+
onFieldsetCodeChange,
|
|
149
|
+
onFieldsetRemoved,
|
|
150
|
+
singleFieldsetPerRecord,
|
|
151
|
+
onSingleFieldsetPerRecordChange,
|
|
152
|
+
onAddField,
|
|
153
|
+
onRemoveField,
|
|
154
|
+
onDefinitionChange,
|
|
155
|
+
onRestoreField,
|
|
156
|
+
onReorder,
|
|
157
|
+
listRef,
|
|
158
|
+
listProps,
|
|
159
|
+
translate,
|
|
160
|
+
}: FieldDefinitionsEditorProps) {
|
|
161
|
+
const dragIndex = React.useRef<number | null>(null)
|
|
162
|
+
const hasFieldsets = fieldsets.length > 0
|
|
163
|
+
const t = React.useCallback((key: string, fallback: string) => (translate ? translate(key, fallback) : fallback), [translate])
|
|
164
|
+
const resolvedActiveFieldset = React.useMemo(() => {
|
|
165
|
+
if (!hasFieldsets) return activeFieldset ?? null
|
|
166
|
+
if (activeFieldset === null) return null
|
|
167
|
+
return fieldsets.some((fs) => fs.code === activeFieldset) ? activeFieldset : (fieldsets[0]?.code ?? null)
|
|
168
|
+
}, [activeFieldset, fieldsets, hasFieldsets])
|
|
169
|
+
|
|
170
|
+
const filteredDefinitions = React.useMemo(
|
|
171
|
+
() =>
|
|
172
|
+
definitions
|
|
173
|
+
.map((definition, index) => ({ definition, index }))
|
|
174
|
+
.filter(({ definition }) => {
|
|
175
|
+
if (!hasFieldsets) return true
|
|
176
|
+
const assigned = typeof definition.configJson?.fieldset === 'string' ? definition.configJson.fieldset : undefined
|
|
177
|
+
if (!resolvedActiveFieldset) return !assigned
|
|
178
|
+
return assigned === resolvedActiveFieldset
|
|
179
|
+
}),
|
|
180
|
+
[definitions, hasFieldsets, resolvedActiveFieldset],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const activeFieldsetConfig = hasFieldsets && resolvedActiveFieldset
|
|
184
|
+
? fieldsets.find((fs) => fs.code === resolvedActiveFieldset) ?? null
|
|
185
|
+
: null
|
|
186
|
+
|
|
187
|
+
const handleActiveFieldsetChange = (value: string) => {
|
|
188
|
+
if (!onActiveFieldsetChange) return
|
|
189
|
+
onActiveFieldsetChange(value ? value : null)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const handleFieldsetPatch = (code: string, patch: Partial<FieldsetConfig>) => {
|
|
193
|
+
if (!onFieldsetsChange) return
|
|
194
|
+
const next = fieldsets.map((fs) => (fs.code === code ? { ...fs, ...patch } : fs))
|
|
195
|
+
onFieldsetsChange(next)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const handleFieldsetCodeInput = (code: string, nextValue: string) => {
|
|
199
|
+
if (!onFieldsetsChange) return
|
|
200
|
+
const target = fieldsets.find((fs) => fs.code === code)
|
|
201
|
+
if (!target) return
|
|
202
|
+
const sanitized = slugifyFieldsetCode(nextValue)
|
|
203
|
+
if (!sanitized) return
|
|
204
|
+
const next = fieldsets.map((fs) => (fs.code === code ? { ...fs, code: sanitized } : fs))
|
|
205
|
+
onFieldsetsChange(next)
|
|
206
|
+
onFieldsetCodeChange?.(code, sanitized)
|
|
207
|
+
onActiveFieldsetChange?.(sanitized)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const handleAddFieldset = () => {
|
|
211
|
+
if (!onFieldsetsChange) return
|
|
212
|
+
const code = ensureUniqueFieldsetCode(`fieldset_${fieldsets.length + 1}`, fieldsets)
|
|
213
|
+
const nextFieldsets = [...fieldsets, { code, label: 'New fieldset', icon: 'layers' }]
|
|
214
|
+
onFieldsetsChange(nextFieldsets)
|
|
215
|
+
onActiveFieldsetChange?.(code)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const handleRemoveFieldset = () => {
|
|
219
|
+
if (!onFieldsetsChange) return
|
|
220
|
+
if (!resolvedActiveFieldset) return
|
|
221
|
+
if (!window.confirm(`Delete fieldset "${resolvedActiveFieldset}"? This will move its fields to Unassigned.`)) return
|
|
222
|
+
const next = fieldsets.filter((fs) => fs.code !== resolvedActiveFieldset)
|
|
223
|
+
onFieldsetsChange(next)
|
|
224
|
+
onFieldsetRemoved?.(resolvedActiveFieldset)
|
|
225
|
+
const fallback = next[0]?.code ?? null
|
|
226
|
+
onActiveFieldsetChange?.(fallback)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const registerGroup = React.useCallback(
|
|
230
|
+
(fieldsetCode: string, group: FieldsetGroup) => {
|
|
231
|
+
if (!onFieldsetsChange || !fieldsetCode) return
|
|
232
|
+
const next = fieldsets.map((fs) => {
|
|
233
|
+
if (fs.code !== fieldsetCode) return fs
|
|
234
|
+
const list = Array.isArray(fs.groups) ? fs.groups : []
|
|
235
|
+
const existingIndex = list.findIndex((entry) => entry.code === group.code)
|
|
236
|
+
if (existingIndex >= 0) {
|
|
237
|
+
const updated = [...list]
|
|
238
|
+
updated[existingIndex] = { ...list[existingIndex], ...group }
|
|
239
|
+
return { ...fs, groups: updated }
|
|
240
|
+
}
|
|
241
|
+
return { ...fs, groups: [...list, group] }
|
|
242
|
+
})
|
|
243
|
+
onFieldsetsChange(next)
|
|
244
|
+
},
|
|
245
|
+
[fieldsets, onFieldsetsChange],
|
|
246
|
+
)
|
|
247
|
+
const removeGroup = React.useCallback(
|
|
248
|
+
(fieldsetCode: string, groupCode: string) => {
|
|
249
|
+
if (!onFieldsetsChange || !fieldsetCode || !groupCode) return
|
|
250
|
+
const next = fieldsets.map((fs) => {
|
|
251
|
+
if (fs.code !== fieldsetCode) return fs
|
|
252
|
+
const list = Array.isArray(fs.groups) ? fs.groups : []
|
|
253
|
+
return { ...fs, groups: list.filter((entry) => entry.code !== groupCode) }
|
|
254
|
+
})
|
|
255
|
+
onFieldsetsChange(next)
|
|
256
|
+
},
|
|
257
|
+
[fieldsets, onFieldsetsChange],
|
|
258
|
+
)
|
|
259
|
+
const availableGroups = activeFieldsetConfig?.groups ?? []
|
|
260
|
+
const canToggleSingleFieldset = hasFieldsets && fieldsets.length > 1
|
|
261
|
+
const singleFieldsetChecked = singleFieldsetPerRecord !== false
|
|
262
|
+
|
|
263
|
+
const handleReorder = React.useCallback(
|
|
264
|
+
(from: number, to: number) => {
|
|
265
|
+
if (from === to) return
|
|
266
|
+
onReorder?.(from, to)
|
|
267
|
+
},
|
|
268
|
+
[onReorder],
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
ref={listRef}
|
|
274
|
+
className="space-y-3"
|
|
275
|
+
{...listProps}
|
|
276
|
+
>
|
|
277
|
+
{hasFieldsets ? (
|
|
278
|
+
<div className="rounded border bg-card p-3 space-y-3">
|
|
279
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
280
|
+
<label className="text-xs font-medium text-muted-foreground">Fieldset</label>
|
|
281
|
+
<select
|
|
282
|
+
className="border rounded px-2 py-1 text-sm"
|
|
283
|
+
value={resolvedActiveFieldset ?? ''}
|
|
284
|
+
onChange={(event) => handleActiveFieldsetChange(event.target.value)}
|
|
285
|
+
>
|
|
286
|
+
<option value="">Unassigned fields</option>
|
|
287
|
+
{fieldsets.map((fs) => (
|
|
288
|
+
<option key={fs.code} value={fs.code}>
|
|
289
|
+
{fs.label || fs.code}
|
|
290
|
+
</option>
|
|
291
|
+
))}
|
|
292
|
+
</select>
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
onClick={handleAddFieldset}
|
|
296
|
+
className="px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1 text-xs"
|
|
297
|
+
>
|
|
298
|
+
<Plus className="h-3.5 w-3.5" /> Add
|
|
299
|
+
</button>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={handleRemoveFieldset}
|
|
303
|
+
disabled={!resolvedActiveFieldset}
|
|
304
|
+
className="px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1 text-xs disabled:opacity-50"
|
|
305
|
+
>
|
|
306
|
+
<Trash2 className="h-3.5 w-3.5" /> Delete
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
{resolvedActiveFieldset && activeFieldsetConfig ? (
|
|
310
|
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
311
|
+
<div>
|
|
312
|
+
<label className="text-xs">Code</label>
|
|
313
|
+
<input
|
|
314
|
+
className="border rounded w-full px-2 py-1 text-sm font-mono"
|
|
315
|
+
value={activeFieldsetConfig.code}
|
|
316
|
+
onChange={(event) => handleFieldsetCodeInput(activeFieldsetConfig.code, event.target.value)}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
<div>
|
|
320
|
+
<label className="text-xs">Label</label>
|
|
321
|
+
<input
|
|
322
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
323
|
+
value={activeFieldsetConfig.label}
|
|
324
|
+
onChange={(event) => handleFieldsetPatch(activeFieldsetConfig.code, { label: event.target.value })}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
<div>
|
|
328
|
+
<label className="text-xs">Icon</label>
|
|
329
|
+
<select
|
|
330
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
331
|
+
value={activeFieldsetConfig.icon ?? ''}
|
|
332
|
+
onChange={(event) =>
|
|
333
|
+
handleFieldsetPatch(activeFieldsetConfig.code, {
|
|
334
|
+
icon: event.target.value || undefined,
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
>
|
|
338
|
+
<option value="">Default</option>
|
|
339
|
+
{FIELDSET_ICON_OPTIONS.map((option) => (
|
|
340
|
+
<option key={option.value} value={option.value}>
|
|
341
|
+
{option.label}
|
|
342
|
+
</option>
|
|
343
|
+
))}
|
|
344
|
+
</select>
|
|
345
|
+
</div>
|
|
346
|
+
<div>
|
|
347
|
+
<label className="text-xs">Description</label>
|
|
348
|
+
<input
|
|
349
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
350
|
+
value={activeFieldsetConfig.description ?? ''}
|
|
351
|
+
onChange={(event) => handleFieldsetPatch(activeFieldsetConfig.code, { description: event.target.value })}
|
|
352
|
+
/>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
) : null}
|
|
356
|
+
<div className="flex items-center gap-2 text-xs">
|
|
357
|
+
<label className="inline-flex items-center gap-2">
|
|
358
|
+
<input
|
|
359
|
+
type="checkbox"
|
|
360
|
+
disabled={!canToggleSingleFieldset}
|
|
361
|
+
checked={singleFieldsetChecked}
|
|
362
|
+
onChange={(event) => onSingleFieldsetPerRecordChange?.(event.target.checked)}
|
|
363
|
+
/>
|
|
364
|
+
Single fieldset per entity
|
|
365
|
+
</label>
|
|
366
|
+
{!canToggleSingleFieldset ? (
|
|
367
|
+
<span className="text-muted-foreground">(add at least two fieldsets to toggle)</span>
|
|
368
|
+
) : null}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
) : (
|
|
372
|
+
<div className="rounded border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground flex flex-col gap-3">
|
|
373
|
+
<div>No fieldsets defined yet. Fieldsets let you group custom fields for different variants of the same entity (e.g., Fashion vs. Sport products).</div>
|
|
374
|
+
<div>
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={handleAddFieldset}
|
|
378
|
+
className="px-3 py-1.5 border rounded bg-card text-sm font-medium inline-flex items-center gap-2"
|
|
379
|
+
>
|
|
380
|
+
<Plus className="h-4 w-4" />
|
|
381
|
+
Add first fieldset
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
{orderNotice?.dirty && (
|
|
387
|
+
<div className="sticky top-0 z-10 -mt-1 -mb-1">
|
|
388
|
+
<div className="inline-flex items-center gap-2 text-xs px-2 py-1 rounded border bg-amber-50 text-amber-800 shadow-sm">
|
|
389
|
+
{orderNotice?.saving ? 'Saving order…' : (orderNotice?.message ?? 'Reordered — saving soon')}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
{filteredDefinitions.map(({ definition, index }) => {
|
|
394
|
+
const assignedFieldset = typeof definition.configJson?.fieldset === 'string' ? definition.configJson.fieldset : null
|
|
395
|
+
const groupOptions = assignedFieldset
|
|
396
|
+
? fieldsets.find((fs) => fs.code === assignedFieldset)?.groups ?? []
|
|
397
|
+
: availableGroups
|
|
398
|
+
return (
|
|
399
|
+
<div
|
|
400
|
+
key={definition.key || `def-${index}`}
|
|
401
|
+
className="group"
|
|
402
|
+
draggable
|
|
403
|
+
onDragStart={() => { dragIndex.current = index }}
|
|
404
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
405
|
+
onDrop={() => {
|
|
406
|
+
const from = dragIndex.current
|
|
407
|
+
if (from == null) return
|
|
408
|
+
dragIndex.current = null
|
|
409
|
+
handleReorder(from, index)
|
|
410
|
+
}}
|
|
411
|
+
onDragEnd={() => { dragIndex.current = null }}
|
|
412
|
+
tabIndex={0}
|
|
413
|
+
onKeyDown={(event) => {
|
|
414
|
+
if (!event.altKey) return
|
|
415
|
+
if (event.key === 'ArrowUp' || event.key === 'Up') {
|
|
416
|
+
event.preventDefault()
|
|
417
|
+
handleReorder(index, Math.max(0, index - 1))
|
|
418
|
+
}
|
|
419
|
+
if (event.key === 'ArrowDown' || event.key === 'Down') {
|
|
420
|
+
event.preventDefault()
|
|
421
|
+
handleReorder(index, Math.min(definitions.length - 1, index + 1))
|
|
422
|
+
}
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
<FieldDefinitionCard
|
|
426
|
+
definition={definition}
|
|
427
|
+
error={errors?.[index]}
|
|
428
|
+
kindOptions={kindOptions}
|
|
429
|
+
onChange={(next) => onDefinitionChange(index, next)}
|
|
430
|
+
onRemove={() => onRemoveField(index)}
|
|
431
|
+
allowFieldsetSelection={hasFieldsets}
|
|
432
|
+
fieldsets={fieldsets}
|
|
433
|
+
activeFieldset={resolvedActiveFieldset}
|
|
434
|
+
availableGroups={groupOptions}
|
|
435
|
+
onRegisterGroup={registerGroup}
|
|
436
|
+
onRemoveGroup={removeGroup}
|
|
437
|
+
translate={t}
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
440
|
+
)})}
|
|
441
|
+
<div>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
onClick={onAddField}
|
|
445
|
+
className="px-3 py-1.5 text-sm border rounded hover:bg-muted inline-flex items-center gap-1"
|
|
446
|
+
>
|
|
447
|
+
<Plus className="h-4 w-4" /> {addButtonLabel}
|
|
448
|
+
</button>
|
|
449
|
+
{infoNote}
|
|
450
|
+
{deletedKeys && deletedKeys.length > 0 && onRestoreField ? (
|
|
451
|
+
<div className="text-xs text-muted-foreground mt-2">
|
|
452
|
+
Restore deleted fields:{' '}
|
|
453
|
+
{deletedKeys.map((key, idx) => (
|
|
454
|
+
<span key={key}>
|
|
455
|
+
<button
|
|
456
|
+
type="button"
|
|
457
|
+
className="underline hover:no-underline text-blue-600 disabled:opacity-50"
|
|
458
|
+
onClick={() => onRestoreField(key)}
|
|
459
|
+
>
|
|
460
|
+
{key}
|
|
461
|
+
</button>
|
|
462
|
+
{idx < deletedKeys.length - 1 ? ', ' : ''}
|
|
463
|
+
</span>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
) : null}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
type FieldDefinitionCardProps = {
|
|
473
|
+
definition: FieldDefinition
|
|
474
|
+
error?: FieldDefinitionError
|
|
475
|
+
kindOptions: Array<{ value: string; label: string }>
|
|
476
|
+
onChange: (next: FieldDefinition) => void
|
|
477
|
+
onRemove: () => void
|
|
478
|
+
allowFieldsetSelection?: boolean
|
|
479
|
+
fieldsets?: FieldsetConfig[]
|
|
480
|
+
activeFieldset?: string | null
|
|
481
|
+
availableGroups?: FieldsetGroup[]
|
|
482
|
+
onRegisterGroup?: (fieldsetCode: string, group: FieldsetGroup) => void
|
|
483
|
+
onRemoveGroup?: (fieldsetCode: string, groupCode: string) => void
|
|
484
|
+
translate?: (key: string, fallback: string) => string
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
|
|
488
|
+
definition,
|
|
489
|
+
error,
|
|
490
|
+
kindOptions,
|
|
491
|
+
onChange,
|
|
492
|
+
onRemove,
|
|
493
|
+
allowFieldsetSelection = false,
|
|
494
|
+
fieldsets = [],
|
|
495
|
+
activeFieldset,
|
|
496
|
+
availableGroups = [],
|
|
497
|
+
onRegisterGroup,
|
|
498
|
+
onRemoveGroup,
|
|
499
|
+
translate,
|
|
500
|
+
}: FieldDefinitionCardProps) {
|
|
501
|
+
const [local, setLocal] = React.useState<FieldDefinition>(definition)
|
|
502
|
+
const [optionValueDraft, setOptionValueDraft] = React.useState('')
|
|
503
|
+
const [optionLabelDraft, setOptionLabelDraft] = React.useState('')
|
|
504
|
+
const [optionDialogOpen, setOptionDialogOpen] = React.useState(false)
|
|
505
|
+
const [optionFormError, setOptionFormError] = React.useState<string | null>(null)
|
|
506
|
+
const [groupDialogOpen, setGroupDialogOpen] = React.useState(false)
|
|
507
|
+
const [groupDraft, setGroupDraft] = React.useState({ code: '', title: '', hint: '' })
|
|
508
|
+
const [editingGroupCode, setEditingGroupCode] = React.useState<string | null>(null)
|
|
509
|
+
const [groupError, setGroupError] = React.useState<string | null>(null)
|
|
510
|
+
const currentFieldsetValue = React.useMemo(
|
|
511
|
+
() => (typeof local.configJson?.fieldset === 'string' ? local.configJson.fieldset : ''),
|
|
512
|
+
[local.configJson?.fieldset],
|
|
513
|
+
)
|
|
514
|
+
React.useEffect(() => { setLocal(definition) }, [definition.key])
|
|
515
|
+
React.useEffect(() => {
|
|
516
|
+
setOptionValueDraft('')
|
|
517
|
+
setOptionLabelDraft('')
|
|
518
|
+
setGroupDialogOpen(false)
|
|
519
|
+
setGroupDraft({ code: '', title: '', hint: '' })
|
|
520
|
+
setEditingGroupCode(null)
|
|
521
|
+
setGroupError(null)
|
|
522
|
+
}, [definition.key])
|
|
523
|
+
React.useEffect(() => {
|
|
524
|
+
if (!currentFieldsetValue) {
|
|
525
|
+
setGroupDialogOpen(false)
|
|
526
|
+
setGroupDraft({ code: '', title: '', hint: '' })
|
|
527
|
+
setEditingGroupCode(null)
|
|
528
|
+
setGroupError(null)
|
|
529
|
+
}
|
|
530
|
+
}, [currentFieldsetValue])
|
|
531
|
+
const currentGroup = React.useMemo(() => normalizeGroupValue(local.configJson?.group), [local])
|
|
532
|
+
const groupOptions = React.useMemo(() => {
|
|
533
|
+
const list = Array.isArray(availableGroups) ? [...availableGroups] : []
|
|
534
|
+
if (currentGroup && !list.some((entry) => entry.code === currentGroup.code)) {
|
|
535
|
+
list.push(currentGroup)
|
|
536
|
+
}
|
|
537
|
+
return list
|
|
538
|
+
}, [availableGroups, currentGroup])
|
|
539
|
+
const resolvedOptions = React.useMemo<CustomFieldOptionDto[]>(
|
|
540
|
+
() => normalizeCustomFieldOptions(local.configJson?.options),
|
|
541
|
+
[local.configJson?.options],
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
const sanitize = (def: FieldDefinition): FieldDefinition => {
|
|
545
|
+
if (!def.configJson || !Array.isArray(def.configJson.options)) return def
|
|
546
|
+
const normalizedOptions = normalizeCustomFieldOptions(def.configJson.options)
|
|
547
|
+
return {
|
|
548
|
+
...def,
|
|
549
|
+
configJson: {
|
|
550
|
+
...def.configJson,
|
|
551
|
+
options: normalizedOptions,
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const apply = (patch: Partial<FieldDefinition> | ((current: FieldDefinition) => Partial<FieldDefinition>), propagateNow = false) => {
|
|
557
|
+
setLocal((prev) => {
|
|
558
|
+
const resolvedPatch = typeof patch === 'function' ? patch(prev) : patch
|
|
559
|
+
const next = { ...prev, ...resolvedPatch }
|
|
560
|
+
if (!propagateNow) return next
|
|
561
|
+
const sanitized = sanitize(next)
|
|
562
|
+
onChange(sanitized)
|
|
563
|
+
return sanitized
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const commit = () => {
|
|
568
|
+
setLocal((prev) => {
|
|
569
|
+
const sanitized = sanitize(prev)
|
|
570
|
+
onChange(sanitized)
|
|
571
|
+
return sanitized
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const handleFieldsetSelect = (value: string) => {
|
|
576
|
+
setLocal((prev) => {
|
|
577
|
+
const nextConfig = { ...(prev.configJson || {}) }
|
|
578
|
+
if (value) nextConfig.fieldset = value
|
|
579
|
+
else delete nextConfig.fieldset
|
|
580
|
+
delete nextConfig.group
|
|
581
|
+
const next = { ...prev, configJson: nextConfig }
|
|
582
|
+
onChange(next)
|
|
583
|
+
return next
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const handleGroupSelect = (value: string) => {
|
|
588
|
+
if (!currentFieldsetValue) return
|
|
589
|
+
if (!value) {
|
|
590
|
+
const nextConfig = { ...(local.configJson || {}) }
|
|
591
|
+
delete nextConfig.group
|
|
592
|
+
apply({ configJson: nextConfig }, true)
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
const match = groupOptions.find((group) => group.code === value)
|
|
596
|
+
const nextGroup = match ?? { code: value }
|
|
597
|
+
const nextConfig = { ...(local.configJson || {}) }
|
|
598
|
+
nextConfig.group = nextGroup
|
|
599
|
+
apply({ configJson: nextConfig }, true)
|
|
600
|
+
onRegisterGroup?.(currentFieldsetValue, nextGroup)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const handleOpenGroupDialog = (group?: FieldsetGroup) => {
|
|
604
|
+
if (!currentFieldsetValue) return
|
|
605
|
+
if (group) {
|
|
606
|
+
setGroupDraft({
|
|
607
|
+
code: group.code,
|
|
608
|
+
title: group.title ?? '',
|
|
609
|
+
hint: group.hint ?? '',
|
|
610
|
+
})
|
|
611
|
+
setEditingGroupCode(group.code)
|
|
612
|
+
} else {
|
|
613
|
+
setGroupDraft({ code: '', title: '', hint: '' })
|
|
614
|
+
setEditingGroupCode(null)
|
|
615
|
+
}
|
|
616
|
+
setGroupError(null)
|
|
617
|
+
setGroupDialogOpen(true)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const handleGroupDialogSubmit = () => {
|
|
621
|
+
if (!currentFieldsetValue) return
|
|
622
|
+
const code = slugifyFieldsetCode(groupDraft.code || '')
|
|
623
|
+
if (!code) {
|
|
624
|
+
setGroupError('Group code is required.')
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
const group: FieldsetGroup = {
|
|
628
|
+
code,
|
|
629
|
+
title: groupDraft.title.trim() || undefined,
|
|
630
|
+
hint: groupDraft.hint.trim() || undefined,
|
|
631
|
+
}
|
|
632
|
+
onRegisterGroup?.(currentFieldsetValue, group)
|
|
633
|
+
const shouldAttachToField = !editingGroupCode || currentGroup?.code === editingGroupCode
|
|
634
|
+
if (shouldAttachToField) {
|
|
635
|
+
const nextConfig = { ...(local.configJson || {}) }
|
|
636
|
+
nextConfig.group = group
|
|
637
|
+
apply({ configJson: nextConfig }, true)
|
|
638
|
+
}
|
|
639
|
+
setGroupDraft({ code: '', title: '', hint: '' })
|
|
640
|
+
setEditingGroupCode(null)
|
|
641
|
+
setGroupDialogOpen(false)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const handleRemoveGroupEntry = (code: string) => {
|
|
645
|
+
if (!currentFieldsetValue) return
|
|
646
|
+
onRemoveGroup?.(currentFieldsetValue, code)
|
|
647
|
+
if (currentGroup?.code === code) {
|
|
648
|
+
handleGroupSelect('')
|
|
649
|
+
}
|
|
650
|
+
if (editingGroupCode === code) {
|
|
651
|
+
setGroupDraft({ code: '', title: '', hint: '' })
|
|
652
|
+
setEditingGroupCode(null)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const handleEditGroupEntry = (group: FieldsetGroup) => {
|
|
657
|
+
handleOpenGroupDialog(group)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const resetOptionDialog = () => {
|
|
661
|
+
setOptionValueDraft('')
|
|
662
|
+
setOptionLabelDraft('')
|
|
663
|
+
setOptionFormError(null)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const handleOpenOptionDialog = () => {
|
|
667
|
+
resetOptionDialog()
|
|
668
|
+
setOptionDialogOpen(true)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const handleCloseOptionDialog = () => {
|
|
672
|
+
resetOptionDialog()
|
|
673
|
+
setOptionDialogOpen(false)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const handleAddOption = () => {
|
|
677
|
+
const value = optionValueDraft.trim()
|
|
678
|
+
const label = optionLabelDraft.trim()
|
|
679
|
+
if (!value) {
|
|
680
|
+
setOptionFormError('Value is required')
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
setOptionFormError(null)
|
|
684
|
+
const nextOptions = Array.isArray(local.configJson?.options) ? [...local.configJson!.options] : []
|
|
685
|
+
nextOptions.push({ value, label: label || value })
|
|
686
|
+
apply({ configJson: { ...(local.configJson || {}), options: nextOptions } }, true)
|
|
687
|
+
handleCloseOptionDialog()
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const handleRemoveOption = (index: number) => {
|
|
691
|
+
const nextOptions = Array.isArray(local.configJson?.options) ? [...local.configJson!.options] : []
|
|
692
|
+
nextOptions.splice(index, 1)
|
|
693
|
+
apply({ configJson: { ...(local.configJson || {}), options: nextOptions } }, true)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return (
|
|
697
|
+
<>
|
|
698
|
+
<div className="rounded border p-3 bg-card transition-colors hover:border-muted-foreground/60">
|
|
699
|
+
<div className="flex items-center justify-between">
|
|
700
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
701
|
+
<span className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted cursor-grab active:cursor-grabbing">
|
|
702
|
+
<GripVertical className="h-4 w-4 opacity-70" />
|
|
703
|
+
</span>
|
|
704
|
+
Drag to reorder
|
|
705
|
+
</div>
|
|
706
|
+
<div className="flex items-center gap-3">
|
|
707
|
+
<label className="inline-flex items-center gap-2 text-sm">
|
|
708
|
+
<input type="checkbox" checked={local.isActive !== false} onChange={(event) => { apply({ isActive: event.target.checked }, true) }} /> Active
|
|
709
|
+
</label>
|
|
710
|
+
<button type="button" onClick={onRemove} className="px-2 py-1 border rounded hover:bg-muted" aria-label="Remove field">
|
|
711
|
+
<Trash2 className="h-4 w-4" />
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div className="mt-3 grid grid-cols-1 md:grid-cols-12 gap-3 items-center">
|
|
717
|
+
<div className="md:col-span-6">
|
|
718
|
+
<label className="text-xs">Key</label>
|
|
719
|
+
<input
|
|
720
|
+
className={`rounded w-full px-2 py-1 text-sm font-mono ${error?.key ? 'border-red-500 border' : 'border'}`}
|
|
721
|
+
placeholder="snake_case"
|
|
722
|
+
value={local.key}
|
|
723
|
+
onChange={(event) => apply({ key: event.target.value })}
|
|
724
|
+
onBlur={commit}
|
|
725
|
+
/>
|
|
726
|
+
{error?.key ? <div className="text-xs text-red-600 mt-1">{error.key}</div> : null}
|
|
727
|
+
</div>
|
|
728
|
+
<div className="md:col-span-6">
|
|
729
|
+
<label className="text-xs">Kind</label>
|
|
730
|
+
<select
|
|
731
|
+
className={`rounded w-full px-2 py-1 text-sm ${error?.kind ? 'border-red-500 border' : 'border'}`}
|
|
732
|
+
value={local.kind}
|
|
733
|
+
onChange={(event) => { apply({ kind: event.target.value }, true) }}
|
|
734
|
+
>
|
|
735
|
+
{kindOptions.map((option) => (
|
|
736
|
+
<option key={option.value} value={option.value}>
|
|
737
|
+
{option.label}
|
|
738
|
+
</option>
|
|
739
|
+
))}
|
|
740
|
+
</select>
|
|
741
|
+
{error?.kind ? <div className="text-xs text-red-600 mt-1">{error.kind}</div> : null}
|
|
742
|
+
</div>
|
|
743
|
+
</div>
|
|
744
|
+
|
|
745
|
+
{allowFieldsetSelection ? (
|
|
746
|
+
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
747
|
+
<div>
|
|
748
|
+
<label className="text-xs">Assign to fieldset</label>
|
|
749
|
+
<select
|
|
750
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
751
|
+
value={currentFieldsetValue}
|
|
752
|
+
onChange={(event) => handleFieldsetSelect(event.target.value)}
|
|
753
|
+
>
|
|
754
|
+
<option value="">Unassigned</option>
|
|
755
|
+
{(fieldsets || []).map((fs) => (
|
|
756
|
+
<option key={fs.code} value={fs.code}>
|
|
757
|
+
{fs.label || fs.code}
|
|
758
|
+
</option>
|
|
759
|
+
))}
|
|
760
|
+
</select>
|
|
761
|
+
</div>
|
|
762
|
+
{currentFieldsetValue ? (
|
|
763
|
+
<div>
|
|
764
|
+
<div className="flex items-center justify-between">
|
|
765
|
+
<label className="text-xs">Group</label>
|
|
766
|
+
</div>
|
|
767
|
+
<div className="flex items-center gap-2 mt-1">
|
|
768
|
+
<select
|
|
769
|
+
className="flex-1 border rounded px-2 py-1 text-sm"
|
|
770
|
+
value={currentGroup?.code ?? ''}
|
|
771
|
+
onChange={(event) => handleGroupSelect(event.target.value)}
|
|
772
|
+
>
|
|
773
|
+
<option value="">No group</option>
|
|
774
|
+
{groupOptions.map((group) => (
|
|
775
|
+
<option key={group.code} value={group.code}>
|
|
776
|
+
{group.title || group.code}
|
|
777
|
+
</option>
|
|
778
|
+
))}
|
|
779
|
+
</select>
|
|
780
|
+
<button
|
|
781
|
+
type="button"
|
|
782
|
+
className="h-8 w-8 inline-flex items-center justify-center rounded border text-muted-foreground hover:bg-muted/40"
|
|
783
|
+
onClick={() => handleOpenGroupDialog()}
|
|
784
|
+
aria-label="Create group"
|
|
785
|
+
>
|
|
786
|
+
<Plus className="h-4 w-4" />
|
|
787
|
+
</button>
|
|
788
|
+
<button
|
|
789
|
+
type="button"
|
|
790
|
+
className="h-8 w-8 inline-flex items-center justify-center rounded border text-muted-foreground hover:bg-muted/40"
|
|
791
|
+
onClick={() => handleOpenGroupDialog()}
|
|
792
|
+
aria-label="Edit groups"
|
|
793
|
+
>
|
|
794
|
+
<Cog className="h-4 w-4" />
|
|
795
|
+
<span className="sr-only">Edit groups</span>
|
|
796
|
+
</button>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
) : null}
|
|
800
|
+
</div>
|
|
801
|
+
) : null}
|
|
802
|
+
|
|
803
|
+
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
804
|
+
<div>
|
|
805
|
+
<label className="text-xs">Label</label>
|
|
806
|
+
<input
|
|
807
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
808
|
+
value={typeof local.configJson?.label === 'string' ? local.configJson.label : ''}
|
|
809
|
+
onChange={(event) => apply({ configJson: { ...(local.configJson || {}), label: event.target.value } })}
|
|
810
|
+
onBlur={commit}
|
|
811
|
+
/>
|
|
812
|
+
</div>
|
|
813
|
+
<div>
|
|
814
|
+
<label className="text-xs">Description</label>
|
|
815
|
+
<input
|
|
816
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
817
|
+
value={typeof local.configJson?.description === 'string' ? local.configJson.description : ''}
|
|
818
|
+
onChange={(event) => apply({ configJson: { ...(local.configJson || {}), description: event.target.value } })}
|
|
819
|
+
onBlur={commit}
|
|
820
|
+
/>
|
|
821
|
+
</div>
|
|
822
|
+
|
|
823
|
+
{(local.kind === 'text' || local.kind === 'multiline') && (
|
|
824
|
+
<>
|
|
825
|
+
<div>
|
|
826
|
+
<label className="text-xs">Editor</label>
|
|
827
|
+
<select
|
|
828
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
829
|
+
value={typeof local.configJson?.editor === 'string' ? local.configJson.editor : ''}
|
|
830
|
+
onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), editor: event.target.value || undefined } }, true) }}
|
|
831
|
+
>
|
|
832
|
+
<option value="">Default</option>
|
|
833
|
+
<option value="markdown">Markdown (UIW)</option>
|
|
834
|
+
<option value="simpleMarkdown">Simple Markdown</option>
|
|
835
|
+
<option value="htmlRichText">HTML Rich Text</option>
|
|
836
|
+
</select>
|
|
837
|
+
</div>
|
|
838
|
+
{local.kind === 'text' && (
|
|
839
|
+
<>
|
|
840
|
+
<div className="md:col-span-2">
|
|
841
|
+
<label className="inline-flex items-center gap-2 text-xs">
|
|
842
|
+
<input type="checkbox" checked={!!local.configJson?.multi} onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), multi: event.target.checked } }, true) }} /> Multiple
|
|
843
|
+
</label>
|
|
844
|
+
</div>
|
|
845
|
+
{!!local.configJson?.multi && (
|
|
846
|
+
<div className="md:col-span-2">
|
|
847
|
+
<label className="text-xs">Multi-select input style</label>
|
|
848
|
+
<select
|
|
849
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
850
|
+
value={local.configJson?.input === 'listbox' ? 'listbox' : 'default'}
|
|
851
|
+
onChange={(event) => {
|
|
852
|
+
const { value } = event.target
|
|
853
|
+
const nextConfig = { ...(local.configJson || {}) }
|
|
854
|
+
if (value === 'listbox') nextConfig.input = 'listbox'
|
|
855
|
+
else delete nextConfig.input
|
|
856
|
+
apply({ configJson: nextConfig }, true)
|
|
857
|
+
}}
|
|
858
|
+
>
|
|
859
|
+
<option value="default">Default</option>
|
|
860
|
+
<option value="listbox">Listbox (searchable)</option>
|
|
861
|
+
</select>
|
|
862
|
+
</div>
|
|
863
|
+
)}
|
|
864
|
+
</>
|
|
865
|
+
)}
|
|
866
|
+
</>
|
|
867
|
+
)}
|
|
868
|
+
|
|
869
|
+
{local.kind === 'select' && (
|
|
870
|
+
<div className="md:col-span-6 space-y-3">
|
|
871
|
+
<label className="text-xs">Options</label>
|
|
872
|
+
<div className="space-y-2">
|
|
873
|
+
{resolvedOptions.length > 0 ? (
|
|
874
|
+
resolvedOptions.map((option, idx) => (
|
|
875
|
+
<div
|
|
876
|
+
key={`${option.value}-${idx}`}
|
|
877
|
+
className="flex items-center justify-between rounded border px-3 py-2 text-xs bg-muted"
|
|
878
|
+
>
|
|
879
|
+
<div>
|
|
880
|
+
<div className="font-medium text-foreground">{option.label}</div>
|
|
881
|
+
<div className="text-muted-foreground font-mono text-[11px]">{option.value}</div>
|
|
882
|
+
</div>
|
|
883
|
+
<button
|
|
884
|
+
type="button"
|
|
885
|
+
onClick={() => handleRemoveOption(idx)}
|
|
886
|
+
className="text-red-500 hover:text-red-700"
|
|
887
|
+
aria-label="Remove option"
|
|
888
|
+
>
|
|
889
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
890
|
+
</button>
|
|
891
|
+
</div>
|
|
892
|
+
))
|
|
893
|
+
) : (
|
|
894
|
+
<span className="text-xs text-muted-foreground">No options defined.</span>
|
|
895
|
+
)}
|
|
896
|
+
</div>
|
|
897
|
+
<div className="flex justify-end">
|
|
898
|
+
<button
|
|
899
|
+
type="button"
|
|
900
|
+
className="text-xs px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1"
|
|
901
|
+
onClick={handleOpenOptionDialog}
|
|
902
|
+
>
|
|
903
|
+
<Plus className="h-3.5 w-3.5" />
|
|
904
|
+
Add option
|
|
905
|
+
</button>
|
|
906
|
+
</div>
|
|
907
|
+
</div>
|
|
908
|
+
)}
|
|
909
|
+
|
|
910
|
+
{(local.kind === 'select' || local.kind === 'relation') && (
|
|
911
|
+
<>
|
|
912
|
+
<div>
|
|
913
|
+
<label className="text-xs">Options URL</label>
|
|
914
|
+
<input
|
|
915
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
916
|
+
placeholder="/api/..."
|
|
917
|
+
value={typeof local.configJson?.optionsUrl === 'string' ? local.configJson.optionsUrl : ''}
|
|
918
|
+
onChange={(event) => apply({ configJson: { ...(local.configJson || {}), optionsUrl: event.target.value } })}
|
|
919
|
+
onBlur={commit}
|
|
920
|
+
/>
|
|
921
|
+
</div>
|
|
922
|
+
{local.kind === 'relation' && (
|
|
923
|
+
<div>
|
|
924
|
+
<label className="text-xs">Related Entity ID</label>
|
|
925
|
+
<input
|
|
926
|
+
className="border rounded w-full px-2 py-1 text-sm font-mono"
|
|
927
|
+
placeholder="module:entity"
|
|
928
|
+
value={typeof local.configJson?.relatedEntityId === 'string' ? local.configJson.relatedEntityId : ''}
|
|
929
|
+
onChange={(event) => {
|
|
930
|
+
const relatedEntityId = event.target.value
|
|
931
|
+
const defOptionsUrl = relatedEntityId
|
|
932
|
+
? `/api/entities/relations/options?entityId=${encodeURIComponent(relatedEntityId)}`
|
|
933
|
+
: ''
|
|
934
|
+
apply({
|
|
935
|
+
configJson: {
|
|
936
|
+
...(local.configJson || {}),
|
|
937
|
+
relatedEntityId,
|
|
938
|
+
optionsUrl: local.configJson?.optionsUrl || defOptionsUrl,
|
|
939
|
+
},
|
|
940
|
+
})
|
|
941
|
+
}}
|
|
942
|
+
onBlur={commit}
|
|
943
|
+
/>
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
946
|
+
</>
|
|
947
|
+
)}
|
|
948
|
+
|
|
949
|
+
{local.kind === 'currency' && (
|
|
950
|
+
<div className="md:col-span-2">
|
|
951
|
+
<label className="text-xs">Options source</label>
|
|
952
|
+
<div className="rounded border bg-muted px-2 py-1 text-xs text-muted-foreground">
|
|
953
|
+
/api/currencies/options
|
|
954
|
+
</div>
|
|
955
|
+
</div>
|
|
956
|
+
)}
|
|
957
|
+
|
|
958
|
+
{(local.kind === 'integer' || local.kind === 'float') && (
|
|
959
|
+
<div className="md:col-span-2">
|
|
960
|
+
<label className="text-xs">Units (optional)</label>
|
|
961
|
+
<input
|
|
962
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
963
|
+
placeholder="kg, cm, etc."
|
|
964
|
+
value={typeof local.configJson?.unit === 'string' ? local.configJson.unit : ''}
|
|
965
|
+
onChange={(event) => apply({ configJson: { ...(local.configJson || {}), unit: event.target.value } })}
|
|
966
|
+
onBlur={commit}
|
|
967
|
+
/>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
</div>
|
|
971
|
+
|
|
972
|
+
<div className="mt-3 pt-3 border-t">
|
|
973
|
+
<div className="flex items-center justify-between mb-2">
|
|
974
|
+
<label className="text-sm font-medium">Validation rules</label>
|
|
975
|
+
<button
|
|
976
|
+
type="button"
|
|
977
|
+
className="text-xs px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1"
|
|
978
|
+
onClick={() => {
|
|
979
|
+
apply((current) => {
|
|
980
|
+
const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
|
|
981
|
+
list.push({ rule: 'required', message: 'This field is required' } as any)
|
|
982
|
+
return { configJson: { ...(current.configJson || {}), validation: list } }
|
|
983
|
+
}, true)
|
|
984
|
+
}}
|
|
985
|
+
>
|
|
986
|
+
<Plus className="h-3.5 w-3.5" />
|
|
987
|
+
Add rule
|
|
988
|
+
</button>
|
|
989
|
+
</div>
|
|
990
|
+
<div className="space-y-2">
|
|
991
|
+
{(Array.isArray(local.configJson?.validation) ? local.configJson!.validation : []).map((rule: any, index: number) => (
|
|
992
|
+
<div key={index} className="grid grid-cols-1 md:grid-cols-12 gap-2 items-center">
|
|
993
|
+
<div className="md:col-span-3">
|
|
994
|
+
<select
|
|
995
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
996
|
+
value={rule?.rule || 'required'}
|
|
997
|
+
onChange={(event) => {
|
|
998
|
+
const nextRule = event.target.value
|
|
999
|
+
apply((current) => {
|
|
1000
|
+
const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
|
|
1001
|
+
const existing = (list[index] as any) || {}
|
|
1002
|
+
list[index] = { ...existing, rule: nextRule, message: existing.message || rule?.message || '' }
|
|
1003
|
+
return { configJson: { ...(current.configJson || {}), validation: list } }
|
|
1004
|
+
}, true)
|
|
1005
|
+
}}
|
|
1006
|
+
>
|
|
1007
|
+
<option value="required">required</option>
|
|
1008
|
+
<option value="date">date</option>
|
|
1009
|
+
<option value="integer">integer</option>
|
|
1010
|
+
<option value="float">float</option>
|
|
1011
|
+
<option value="lt">lt</option>
|
|
1012
|
+
<option value="lte">lte</option>
|
|
1013
|
+
<option value="gt">gt</option>
|
|
1014
|
+
<option value="gte">gte</option>
|
|
1015
|
+
<option value="eq">eq</option>
|
|
1016
|
+
<option value="ne">ne</option>
|
|
1017
|
+
<option value="regex">regex</option>
|
|
1018
|
+
</select>
|
|
1019
|
+
</div>
|
|
1020
|
+
<div className="md:col-span-4">
|
|
1021
|
+
<input
|
|
1022
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
1023
|
+
placeholder={rule?.rule === 'regex' ? 'Pattern (e.g. ^[a-z]+$)' : (['lt','lte','gt','gte'].includes(rule?.rule) ? 'Number' : '—')}
|
|
1024
|
+
value={rule?.param ?? ''}
|
|
1025
|
+
onChange={(event) => {
|
|
1026
|
+
const value = ['lt','lte','gt','gte'].includes(rule?.rule) ? Number(event.target.value) : event.target.value
|
|
1027
|
+
apply((current) => {
|
|
1028
|
+
const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
|
|
1029
|
+
const existing = (list[index] as any) || {}
|
|
1030
|
+
list[index] = { ...existing, ...rule, param: value }
|
|
1031
|
+
return { configJson: { ...(current.configJson || {}), validation: list } }
|
|
1032
|
+
})
|
|
1033
|
+
}}
|
|
1034
|
+
onBlur={commit}
|
|
1035
|
+
disabled={rule?.rule === 'required' || rule?.rule === 'date' || rule?.rule === 'integer' || rule?.rule === 'float'}
|
|
1036
|
+
/>
|
|
1037
|
+
</div>
|
|
1038
|
+
<div className="md:col-span-4">
|
|
1039
|
+
<input
|
|
1040
|
+
className="border rounded w-full px-2 py-1 text-sm"
|
|
1041
|
+
placeholder="Error message"
|
|
1042
|
+
value={rule?.message || ''}
|
|
1043
|
+
onChange={(event) => {
|
|
1044
|
+
const message = event.target.value
|
|
1045
|
+
apply((current) => {
|
|
1046
|
+
const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
|
|
1047
|
+
const existing = (list[index] as any) || {}
|
|
1048
|
+
list[index] = { ...existing, ...rule, message }
|
|
1049
|
+
return { configJson: { ...(current.configJson || {}), validation: list } }
|
|
1050
|
+
})
|
|
1051
|
+
}}
|
|
1052
|
+
onBlur={commit}
|
|
1053
|
+
/>
|
|
1054
|
+
</div>
|
|
1055
|
+
<div className="md:col-span-1 flex justify-end">
|
|
1056
|
+
<button
|
|
1057
|
+
type="button"
|
|
1058
|
+
className="px-2 py-1 border rounded hover:bg-muted"
|
|
1059
|
+
aria-label="Remove rule"
|
|
1060
|
+
onClick={() => {
|
|
1061
|
+
apply((current) => {
|
|
1062
|
+
const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
|
|
1063
|
+
list.splice(index, 1)
|
|
1064
|
+
return { configJson: { ...(current.configJson || {}), validation: list } }
|
|
1065
|
+
}, true)
|
|
1066
|
+
}}
|
|
1067
|
+
>
|
|
1068
|
+
<Trash2 className="h-4 w-4" />
|
|
1069
|
+
</button>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
))}
|
|
1073
|
+
{(!Array.isArray(local.configJson?.validation) || local.configJson!.validation.length === 0) && (
|
|
1074
|
+
<div className="text-xs text-muted-foreground">No validation rules defined.</div>
|
|
1075
|
+
)}
|
|
1076
|
+
</div>
|
|
1077
|
+
</div>
|
|
1078
|
+
|
|
1079
|
+
<div className="mt-3">
|
|
1080
|
+
{(() => {
|
|
1081
|
+
const Editor = FieldRegistry.getDefEditor(local.kind)
|
|
1082
|
+
if (!Editor) return null
|
|
1083
|
+
return (
|
|
1084
|
+
<Editor
|
|
1085
|
+
def={{ key: local.key, kind: local.kind, configJson: local.configJson }}
|
|
1086
|
+
onChange={(patch) => apply({ configJson: { ...(local.configJson || {}), ...(patch || {}) } }, true)}
|
|
1087
|
+
/>
|
|
1088
|
+
)
|
|
1089
|
+
})()}
|
|
1090
|
+
</div>
|
|
1091
|
+
|
|
1092
|
+
<div className="mt-3 pt-2 border-t flex flex-wrap items-center gap-4">
|
|
1093
|
+
<span className="text-xs text-muted-foreground">Visibility:</span>
|
|
1094
|
+
<label className="inline-flex items-center gap-2 text-xs">
|
|
1095
|
+
<input
|
|
1096
|
+
type="checkbox"
|
|
1097
|
+
checked={local.configJson?.listVisible !== false}
|
|
1098
|
+
onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), listVisible: event.target.checked } }, true) }}
|
|
1099
|
+
/>
|
|
1100
|
+
List
|
|
1101
|
+
</label>
|
|
1102
|
+
<label className="inline-flex items-center gap-2 text-xs">
|
|
1103
|
+
<input
|
|
1104
|
+
type="checkbox"
|
|
1105
|
+
checked={!!local.configJson?.filterable}
|
|
1106
|
+
onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), filterable: event.target.checked } }, true) }}
|
|
1107
|
+
/>
|
|
1108
|
+
Filter
|
|
1109
|
+
</label>
|
|
1110
|
+
<label className="inline-flex items-center gap-2 text-xs">
|
|
1111
|
+
<input
|
|
1112
|
+
type="checkbox"
|
|
1113
|
+
checked={local.configJson?.formEditable !== false}
|
|
1114
|
+
onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), formEditable: event.target.checked } }, true) }}
|
|
1115
|
+
/>
|
|
1116
|
+
Form
|
|
1117
|
+
</label>
|
|
1118
|
+
<label className="inline-flex items-center gap-2 text-xs">
|
|
1119
|
+
<input
|
|
1120
|
+
type="checkbox"
|
|
1121
|
+
checked={!!local.configJson?.encrypted}
|
|
1122
|
+
onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), encrypted: event.target.checked } }, true) }}
|
|
1123
|
+
/>
|
|
1124
|
+
{translate?.('entities.customFields.fields.encrypted', 'Encrypted') ?? 'Encrypted'}
|
|
1125
|
+
</label>
|
|
1126
|
+
</div>
|
|
1127
|
+
</div>
|
|
1128
|
+
<Dialog
|
|
1129
|
+
open={optionDialogOpen}
|
|
1130
|
+
onOpenChange={(open) => {
|
|
1131
|
+
setOptionDialogOpen(open)
|
|
1132
|
+
if (!open) resetOptionDialog()
|
|
1133
|
+
}}
|
|
1134
|
+
>
|
|
1135
|
+
<DialogContent className="max-w-sm">
|
|
1136
|
+
<DialogHeader>
|
|
1137
|
+
<DialogTitle>Add option</DialogTitle>
|
|
1138
|
+
<DialogDescription>Provide the stored value and optional label shown to users.</DialogDescription>
|
|
1139
|
+
</DialogHeader>
|
|
1140
|
+
<div className="space-y-4 py-2">
|
|
1141
|
+
<div>
|
|
1142
|
+
<label className="text-xs">Value</label>
|
|
1143
|
+
<input
|
|
1144
|
+
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
|
|
1145
|
+
placeholder="unique_value"
|
|
1146
|
+
value={optionValueDraft}
|
|
1147
|
+
onChange={(event) => {
|
|
1148
|
+
setOptionFormError(null)
|
|
1149
|
+
setOptionValueDraft(event.target.value)
|
|
1150
|
+
}}
|
|
1151
|
+
/>
|
|
1152
|
+
{optionFormError ? <p className="mt-1 text-xs text-red-600">{optionFormError}</p> : null}
|
|
1153
|
+
</div>
|
|
1154
|
+
<div>
|
|
1155
|
+
<label className="text-xs">Label</label>
|
|
1156
|
+
<input
|
|
1157
|
+
className="mt-1 w-full rounded border px-2 py-1 text-sm"
|
|
1158
|
+
placeholder="Label shown to users (optional)"
|
|
1159
|
+
value={optionLabelDraft}
|
|
1160
|
+
onChange={(event) => setOptionLabelDraft(event.target.value)}
|
|
1161
|
+
/>
|
|
1162
|
+
</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
<DialogFooter>
|
|
1165
|
+
<button
|
|
1166
|
+
type="button"
|
|
1167
|
+
className="h-8 rounded border px-3 text-sm"
|
|
1168
|
+
onClick={handleCloseOptionDialog}
|
|
1169
|
+
>
|
|
1170
|
+
Cancel
|
|
1171
|
+
</button>
|
|
1172
|
+
<button
|
|
1173
|
+
type="button"
|
|
1174
|
+
className="h-8 rounded bg-primary px-3 text-sm text-primary-foreground"
|
|
1175
|
+
onClick={handleAddOption}
|
|
1176
|
+
>
|
|
1177
|
+
Add option
|
|
1178
|
+
</button>
|
|
1179
|
+
</DialogFooter>
|
|
1180
|
+
</DialogContent>
|
|
1181
|
+
</Dialog>
|
|
1182
|
+
<Dialog
|
|
1183
|
+
open={groupDialogOpen}
|
|
1184
|
+
onOpenChange={(open) => {
|
|
1185
|
+
setGroupDialogOpen(open)
|
|
1186
|
+
if (!open) {
|
|
1187
|
+
setGroupError(null)
|
|
1188
|
+
setEditingGroupCode(null)
|
|
1189
|
+
setGroupDraft({ code: '', title: '', hint: '' })
|
|
1190
|
+
}
|
|
1191
|
+
}}
|
|
1192
|
+
>
|
|
1193
|
+
<DialogContent className="max-w-md">
|
|
1194
|
+
<DialogHeader>
|
|
1195
|
+
<DialogTitle>{editingGroupCode ? 'Edit group' : 'New group'}</DialogTitle>
|
|
1196
|
+
<DialogDescription>
|
|
1197
|
+
{editingGroupCode ? 'Update the selected group for this fieldset.' : 'Add a reusable group for this fieldset.'}
|
|
1198
|
+
</DialogDescription>
|
|
1199
|
+
</DialogHeader>
|
|
1200
|
+
<div className="space-y-3">
|
|
1201
|
+
<div>
|
|
1202
|
+
<label className="text-xs font-medium">Group code</label>
|
|
1203
|
+
<input
|
|
1204
|
+
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono disabled:cursor-not-allowed disabled:bg-muted/40 disabled:text-muted-foreground"
|
|
1205
|
+
value={groupDraft.code}
|
|
1206
|
+
onChange={(event) => {
|
|
1207
|
+
setGroupDraft((prev) => ({ ...prev, code: event.target.value }))
|
|
1208
|
+
if (groupError) setGroupError(null)
|
|
1209
|
+
}}
|
|
1210
|
+
disabled={!!editingGroupCode}
|
|
1211
|
+
placeholder="e.g. buying_committee"
|
|
1212
|
+
/>
|
|
1213
|
+
{groupError ? <div className="text-xs text-red-600 mt-1">{groupError}</div> : null}
|
|
1214
|
+
</div>
|
|
1215
|
+
<div>
|
|
1216
|
+
<label className="text-xs font-medium">Label</label>
|
|
1217
|
+
<input
|
|
1218
|
+
className="mt-1 w-full rounded border px-2 py-1 text-sm"
|
|
1219
|
+
value={groupDraft.title}
|
|
1220
|
+
onChange={(event) => setGroupDraft((prev) => ({ ...prev, title: event.target.value }))}
|
|
1221
|
+
placeholder="Buying committee"
|
|
1222
|
+
/>
|
|
1223
|
+
</div>
|
|
1224
|
+
<div>
|
|
1225
|
+
<label className="text-xs font-medium">Hint</label>
|
|
1226
|
+
<input
|
|
1227
|
+
className="mt-1 w-full rounded border px-2 py-1 text-sm"
|
|
1228
|
+
value={groupDraft.hint}
|
|
1229
|
+
onChange={(event) => setGroupDraft((prev) => ({ ...prev, hint: event.target.value }))}
|
|
1230
|
+
placeholder="Visible to merchandisers"
|
|
1231
|
+
/>
|
|
1232
|
+
</div>
|
|
1233
|
+
{currentFieldsetValue && groupOptions.length > 0 ? (
|
|
1234
|
+
<div>
|
|
1235
|
+
<div className="text-xs font-medium mb-1">Existing groups</div>
|
|
1236
|
+
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
1237
|
+
{groupOptions.map((group) => (
|
|
1238
|
+
<div key={group.code} className="flex items-center justify-between rounded border px-3 py-2 text-sm">
|
|
1239
|
+
<div>
|
|
1240
|
+
<div className="font-medium">{group.title || group.code}</div>
|
|
1241
|
+
<div className="text-xs text-muted-foreground font-mono">{group.code}</div>
|
|
1242
|
+
{group.hint ? (
|
|
1243
|
+
<div className="text-xs text-muted-foreground">{group.hint}</div>
|
|
1244
|
+
) : null}
|
|
1245
|
+
</div>
|
|
1246
|
+
<div className="flex items-center gap-2">
|
|
1247
|
+
<button
|
|
1248
|
+
type="button"
|
|
1249
|
+
className="text-muted-foreground hover:text-foreground"
|
|
1250
|
+
onClick={() => handleEditGroupEntry(group)}
|
|
1251
|
+
aria-label={`Edit ${group.code}`}
|
|
1252
|
+
>
|
|
1253
|
+
<Pencil className="h-4 w-4" />
|
|
1254
|
+
</button>
|
|
1255
|
+
<button
|
|
1256
|
+
type="button"
|
|
1257
|
+
className="text-red-500 hover:text-red-600"
|
|
1258
|
+
onClick={() => handleRemoveGroupEntry(group.code)}
|
|
1259
|
+
aria-label={`Delete ${group.code}`}
|
|
1260
|
+
>
|
|
1261
|
+
<Trash2 className="h-4 w-4" />
|
|
1262
|
+
</button>
|
|
1263
|
+
</div>
|
|
1264
|
+
</div>
|
|
1265
|
+
))}
|
|
1266
|
+
</div>
|
|
1267
|
+
</div>
|
|
1268
|
+
) : null}
|
|
1269
|
+
</div>
|
|
1270
|
+
<DialogFooter>
|
|
1271
|
+
<button
|
|
1272
|
+
type="button"
|
|
1273
|
+
className="h-8 rounded border px-3 text-sm"
|
|
1274
|
+
onClick={() => setGroupDialogOpen(false)}
|
|
1275
|
+
>
|
|
1276
|
+
Cancel
|
|
1277
|
+
</button>
|
|
1278
|
+
<button
|
|
1279
|
+
type="button"
|
|
1280
|
+
className="h-8 rounded bg-primary px-3 text-sm text-primary-foreground"
|
|
1281
|
+
onClick={handleGroupDialogSubmit}
|
|
1282
|
+
>
|
|
1283
|
+
Save group
|
|
1284
|
+
</button>
|
|
1285
|
+
</DialogFooter>
|
|
1286
|
+
</DialogContent>
|
|
1287
|
+
</Dialog>
|
|
1288
|
+
</>
|
|
1289
|
+
)
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
FieldDefinitionCard.displayName = 'FieldDefinitionCard'
|