@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,2503 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { useRouter } from 'next/navigation'
|
|
6
|
+
import { Button } from '../primitives/button'
|
|
7
|
+
import { DataLoader } from '../primitives/DataLoader'
|
|
8
|
+
import { flash } from './FlashMessages'
|
|
9
|
+
import dynamic from 'next/dynamic'
|
|
10
|
+
import remarkGfm from 'remark-gfm'
|
|
11
|
+
import {
|
|
12
|
+
Trash2,
|
|
13
|
+
Save,
|
|
14
|
+
Settings,
|
|
15
|
+
Layers,
|
|
16
|
+
Tag,
|
|
17
|
+
Sparkles,
|
|
18
|
+
Package,
|
|
19
|
+
Shirt,
|
|
20
|
+
Grid,
|
|
21
|
+
ShoppingBag,
|
|
22
|
+
ShoppingCart,
|
|
23
|
+
Store,
|
|
24
|
+
Users,
|
|
25
|
+
Briefcase,
|
|
26
|
+
Building,
|
|
27
|
+
BookOpen,
|
|
28
|
+
Bookmark,
|
|
29
|
+
Camera,
|
|
30
|
+
Car,
|
|
31
|
+
Clock,
|
|
32
|
+
Cloud,
|
|
33
|
+
Compass,
|
|
34
|
+
CreditCard,
|
|
35
|
+
Database,
|
|
36
|
+
Flame,
|
|
37
|
+
Gift,
|
|
38
|
+
Globe,
|
|
39
|
+
Heart,
|
|
40
|
+
Key,
|
|
41
|
+
Map as MapIcon,
|
|
42
|
+
Palette,
|
|
43
|
+
Shield,
|
|
44
|
+
Star,
|
|
45
|
+
Truck,
|
|
46
|
+
Zap,
|
|
47
|
+
Coins,
|
|
48
|
+
} from 'lucide-react'
|
|
49
|
+
import { loadGeneratedFieldRegistrations } from './fields/registry'
|
|
50
|
+
import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
|
|
51
|
+
import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
|
|
52
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
53
|
+
import { TagsInput } from './inputs/TagsInput'
|
|
54
|
+
import { ComboboxInput } from './inputs/ComboboxInput'
|
|
55
|
+
import { mapCrudServerErrorToFormErrors, parseServerMessage } from './utils/serverErrors'
|
|
56
|
+
import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
|
|
57
|
+
import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
|
|
58
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
|
|
59
|
+
import { FieldDefinitionsManager, type FieldDefinitionsManagerHandle } from './custom-fields/FieldDefinitionsManager'
|
|
60
|
+
import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './injection/InjectionSpot'
|
|
61
|
+
|
|
62
|
+
// Stable empty options array to avoid creating a new [] every render
|
|
63
|
+
const EMPTY_OPTIONS: CrudFieldOption[] = []
|
|
64
|
+
const FOCUSABLE_SELECTOR =
|
|
65
|
+
'[data-crud-focus-target], input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
66
|
+
|
|
67
|
+
export type CrudFieldBase = {
|
|
68
|
+
id: string
|
|
69
|
+
label: string
|
|
70
|
+
placeholder?: string
|
|
71
|
+
description?: string // inline field-level help
|
|
72
|
+
required?: boolean
|
|
73
|
+
layout?: 'full' | 'half' | 'third'
|
|
74
|
+
disabled?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type CrudFieldOption = { value: string; label: string }
|
|
78
|
+
|
|
79
|
+
export type CrudBuiltinField = CrudFieldBase & {
|
|
80
|
+
type:
|
|
81
|
+
| 'text'
|
|
82
|
+
| 'textarea'
|
|
83
|
+
| 'checkbox'
|
|
84
|
+
| 'select'
|
|
85
|
+
| 'number'
|
|
86
|
+
| 'date'
|
|
87
|
+
| 'datetime-local'
|
|
88
|
+
| 'tags'
|
|
89
|
+
| 'richtext'
|
|
90
|
+
| 'relation'
|
|
91
|
+
| 'combobox'
|
|
92
|
+
placeholder?: string
|
|
93
|
+
options?: CrudFieldOption[]
|
|
94
|
+
multiple?: boolean
|
|
95
|
+
listbox?: boolean
|
|
96
|
+
// for relation/select style fields; if provided, options are loaded on mount
|
|
97
|
+
loadOptions?: (query?: string) => Promise<CrudFieldOption[]>
|
|
98
|
+
// when type === 'richtext', choose editor implementation
|
|
99
|
+
editor?: 'simple' | 'uiw' | 'html'
|
|
100
|
+
// for text fields; provides datalist suggestions while allowing free-text input
|
|
101
|
+
suggestions?: string[]
|
|
102
|
+
// for combobox fields; allow custom values or restrict to suggestions only
|
|
103
|
+
allowCustomValues?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type CrudCustomFieldRenderProps = {
|
|
107
|
+
id: string
|
|
108
|
+
value: unknown
|
|
109
|
+
error?: string
|
|
110
|
+
autoFocus?: boolean
|
|
111
|
+
disabled?: boolean
|
|
112
|
+
values?: Record<string, unknown>
|
|
113
|
+
setValue: (value: unknown) => void
|
|
114
|
+
// Optional helper to update other form values from within a custom field
|
|
115
|
+
setFormValue?: (id: string, value: unknown) => void
|
|
116
|
+
// Optional context for advanced custom inputs
|
|
117
|
+
entityId?: string
|
|
118
|
+
recordId?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type CrudCustomField = CrudFieldBase & {
|
|
122
|
+
type: 'custom'
|
|
123
|
+
component: (props: CrudCustomFieldRenderProps) => React.ReactNode
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type CrudField = CrudBuiltinField | CrudCustomField
|
|
127
|
+
|
|
128
|
+
type CrudFormValues<TValues extends Record<string, unknown>> = Partial<TValues> & Record<string, unknown>
|
|
129
|
+
|
|
130
|
+
export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
131
|
+
schema?: z.ZodType<TValues>
|
|
132
|
+
fields: CrudField[]
|
|
133
|
+
initialValues?: Partial<TValues>
|
|
134
|
+
submitLabel?: string
|
|
135
|
+
customFieldsLoadingMessage?: string
|
|
136
|
+
cancelHref?: string
|
|
137
|
+
successRedirect?: string
|
|
138
|
+
deleteRedirect?: string
|
|
139
|
+
onSubmit?: (values: TValues) => Promise<void> | void
|
|
140
|
+
onDelete?: () => Promise<void> | void
|
|
141
|
+
// When true, shows Delete button whenever onDelete is provided, even without an id
|
|
142
|
+
deleteVisible?: boolean
|
|
143
|
+
// Legacy field-only grid toggle. Use `groups` for advanced layout.
|
|
144
|
+
twoColumn?: boolean
|
|
145
|
+
title?: string
|
|
146
|
+
backHref?: string
|
|
147
|
+
// Optional extra action buttons rendered next to Delete/Cancel/Save
|
|
148
|
+
// Useful for custom links like "Show Records" etc.
|
|
149
|
+
extraActions?: React.ReactNode
|
|
150
|
+
// When provided, CrudForm will fetch custom field definitions and append
|
|
151
|
+
// form-editable custom fields automatically to the provided `fields`.
|
|
152
|
+
entityId?: string
|
|
153
|
+
entityIds?: string[]
|
|
154
|
+
// Optional grouped layout rendered in two responsive columns (1 on mobile).
|
|
155
|
+
groups?: CrudFormGroup[]
|
|
156
|
+
// Loading state for the entire form (e.g., when loading record data)
|
|
157
|
+
isLoading?: boolean
|
|
158
|
+
loadingMessage?: string
|
|
159
|
+
// User-defined entity mode: all fields are custom, use bare keys (no cf_)
|
|
160
|
+
customEntity?: boolean
|
|
161
|
+
// Embedded mode hides outer chrome; useful for inline sections
|
|
162
|
+
embedded?: boolean
|
|
163
|
+
// Optional custom content injected between the header actions and the form body
|
|
164
|
+
contentHeader?: React.ReactNode
|
|
165
|
+
// Optional mapping of entityId -> form value key storing the selected fieldset code
|
|
166
|
+
customFieldsetBindings?: Record<string, { valueKey: string }>
|
|
167
|
+
// Optional injection spot ID for widget injection
|
|
168
|
+
injectionSpotId?: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Group-level custom component context
|
|
172
|
+
export type CrudFormGroupComponentProps = {
|
|
173
|
+
values: Record<string, unknown>
|
|
174
|
+
setValue: (id: string, v: unknown) => void
|
|
175
|
+
errors: Record<string, string>
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Special group kind for automatic Custom Fields section
|
|
179
|
+
export type CrudFormGroup = {
|
|
180
|
+
id: string
|
|
181
|
+
title?: string
|
|
182
|
+
column?: 1 | 2
|
|
183
|
+
description?: string
|
|
184
|
+
// Either list field ids, inline field configs, or mix of both
|
|
185
|
+
fields?: (CrudField | string)[]
|
|
186
|
+
// Inject a custom component into the group card
|
|
187
|
+
component?: (ctx: CrudFormGroupComponentProps) => React.ReactNode
|
|
188
|
+
// When kind === 'customFields', the group renders form-editable custom fields
|
|
189
|
+
kind?: 'customFields'
|
|
190
|
+
// When true, render component output inline without wrapping group chrome
|
|
191
|
+
bare?: boolean
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
195
|
+
layers: Layers,
|
|
196
|
+
tag: Tag,
|
|
197
|
+
sparkles: Sparkles,
|
|
198
|
+
package: Package,
|
|
199
|
+
shirt: Shirt,
|
|
200
|
+
grid: Grid,
|
|
201
|
+
shoppingBag: ShoppingBag,
|
|
202
|
+
shoppingCart: ShoppingCart,
|
|
203
|
+
store: Store,
|
|
204
|
+
users: Users,
|
|
205
|
+
briefcase: Briefcase,
|
|
206
|
+
building: Building,
|
|
207
|
+
bookOpen: BookOpen,
|
|
208
|
+
bookmark: Bookmark,
|
|
209
|
+
camera: Camera,
|
|
210
|
+
car: Car,
|
|
211
|
+
clock: Clock,
|
|
212
|
+
cloud: Cloud,
|
|
213
|
+
compass: Compass,
|
|
214
|
+
creditCard: CreditCard,
|
|
215
|
+
database: Database,
|
|
216
|
+
flame: Flame,
|
|
217
|
+
gift: Gift,
|
|
218
|
+
globe: Globe,
|
|
219
|
+
heart: Heart,
|
|
220
|
+
key: Key,
|
|
221
|
+
map: MapIcon,
|
|
222
|
+
palette: Palette,
|
|
223
|
+
shield: Shield,
|
|
224
|
+
star: Star,
|
|
225
|
+
truck: Truck,
|
|
226
|
+
zap: Zap,
|
|
227
|
+
coins: Coins,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type CustomFieldGroupLayout = {
|
|
231
|
+
code: string | null
|
|
232
|
+
label?: string
|
|
233
|
+
hint?: string
|
|
234
|
+
fields: CrudField[]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type CustomFieldSectionLayout = {
|
|
238
|
+
entityId: string
|
|
239
|
+
fieldsetCode: string | null
|
|
240
|
+
fieldset?: CustomFieldsetDto
|
|
241
|
+
title: string
|
|
242
|
+
description?: string
|
|
243
|
+
groups: CustomFieldGroupLayout[]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type CustomFieldEntityLayout = {
|
|
247
|
+
entityId: string
|
|
248
|
+
sections: CustomFieldSectionLayout[]
|
|
249
|
+
availableFieldsets: CustomFieldsetDto[]
|
|
250
|
+
singleFieldsetPerRecord: boolean
|
|
251
|
+
hasFieldsets: boolean
|
|
252
|
+
activeFieldset: string | null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function CrudForm<TValues extends Record<string, unknown>>({
|
|
256
|
+
schema,
|
|
257
|
+
fields,
|
|
258
|
+
initialValues,
|
|
259
|
+
submitLabel,
|
|
260
|
+
customFieldsLoadingMessage,
|
|
261
|
+
cancelHref,
|
|
262
|
+
successRedirect,
|
|
263
|
+
deleteRedirect,
|
|
264
|
+
onSubmit,
|
|
265
|
+
onDelete,
|
|
266
|
+
deleteVisible,
|
|
267
|
+
twoColumn = false,
|
|
268
|
+
title,
|
|
269
|
+
backHref,
|
|
270
|
+
entityId,
|
|
271
|
+
entityIds,
|
|
272
|
+
groups,
|
|
273
|
+
isLoading = false,
|
|
274
|
+
loadingMessage,
|
|
275
|
+
customEntity = false,
|
|
276
|
+
embedded = false,
|
|
277
|
+
extraActions,
|
|
278
|
+
contentHeader,
|
|
279
|
+
customFieldsetBindings,
|
|
280
|
+
injectionSpotId,
|
|
281
|
+
}: CrudFormProps<TValues>) {
|
|
282
|
+
// Ensure module field components are registered (client-side)
|
|
283
|
+
React.useEffect(() => { loadGeneratedFieldRegistrations().catch(() => {}) }, [])
|
|
284
|
+
const router = useRouter()
|
|
285
|
+
const t = useT()
|
|
286
|
+
const resolvedSubmitLabel = submitLabel ?? t('ui.forms.actions.save')
|
|
287
|
+
const resolvedLoadingMessage = loadingMessage ?? t('ui.forms.loading')
|
|
288
|
+
const resolvedCustomFieldsLoadingMessage = customFieldsLoadingMessage ?? resolvedLoadingMessage
|
|
289
|
+
const cancelLabel = t('ui.forms.actions.cancel')
|
|
290
|
+
const deleteLabel = t('ui.forms.actions.delete')
|
|
291
|
+
const savingLabel = t('ui.forms.status.saving')
|
|
292
|
+
const backLabel = t('ui.navigation.back')
|
|
293
|
+
const customFieldsLabel = t('entities.customFields.title')
|
|
294
|
+
const fieldsetSelectorLabel = t('entities.customFields.fieldsetSelectorLabel', 'Fieldset')
|
|
295
|
+
const emptyFieldsetMessage = t('entities.customFields.emptyFieldset', 'No fields defined for this fieldset.')
|
|
296
|
+
const defaultFieldsetLabel = t('entities.customFields.defaultFieldset', 'Default')
|
|
297
|
+
const manageFieldsetLabel = t('entities.customFields.manageFieldset', 'Manage fields')
|
|
298
|
+
const fieldsetDialogTitle = t('entities.customFields.manageDialogTitle', 'Edit custom fields')
|
|
299
|
+
const fieldsetDialogUnavailable = t('entities.customFields.manageDialogUnavailable', 'Field definitions page is unavailable.')
|
|
300
|
+
const deleteConfirmMessage = t('ui.forms.confirmDelete')
|
|
301
|
+
const deleteSuccessMessage = t('ui.forms.flash.deleteSuccess')
|
|
302
|
+
const deleteErrorMessage = t('ui.forms.flash.deleteError')
|
|
303
|
+
const saveErrorMessage = t('ui.forms.flash.saveError')
|
|
304
|
+
const formId = React.useId()
|
|
305
|
+
const [values, setValues] = React.useState<CrudFormValues<TValues>>(
|
|
306
|
+
() => ({ ...(initialValues ?? {}) } as CrudFormValues<TValues>)
|
|
307
|
+
)
|
|
308
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
|
309
|
+
const [pending, setPending] = React.useState(false)
|
|
310
|
+
const [formError, setFormError] = React.useState<string | null>(null)
|
|
311
|
+
const [dynamicOptions, setDynamicOptions] = React.useState<Record<string, CrudFieldOption[]>>({})
|
|
312
|
+
const [cfDefinitions, setCfDefinitions] = React.useState<CustomFieldDefDto[]>([])
|
|
313
|
+
const [cfMetadata, setCfMetadata] = React.useState<CustomFieldDefinitionsPayload | null>(null)
|
|
314
|
+
const [cfFieldsetSelections, setCfFieldsetSelections] = React.useState<Record<string, string | null>>({})
|
|
315
|
+
const [isLoadingCustomFields, setIsLoadingCustomFields] = React.useState(false)
|
|
316
|
+
const [customFieldDefsVersion, setCustomFieldDefsVersion] = React.useState(0)
|
|
317
|
+
const [fieldsetEditorTarget, setFieldsetEditorTarget] = React.useState<{ entityId: string; fieldsetCode: string | null; view: 'entity' | 'fieldset' } | null>(null)
|
|
318
|
+
const [isInDialog, setIsInDialog] = React.useState(false)
|
|
319
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
|
320
|
+
const fieldsetManagerRef = React.useRef<FieldDefinitionsManagerHandle | null>(null)
|
|
321
|
+
const resolvedEntityIds = React.useMemo(() => {
|
|
322
|
+
if (Array.isArray(entityIds) && entityIds.length) {
|
|
323
|
+
const dedup = new Set<string>()
|
|
324
|
+
const list: string[] = []
|
|
325
|
+
entityIds.forEach((id) => {
|
|
326
|
+
const trimmed = typeof id === 'string' ? id.trim() : ''
|
|
327
|
+
if (!trimmed || dedup.has(trimmed)) return
|
|
328
|
+
dedup.add(trimmed)
|
|
329
|
+
list.push(trimmed)
|
|
330
|
+
})
|
|
331
|
+
return list
|
|
332
|
+
}
|
|
333
|
+
if (typeof entityId === 'string' && entityId.trim().length > 0) {
|
|
334
|
+
return [entityId.trim()]
|
|
335
|
+
}
|
|
336
|
+
return []
|
|
337
|
+
}, [entityId, entityIds])
|
|
338
|
+
const primaryEntityId = resolvedEntityIds.length ? resolvedEntityIds[0] : null
|
|
339
|
+
|
|
340
|
+
// Injection spot events for widget lifecycle management
|
|
341
|
+
const resolvedInjectionSpotId = React.useMemo(() => {
|
|
342
|
+
if (injectionSpotId) return injectionSpotId
|
|
343
|
+
if (resolvedEntityIds.length) {
|
|
344
|
+
const normalized = resolvedEntityIds[0].replace(/[:]+/g, '.')
|
|
345
|
+
return `crud-form:${normalized}`
|
|
346
|
+
}
|
|
347
|
+
return undefined
|
|
348
|
+
}, [injectionSpotId, resolvedEntityIds])
|
|
349
|
+
|
|
350
|
+
const injectionContext = React.useMemo(() => ({
|
|
351
|
+
formId,
|
|
352
|
+
entityId: primaryEntityId,
|
|
353
|
+
isLoading,
|
|
354
|
+
pending,
|
|
355
|
+
}), [formId, primaryEntityId, isLoading, pending])
|
|
356
|
+
|
|
357
|
+
const { widgets: injectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
|
|
358
|
+
context: injectionContext,
|
|
359
|
+
triggerOnLoad: true,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
|
|
363
|
+
|
|
364
|
+
React.useEffect(() => {
|
|
365
|
+
const root = rootRef.current
|
|
366
|
+
if (!root) return
|
|
367
|
+
setIsInDialog(Boolean(root.closest('[data-dialog-content]')))
|
|
368
|
+
}, [])
|
|
369
|
+
const dialogFooterClass = isInDialog
|
|
370
|
+
? 'sticky bottom-0 left-0 right-0 z-20 -mx-6 px-6 bg-card border-t border-border/70 py-2 sm:-mx-6 sm:px-6'
|
|
371
|
+
: ''
|
|
372
|
+
const dialogFormPadding = isInDialog ? 'pb-4' : ''
|
|
373
|
+
|
|
374
|
+
const buildCustomFieldsManageHref = React.useCallback(
|
|
375
|
+
(targetEntityId: string | null) => {
|
|
376
|
+
if (!targetEntityId) return null
|
|
377
|
+
try {
|
|
378
|
+
const encoded = encodeURIComponent(targetEntityId)
|
|
379
|
+
return customEntity ? `/backend/entities/user/${encoded}` : `/backend/entities/system/${encoded}`
|
|
380
|
+
} catch {
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
[customEntity],
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const refreshCustomFieldDefinitions = React.useCallback(() => {
|
|
388
|
+
setCustomFieldDefsVersion((prev) => prev + 1)
|
|
389
|
+
}, [])
|
|
390
|
+
|
|
391
|
+
const recordId = React.useMemo(() => {
|
|
392
|
+
const raw = values.id
|
|
393
|
+
if (typeof raw === 'string') return raw
|
|
394
|
+
if (typeof raw === 'number') return String(raw)
|
|
395
|
+
return undefined
|
|
396
|
+
}, [values])
|
|
397
|
+
// Unified delete handler with confirmation
|
|
398
|
+
const handleDelete = React.useCallback(async () => {
|
|
399
|
+
if (!onDelete) return
|
|
400
|
+
try {
|
|
401
|
+
const ok = typeof window !== 'undefined' ? window.confirm(deleteConfirmMessage) : true
|
|
402
|
+
if (!ok) return
|
|
403
|
+
await onDelete()
|
|
404
|
+
try { flash(deleteSuccessMessage, 'success') } catch {}
|
|
405
|
+
// Redirect if requested by caller
|
|
406
|
+
if (typeof deleteRedirect === 'string' && deleteRedirect) {
|
|
407
|
+
router.push(deleteRedirect)
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
|
|
411
|
+
try { flash(message, 'error') } catch {}
|
|
412
|
+
}
|
|
413
|
+
}, [onDelete, deleteRedirect, router, deleteConfirmMessage, deleteSuccessMessage, deleteErrorMessage])
|
|
414
|
+
|
|
415
|
+
// Determine whether this form is creating a new record (no `id` yet)
|
|
416
|
+
const isNewRecord = React.useMemo(() => {
|
|
417
|
+
const rawId = values.id
|
|
418
|
+
if (rawId === undefined || rawId === null) return true
|
|
419
|
+
return typeof rawId === 'string' ? rawId.trim().length === 0 : false
|
|
420
|
+
}, [values])
|
|
421
|
+
const showDelete = Boolean(onDelete) && (typeof deleteVisible === 'boolean' ? deleteVisible : !isNewRecord)
|
|
422
|
+
|
|
423
|
+
// Auto-append custom fields for this entityId
|
|
424
|
+
React.useEffect(() => {
|
|
425
|
+
let cancelled = false
|
|
426
|
+
async function load() {
|
|
427
|
+
if (!resolvedEntityIds.length) {
|
|
428
|
+
setCfDefinitions([])
|
|
429
|
+
setCfMetadata(null)
|
|
430
|
+
setIsLoadingCustomFields(false)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
setIsLoadingCustomFields(true)
|
|
435
|
+
try {
|
|
436
|
+
const mod = await import('./utils/customFieldForms')
|
|
437
|
+
const { definitions, metadata } = await mod.fetchCustomFieldFormStructure(resolvedEntityIds, undefined, { bareIds: customEntity })
|
|
438
|
+
if (!cancelled) {
|
|
439
|
+
setCfDefinitions(definitions)
|
|
440
|
+
setCfMetadata(metadata)
|
|
441
|
+
setCfFieldsetSelections((prev) => {
|
|
442
|
+
const next: Record<string, string | null> = {}
|
|
443
|
+
let changed = false
|
|
444
|
+
resolvedEntityIds.forEach((entityId) => {
|
|
445
|
+
const existing = prev[entityId]
|
|
446
|
+
const fieldsets = metadata.fieldsetsByEntity?.[entityId] ?? []
|
|
447
|
+
const defaultSelection = fieldsets[0]?.code ?? null
|
|
448
|
+
const value = existing !== undefined ? existing : defaultSelection
|
|
449
|
+
next[entityId] = value
|
|
450
|
+
if (existing !== value) changed = true
|
|
451
|
+
})
|
|
452
|
+
if (Object.keys(prev).length !== Object.keys(next).length) changed = true
|
|
453
|
+
return changed ? next : prev
|
|
454
|
+
})
|
|
455
|
+
setIsLoadingCustomFields(false)
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
if (!cancelled) {
|
|
459
|
+
setCfDefinitions([])
|
|
460
|
+
setCfMetadata(null)
|
|
461
|
+
setIsLoadingCustomFields(false)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
load()
|
|
466
|
+
return () => { cancelled = true }
|
|
467
|
+
}, [resolvedEntityIds, customEntity, customFieldDefsVersion])
|
|
468
|
+
|
|
469
|
+
React.useEffect(() => {
|
|
470
|
+
if (!customFieldsetBindings) return
|
|
471
|
+
setCfFieldsetSelections((prev) => {
|
|
472
|
+
let changed = false
|
|
473
|
+
const next = { ...prev }
|
|
474
|
+
resolvedEntityIds.forEach((entityId) => {
|
|
475
|
+
const binding = customFieldsetBindings[entityId]
|
|
476
|
+
if (!binding) return
|
|
477
|
+
const raw = values[binding.valueKey]
|
|
478
|
+
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
479
|
+
const normalized = raw.trim()
|
|
480
|
+
if (next[entityId] !== normalized) {
|
|
481
|
+
next[entityId] = normalized
|
|
482
|
+
changed = true
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
return changed ? next : prev
|
|
487
|
+
})
|
|
488
|
+
}, [customFieldsetBindings, resolvedEntityIds, values])
|
|
489
|
+
|
|
490
|
+
const fieldsetsByEntity = cfMetadata?.fieldsetsByEntity ?? {}
|
|
491
|
+
const entitySettings = cfMetadata?.entitySettings ?? {}
|
|
492
|
+
|
|
493
|
+
const { cfFields, customFieldLayout } = React.useMemo(() => {
|
|
494
|
+
if (!cfDefinitions.length) return { cfFields: [], customFieldLayout: [] as CustomFieldEntityLayout[] }
|
|
495
|
+
const aggregated: CrudField[] = []
|
|
496
|
+
const layout: CustomFieldEntityLayout[] = []
|
|
497
|
+
const defsByEntity = new globalThis.Map<string, CustomFieldDefDto[]>()
|
|
498
|
+
cfDefinitions.forEach((def) => {
|
|
499
|
+
const entityId = typeof def.entityId === 'string' && def.entityId.trim().length
|
|
500
|
+
? def.entityId.trim()
|
|
501
|
+
: resolvedEntityIds[0]
|
|
502
|
+
if (!entityId) return
|
|
503
|
+
const bucket = defsByEntity.get(entityId) ?? []
|
|
504
|
+
bucket.push(def)
|
|
505
|
+
defsByEntity.set(entityId, bucket)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const buildSection = (
|
|
509
|
+
entityId: string,
|
|
510
|
+
fieldsetCode: string | null,
|
|
511
|
+
defList: CustomFieldDefDto[],
|
|
512
|
+
fieldset?: CustomFieldsetDto,
|
|
513
|
+
): CustomFieldSectionLayout | null => {
|
|
514
|
+
if (!defList.length) return null
|
|
515
|
+
const groupsMap = new globalThis.Map<string, CustomFieldGroupLayout>()
|
|
516
|
+
const order: string[] = []
|
|
517
|
+
const fieldsetGroupMap = new globalThis.Map<string, { title?: string; hint?: string; code: string }>()
|
|
518
|
+
if (Array.isArray(fieldset?.groups)) {
|
|
519
|
+
fieldset.groups.forEach((group) => {
|
|
520
|
+
if (!group?.code) return
|
|
521
|
+
fieldsetGroupMap.set(group.code, { code: group.code, title: group.title, hint: group.hint })
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
const sortedDefs = [...defList].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
|
525
|
+
const ensureBucket = (code: string | null, def: CustomFieldDefDto): CustomFieldGroupLayout => {
|
|
526
|
+
const key = code ?? '__default__'
|
|
527
|
+
let bucket = groupsMap.get(key)
|
|
528
|
+
if (!bucket) {
|
|
529
|
+
const fallbackMeta = code ? fieldsetGroupMap.get(code) : undefined
|
|
530
|
+
const directMeta = code ? def.group : undefined
|
|
531
|
+
const label =
|
|
532
|
+
code === null
|
|
533
|
+
? undefined
|
|
534
|
+
: directMeta?.title || fallbackMeta?.title || directMeta?.code || fallbackMeta?.code || code
|
|
535
|
+
const hint = directMeta?.hint || fallbackMeta?.hint
|
|
536
|
+
bucket = { code, label, hint, fields: [] }
|
|
537
|
+
groupsMap.set(key, bucket)
|
|
538
|
+
order.push(key)
|
|
539
|
+
} else if (code && !bucket.label) {
|
|
540
|
+
const fallbackMeta = fieldsetGroupMap.get(code)
|
|
541
|
+
const directMeta = def.group ?? undefined
|
|
542
|
+
bucket.label = directMeta?.title || fallbackMeta?.title || directMeta?.code || fallbackMeta?.code || bucket.label
|
|
543
|
+
bucket.hint = directMeta?.hint || fallbackMeta?.hint || bucket.hint
|
|
544
|
+
}
|
|
545
|
+
return bucket
|
|
546
|
+
}
|
|
547
|
+
sortedDefs.forEach((definition) => {
|
|
548
|
+
const field = buildFormFieldFromCustomFieldDef(definition, { bareIds: customEntity })
|
|
549
|
+
if (!field) return
|
|
550
|
+
aggregated.push(field)
|
|
551
|
+
const bucket = ensureBucket(definition.group?.code ?? null, definition)
|
|
552
|
+
bucket.fields.push(field)
|
|
553
|
+
})
|
|
554
|
+
const groups = order
|
|
555
|
+
.map((key) => groupsMap.get(key)!)
|
|
556
|
+
.filter((group) => group.fields.length > 0)
|
|
557
|
+
if (!groups.length) return null
|
|
558
|
+
return {
|
|
559
|
+
entityId,
|
|
560
|
+
fieldsetCode,
|
|
561
|
+
fieldset,
|
|
562
|
+
title: fieldset?.label ?? customFieldsLabel,
|
|
563
|
+
description: fieldset?.description,
|
|
564
|
+
groups,
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const entityIds = resolvedEntityIds.length ? resolvedEntityIds : Array.from(defsByEntity.keys())
|
|
569
|
+
entityIds.forEach((entityId) => {
|
|
570
|
+
const defsForEntity = defsByEntity.get(entityId) ?? []
|
|
571
|
+
if (!defsForEntity.length) return
|
|
572
|
+
const availableFieldsets = fieldsetsByEntity[entityId] ?? []
|
|
573
|
+
const hasFieldsets = availableFieldsets.length > 0
|
|
574
|
+
const singleFieldsetPerRecord =
|
|
575
|
+
entitySettings[entityId]?.singleFieldsetPerRecord !== false
|
|
576
|
+
const defsByFieldset = new globalThis.Map<string | null, CustomFieldDefDto[]>()
|
|
577
|
+
defsForEntity.forEach((def) => {
|
|
578
|
+
const code = typeof def.fieldset === 'string' && def.fieldset.trim().length > 0 ? def.fieldset.trim() : null
|
|
579
|
+
const bucket = defsByFieldset.get(code) ?? []
|
|
580
|
+
bucket.push(def)
|
|
581
|
+
defsByFieldset.set(code, bucket)
|
|
582
|
+
})
|
|
583
|
+
const sections: CustomFieldSectionLayout[] = []
|
|
584
|
+
|
|
585
|
+
const createEmptySection = (code: string | null): CustomFieldSectionLayout => {
|
|
586
|
+
const fieldset = code ? availableFieldsets.find((fs) => fs.code === code) : undefined
|
|
587
|
+
return {
|
|
588
|
+
entityId,
|
|
589
|
+
fieldsetCode: code,
|
|
590
|
+
fieldset,
|
|
591
|
+
title: fieldset?.label ?? customFieldsLabel,
|
|
592
|
+
description: fieldset?.description,
|
|
593
|
+
groups: [],
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!hasFieldsets) {
|
|
598
|
+
const fallbackDefs =
|
|
599
|
+
defsByFieldset.get(null) ?? Array.from(defsByFieldset.values()).flat()
|
|
600
|
+
const section = buildSection(entityId, null, fallbackDefs, undefined)
|
|
601
|
+
if (section) sections.push(section)
|
|
602
|
+
} else if (singleFieldsetPerRecord) {
|
|
603
|
+
const availableCodes = availableFieldsets.map((fs) => fs.code)
|
|
604
|
+
const activeFieldset =
|
|
605
|
+
cfFieldsetSelections[entityId] && availableCodes.includes(cfFieldsetSelections[entityId]!)
|
|
606
|
+
? cfFieldsetSelections[entityId]
|
|
607
|
+
: availableFieldsets[0]?.code ?? null
|
|
608
|
+
const targetDefs = activeFieldset ? defsByFieldset.get(activeFieldset) ?? [] : defsByFieldset.get(null) ?? []
|
|
609
|
+
const targetSection = activeFieldset
|
|
610
|
+
? buildSection(
|
|
611
|
+
entityId,
|
|
612
|
+
activeFieldset,
|
|
613
|
+
targetDefs,
|
|
614
|
+
availableFieldsets.find((fs) => fs.code === activeFieldset),
|
|
615
|
+
)
|
|
616
|
+
: buildSection(entityId, null, targetDefs, undefined)
|
|
617
|
+
if (targetSection) {
|
|
618
|
+
sections.push(targetSection)
|
|
619
|
+
} else if (activeFieldset) {
|
|
620
|
+
sections.push(createEmptySection(activeFieldset))
|
|
621
|
+
}
|
|
622
|
+
const unassigned = defsByFieldset.get(null)
|
|
623
|
+
if (unassigned?.length && activeFieldset) {
|
|
624
|
+
const generalSection = buildSection(entityId, null, unassigned, undefined)
|
|
625
|
+
if (generalSection) sections.push(generalSection)
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
availableFieldsets.forEach((fieldset) => {
|
|
629
|
+
const list = defsByFieldset.get(fieldset.code) ?? []
|
|
630
|
+
const section = buildSection(entityId, fieldset.code, list, fieldset)
|
|
631
|
+
if (section) sections.push(section)
|
|
632
|
+
})
|
|
633
|
+
const unassigned = defsByFieldset.get(null)
|
|
634
|
+
if (unassigned?.length) {
|
|
635
|
+
const section = buildSection(entityId, null, unassigned, undefined)
|
|
636
|
+
if (section) sections.push(section)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!sections.length && hasFieldsets) {
|
|
641
|
+
const fallbackCode = availableFieldsets[0]?.code ?? null
|
|
642
|
+
sections.push(createEmptySection(fallbackCode))
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
layout.push({
|
|
646
|
+
entityId,
|
|
647
|
+
sections,
|
|
648
|
+
availableFieldsets,
|
|
649
|
+
singleFieldsetPerRecord,
|
|
650
|
+
hasFieldsets,
|
|
651
|
+
activeFieldset: cfFieldsetSelections[entityId] ?? availableFieldsets[0]?.code ?? null,
|
|
652
|
+
})
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
return { cfFields: aggregated, customFieldLayout: layout }
|
|
656
|
+
}, [
|
|
657
|
+
cfDefinitions,
|
|
658
|
+
cfFieldsetSelections,
|
|
659
|
+
customEntity,
|
|
660
|
+
customFieldsLabel,
|
|
661
|
+
entitySettings,
|
|
662
|
+
fieldsetsByEntity,
|
|
663
|
+
resolvedEntityIds,
|
|
664
|
+
])
|
|
665
|
+
|
|
666
|
+
const allFields = React.useMemo(() => {
|
|
667
|
+
if (!cfFields.length) return fields
|
|
668
|
+
const provided = new Set(fields.map(f => f.id))
|
|
669
|
+
const extras = cfFields.filter(f => !provided.has(f.id))
|
|
670
|
+
return [...fields, ...extras]
|
|
671
|
+
}, [fields, cfFields])
|
|
672
|
+
|
|
673
|
+
const fieldById = React.useMemo(() => {
|
|
674
|
+
return new globalThis.Map(allFields.map((f) => [f.id, f]))
|
|
675
|
+
}, [allFields])
|
|
676
|
+
|
|
677
|
+
const injectionGroupCards = React.useMemo<CrudFormGroup[]>(() => {
|
|
678
|
+
if (!injectionWidgets || injectionWidgets.length === 0) return []
|
|
679
|
+
const pairs = injectionWidgets
|
|
680
|
+
.filter((widget) => (widget.placement?.kind ?? 'stack') === 'group')
|
|
681
|
+
.map((widget) => {
|
|
682
|
+
const priority = typeof widget.placement?.priority === 'number' ? widget.placement.priority : 0
|
|
683
|
+
const group: CrudFormGroup = {
|
|
684
|
+
id: `widget:${widget.widgetId}`,
|
|
685
|
+
title: widget.placement?.groupLabel ?? widget.module.metadata.title,
|
|
686
|
+
description: widget.placement?.groupDescription ?? widget.module.metadata.description,
|
|
687
|
+
column: widget.placement?.column === 2 ? 2 : 1,
|
|
688
|
+
component: () => (
|
|
689
|
+
<widget.module.Widget
|
|
690
|
+
context={injectionContext}
|
|
691
|
+
data={values as unknown as CrudFormValues<TValues>}
|
|
692
|
+
onDataChange={(next) => setValues(next as CrudFormValues<TValues>)}
|
|
693
|
+
disabled={pending}
|
|
694
|
+
/>
|
|
695
|
+
),
|
|
696
|
+
}
|
|
697
|
+
return { group, priority }
|
|
698
|
+
})
|
|
699
|
+
pairs.sort((a, b) => b.priority - a.priority)
|
|
700
|
+
return pairs.map((p) => p.group)
|
|
701
|
+
}, [injectionWidgets, injectionContext, pending, setValues, values])
|
|
702
|
+
|
|
703
|
+
const shouldAutoGroup = (!groups || groups.length === 0) && injectionGroupCards.length > 0
|
|
704
|
+
const resolvedGroupsForLayout = React.useMemo(() => {
|
|
705
|
+
const baseGroups = groups && groups.length ? groups : []
|
|
706
|
+
const autoGroup = shouldAutoGroup ? [{ id: '__auto-fields__', fields: allFields }] as CrudFormGroup[] : []
|
|
707
|
+
return [...(baseGroups.length ? baseGroups : autoGroup), ...injectionGroupCards]
|
|
708
|
+
}, [allFields, groups, injectionGroupCards, shouldAutoGroup])
|
|
709
|
+
const useGroupedLayout = resolvedGroupsForLayout.length > 0
|
|
710
|
+
const stackedInjectionWidgets = React.useMemo(
|
|
711
|
+
() => (injectionWidgets ?? []).filter((widget) => (widget.placement?.kind ?? 'stack') === 'stack'),
|
|
712
|
+
[injectionWidgets],
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
const resolveGroupFields = React.useCallback((g: CrudFormGroup): CrudField[] => {
|
|
716
|
+
if (g.kind === 'customFields') {
|
|
717
|
+
return cfFields
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const src = g.fields || []
|
|
721
|
+
const result: CrudField[] = []
|
|
722
|
+
|
|
723
|
+
for (const item of src) {
|
|
724
|
+
if (typeof item === 'string') {
|
|
725
|
+
const found = fieldById.get(item)
|
|
726
|
+
if (found) result.push(found)
|
|
727
|
+
} else if (item) {
|
|
728
|
+
result.push(item as CrudField)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return result
|
|
733
|
+
}, [cfFields, fieldById])
|
|
734
|
+
|
|
735
|
+
const customFieldsManageHref = React.useMemo(() => buildCustomFieldsManageHref(primaryEntityId), [buildCustomFieldsManageHref, primaryEntityId])
|
|
736
|
+
|
|
737
|
+
const customFieldsEmptyState = React.useMemo(() => {
|
|
738
|
+
const text = t('entities.customFields.empty')
|
|
739
|
+
const action = t('entities.customFields.addFirst')
|
|
740
|
+
return (
|
|
741
|
+
<div className="rounded-md border border-dashed border-muted-foreground/50 bg-muted/10 px-3 py-4 text-sm text-muted-foreground">
|
|
742
|
+
<span>{text} </span>
|
|
743
|
+
{customFieldsManageHref ? (
|
|
744
|
+
<Link href={customFieldsManageHref} className="font-medium text-primary hover:underline">
|
|
745
|
+
{action}
|
|
746
|
+
</Link>
|
|
747
|
+
) : (
|
|
748
|
+
<span className="font-medium text-foreground">{action}</span>
|
|
749
|
+
)}
|
|
750
|
+
</div>
|
|
751
|
+
)
|
|
752
|
+
}, [customFieldsManageHref, t])
|
|
753
|
+
|
|
754
|
+
const firstFieldId = React.useMemo(() => {
|
|
755
|
+
if (useGroupedLayout) {
|
|
756
|
+
const col1: CrudFormGroup[] = []
|
|
757
|
+
const col2: CrudFormGroup[] = []
|
|
758
|
+
|
|
759
|
+
for (const g of resolvedGroupsForLayout) {
|
|
760
|
+
if ((g.column ?? 1) === 2) col2.push(g)
|
|
761
|
+
else col1.push(g)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const scan = (list: CrudFormGroup[]) => {
|
|
765
|
+
for (const group of list) {
|
|
766
|
+
const resolved = resolveGroupFields(group)
|
|
767
|
+
for (const field of resolved) {
|
|
768
|
+
if (field?.id && !field.disabled) return field.id
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return null as string | null
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const fromCol1 = scan(col1)
|
|
775
|
+
if (fromCol1) return fromCol1
|
|
776
|
+
const fromCol2 = scan(col2)
|
|
777
|
+
if (fromCol2) return fromCol2
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
for (const field of allFields) {
|
|
781
|
+
if (field?.id && !field.disabled) return field.id
|
|
782
|
+
}
|
|
783
|
+
return null
|
|
784
|
+
}, [allFields, resolveGroupFields, resolvedGroupsForLayout, useGroupedLayout])
|
|
785
|
+
|
|
786
|
+
const requestSubmit = React.useCallback(() => {
|
|
787
|
+
if (typeof document === 'undefined') return
|
|
788
|
+
const form = document.getElementById(formId) as HTMLFormElement | null
|
|
789
|
+
form?.requestSubmit()
|
|
790
|
+
}, [formId])
|
|
791
|
+
|
|
792
|
+
const lastFocusedFieldRef = React.useRef<string | null>(null)
|
|
793
|
+
const lastErrorFieldRef = React.useRef<string | null>(null)
|
|
794
|
+
|
|
795
|
+
React.useEffect(() => {
|
|
796
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
797
|
+
|
|
798
|
+
if (isLoading || isLoadingCustomFields) {
|
|
799
|
+
lastFocusedFieldRef.current = null
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!firstFieldId) return
|
|
804
|
+
if (lastFocusedFieldRef.current === firstFieldId) return
|
|
805
|
+
|
|
806
|
+
const run = () => {
|
|
807
|
+
const form = document.getElementById(formId)
|
|
808
|
+
if (!form) return
|
|
809
|
+
|
|
810
|
+
// Do not steal focus if the user is already interacting with any element inside the form
|
|
811
|
+
const active = typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null
|
|
812
|
+
if (active && form.contains(active)) {
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const container = form.querySelector<HTMLElement>(`[data-crud-field-id="${firstFieldId}"]`)
|
|
817
|
+
const target =
|
|
818
|
+
container?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ??
|
|
819
|
+
form.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
|
820
|
+
|
|
821
|
+
if (target && typeof target.focus === 'function') {
|
|
822
|
+
target.focus()
|
|
823
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
824
|
+
try {
|
|
825
|
+
target.select()
|
|
826
|
+
} catch {}
|
|
827
|
+
}
|
|
828
|
+
lastFocusedFieldRef.current = firstFieldId
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const frame =
|
|
833
|
+
typeof window.requestAnimationFrame === 'function'
|
|
834
|
+
? window.requestAnimationFrame(run)
|
|
835
|
+
: window.setTimeout(run, 0)
|
|
836
|
+
|
|
837
|
+
return () => {
|
|
838
|
+
if (typeof window === 'undefined') return
|
|
839
|
+
if (typeof window.cancelAnimationFrame === 'function') {
|
|
840
|
+
window.cancelAnimationFrame(frame as number)
|
|
841
|
+
} else {
|
|
842
|
+
window.clearTimeout(frame as number)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}, [firstFieldId, formId, isLoading, isLoadingCustomFields])
|
|
846
|
+
|
|
847
|
+
React.useEffect(() => {
|
|
848
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
849
|
+
const entries = Object.entries(errors)
|
|
850
|
+
if (!entries.length) {
|
|
851
|
+
lastErrorFieldRef.current = null
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
const [fieldId] = entries[0]
|
|
855
|
+
if (!fieldId || lastErrorFieldRef.current === fieldId) return
|
|
856
|
+
|
|
857
|
+
const form = document.getElementById(formId)
|
|
858
|
+
if (!form) return
|
|
859
|
+
const container = form.querySelector<HTMLElement>(`[data-crud-field-id="${fieldId}"]`)
|
|
860
|
+
const target =
|
|
861
|
+
container?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ??
|
|
862
|
+
form.querySelector<HTMLElement>(`[name="${fieldId}"]`) ??
|
|
863
|
+
container ??
|
|
864
|
+
null
|
|
865
|
+
|
|
866
|
+
if (target && typeof target.focus === 'function') {
|
|
867
|
+
target.focus()
|
|
868
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
869
|
+
try {
|
|
870
|
+
target.select()
|
|
871
|
+
} catch {}
|
|
872
|
+
}
|
|
873
|
+
lastErrorFieldRef.current = fieldId
|
|
874
|
+
}
|
|
875
|
+
}, [errors, formId])
|
|
876
|
+
|
|
877
|
+
const setValue = React.useCallback((id: string, nextValue: unknown) => {
|
|
878
|
+
setValues((prev) => {
|
|
879
|
+
if (Object.is(prev[id], nextValue)) return prev
|
|
880
|
+
return { ...prev, [id]: nextValue } as CrudFormValues<TValues>
|
|
881
|
+
})
|
|
882
|
+
}, [])
|
|
883
|
+
|
|
884
|
+
const handleFieldsetSelectionChange = React.useCallback(
|
|
885
|
+
(entityId: string, nextCode: string | null) => {
|
|
886
|
+
setCfFieldsetSelections((prev) => ({ ...prev, [entityId]: nextCode }))
|
|
887
|
+
const bindingKey = customFieldsetBindings?.[entityId]?.valueKey
|
|
888
|
+
if (bindingKey) {
|
|
889
|
+
setValue(bindingKey, nextCode ?? undefined)
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
[customFieldsetBindings, setValue],
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
const handleOpenFieldsetEditor = React.useCallback(
|
|
896
|
+
(entityId: string, fieldsetCode: string | null, view: 'entity' | 'fieldset' = 'entity') => {
|
|
897
|
+
const href = buildCustomFieldsManageHref(entityId)
|
|
898
|
+
if (!href) return
|
|
899
|
+
setFieldsetEditorTarget({ entityId, fieldsetCode, view })
|
|
900
|
+
},
|
|
901
|
+
[buildCustomFieldsManageHref],
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
// Apply initialValues when provided (reapply when initialValues change for edit forms)
|
|
905
|
+
React.useEffect(() => {
|
|
906
|
+
if (!initialValues) return
|
|
907
|
+
setValues((prev) => ({ ...prev, ...initialValues } as CrudFormValues<TValues>))
|
|
908
|
+
}, [initialValues])
|
|
909
|
+
|
|
910
|
+
const buildFieldsetEditorHref = React.useCallback(
|
|
911
|
+
(includeViewParam: boolean) => {
|
|
912
|
+
if (!fieldsetEditorTarget) return null
|
|
913
|
+
const base = buildCustomFieldsManageHref(fieldsetEditorTarget.entityId)
|
|
914
|
+
if (!base) return null
|
|
915
|
+
const params: string[] = []
|
|
916
|
+
if (fieldsetEditorTarget.fieldsetCode) {
|
|
917
|
+
params.push(`fieldset=${encodeURIComponent(fieldsetEditorTarget.fieldsetCode)}`)
|
|
918
|
+
}
|
|
919
|
+
if (includeViewParam && fieldsetEditorTarget.view === 'fieldset') {
|
|
920
|
+
params.push('view=fieldset')
|
|
921
|
+
}
|
|
922
|
+
if (!params.length) return base
|
|
923
|
+
const connector = base.includes('?') ? '&' : '?'
|
|
924
|
+
return `${base}${connector}${params.join('&')}`
|
|
925
|
+
},
|
|
926
|
+
[buildCustomFieldsManageHref, fieldsetEditorTarget],
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
const fieldsetEditorFullHref = React.useMemo(() => buildFieldsetEditorHref(false), [buildFieldsetEditorHref])
|
|
930
|
+
|
|
931
|
+
const handleFieldsetDialogSave = React.useCallback(() => {
|
|
932
|
+
if (!fieldsetManagerRef.current) return
|
|
933
|
+
void fieldsetManagerRef.current.submit()
|
|
934
|
+
}, [])
|
|
935
|
+
|
|
936
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
937
|
+
e.preventDefault()
|
|
938
|
+
setFormError(null)
|
|
939
|
+
setErrors({})
|
|
940
|
+
|
|
941
|
+
const requiredMessage = t('ui.forms.errors.required')
|
|
942
|
+
const highlightedMessage = t('ui.forms.errors.highlighted')
|
|
943
|
+
|
|
944
|
+
// Make sure inputs that commit on blur flush their local state before submit.
|
|
945
|
+
try {
|
|
946
|
+
if (typeof document !== 'undefined') {
|
|
947
|
+
const activeElement = document.activeElement
|
|
948
|
+
if (activeElement instanceof HTMLElement) {
|
|
949
|
+
activeElement.blur()
|
|
950
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
} catch {
|
|
954
|
+
// ignore focus cleanup errors
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Basic required-field validation when no zod schema is provided
|
|
958
|
+
const requiredErrors: Record<string, string> = {}
|
|
959
|
+
for (const field of allFields) {
|
|
960
|
+
if (!field.required) continue
|
|
961
|
+
if (field.disabled) continue
|
|
962
|
+
const v = values[field.id]
|
|
963
|
+
const isArray = Array.isArray(v)
|
|
964
|
+
const isString = typeof v === 'string'
|
|
965
|
+
const empty =
|
|
966
|
+
v === undefined ||
|
|
967
|
+
v === null ||
|
|
968
|
+
(isString && v.trim() === '') ||
|
|
969
|
+
(isArray && v.length === 0) ||
|
|
970
|
+
(field.type === 'checkbox' && v !== true)
|
|
971
|
+
if (empty) requiredErrors[field.id] = requiredMessage
|
|
972
|
+
}
|
|
973
|
+
if (Object.keys(requiredErrors).length) {
|
|
974
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
975
|
+
console.debug('[crud-form] Required field errors prevented submit', requiredErrors)
|
|
976
|
+
}
|
|
977
|
+
setErrors(requiredErrors)
|
|
978
|
+
flash(highlightedMessage, 'error')
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Custom fields validation via definitions (rules)
|
|
983
|
+
if (resolvedEntityIds.length) {
|
|
984
|
+
try {
|
|
985
|
+
const mod = await import('./utils/customFieldDefs')
|
|
986
|
+
const defs = await mod.fetchCustomFieldDefs(resolvedEntityIds)
|
|
987
|
+
const { validateValuesAgainstDefs } = await import('@open-mercato/shared/modules/entities/validation')
|
|
988
|
+
// Build values keyed by def.key for validation
|
|
989
|
+
const cfValues: Record<string, unknown> = {}
|
|
990
|
+
if (customEntity) {
|
|
991
|
+
for (const def of defs) {
|
|
992
|
+
if (Object.prototype.hasOwnProperty.call(values, def.key)) {
|
|
993
|
+
cfValues[def.key] = values[def.key]
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
} else {
|
|
997
|
+
for (const [k, v] of Object.entries(values)) {
|
|
998
|
+
if (k.startsWith('cf_')) cfValues[k.replace(/^cf_/, '')] = v
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const defsForValidation = defs as unknown as CustomFieldDefLike[]
|
|
1002
|
+
const result = validateValuesAgainstDefs(cfValues, defsForValidation)
|
|
1003
|
+
if (!result.ok) {
|
|
1004
|
+
if (customEntity) {
|
|
1005
|
+
const mapped: Record<string, string> = {}
|
|
1006
|
+
for (const [ek, ev] of Object.entries(result.fieldErrors)) mapped[ek.replace(/^cf_/, '')] = String(ev)
|
|
1007
|
+
setErrors((prev) => ({ ...prev, ...mapped }))
|
|
1008
|
+
} else {
|
|
1009
|
+
setErrors((prev) => ({ ...prev, ...result.fieldErrors }))
|
|
1010
|
+
}
|
|
1011
|
+
flash(highlightedMessage, 'error')
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
} catch {
|
|
1015
|
+
// ignore validation errors if helper not available
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
let parsedValues: TValues
|
|
1020
|
+
if (schema) {
|
|
1021
|
+
const res = schema.safeParse(values)
|
|
1022
|
+
if (!res.success) {
|
|
1023
|
+
const fieldErrors: Record<string, string> = {}
|
|
1024
|
+
res.error.issues.forEach((issue) => {
|
|
1025
|
+
if (issue.path && issue.path.length) fieldErrors[String(issue.path[0])] = issue.message
|
|
1026
|
+
})
|
|
1027
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
1028
|
+
console.debug('[crud-form] Schema validation failed', res.error.issues)
|
|
1029
|
+
}
|
|
1030
|
+
setErrors(fieldErrors)
|
|
1031
|
+
flash(highlightedMessage, 'error')
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
parsedValues = res.data
|
|
1035
|
+
} else {
|
|
1036
|
+
parsedValues = values as TValues
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Trigger onBeforeSave event for injection widgets
|
|
1040
|
+
if (resolvedInjectionSpotId) {
|
|
1041
|
+
try {
|
|
1042
|
+
const result = await triggerInjectionEvent('onBeforeSave', parsedValues, injectionContext)
|
|
1043
|
+
if (!result.ok) {
|
|
1044
|
+
if (result.fieldErrors && Object.keys(result.fieldErrors).length) {
|
|
1045
|
+
setErrors(result.fieldErrors)
|
|
1046
|
+
}
|
|
1047
|
+
const message = result.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation')
|
|
1048
|
+
flash(message, 'error')
|
|
1049
|
+
setPending(false)
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
console.error('[CrudForm] Error in onBeforeSave:', err)
|
|
1054
|
+
flash(t('ui.forms.flash.saveBlocked', 'Save blocked by validation'), 'error')
|
|
1055
|
+
setPending(false)
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
setPending(true)
|
|
1061
|
+
|
|
1062
|
+
// Trigger onSave event for injection widgets
|
|
1063
|
+
if (resolvedInjectionSpotId) {
|
|
1064
|
+
try {
|
|
1065
|
+
await triggerInjectionEvent('onSave', parsedValues, injectionContext)
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
console.error('[CrudForm] Error in onSave:', err)
|
|
1068
|
+
flash(t('ui.forms.flash.saveBlocked', 'Save blocked by validation'), 'error')
|
|
1069
|
+
setPending(false)
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
await onSubmit?.(parsedValues)
|
|
1076
|
+
|
|
1077
|
+
// Trigger onAfterSave event for injection widgets
|
|
1078
|
+
if (resolvedInjectionSpotId) {
|
|
1079
|
+
try {
|
|
1080
|
+
await triggerInjectionEvent('onAfterSave', parsedValues, injectionContext)
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
console.error('[CrudForm] Error in onAfterSave:', err)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (successRedirect) router.push(successRedirect)
|
|
1087
|
+
} catch (err: unknown) {
|
|
1088
|
+
const { message: helperMessage, fieldErrors: serverFieldErrors } = mapCrudServerErrorToFormErrors(err, { customEntity })
|
|
1089
|
+
const combinedFieldErrors = serverFieldErrors ?? {}
|
|
1090
|
+
const hasFieldErrors = Object.keys(combinedFieldErrors).length > 0
|
|
1091
|
+
const firstFieldMessage = hasFieldErrors
|
|
1092
|
+
? (() => {
|
|
1093
|
+
const firstKey = Object.keys(combinedFieldErrors)[0]
|
|
1094
|
+
if (!firstKey) return null
|
|
1095
|
+
const value = combinedFieldErrors[firstKey]
|
|
1096
|
+
return typeof value === 'string' && value.trim().length ? value.trim() : null
|
|
1097
|
+
})()
|
|
1098
|
+
: null
|
|
1099
|
+
if (hasFieldErrors) {
|
|
1100
|
+
setErrors(combinedFieldErrors)
|
|
1101
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
1102
|
+
console.debug('[crud-form] Submission failed with field errors', combinedFieldErrors)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let displayMessage = typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
|
|
1107
|
+
if (hasFieldErrors) {
|
|
1108
|
+
const lowered = displayMessage.toLowerCase()
|
|
1109
|
+
const highlightedLower = highlightedMessage.toLowerCase()
|
|
1110
|
+
if (!displayMessage || lowered === 'invalid input' || lowered === highlightedLower) {
|
|
1111
|
+
displayMessage = firstFieldMessage ?? highlightedMessage
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (!displayMessage && err instanceof Error && typeof err.message === 'string' && err.message.trim()) {
|
|
1115
|
+
displayMessage = err.message.trim()
|
|
1116
|
+
}
|
|
1117
|
+
if (!displayMessage) {
|
|
1118
|
+
displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
|
|
1119
|
+
}
|
|
1120
|
+
displayMessage = parseServerMessage(displayMessage)
|
|
1121
|
+
flash(displayMessage, 'error')
|
|
1122
|
+
setFormError(displayMessage)
|
|
1123
|
+
} finally {
|
|
1124
|
+
setPending(false)
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// Load dynamic options for fields that require it
|
|
1128
|
+
React.useEffect(() => {
|
|
1129
|
+
let cancelled = false
|
|
1130
|
+
const loadAll = async () => {
|
|
1131
|
+
const loaders = allFields
|
|
1132
|
+
.filter(
|
|
1133
|
+
(f): f is CrudBuiltinField & { loadOptions: NonNullable<CrudBuiltinField['loadOptions']> } =>
|
|
1134
|
+
f.type !== 'custom' && typeof f.loadOptions === 'function'
|
|
1135
|
+
)
|
|
1136
|
+
.map(async (f) => {
|
|
1137
|
+
try {
|
|
1138
|
+
const opts = await f.loadOptions()
|
|
1139
|
+
if (!cancelled) setDynamicOptions((prev) => ({ ...prev, [f.id]: opts }))
|
|
1140
|
+
} catch {
|
|
1141
|
+
// ignore
|
|
1142
|
+
}
|
|
1143
|
+
})
|
|
1144
|
+
await Promise.all(loaders)
|
|
1145
|
+
}
|
|
1146
|
+
loadAll()
|
|
1147
|
+
return () => {
|
|
1148
|
+
cancelled = true
|
|
1149
|
+
}
|
|
1150
|
+
}, [allFields])
|
|
1151
|
+
|
|
1152
|
+
const loadFieldOptions = React.useCallback(async (field: CrudField, query?: string): Promise<CrudFieldOption[]> => {
|
|
1153
|
+
if (!('type' in field) || field.type === 'custom') return EMPTY_OPTIONS
|
|
1154
|
+
const builtin = field as CrudBuiltinField
|
|
1155
|
+
const loader = builtin.loadOptions
|
|
1156
|
+
if (typeof loader === 'function') {
|
|
1157
|
+
if (query === undefined && Array.isArray(dynamicOptions[field.id])) return dynamicOptions[field.id]
|
|
1158
|
+
try {
|
|
1159
|
+
const fetched = await loader(query)
|
|
1160
|
+
if (query === undefined) {
|
|
1161
|
+
setDynamicOptions((prev) => ({
|
|
1162
|
+
...prev,
|
|
1163
|
+
[field.id]: fetched,
|
|
1164
|
+
}))
|
|
1165
|
+
}
|
|
1166
|
+
return fetched
|
|
1167
|
+
} catch {
|
|
1168
|
+
return builtin.options ?? EMPTY_OPTIONS
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return dynamicOptions[field.id] || builtin.options || EMPTY_OPTIONS
|
|
1172
|
+
}, [dynamicOptions])
|
|
1173
|
+
|
|
1174
|
+
const fieldOptionsById = React.useMemo(() => {
|
|
1175
|
+
const map = new globalThis.Map<string, CrudFieldOption[]>()
|
|
1176
|
+
for (const f of allFields) {
|
|
1177
|
+
if (!('type' in f) || f.type === 'custom') continue
|
|
1178
|
+
const builtin = f as CrudBuiltinField
|
|
1179
|
+
const staticOptions = builtin.options ?? EMPTY_OPTIONS
|
|
1180
|
+
const dynamic = dynamicOptions[f.id]
|
|
1181
|
+
if (dynamic && dynamic.length) {
|
|
1182
|
+
const merged: CrudFieldOption[] = []
|
|
1183
|
+
const seen = new Set<string>()
|
|
1184
|
+
for (const opt of staticOptions) {
|
|
1185
|
+
if (seen.has(opt.value)) continue
|
|
1186
|
+
seen.add(opt.value)
|
|
1187
|
+
merged.push(opt)
|
|
1188
|
+
}
|
|
1189
|
+
for (const opt of dynamic) {
|
|
1190
|
+
if (seen.has(opt.value)) continue
|
|
1191
|
+
seen.add(opt.value)
|
|
1192
|
+
merged.push(opt)
|
|
1193
|
+
}
|
|
1194
|
+
map.set(f.id, merged)
|
|
1195
|
+
} else if (staticOptions.length) {
|
|
1196
|
+
map.set(f.id, staticOptions)
|
|
1197
|
+
} else if (dynamic) {
|
|
1198
|
+
map.set(f.id, dynamic)
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return map
|
|
1202
|
+
}, [allFields, dynamicOptions])
|
|
1203
|
+
|
|
1204
|
+
// no auto-focus; let the browser/user manage focus
|
|
1205
|
+
|
|
1206
|
+
const usesResponsiveLayout = allFields.some(
|
|
1207
|
+
(field) => field.layout === 'half' || field.layout === 'third'
|
|
1208
|
+
)
|
|
1209
|
+
const grid = twoColumn
|
|
1210
|
+
? 'grid grid-cols-1 lg:grid-cols-[7fr_3fr] gap-4'
|
|
1211
|
+
: usesResponsiveLayout
|
|
1212
|
+
? 'grid grid-cols-1 gap-4 md:grid-cols-6'
|
|
1213
|
+
: 'grid grid-cols-1 gap-4'
|
|
1214
|
+
|
|
1215
|
+
// Helper to render a list of field configs
|
|
1216
|
+
const resolveLayoutClass = (layout?: CrudFieldBase['layout']) => {
|
|
1217
|
+
switch (layout) {
|
|
1218
|
+
case 'half':
|
|
1219
|
+
return 'md:col-span-3'
|
|
1220
|
+
case 'third':
|
|
1221
|
+
return 'md:col-span-2'
|
|
1222
|
+
default:
|
|
1223
|
+
return 'md:col-span-6'
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const renderFields = (fieldList: CrudField[]) => {
|
|
1228
|
+
const usesResponsive = fieldList.some(
|
|
1229
|
+
(field) => field.layout === 'half' || field.layout === 'third'
|
|
1230
|
+
)
|
|
1231
|
+
const gridClass = usesResponsive ? 'grid grid-cols-1 gap-4 md:grid-cols-6' : 'grid grid-cols-1 gap-4'
|
|
1232
|
+
return (
|
|
1233
|
+
<div className={gridClass}>
|
|
1234
|
+
{fieldList.map((f) => {
|
|
1235
|
+
const layout = f.layout ?? 'full'
|
|
1236
|
+
const wrapperClassName = usesResponsive ? resolveLayoutClass(layout) : undefined
|
|
1237
|
+
return (
|
|
1238
|
+
<FieldControl
|
|
1239
|
+
key={f.id}
|
|
1240
|
+
field={f}
|
|
1241
|
+
value={values[f.id]}
|
|
1242
|
+
error={errors[f.id]}
|
|
1243
|
+
options={fieldOptionsById.get(f.id) || EMPTY_OPTIONS}
|
|
1244
|
+
setValue={setValue}
|
|
1245
|
+
values={values}
|
|
1246
|
+
loadFieldOptions={loadFieldOptions}
|
|
1247
|
+
autoFocus={Boolean(firstFieldId && f.id === firstFieldId)}
|
|
1248
|
+
onSubmitRequest={requestSubmit}
|
|
1249
|
+
wrapperClassName={wrapperClassName}
|
|
1250
|
+
entityIdForField={primaryEntityId ?? undefined}
|
|
1251
|
+
recordId={recordId}
|
|
1252
|
+
/>
|
|
1253
|
+
)
|
|
1254
|
+
})}
|
|
1255
|
+
</div>
|
|
1256
|
+
)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const renderCustomFieldsContent = React.useCallback((): React.ReactNode[] => {
|
|
1260
|
+
if (!customFieldLayout.length) {
|
|
1261
|
+
return [
|
|
1262
|
+
<div key="custom-fields-empty" className="rounded-lg border bg-card p-4">
|
|
1263
|
+
{customFieldsEmptyState}
|
|
1264
|
+
</div>,
|
|
1265
|
+
]
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const nodes: React.ReactNode[] = []
|
|
1269
|
+
const multipleEntities = customFieldLayout.length > 1
|
|
1270
|
+
|
|
1271
|
+
customFieldLayout.forEach((entityLayout) => {
|
|
1272
|
+
const manageHref = buildCustomFieldsManageHref(entityLayout.entityId)
|
|
1273
|
+
const showSelector =
|
|
1274
|
+
entityLayout.hasFieldsets &&
|
|
1275
|
+
entityLayout.singleFieldsetPerRecord &&
|
|
1276
|
+
entityLayout.availableFieldsets.length > 0
|
|
1277
|
+
|
|
1278
|
+
if (multipleEntities) {
|
|
1279
|
+
nodes.push(
|
|
1280
|
+
<div
|
|
1281
|
+
key={`custom-fields-entity-${entityLayout.entityId}`}
|
|
1282
|
+
className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
1283
|
+
>
|
|
1284
|
+
{entityLayout.entityId}
|
|
1285
|
+
</div>,
|
|
1286
|
+
)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (showSelector) {
|
|
1290
|
+
nodes.push(
|
|
1291
|
+
<div key={`custom-fields-selector-${entityLayout.entityId}`} className="rounded-lg border bg-card p-4">
|
|
1292
|
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
1293
|
+
<label className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
1294
|
+
{fieldsetSelectorLabel}
|
|
1295
|
+
</label>
|
|
1296
|
+
<select
|
|
1297
|
+
className="h-9 rounded border px-2 text-sm"
|
|
1298
|
+
value={entityLayout.activeFieldset ?? ''}
|
|
1299
|
+
onChange={(event) =>
|
|
1300
|
+
handleFieldsetSelectionChange(
|
|
1301
|
+
entityLayout.entityId,
|
|
1302
|
+
event.target.value || null,
|
|
1303
|
+
)}
|
|
1304
|
+
>
|
|
1305
|
+
<option value="">{defaultFieldsetLabel}</option>
|
|
1306
|
+
{entityLayout.availableFieldsets.map((fs) => (
|
|
1307
|
+
<option key={fs.code} value={fs.code}>
|
|
1308
|
+
{fs.label}
|
|
1309
|
+
</option>
|
|
1310
|
+
))}
|
|
1311
|
+
</select>
|
|
1312
|
+
<button
|
|
1313
|
+
type="button"
|
|
1314
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded border text-muted-foreground hover:text-foreground"
|
|
1315
|
+
onClick={() =>
|
|
1316
|
+
handleOpenFieldsetEditor(entityLayout.entityId, entityLayout.activeFieldset ?? null, 'fieldset')}
|
|
1317
|
+
disabled={!manageHref}
|
|
1318
|
+
title={manageFieldsetLabel}
|
|
1319
|
+
>
|
|
1320
|
+
<Settings className="size-4" />
|
|
1321
|
+
<span className="sr-only">{manageFieldsetLabel}</span>
|
|
1322
|
+
</button>
|
|
1323
|
+
</div>
|
|
1324
|
+
</div>,
|
|
1325
|
+
)
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (entityLayout.sections.length) {
|
|
1329
|
+
entityLayout.sections.forEach((section) => {
|
|
1330
|
+
const FieldsetIcon = section.fieldset?.icon
|
|
1331
|
+
? FIELDSET_ICON_COMPONENTS[section.fieldset.icon]
|
|
1332
|
+
: null
|
|
1333
|
+
const sectionKey = `${entityLayout.entityId}:${section.fieldsetCode ?? 'default'}`
|
|
1334
|
+
const manageDisabled = !manageHref
|
|
1335
|
+
nodes.push(
|
|
1336
|
+
<div key={sectionKey} className="rounded-lg border bg-card p-4 space-y-4">
|
|
1337
|
+
<div className="flex items-start justify-between gap-3">
|
|
1338
|
+
<div className="flex items-start gap-2">
|
|
1339
|
+
{FieldsetIcon ? (
|
|
1340
|
+
<FieldsetIcon className="size-5 text-muted-foreground" />
|
|
1341
|
+
) : null}
|
|
1342
|
+
<div>
|
|
1343
|
+
<div className="text-sm font-medium">{section.title}</div>
|
|
1344
|
+
{section.description ? (
|
|
1345
|
+
<div className="text-xs text-muted-foreground">
|
|
1346
|
+
{section.description}
|
|
1347
|
+
</div>
|
|
1348
|
+
) : null}
|
|
1349
|
+
</div>
|
|
1350
|
+
</div>
|
|
1351
|
+
<button
|
|
1352
|
+
type="button"
|
|
1353
|
+
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
1354
|
+
onClick={() => handleOpenFieldsetEditor(entityLayout.entityId, section.fieldsetCode, 'fieldset')}
|
|
1355
|
+
disabled={manageDisabled}
|
|
1356
|
+
>
|
|
1357
|
+
<Settings className="size-4" />
|
|
1358
|
+
{manageFieldsetLabel}
|
|
1359
|
+
</button>
|
|
1360
|
+
</div>
|
|
1361
|
+
{section.groups.map((group) => {
|
|
1362
|
+
const groupKey = `${section.fieldsetCode ?? 'default'}:${group.code ?? 'default'}`
|
|
1363
|
+
return (
|
|
1364
|
+
<div key={groupKey} className="space-y-2">
|
|
1365
|
+
{group.label ? (
|
|
1366
|
+
<div>
|
|
1367
|
+
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
1368
|
+
{group.label}
|
|
1369
|
+
</div>
|
|
1370
|
+
{group.hint ? (
|
|
1371
|
+
<div className="text-xs text-muted-foreground">{group.hint}</div>
|
|
1372
|
+
) : null}
|
|
1373
|
+
</div>
|
|
1374
|
+
) : null}
|
|
1375
|
+
{renderFields(group.fields)}
|
|
1376
|
+
</div>
|
|
1377
|
+
)
|
|
1378
|
+
})}
|
|
1379
|
+
{!section.groups.length ? (
|
|
1380
|
+
<div className="text-xs text-muted-foreground">{emptyFieldsetMessage}</div>
|
|
1381
|
+
) : null}
|
|
1382
|
+
</div>,
|
|
1383
|
+
)
|
|
1384
|
+
})
|
|
1385
|
+
} else {
|
|
1386
|
+
nodes.push(
|
|
1387
|
+
<div key={`custom-fields-empty-${entityLayout.entityId}`} className="rounded-lg border bg-card p-4">
|
|
1388
|
+
{customFieldsEmptyState}
|
|
1389
|
+
</div>,
|
|
1390
|
+
)
|
|
1391
|
+
}
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
return nodes
|
|
1395
|
+
}, [
|
|
1396
|
+
buildCustomFieldsManageHref,
|
|
1397
|
+
customFieldLayout,
|
|
1398
|
+
customFieldsEmptyState,
|
|
1399
|
+
defaultFieldsetLabel,
|
|
1400
|
+
emptyFieldsetMessage,
|
|
1401
|
+
fieldsetSelectorLabel,
|
|
1402
|
+
handleFieldsetSelectionChange,
|
|
1403
|
+
handleOpenFieldsetEditor,
|
|
1404
|
+
manageFieldsetLabel,
|
|
1405
|
+
renderFields,
|
|
1406
|
+
])
|
|
1407
|
+
|
|
1408
|
+
const fieldsetManagerDialog = (
|
|
1409
|
+
<Dialog open={fieldsetEditorTarget !== null} onOpenChange={(open) => { if (!open) setFieldsetEditorTarget(null) }}>
|
|
1410
|
+
<DialogContent
|
|
1411
|
+
className="max-w-5xl w-full"
|
|
1412
|
+
onKeyDown={(event) => {
|
|
1413
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
1414
|
+
event.preventDefault()
|
|
1415
|
+
handleFieldsetDialogSave()
|
|
1416
|
+
}
|
|
1417
|
+
}}
|
|
1418
|
+
>
|
|
1419
|
+
<DialogHeader>
|
|
1420
|
+
<DialogTitle>{fieldsetDialogTitle}</DialogTitle>
|
|
1421
|
+
</DialogHeader>
|
|
1422
|
+
{fieldsetEditorTarget ? (
|
|
1423
|
+
<FieldDefinitionsManager
|
|
1424
|
+
ref={fieldsetManagerRef}
|
|
1425
|
+
entityId={fieldsetEditorTarget.entityId}
|
|
1426
|
+
initialFieldset={fieldsetEditorTarget.fieldsetCode}
|
|
1427
|
+
fullEditorHref={fieldsetEditorFullHref ?? undefined}
|
|
1428
|
+
onSaved={refreshCustomFieldDefinitions}
|
|
1429
|
+
onClose={() => setFieldsetEditorTarget(null)}
|
|
1430
|
+
/>
|
|
1431
|
+
) : (
|
|
1432
|
+
<div className="flex h-full items-center justify-center text-sm text-muted-foreground px-4 text-center">
|
|
1433
|
+
{fieldsetDialogUnavailable}
|
|
1434
|
+
</div>
|
|
1435
|
+
)}
|
|
1436
|
+
</DialogContent>
|
|
1437
|
+
</Dialog>
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
// If groups are provided, render the two-column grouped layout
|
|
1441
|
+
if (useGroupedLayout) {
|
|
1442
|
+
|
|
1443
|
+
const col1: CrudFormGroup[] = []
|
|
1444
|
+
const col2: CrudFormGroup[] = []
|
|
1445
|
+
for (const g of resolvedGroupsForLayout) {
|
|
1446
|
+
if ((g.column ?? 1) === 2) col2.push(g)
|
|
1447
|
+
else col1.push(g)
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const renderGroupedCards = (items: CrudFormGroup[]) => {
|
|
1451
|
+
const nodes: React.ReactNode[] = []
|
|
1452
|
+
for (const g of items) {
|
|
1453
|
+
const isCustomFieldsGroup = g.kind === 'customFields'
|
|
1454
|
+
if (isCustomFieldsGroup) {
|
|
1455
|
+
if (isLoadingCustomFields) {
|
|
1456
|
+
nodes.push(
|
|
1457
|
+
<div key={`${g.id}-loading`} className="rounded-lg border bg-card p-4">
|
|
1458
|
+
<DataLoader
|
|
1459
|
+
isLoading
|
|
1460
|
+
loadingMessage={resolvedCustomFieldsLoadingMessage}
|
|
1461
|
+
spinnerSize="md"
|
|
1462
|
+
className="min-h-[1px]"
|
|
1463
|
+
>
|
|
1464
|
+
<div />
|
|
1465
|
+
</DataLoader>
|
|
1466
|
+
</div>,
|
|
1467
|
+
)
|
|
1468
|
+
continue
|
|
1469
|
+
}
|
|
1470
|
+
if (g.component) {
|
|
1471
|
+
nodes.push(
|
|
1472
|
+
<div key={`${g.id}-component`} className="rounded-lg border bg-card px-4 py-3">
|
|
1473
|
+
{g.component({ values, setValue, errors })}
|
|
1474
|
+
</div>,
|
|
1475
|
+
)
|
|
1476
|
+
}
|
|
1477
|
+
const renderedSections = renderCustomFieldsContent()
|
|
1478
|
+
if (renderedSections.length) nodes.push(...renderedSections)
|
|
1479
|
+
continue
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const componentNode = g.component ? g.component({ values, setValue, errors }) : null
|
|
1483
|
+
if (g.bare) {
|
|
1484
|
+
if (componentNode) {
|
|
1485
|
+
nodes.push(<React.Fragment key={g.id}>{componentNode}</React.Fragment>)
|
|
1486
|
+
}
|
|
1487
|
+
continue
|
|
1488
|
+
}
|
|
1489
|
+
const groupFields = resolveGroupFields(g)
|
|
1490
|
+
nodes.push(
|
|
1491
|
+
<div key={g.id} className="rounded-lg border bg-card px-4 py-3 space-y-3">
|
|
1492
|
+
{g.title ? (
|
|
1493
|
+
<div className="text-sm font-medium">{t(g.title, g.title)}</div>
|
|
1494
|
+
) : null}
|
|
1495
|
+
{g.description ? <div className="text-xs text-muted-foreground">{t(g.description, g.description)}</div> : null}
|
|
1496
|
+
{componentNode ? (
|
|
1497
|
+
<div>{componentNode}</div>
|
|
1498
|
+
) : null}
|
|
1499
|
+
<DataLoader
|
|
1500
|
+
isLoading={false}
|
|
1501
|
+
loadingMessage={resolvedLoadingMessage}
|
|
1502
|
+
spinnerSize="md"
|
|
1503
|
+
className="min-h-[1px]"
|
|
1504
|
+
>
|
|
1505
|
+
{groupFields.length > 0 ? renderFields(groupFields) : <div className="min-h-[1px]" />}
|
|
1506
|
+
</DataLoader>
|
|
1507
|
+
</div>,
|
|
1508
|
+
)
|
|
1509
|
+
}
|
|
1510
|
+
return nodes
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const col1Content = renderGroupedCards(col1)
|
|
1514
|
+
const col2Content = renderGroupedCards(col2)
|
|
1515
|
+
const hasSecondaryColumn = col2Content.length > 0
|
|
1516
|
+
|
|
1517
|
+
return (
|
|
1518
|
+
<div className="space-y-4" ref={rootRef}>
|
|
1519
|
+
{!embedded ? (
|
|
1520
|
+
<div className="flex items-center justify-between gap-3">
|
|
1521
|
+
<div className="flex items-center gap-3">
|
|
1522
|
+
{backHref ? (
|
|
1523
|
+
<Link href={backHref} className="text-sm text-muted-foreground hover:text-foreground">
|
|
1524
|
+
← {backLabel}
|
|
1525
|
+
</Link>
|
|
1526
|
+
) : null}
|
|
1527
|
+
{title ? <div className="text-base font-medium">{title}</div> : null}
|
|
1528
|
+
</div>
|
|
1529
|
+
<div className="flex items-center gap-2">
|
|
1530
|
+
{extraActions}
|
|
1531
|
+
{showDelete ? (
|
|
1532
|
+
<Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50 rounded">
|
|
1533
|
+
<Trash2 className="size-4 mr-2" />
|
|
1534
|
+
{deleteLabel}
|
|
1535
|
+
</Button>
|
|
1536
|
+
) : null}
|
|
1537
|
+
{cancelHref ? (
|
|
1538
|
+
<Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
|
|
1539
|
+
{cancelLabel}
|
|
1540
|
+
</Link>
|
|
1541
|
+
) : null}
|
|
1542
|
+
<Button type="submit" form={formId} disabled={pending}>
|
|
1543
|
+
<Save className="size-4 mr-2" />
|
|
1544
|
+
{pending ? savingLabel : resolvedSubmitLabel}
|
|
1545
|
+
</Button>
|
|
1546
|
+
</div>
|
|
1547
|
+
</div>
|
|
1548
|
+
) : null}
|
|
1549
|
+
{contentHeader}
|
|
1550
|
+
<DataLoader
|
|
1551
|
+
isLoading={isLoading}
|
|
1552
|
+
loadingMessage={resolvedLoadingMessage}
|
|
1553
|
+
spinnerSize="md"
|
|
1554
|
+
className="min-h-[400px]"
|
|
1555
|
+
>
|
|
1556
|
+
<form id={formId} onSubmit={handleSubmit} className={`space-y-4 ${dialogFormPadding}`}>
|
|
1557
|
+
{resolvedInjectionSpotId ? (
|
|
1558
|
+
<InjectionSpot
|
|
1559
|
+
spotId={resolvedInjectionSpotId}
|
|
1560
|
+
context={injectionContext}
|
|
1561
|
+
data={values}
|
|
1562
|
+
onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
|
|
1563
|
+
disabled={pending}
|
|
1564
|
+
widgetsOverride={stackedInjectionWidgets}
|
|
1565
|
+
/>
|
|
1566
|
+
) : null}
|
|
1567
|
+
<div
|
|
1568
|
+
className={hasSecondaryColumn
|
|
1569
|
+
? 'grid grid-cols-1 lg:grid-cols-[7fr_3fr] gap-4'
|
|
1570
|
+
: 'grid grid-cols-1 gap-4'}
|
|
1571
|
+
>
|
|
1572
|
+
<div className="space-y-3">{col1Content}</div>
|
|
1573
|
+
{hasSecondaryColumn ? <div className="space-y-3">{col2Content}</div> : null}
|
|
1574
|
+
</div>
|
|
1575
|
+
{formError ? <div className="text-sm text-red-600">{formError}</div> : null}
|
|
1576
|
+
<div className={`flex items-center ${embedded ? 'justify-end' : 'justify-between'} gap-2 ${dialogFooterClass}`}>
|
|
1577
|
+
{embedded ? null : <div />}
|
|
1578
|
+
<div className="flex items-center gap-2">
|
|
1579
|
+
{extraActions}
|
|
1580
|
+
{!embedded && showDelete ? (
|
|
1581
|
+
<Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50 rounded">
|
|
1582
|
+
<Trash2 className="size-4 mr-2" />
|
|
1583
|
+
{deleteLabel}
|
|
1584
|
+
</Button>
|
|
1585
|
+
) : null}
|
|
1586
|
+
{!embedded && cancelHref ? (
|
|
1587
|
+
<Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
|
|
1588
|
+
{cancelLabel}
|
|
1589
|
+
</Link>
|
|
1590
|
+
) : null}
|
|
1591
|
+
<Button type="submit" disabled={pending}>
|
|
1592
|
+
<Save className="size-4 mr-2" />
|
|
1593
|
+
{pending ? savingLabel : resolvedSubmitLabel}
|
|
1594
|
+
</Button>
|
|
1595
|
+
</div>
|
|
1596
|
+
</div>
|
|
1597
|
+
</form>
|
|
1598
|
+
</DataLoader>
|
|
1599
|
+
{fieldsetManagerDialog}
|
|
1600
|
+
</div>
|
|
1601
|
+
)
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Default single-card layout (compatible with previous API)
|
|
1605
|
+
return (
|
|
1606
|
+
<div className="space-y-4" ref={rootRef}>
|
|
1607
|
+
{!embedded ? (
|
|
1608
|
+
<div className="flex items-center justify-between gap-3">
|
|
1609
|
+
<div className="flex items-center gap-3">
|
|
1610
|
+
{backHref ? (
|
|
1611
|
+
<Link href={backHref} className="text-sm text-muted-foreground hover:text-foreground">
|
|
1612
|
+
← {backLabel}
|
|
1613
|
+
</Link>
|
|
1614
|
+
) : null}
|
|
1615
|
+
{title ? <div className="text-base font-medium">{title}</div> : null}
|
|
1616
|
+
</div>
|
|
1617
|
+
<div className="flex items-center gap-2">
|
|
1618
|
+
{extraActions}
|
|
1619
|
+
{showDelete ? (
|
|
1620
|
+
<Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50 rounded">
|
|
1621
|
+
<Trash2 className="size-4 mr-2" />
|
|
1622
|
+
{deleteLabel}
|
|
1623
|
+
</Button>
|
|
1624
|
+
) : null}
|
|
1625
|
+
{cancelHref ? (
|
|
1626
|
+
<Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
|
|
1627
|
+
{cancelLabel}
|
|
1628
|
+
</Link>
|
|
1629
|
+
) : null}
|
|
1630
|
+
<Button type="submit" form={formId} disabled={pending}>
|
|
1631
|
+
<Save className="size-4 mr-2" />
|
|
1632
|
+
{pending ? savingLabel : resolvedSubmitLabel}
|
|
1633
|
+
</Button>
|
|
1634
|
+
</div>
|
|
1635
|
+
</div>
|
|
1636
|
+
) : null}
|
|
1637
|
+
{contentHeader}
|
|
1638
|
+
<DataLoader
|
|
1639
|
+
isLoading={isLoading}
|
|
1640
|
+
loadingMessage={resolvedLoadingMessage}
|
|
1641
|
+
spinnerSize="md"
|
|
1642
|
+
className="min-h-[400px]"
|
|
1643
|
+
>
|
|
1644
|
+
<div>
|
|
1645
|
+
<form
|
|
1646
|
+
id={formId}
|
|
1647
|
+
onSubmit={handleSubmit}
|
|
1648
|
+
className={`${embedded ? 'space-y-4' : 'rounded-lg border bg-card p-4 space-y-4'} ${dialogFormPadding}`}
|
|
1649
|
+
>
|
|
1650
|
+
{resolvedInjectionSpotId ? (
|
|
1651
|
+
<InjectionSpot
|
|
1652
|
+
spotId={resolvedInjectionSpotId}
|
|
1653
|
+
context={injectionContext}
|
|
1654
|
+
data={values}
|
|
1655
|
+
onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
|
|
1656
|
+
disabled={pending}
|
|
1657
|
+
widgetsOverride={stackedInjectionWidgets}
|
|
1658
|
+
/>
|
|
1659
|
+
) : null}
|
|
1660
|
+
<div className={grid}>
|
|
1661
|
+
{allFields.map((f) => {
|
|
1662
|
+
const layout = f.layout ?? 'full'
|
|
1663
|
+
const wrapperClassName = usesResponsiveLayout ? resolveLayoutClass(layout) : undefined
|
|
1664
|
+
return (
|
|
1665
|
+
<FieldControl
|
|
1666
|
+
key={f.id}
|
|
1667
|
+
field={f}
|
|
1668
|
+
value={values[f.id]}
|
|
1669
|
+
error={errors[f.id]}
|
|
1670
|
+
options={fieldOptionsById.get(f.id) || EMPTY_OPTIONS}
|
|
1671
|
+
setValue={setValue}
|
|
1672
|
+
values={values}
|
|
1673
|
+
loadFieldOptions={loadFieldOptions}
|
|
1674
|
+
autoFocus={Boolean(firstFieldId && f.id === firstFieldId)}
|
|
1675
|
+
onSubmitRequest={requestSubmit}
|
|
1676
|
+
wrapperClassName={wrapperClassName}
|
|
1677
|
+
entityIdForField={primaryEntityId ?? undefined}
|
|
1678
|
+
recordId={recordId}
|
|
1679
|
+
/>
|
|
1680
|
+
)
|
|
1681
|
+
})}
|
|
1682
|
+
</div>
|
|
1683
|
+
{formError ? <div className="text-sm text-red-600">{formError}</div> : null}
|
|
1684
|
+
<div className={`flex items-center ${embedded ? 'justify-end' : 'justify-end'} gap-2 ${dialogFooterClass}`}>
|
|
1685
|
+
{extraActions}
|
|
1686
|
+
{!embedded && showDelete ? (
|
|
1687
|
+
<Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50">
|
|
1688
|
+
<Trash2 className="size-4 mr-2" />
|
|
1689
|
+
{deleteLabel}
|
|
1690
|
+
</Button>
|
|
1691
|
+
) : null}
|
|
1692
|
+
{!embedded && cancelHref ? (
|
|
1693
|
+
<Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
|
|
1694
|
+
{cancelLabel}
|
|
1695
|
+
</Link>
|
|
1696
|
+
) : null}
|
|
1697
|
+
<Button type="submit" disabled={pending}>
|
|
1698
|
+
<Save className="size-4 mr-2" />
|
|
1699
|
+
{pending ? savingLabel : resolvedSubmitLabel}
|
|
1700
|
+
</Button>
|
|
1701
|
+
</div>
|
|
1702
|
+
</form>
|
|
1703
|
+
</div>
|
|
1704
|
+
</DataLoader>
|
|
1705
|
+
{fieldsetManagerDialog}
|
|
1706
|
+
</div>
|
|
1707
|
+
)
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function RelationSelect({
|
|
1711
|
+
value,
|
|
1712
|
+
onChange,
|
|
1713
|
+
options,
|
|
1714
|
+
placeholder,
|
|
1715
|
+
autoFocus,
|
|
1716
|
+
}: {
|
|
1717
|
+
value: string
|
|
1718
|
+
onChange: (v: string) => void
|
|
1719
|
+
options: CrudFieldOption[]
|
|
1720
|
+
placeholder?: string
|
|
1721
|
+
autoFocus?: boolean
|
|
1722
|
+
}) {
|
|
1723
|
+
const t = useT()
|
|
1724
|
+
const [query, setQuery] = React.useState('')
|
|
1725
|
+
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
1726
|
+
|
|
1727
|
+
const filtered = React.useMemo(() => {
|
|
1728
|
+
const q = query.toLowerCase().trim()
|
|
1729
|
+
if (!q) return options
|
|
1730
|
+
return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
1731
|
+
}, [query, options])
|
|
1732
|
+
|
|
1733
|
+
return (
|
|
1734
|
+
<div className="space-y-1">
|
|
1735
|
+
<input
|
|
1736
|
+
ref={inputRef}
|
|
1737
|
+
className="w-full h-9 rounded border px-2 text-sm"
|
|
1738
|
+
placeholder={placeholder || t('ui.forms.listbox.searchPlaceholder', 'Search...')}
|
|
1739
|
+
value={query}
|
|
1740
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
1741
|
+
autoFocus={autoFocus}
|
|
1742
|
+
data-crud-focus-target=""
|
|
1743
|
+
/>
|
|
1744
|
+
<div className="max-h-40 overflow-auto rounded border">
|
|
1745
|
+
<button
|
|
1746
|
+
type="button"
|
|
1747
|
+
className="block w-full text-left px-2 py-1 text-sm hover:bg-muted"
|
|
1748
|
+
onClick={() => onChange('')}
|
|
1749
|
+
>
|
|
1750
|
+
—
|
|
1751
|
+
</button>
|
|
1752
|
+
{filtered.map((opt) => (
|
|
1753
|
+
<button
|
|
1754
|
+
key={opt.value}
|
|
1755
|
+
type="button"
|
|
1756
|
+
className={`block w-full text-left px-2 py-1 text-sm hover:bg-muted ${
|
|
1757
|
+
value === opt.value ? 'bg-muted' : ''
|
|
1758
|
+
}`}
|
|
1759
|
+
onClick={() => onChange(opt.value)}
|
|
1760
|
+
>
|
|
1761
|
+
{opt.label}
|
|
1762
|
+
</button>
|
|
1763
|
+
))}
|
|
1764
|
+
</div>
|
|
1765
|
+
</div>
|
|
1766
|
+
)
|
|
1767
|
+
}
|
|
1768
|
+
// Local-buffer text input to avoid focus loss when parent re-renders
|
|
1769
|
+
function TextInput({
|
|
1770
|
+
value,
|
|
1771
|
+
onChange,
|
|
1772
|
+
placeholder,
|
|
1773
|
+
autoFocus,
|
|
1774
|
+
onSubmit,
|
|
1775
|
+
disabled,
|
|
1776
|
+
suggestions,
|
|
1777
|
+
}: {
|
|
1778
|
+
value: string
|
|
1779
|
+
onChange: (v: string) => void
|
|
1780
|
+
placeholder?: string
|
|
1781
|
+
autoFocus?: boolean
|
|
1782
|
+
onSubmit?: () => void
|
|
1783
|
+
disabled?: boolean
|
|
1784
|
+
suggestions?: string[]
|
|
1785
|
+
}) {
|
|
1786
|
+
const [local, setLocal] = React.useState<string>(value)
|
|
1787
|
+
const isFocusedRef = React.useRef(false)
|
|
1788
|
+
const userTypingRef = React.useRef(false)
|
|
1789
|
+
const datalistId = React.useId()
|
|
1790
|
+
|
|
1791
|
+
React.useEffect(() => {
|
|
1792
|
+
// Sync from props whenever the input is unfocused or the user hasn't typed yet.
|
|
1793
|
+
if (!isFocusedRef.current || !userTypingRef.current) {
|
|
1794
|
+
setLocal(value)
|
|
1795
|
+
}
|
|
1796
|
+
}, [value])
|
|
1797
|
+
|
|
1798
|
+
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1799
|
+
if (disabled) return
|
|
1800
|
+
const next = e.target.value
|
|
1801
|
+
userTypingRef.current = true
|
|
1802
|
+
setLocal(next)
|
|
1803
|
+
onChange(next)
|
|
1804
|
+
}, [disabled, onChange])
|
|
1805
|
+
|
|
1806
|
+
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
1807
|
+
if (disabled) return
|
|
1808
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1809
|
+
e.preventDefault()
|
|
1810
|
+
onChange(local)
|
|
1811
|
+
onSubmit?.()
|
|
1812
|
+
}
|
|
1813
|
+
}, [disabled, local, onChange, onSubmit])
|
|
1814
|
+
|
|
1815
|
+
const handleFocus = React.useCallback(() => {
|
|
1816
|
+
isFocusedRef.current = true
|
|
1817
|
+
}, [])
|
|
1818
|
+
|
|
1819
|
+
const handleBlur = React.useCallback(() => {
|
|
1820
|
+
isFocusedRef.current = false
|
|
1821
|
+
userTypingRef.current = false
|
|
1822
|
+
onChange(local)
|
|
1823
|
+
}, [local, onChange])
|
|
1824
|
+
|
|
1825
|
+
return (
|
|
1826
|
+
<>
|
|
1827
|
+
<input
|
|
1828
|
+
type="text"
|
|
1829
|
+
className="w-full h-9 rounded border px-2 text-sm"
|
|
1830
|
+
placeholder={placeholder}
|
|
1831
|
+
value={local}
|
|
1832
|
+
onChange={handleChange}
|
|
1833
|
+
onKeyDown={handleKeyDown}
|
|
1834
|
+
onFocus={handleFocus}
|
|
1835
|
+
onBlur={handleBlur}
|
|
1836
|
+
spellCheck={false}
|
|
1837
|
+
autoFocus={autoFocus}
|
|
1838
|
+
data-crud-focus-target=""
|
|
1839
|
+
disabled={disabled}
|
|
1840
|
+
list={suggestions && suggestions.length > 0 ? datalistId : undefined}
|
|
1841
|
+
/>
|
|
1842
|
+
{suggestions && suggestions.length > 0 && (
|
|
1843
|
+
<datalist id={datalistId}>
|
|
1844
|
+
{suggestions.map((suggestion) => (
|
|
1845
|
+
<option key={suggestion} value={suggestion} />
|
|
1846
|
+
))}
|
|
1847
|
+
</datalist>
|
|
1848
|
+
)}
|
|
1849
|
+
</>
|
|
1850
|
+
)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// Local-buffer number input to avoid focus loss when parent re-renders
|
|
1854
|
+
function NumberInput({
|
|
1855
|
+
value,
|
|
1856
|
+
onChange,
|
|
1857
|
+
placeholder,
|
|
1858
|
+
autoFocus,
|
|
1859
|
+
onSubmit,
|
|
1860
|
+
}: {
|
|
1861
|
+
value: number | string | null | undefined
|
|
1862
|
+
onChange: (v: number | undefined) => void
|
|
1863
|
+
placeholder?: string
|
|
1864
|
+
autoFocus?: boolean
|
|
1865
|
+
onSubmit?: () => void
|
|
1866
|
+
}) {
|
|
1867
|
+
const [local, setLocal] = React.useState<string>(value !== undefined && value !== null ? String(value) : '')
|
|
1868
|
+
const isFocusedRef = React.useRef(false)
|
|
1869
|
+
|
|
1870
|
+
React.useEffect(() => {
|
|
1871
|
+
// Only sync from props when not focused to avoid caret jumps
|
|
1872
|
+
if (!isFocusedRef.current) {
|
|
1873
|
+
setLocal(value !== undefined && value !== null ? String(value) : '')
|
|
1874
|
+
}
|
|
1875
|
+
}, [value])
|
|
1876
|
+
|
|
1877
|
+
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1878
|
+
const next = e.target.value
|
|
1879
|
+
setLocal(next)
|
|
1880
|
+
const numValue = next === '' ? undefined : Number(next)
|
|
1881
|
+
onChange(numValue)
|
|
1882
|
+
}, [onChange])
|
|
1883
|
+
|
|
1884
|
+
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
1885
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1886
|
+
e.preventDefault()
|
|
1887
|
+
const numValue = local === '' ? undefined : Number(local)
|
|
1888
|
+
onChange(numValue)
|
|
1889
|
+
onSubmit?.()
|
|
1890
|
+
}
|
|
1891
|
+
}, [local, onChange, onSubmit])
|
|
1892
|
+
|
|
1893
|
+
const handleFocus = React.useCallback(() => {
|
|
1894
|
+
isFocusedRef.current = true
|
|
1895
|
+
}, [])
|
|
1896
|
+
|
|
1897
|
+
const handleBlur = React.useCallback(() => {
|
|
1898
|
+
isFocusedRef.current = false
|
|
1899
|
+
const numValue = local === '' ? undefined : Number(local)
|
|
1900
|
+
onChange(numValue)
|
|
1901
|
+
}, [local, onChange])
|
|
1902
|
+
|
|
1903
|
+
return (
|
|
1904
|
+
<input
|
|
1905
|
+
type="number"
|
|
1906
|
+
className="w-full h-9 rounded border px-2 text-sm"
|
|
1907
|
+
placeholder={placeholder}
|
|
1908
|
+
value={local}
|
|
1909
|
+
onChange={handleChange}
|
|
1910
|
+
onKeyDown={handleKeyDown}
|
|
1911
|
+
onFocus={handleFocus}
|
|
1912
|
+
onBlur={handleBlur}
|
|
1913
|
+
autoFocus={autoFocus}
|
|
1914
|
+
data-crud-focus-target=""
|
|
1915
|
+
/>
|
|
1916
|
+
)
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// Local-buffer textarea to avoid form-wide re-renders while typing
|
|
1920
|
+
function TextAreaInput({
|
|
1921
|
+
value,
|
|
1922
|
+
onChange,
|
|
1923
|
+
placeholder,
|
|
1924
|
+
autoFocus,
|
|
1925
|
+
}: {
|
|
1926
|
+
value: string
|
|
1927
|
+
onChange: (v: string) => void
|
|
1928
|
+
placeholder?: string
|
|
1929
|
+
autoFocus?: boolean
|
|
1930
|
+
}) {
|
|
1931
|
+
const [local, setLocal] = React.useState<string>(value)
|
|
1932
|
+
const isFocusedRef = React.useRef(false)
|
|
1933
|
+
|
|
1934
|
+
React.useEffect(() => {
|
|
1935
|
+
if (!isFocusedRef.current) setLocal(value)
|
|
1936
|
+
}, [value])
|
|
1937
|
+
|
|
1938
|
+
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
1939
|
+
const next = e.target.value
|
|
1940
|
+
setLocal(next)
|
|
1941
|
+
onChange(next)
|
|
1942
|
+
}, [onChange])
|
|
1943
|
+
|
|
1944
|
+
const handleFocus = React.useCallback(() => { isFocusedRef.current = true }, [])
|
|
1945
|
+
const handleBlur = React.useCallback(() => { isFocusedRef.current = false; onChange(local) }, [local, onChange])
|
|
1946
|
+
|
|
1947
|
+
return (
|
|
1948
|
+
<textarea
|
|
1949
|
+
className="w-full rounded border px-2 py-2 min-h-[120px] text-sm"
|
|
1950
|
+
placeholder={placeholder}
|
|
1951
|
+
value={local}
|
|
1952
|
+
onChange={handleChange}
|
|
1953
|
+
onFocus={handleFocus}
|
|
1954
|
+
onBlur={handleBlur}
|
|
1955
|
+
autoFocus={autoFocus}
|
|
1956
|
+
data-crud-focus-target=""
|
|
1957
|
+
/>
|
|
1958
|
+
)
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// Markdown editor using @uiw/react-md-editor (client-only)
|
|
1962
|
+
type MDProps = { value?: string; onChange: (md: string) => void }
|
|
1963
|
+
const MDEditor = dynamic(async () => {
|
|
1964
|
+
const mod = await import('@uiw/react-md-editor')
|
|
1965
|
+
return mod.default
|
|
1966
|
+
}, { ssr: false }) as React.ComponentType<UiWMDEditorProps>
|
|
1967
|
+
const MarkdownEditor = React.memo(function MarkdownEditor({ value = '', onChange }: MDProps) {
|
|
1968
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
|
1969
|
+
const [local, setLocal] = React.useState<string>(value)
|
|
1970
|
+
const typingRef = React.useRef(false)
|
|
1971
|
+
|
|
1972
|
+
React.useEffect(() => {
|
|
1973
|
+
if (!typingRef.current) setLocal(value)
|
|
1974
|
+
}, [value])
|
|
1975
|
+
|
|
1976
|
+
const handleChange = React.useCallback((v?: string) => {
|
|
1977
|
+
typingRef.current = true
|
|
1978
|
+
setLocal(v ?? '')
|
|
1979
|
+
}, [])
|
|
1980
|
+
|
|
1981
|
+
const commit = React.useCallback(() => {
|
|
1982
|
+
if (!typingRef.current) return
|
|
1983
|
+
typingRef.current = false
|
|
1984
|
+
onChange(local)
|
|
1985
|
+
requestAnimationFrame(() => {
|
|
1986
|
+
const ta = containerRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
|
|
1987
|
+
ta?.focus()
|
|
1988
|
+
})
|
|
1989
|
+
}, [local, onChange])
|
|
1990
|
+
|
|
1991
|
+
return (
|
|
1992
|
+
<div ref={containerRef} data-color-mode="light" className="w-full" onBlur={() => commit()}>
|
|
1993
|
+
<MDEditor
|
|
1994
|
+
value={local}
|
|
1995
|
+
height={220}
|
|
1996
|
+
onChange={handleChange}
|
|
1997
|
+
previewOptions={{ remarkPlugins: [remarkGfm] }}
|
|
1998
|
+
/>
|
|
1999
|
+
</div>
|
|
2000
|
+
)
|
|
2001
|
+
}, (prev, next) => prev.value === next.value)
|
|
2002
|
+
|
|
2003
|
+
// HTML Rich Text editor (contentEditable) with shortcuts; returns HTML string
|
|
2004
|
+
type HtmlRTProps = { value?: string; onChange: (html: string) => void }
|
|
2005
|
+
const HtmlRichTextEditor = React.memo(function HtmlRichTextEditor({ value = '', onChange }: HtmlRTProps) {
|
|
2006
|
+
const t = useT()
|
|
2007
|
+
const boldLabel = t('ui.forms.richtext.bold')
|
|
2008
|
+
const italicLabel = t('ui.forms.richtext.italic')
|
|
2009
|
+
const underlineLabel = t('ui.forms.richtext.underline')
|
|
2010
|
+
const listLabel = t('ui.forms.richtext.list')
|
|
2011
|
+
const heading3Label = t('ui.forms.richtext.heading3')
|
|
2012
|
+
const linkLabel = t('ui.forms.richtext.link')
|
|
2013
|
+
const linkUrlPrompt = t('ui.forms.richtext.linkUrlPrompt')
|
|
2014
|
+
const ref = React.useRef<HTMLDivElement | null>(null)
|
|
2015
|
+
const applyingExternal = React.useRef(false)
|
|
2016
|
+
const typingRef = React.useRef(false)
|
|
2017
|
+
|
|
2018
|
+
React.useEffect(() => {
|
|
2019
|
+
const el = ref.current
|
|
2020
|
+
if (!el) return
|
|
2021
|
+
const current = el.innerHTML
|
|
2022
|
+
if (!typingRef.current && current !== value) {
|
|
2023
|
+
applyingExternal.current = true
|
|
2024
|
+
el.innerHTML = value || ''
|
|
2025
|
+
requestAnimationFrame(() => { applyingExternal.current = false })
|
|
2026
|
+
}
|
|
2027
|
+
}, [value])
|
|
2028
|
+
|
|
2029
|
+
const exec = (cmd: string, arg?: string) => {
|
|
2030
|
+
const el = ref.current
|
|
2031
|
+
if (!el) return
|
|
2032
|
+
el.focus()
|
|
2033
|
+
try {
|
|
2034
|
+
document.execCommand(cmd, false, arg)
|
|
2035
|
+
} catch {
|
|
2036
|
+
// ignore execCommand failures
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
2041
|
+
const isMod = e.metaKey || e.ctrlKey
|
|
2042
|
+
if (!isMod) return
|
|
2043
|
+
const k = e.key.toLowerCase()
|
|
2044
|
+
if (k === 'b') { e.preventDefault(); exec('bold') }
|
|
2045
|
+
if (k === 'i') { e.preventDefault(); exec('italic') }
|
|
2046
|
+
if (k === 'u') { e.preventDefault(); exec('underline') }
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return (
|
|
2050
|
+
<div className="w-full rounded border">
|
|
2051
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b">
|
|
2052
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('bold')}>{boldLabel}</button>
|
|
2053
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('italic')}>{italicLabel}</button>
|
|
2054
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('underline')}>{underlineLabel}</button>
|
|
2055
|
+
<span className="mx-2 text-muted-foreground">|</span>
|
|
2056
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>• {listLabel}</button>
|
|
2057
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', '<h3>')}>{heading3Label}</button>
|
|
2058
|
+
<button
|
|
2059
|
+
type="button"
|
|
2060
|
+
className="px-2 py-0.5 text-xs rounded hover:bg-muted"
|
|
2061
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
2062
|
+
onClick={() => {
|
|
2063
|
+
const url = window.prompt(linkUrlPrompt)?.trim()
|
|
2064
|
+
if (url) exec('createLink', url)
|
|
2065
|
+
}}
|
|
2066
|
+
>{linkLabel}</button>
|
|
2067
|
+
</div>
|
|
2068
|
+
<div
|
|
2069
|
+
ref={ref}
|
|
2070
|
+
className="w-full px-2 py-2 min-h-[160px] focus:outline-none prose prose-sm max-w-none"
|
|
2071
|
+
contentEditable
|
|
2072
|
+
suppressContentEditableWarning
|
|
2073
|
+
onKeyDown={onKeyDown}
|
|
2074
|
+
onInput={() => { if (!applyingExternal.current) typingRef.current = true }}
|
|
2075
|
+
onBlur={() => {
|
|
2076
|
+
const el = ref.current
|
|
2077
|
+
if (!el) return
|
|
2078
|
+
typingRef.current = false
|
|
2079
|
+
onChange(el.innerHTML)
|
|
2080
|
+
}}
|
|
2081
|
+
/>
|
|
2082
|
+
</div>
|
|
2083
|
+
)
|
|
2084
|
+
}, (prev, next) => prev.value === next.value)
|
|
2085
|
+
|
|
2086
|
+
// Very simple markdown editor with Bold/Italic/Underline + shortcuts.
|
|
2087
|
+
type SimpleMDProps = { value?: string; onChange: (md: string) => void }
|
|
2088
|
+
const SimpleMarkdownEditor = React.memo(function SimpleMarkdownEditor({ value = '', onChange }: SimpleMDProps) {
|
|
2089
|
+
const t = useT()
|
|
2090
|
+
const boldLabel = t('ui.forms.richtext.bold')
|
|
2091
|
+
const italicLabel = t('ui.forms.richtext.italic')
|
|
2092
|
+
const underlineLabel = t('ui.forms.richtext.underline')
|
|
2093
|
+
const markdownPlaceholder = t('ui.forms.richtext.placeholder')
|
|
2094
|
+
const sampleText = t('ui.forms.richtext.sampleText')
|
|
2095
|
+
const taRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
2096
|
+
const [local, setLocal] = React.useState<string>(value)
|
|
2097
|
+
const typingRef = React.useRef(false)
|
|
2098
|
+
|
|
2099
|
+
React.useEffect(() => {
|
|
2100
|
+
if (!typingRef.current) setLocal(value)
|
|
2101
|
+
}, [value])
|
|
2102
|
+
|
|
2103
|
+
const wrap = (before: string, after: string = before) => {
|
|
2104
|
+
const el = taRef.current
|
|
2105
|
+
if (!el) return
|
|
2106
|
+
const start = el.selectionStart ?? 0
|
|
2107
|
+
const end = el.selectionEnd ?? 0
|
|
2108
|
+
const sel = value.slice(start, end) || sampleText
|
|
2109
|
+
const next = value.slice(0, start) + before + sel + after + value.slice(end)
|
|
2110
|
+
onChange(next)
|
|
2111
|
+
queueMicrotask(() => {
|
|
2112
|
+
const caret = start + before.length + sel.length + after.length
|
|
2113
|
+
el.focus()
|
|
2114
|
+
el.setSelectionRange(caret, caret)
|
|
2115
|
+
})
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
2119
|
+
const isMod = e.metaKey || e.ctrlKey
|
|
2120
|
+
if (!isMod) return
|
|
2121
|
+
const key = e.key.toLowerCase()
|
|
2122
|
+
if (key === 'b') { e.preventDefault(); wrap('**') }
|
|
2123
|
+
if (key === 'i') { e.preventDefault(); wrap('_') }
|
|
2124
|
+
if (key === 'u') { e.preventDefault(); wrap('__') }
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
return (
|
|
2128
|
+
<div className="w-full rounded border">
|
|
2129
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b">
|
|
2130
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => wrap('**')}>{boldLabel}</button>
|
|
2131
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => wrap('_')}>{italicLabel}</button>
|
|
2132
|
+
<button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => wrap('__')}>{underlineLabel}</button>
|
|
2133
|
+
</div>
|
|
2134
|
+
<textarea
|
|
2135
|
+
ref={taRef}
|
|
2136
|
+
className="w-full min-h-[160px] resize-y px-2 py-2 font-mono text-sm outline-none"
|
|
2137
|
+
spellCheck={false}
|
|
2138
|
+
value={local}
|
|
2139
|
+
onChange={(e) => { typingRef.current = true; setLocal(e.target.value) }}
|
|
2140
|
+
onBlur={() => { if (typingRef.current) { typingRef.current = false; onChange(local) } }}
|
|
2141
|
+
onKeyDown={onKeyDown}
|
|
2142
|
+
placeholder={markdownPlaceholder}
|
|
2143
|
+
/>
|
|
2144
|
+
</div>
|
|
2145
|
+
)
|
|
2146
|
+
}, (prev, next) => prev.value === next.value)
|
|
2147
|
+
|
|
2148
|
+
type FieldControlProps = {
|
|
2149
|
+
field: CrudField
|
|
2150
|
+
value: unknown
|
|
2151
|
+
error?: string
|
|
2152
|
+
options: CrudFieldOption[]
|
|
2153
|
+
setValue: (id: string, v: unknown) => void
|
|
2154
|
+
values: Record<string, unknown>
|
|
2155
|
+
loadFieldOptions: (field: CrudField, query?: string) => Promise<CrudFieldOption[]>
|
|
2156
|
+
autoFocus: boolean
|
|
2157
|
+
onSubmitRequest: () => void
|
|
2158
|
+
wrapperClassName?: string
|
|
2159
|
+
entityIdForField?: string
|
|
2160
|
+
recordId?: string
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
type ListboxMultiSelectProps = {
|
|
2164
|
+
options: CrudFieldOption[]
|
|
2165
|
+
placeholder?: string
|
|
2166
|
+
value: string[]
|
|
2167
|
+
onChange: (vals: string[]) => void
|
|
2168
|
+
autoFocus?: boolean
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
|
|
2172
|
+
options,
|
|
2173
|
+
placeholder,
|
|
2174
|
+
value,
|
|
2175
|
+
onChange,
|
|
2176
|
+
autoFocus,
|
|
2177
|
+
}: ListboxMultiSelectProps) {
|
|
2178
|
+
const t = useT()
|
|
2179
|
+
const searchPlaceholder = placeholder || t('ui.forms.listbox.searchPlaceholder')
|
|
2180
|
+
const noMatchesLabel = t('ui.forms.listbox.noMatches')
|
|
2181
|
+
const [query, setQuery] = React.useState('')
|
|
2182
|
+
const filtered = React.useMemo(() => {
|
|
2183
|
+
const q = query.toLowerCase().trim()
|
|
2184
|
+
if (!q) return options
|
|
2185
|
+
return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
2186
|
+
}, [options, query])
|
|
2187
|
+
const toggle = React.useCallback(
|
|
2188
|
+
(val: string) => {
|
|
2189
|
+
const set = new Set(value)
|
|
2190
|
+
if (set.has(val)) set.delete(val)
|
|
2191
|
+
else set.add(val)
|
|
2192
|
+
onChange(Array.from(set))
|
|
2193
|
+
},
|
|
2194
|
+
[value, onChange]
|
|
2195
|
+
)
|
|
2196
|
+
return (
|
|
2197
|
+
<div className="w-full">
|
|
2198
|
+
<input
|
|
2199
|
+
className="mb-2 w-full h-8 rounded border px-2 text-sm"
|
|
2200
|
+
placeholder={searchPlaceholder}
|
|
2201
|
+
value={query}
|
|
2202
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2203
|
+
autoFocus={autoFocus}
|
|
2204
|
+
data-crud-focus-target=""
|
|
2205
|
+
/>
|
|
2206
|
+
<div className="rounded border max-h-48 overflow-auto divide-y">
|
|
2207
|
+
{filtered.map((opt) => {
|
|
2208
|
+
const isSel = value.includes(opt.value)
|
|
2209
|
+
return (
|
|
2210
|
+
<button
|
|
2211
|
+
key={opt.value}
|
|
2212
|
+
type="button"
|
|
2213
|
+
onClick={() => toggle(opt.value)}
|
|
2214
|
+
className={`w-full text-left px-3 py-2 text-sm hover:bg-muted ${isSel ? 'bg-muted' : ''}`}
|
|
2215
|
+
>
|
|
2216
|
+
<span className="inline-flex items-center gap-2">
|
|
2217
|
+
<input type="checkbox" className="size-4" readOnly checked={isSel} />
|
|
2218
|
+
<span>{opt.label}</span>
|
|
2219
|
+
</span>
|
|
2220
|
+
</button>
|
|
2221
|
+
)
|
|
2222
|
+
})}
|
|
2223
|
+
{!filtered.length ? (
|
|
2224
|
+
<div className="px-3 py-2 text-sm text-muted-foreground">{noMatchesLabel}</div>
|
|
2225
|
+
) : null}
|
|
2226
|
+
</div>
|
|
2227
|
+
</div>
|
|
2228
|
+
)
|
|
2229
|
+
})
|
|
2230
|
+
|
|
2231
|
+
const FieldControl = React.memo(function FieldControlImpl({
|
|
2232
|
+
field,
|
|
2233
|
+
value,
|
|
2234
|
+
error,
|
|
2235
|
+
options,
|
|
2236
|
+
setValue,
|
|
2237
|
+
values,
|
|
2238
|
+
loadFieldOptions,
|
|
2239
|
+
autoFocus,
|
|
2240
|
+
onSubmitRequest,
|
|
2241
|
+
wrapperClassName,
|
|
2242
|
+
entityIdForField,
|
|
2243
|
+
recordId,
|
|
2244
|
+
}: FieldControlProps) {
|
|
2245
|
+
const t = useT()
|
|
2246
|
+
const fieldSetValue = React.useCallback(
|
|
2247
|
+
(nextValue: unknown) => setValue(field.id, nextValue),
|
|
2248
|
+
[setValue, field.id]
|
|
2249
|
+
)
|
|
2250
|
+
const setFormValue = React.useCallback(
|
|
2251
|
+
(targetId: string, nextValue: unknown) => setValue(targetId, nextValue),
|
|
2252
|
+
[setValue],
|
|
2253
|
+
)
|
|
2254
|
+
const builtin = field.type === 'custom' ? null : field
|
|
2255
|
+
const hasLoader = typeof builtin?.loadOptions === 'function'
|
|
2256
|
+
const disabled = Boolean(field.disabled)
|
|
2257
|
+
const autoFocusField = autoFocus && !disabled
|
|
2258
|
+
|
|
2259
|
+
React.useEffect(() => {
|
|
2260
|
+
if (!hasLoader || field.type === 'custom') return
|
|
2261
|
+
loadFieldOptions(field).catch(() => {})
|
|
2262
|
+
}, [field, hasLoader, loadFieldOptions])
|
|
2263
|
+
|
|
2264
|
+
const placeholder = builtin?.placeholder
|
|
2265
|
+
const rootClassName = wrapperClassName ? `space-y-1 ${wrapperClassName}` : 'space-y-1'
|
|
2266
|
+
|
|
2267
|
+
return (
|
|
2268
|
+
<div className={rootClassName} data-crud-field-id={field.id}>
|
|
2269
|
+
{field.type !== 'checkbox' && field.label.trim().length > 0 ? (
|
|
2270
|
+
<label className="block text-sm font-medium">
|
|
2271
|
+
{field.label}
|
|
2272
|
+
{field.required ? <span className="text-red-600"> *</span> : null}
|
|
2273
|
+
</label>
|
|
2274
|
+
) : null}
|
|
2275
|
+
{field.type === 'text' && (
|
|
2276
|
+
<TextInput
|
|
2277
|
+
value={value == null ? '' : String(value)}
|
|
2278
|
+
placeholder={placeholder}
|
|
2279
|
+
onChange={(next) => fieldSetValue(next)}
|
|
2280
|
+
autoFocus={autoFocusField}
|
|
2281
|
+
onSubmit={onSubmitRequest}
|
|
2282
|
+
disabled={disabled}
|
|
2283
|
+
suggestions={field.type === 'text' ? field.suggestions : undefined}
|
|
2284
|
+
/>
|
|
2285
|
+
)}
|
|
2286
|
+
{field.type === 'number' && (
|
|
2287
|
+
<NumberInput
|
|
2288
|
+
value={typeof value === 'number' || typeof value === 'string' ? value : null}
|
|
2289
|
+
placeholder={placeholder}
|
|
2290
|
+
onChange={fieldSetValue}
|
|
2291
|
+
autoFocus={autoFocusField}
|
|
2292
|
+
onSubmit={onSubmitRequest}
|
|
2293
|
+
/>
|
|
2294
|
+
)}
|
|
2295
|
+
{field.type === 'date' && (
|
|
2296
|
+
<input
|
|
2297
|
+
type="date"
|
|
2298
|
+
className="w-full h-9 rounded border px-2 text-sm"
|
|
2299
|
+
value={typeof value === 'string' ? value : ''}
|
|
2300
|
+
onChange={(e) => setValue(field.id, e.target.value || undefined)}
|
|
2301
|
+
autoFocus={autoFocusField}
|
|
2302
|
+
data-crud-focus-target=""
|
|
2303
|
+
disabled={disabled}
|
|
2304
|
+
/>
|
|
2305
|
+
)}
|
|
2306
|
+
{field.type === 'datetime-local' && (
|
|
2307
|
+
<input
|
|
2308
|
+
type="datetime-local"
|
|
2309
|
+
className="w-full h-9 rounded border px-2 text-sm"
|
|
2310
|
+
value={typeof value === 'string' ? value : ''}
|
|
2311
|
+
onChange={(e) => setValue(field.id, e.target.value || undefined)}
|
|
2312
|
+
autoFocus={autoFocusField}
|
|
2313
|
+
data-crud-focus-target=""
|
|
2314
|
+
disabled={disabled}
|
|
2315
|
+
/>
|
|
2316
|
+
)}
|
|
2317
|
+
{field.type === 'textarea' && (
|
|
2318
|
+
<TextAreaInput
|
|
2319
|
+
value={value == null ? '' : String(value)}
|
|
2320
|
+
placeholder={placeholder}
|
|
2321
|
+
onChange={(next) => fieldSetValue(next)}
|
|
2322
|
+
autoFocus={autoFocusField}
|
|
2323
|
+
/>
|
|
2324
|
+
)}
|
|
2325
|
+
{field.type === 'richtext' && builtin?.editor === 'simple' && (
|
|
2326
|
+
<SimpleMarkdownEditor value={String(value ?? '')} onChange={fieldSetValue} />
|
|
2327
|
+
)}
|
|
2328
|
+
{field.type === 'richtext' && builtin?.editor === 'html' && (
|
|
2329
|
+
<HtmlRichTextEditor value={String(value ?? '')} onChange={fieldSetValue} />
|
|
2330
|
+
)}
|
|
2331
|
+
{field.type === 'richtext' && (!builtin?.editor || (builtin.editor !== 'simple' && builtin.editor !== 'html')) && (
|
|
2332
|
+
<MarkdownEditor value={String(value ?? '')} onChange={fieldSetValue} />
|
|
2333
|
+
)}
|
|
2334
|
+
{field.type === 'tags' && (
|
|
2335
|
+
<TagsInput
|
|
2336
|
+
value={Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []}
|
|
2337
|
+
onChange={(next) => fieldSetValue(next)}
|
|
2338
|
+
placeholder={placeholder}
|
|
2339
|
+
autoFocus={autoFocusField}
|
|
2340
|
+
suggestions={options.map((opt) => opt.label)}
|
|
2341
|
+
loadSuggestions={
|
|
2342
|
+
typeof builtin?.loadOptions === 'function'
|
|
2343
|
+
? async (query?: string) => {
|
|
2344
|
+
const opts = await loadFieldOptions(field, query)
|
|
2345
|
+
return opts.map((opt) => opt.label)
|
|
2346
|
+
}
|
|
2347
|
+
: undefined
|
|
2348
|
+
}
|
|
2349
|
+
/>
|
|
2350
|
+
)}
|
|
2351
|
+
{field.type === 'combobox' && (
|
|
2352
|
+
<ComboboxInput
|
|
2353
|
+
value={typeof value === 'string' ? value : String(value ?? '')}
|
|
2354
|
+
onChange={(next) => fieldSetValue(next)}
|
|
2355
|
+
placeholder={placeholder}
|
|
2356
|
+
autoFocus={autoFocusField}
|
|
2357
|
+
suggestions={
|
|
2358
|
+
builtin?.suggestions
|
|
2359
|
+
? builtin.suggestions
|
|
2360
|
+
: options.map((opt) => ({ value: opt.value, label: opt.label }))
|
|
2361
|
+
}
|
|
2362
|
+
loadSuggestions={
|
|
2363
|
+
typeof builtin?.loadOptions === 'function'
|
|
2364
|
+
? async (query?: string) => {
|
|
2365
|
+
const opts = await loadFieldOptions(field, query)
|
|
2366
|
+
return opts.map((opt) => ({ value: opt.value, label: opt.label }))
|
|
2367
|
+
}
|
|
2368
|
+
: undefined
|
|
2369
|
+
}
|
|
2370
|
+
allowCustomValues={builtin?.allowCustomValues ?? true}
|
|
2371
|
+
disabled={disabled}
|
|
2372
|
+
/>
|
|
2373
|
+
)}
|
|
2374
|
+
{field.type === 'checkbox' && (
|
|
2375
|
+
<label className="inline-flex items-center gap-2">
|
|
2376
|
+
<input
|
|
2377
|
+
type="checkbox"
|
|
2378
|
+
className="size-4"
|
|
2379
|
+
checked={value === true}
|
|
2380
|
+
onChange={(e) => setValue(field.id, e.target.checked)}
|
|
2381
|
+
data-crud-focus-target=""
|
|
2382
|
+
disabled={disabled}
|
|
2383
|
+
/>
|
|
2384
|
+
<span className="text-sm">{field.label}</span>
|
|
2385
|
+
</label>
|
|
2386
|
+
)}
|
|
2387
|
+
{field.type === 'select' && !builtin?.multiple && (
|
|
2388
|
+
<select
|
|
2389
|
+
className="w-full h-9 rounded border px-2 text-sm"
|
|
2390
|
+
value={
|
|
2391
|
+
Array.isArray(value)
|
|
2392
|
+
? String(value[0] ?? '')
|
|
2393
|
+
: value == null
|
|
2394
|
+
? ''
|
|
2395
|
+
: String(value)
|
|
2396
|
+
}
|
|
2397
|
+
onChange={(e) => setValue(field.id, e.target.value || undefined)}
|
|
2398
|
+
data-crud-focus-target=""
|
|
2399
|
+
disabled={disabled}
|
|
2400
|
+
>
|
|
2401
|
+
<option value="">{t('ui.forms.select.emptyOption', '—')}</option>
|
|
2402
|
+
{options.map((opt) => (
|
|
2403
|
+
<option key={opt.value} value={opt.value}>
|
|
2404
|
+
{opt.label}
|
|
2405
|
+
</option>
|
|
2406
|
+
))}
|
|
2407
|
+
</select>
|
|
2408
|
+
)}
|
|
2409
|
+
{field.type === 'select' && builtin?.multiple && builtin.listbox === true && (
|
|
2410
|
+
<ListboxMultiSelect
|
|
2411
|
+
options={options}
|
|
2412
|
+
placeholder={placeholder}
|
|
2413
|
+
value={Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []}
|
|
2414
|
+
onChange={(vals) => setValue(field.id, vals)}
|
|
2415
|
+
autoFocus={autoFocusField}
|
|
2416
|
+
/>
|
|
2417
|
+
)}
|
|
2418
|
+
{field.type === 'select' && builtin?.multiple && builtin.listbox !== true && (
|
|
2419
|
+
<div className="flex flex-wrap gap-3">
|
|
2420
|
+
{options.map((opt) => {
|
|
2421
|
+
const arr = Array.isArray(value)
|
|
2422
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
2423
|
+
: []
|
|
2424
|
+
const checked = arr.includes(opt.value)
|
|
2425
|
+
return (
|
|
2426
|
+
<label key={opt.value} className="inline-flex items-center gap-2">
|
|
2427
|
+
<input
|
|
2428
|
+
type="checkbox"
|
|
2429
|
+
className="size-4"
|
|
2430
|
+
checked={checked}
|
|
2431
|
+
onChange={(e) => {
|
|
2432
|
+
const next = new Set(arr)
|
|
2433
|
+
if (e.target.checked) {
|
|
2434
|
+
next.add(opt.value)
|
|
2435
|
+
} else {
|
|
2436
|
+
next.delete(opt.value)
|
|
2437
|
+
}
|
|
2438
|
+
setValue(field.id, Array.from(next))
|
|
2439
|
+
}}
|
|
2440
|
+
disabled={disabled}
|
|
2441
|
+
/>
|
|
2442
|
+
<span className="text-sm">{opt.label}</span>
|
|
2443
|
+
</label>
|
|
2444
|
+
)
|
|
2445
|
+
})}
|
|
2446
|
+
</div>
|
|
2447
|
+
)}
|
|
2448
|
+
{field.type === 'relation' && (
|
|
2449
|
+
<RelationSelect
|
|
2450
|
+
options={options}
|
|
2451
|
+
placeholder={placeholder}
|
|
2452
|
+
value={
|
|
2453
|
+
Array.isArray(value)
|
|
2454
|
+
? String(value[0] ?? '')
|
|
2455
|
+
: value == null
|
|
2456
|
+
? ''
|
|
2457
|
+
: String(value)
|
|
2458
|
+
}
|
|
2459
|
+
onChange={(selected) => setValue(field.id, selected)}
|
|
2460
|
+
autoFocus={autoFocusField}
|
|
2461
|
+
/>
|
|
2462
|
+
)}
|
|
2463
|
+
{field.type === 'custom' && (
|
|
2464
|
+
<>
|
|
2465
|
+
{field.component({
|
|
2466
|
+
id: field.id,
|
|
2467
|
+
value,
|
|
2468
|
+
error,
|
|
2469
|
+
setValue: fieldSetValue,
|
|
2470
|
+
setFormValue,
|
|
2471
|
+
values,
|
|
2472
|
+
entityId: entityIdForField,
|
|
2473
|
+
recordId,
|
|
2474
|
+
autoFocus,
|
|
2475
|
+
disabled,
|
|
2476
|
+
})}
|
|
2477
|
+
</>
|
|
2478
|
+
)}
|
|
2479
|
+
{field.description ? (
|
|
2480
|
+
<div className="text-xs text-muted-foreground">{field.description}</div>
|
|
2481
|
+
) : null}
|
|
2482
|
+
{error ? <div className="text-xs text-red-600">{error}</div> : null}
|
|
2483
|
+
</div>
|
|
2484
|
+
)
|
|
2485
|
+
},
|
|
2486
|
+
(prev, next) =>
|
|
2487
|
+
prev.field.id === next.field.id &&
|
|
2488
|
+
prev.field.type === next.field.type &&
|
|
2489
|
+
prev.field.label === next.field.label &&
|
|
2490
|
+
prev.field.required === next.field.required &&
|
|
2491
|
+
prev.value === next.value &&
|
|
2492
|
+
prev.error === next.error &&
|
|
2493
|
+
prev.options === next.options &&
|
|
2494
|
+
prev.loadFieldOptions === next.loadFieldOptions &&
|
|
2495
|
+
prev.autoFocus === next.autoFocus &&
|
|
2496
|
+
prev.onSubmitRequest === next.onSubmitRequest &&
|
|
2497
|
+
prev.wrapperClassName === next.wrapperClassName &&
|
|
2498
|
+
prev.entityIdForField === next.entityIdForField &&
|
|
2499
|
+
prev.recordId === next.recordId &&
|
|
2500
|
+
(prev.field.type !== 'custom' ||
|
|
2501
|
+
(prev.values === next.values &&
|
|
2502
|
+
prev.field.component === (next.field as CrudCustomField).component))
|
|
2503
|
+
)
|