@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
- package/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -29,12 +29,12 @@ import {
|
|
|
29
29
|
SelectValue,
|
|
30
30
|
} from '../primitives/select'
|
|
31
31
|
import { flash } from './FlashMessages'
|
|
32
|
-
import dynamic from 'next/dynamic'
|
|
33
32
|
import { FormHeader } from './forms/FormHeader'
|
|
34
33
|
import { FormFooter } from './forms/FormFooter'
|
|
35
34
|
import { Button } from '../primitives/button'
|
|
36
35
|
import { IconButton } from '../primitives/icon-button'
|
|
37
36
|
import {
|
|
37
|
+
Check,
|
|
38
38
|
Settings,
|
|
39
39
|
Layers,
|
|
40
40
|
Tag,
|
|
@@ -85,8 +85,9 @@ import { TimePicker } from './inputs/TimePicker'
|
|
|
85
85
|
import { DatePicker } from './inputs/DatePicker'
|
|
86
86
|
import { mapCrudServerErrorToFormErrors, parseServerMessage } from './utils/serverErrors'
|
|
87
87
|
import { withScopedApiRequestHeaders } from './utils/apiCall'
|
|
88
|
+
import { buildOptimisticLockHeader, extractOptimisticLockConflict } from './utils/optimisticLock'
|
|
89
|
+
import { surfaceRecordConflict } from './conflicts'
|
|
88
90
|
import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
|
|
89
|
-
import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
|
|
90
91
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
|
|
91
92
|
import { FieldDefinitionsManager, type FieldDefinitionsManagerHandle } from './custom-fields/FieldDefinitionsManager'
|
|
92
93
|
import { useConfirmDialog } from './confirm-dialog'
|
|
@@ -104,6 +105,7 @@ import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/share
|
|
|
104
105
|
import { evaluateInjectedVisibility } from './injection/visibility-utils'
|
|
105
106
|
import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
106
107
|
import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
|
|
108
|
+
import MarkdownField from './inputs/MarkdownField'
|
|
107
109
|
|
|
108
110
|
// Stable empty options array to avoid creating a new [] every render
|
|
109
111
|
const EMPTY_OPTIONS: CrudFieldOption[] = []
|
|
@@ -307,6 +309,30 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
307
309
|
onDelete?: () => Promise<void> | void
|
|
308
310
|
// When true, shows Delete button whenever onDelete is provided, even without an id
|
|
309
311
|
deleteVisible?: boolean
|
|
312
|
+
/**
|
|
313
|
+
* OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
314
|
+
*
|
|
315
|
+
* When set to a non-empty ISO-8601 string (typically `record.updatedAt`
|
|
316
|
+
* from the API response), the form auto-injects the
|
|
317
|
+
* `x-om-ext-optimistic-lock-expected-updated-at` extension header on
|
|
318
|
+
* every PUT / PATCH / DELETE issued through the underlying `onSubmit` /
|
|
319
|
+
* `onDelete` callbacks (via `withScopedApiRequestHeaders`). When the
|
|
320
|
+
* server-side guard is opted in for the resource and the timestamp is
|
|
321
|
+
* stale, the API responds with a structured 409 conflict body.
|
|
322
|
+
*
|
|
323
|
+
* Strictly additive: when the prop is absent / null / empty, the form
|
|
324
|
+
* behaves exactly as before (no header injected).
|
|
325
|
+
*/
|
|
326
|
+
optimisticLockUpdatedAt?: string | null
|
|
327
|
+
/**
|
|
328
|
+
* Opt out of optimistic locking entirely for this form. When `true`, the
|
|
329
|
+
* extension header is never attached, regardless of `optimisticLockUpdatedAt`
|
|
330
|
+
* or the value auto-derived from `initialValues.updatedAt`. Use for forms
|
|
331
|
+
* whose locking is owned elsewhere (e.g. sales document sub-resources guarded
|
|
332
|
+
* at the command layer against the parent aggregate) or for entities without
|
|
333
|
+
* an `updated_at` column.
|
|
334
|
+
*/
|
|
335
|
+
disableOptimisticLock?: boolean
|
|
310
336
|
// Legacy field-only grid toggle. Use `groups` for advanced layout.
|
|
311
337
|
twoColumn?: boolean
|
|
312
338
|
title?: string
|
|
@@ -407,6 +433,62 @@ function readByDotPath(source: Record<string, unknown> | undefined, path: string
|
|
|
407
433
|
return current
|
|
408
434
|
}
|
|
409
435
|
|
|
436
|
+
// Keys that would mutate the prototype chain if used as dot-path segments.
|
|
437
|
+
// Guard against them so a declared field id such as `__proto__.polluted` can
|
|
438
|
+
// never reach into Object.prototype (prototype-pollution hardening).
|
|
439
|
+
const PROTO_POLLUTING_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
440
|
+
|
|
441
|
+
function isProtoPollutingKey(key: string): boolean {
|
|
442
|
+
return PROTO_POLLUTING_KEYS.has(key)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function writeByDotPath(target: Record<string, unknown>, path: string, value: unknown): void {
|
|
446
|
+
const segments = path.split('.').filter(Boolean)
|
|
447
|
+
if (segments.length === 0) return
|
|
448
|
+
if (segments.some(isProtoPollutingKey)) return
|
|
449
|
+
let current: Record<string, unknown> = target
|
|
450
|
+
for (let index = 0; index < segments.length - 1; index++) {
|
|
451
|
+
const segment = segments[index]
|
|
452
|
+
const existing = current[segment]
|
|
453
|
+
const isSafeObject =
|
|
454
|
+
!!existing &&
|
|
455
|
+
typeof existing === 'object' &&
|
|
456
|
+
!Array.isArray(existing) &&
|
|
457
|
+
(Object.getPrototypeOf(existing) === Object.prototype || Object.getPrototypeOf(existing) === null)
|
|
458
|
+
|
|
459
|
+
if (!isSafeObject) {
|
|
460
|
+
const created = Object.create(null) as Record<string, unknown>
|
|
461
|
+
current[segment] = created
|
|
462
|
+
current = created
|
|
463
|
+
} else {
|
|
464
|
+
current = existing as Record<string, unknown>
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const finalSegment = segments[segments.length - 1]
|
|
468
|
+
if (isProtoPollutingKey(finalSegment)) return
|
|
469
|
+
current[finalSegment] = value
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Collapse flat dot-path keys (e.g. `metadata.category`) declared as base form
|
|
473
|
+
// fields into the nested object the schema expects (`{ metadata: { category } }`).
|
|
474
|
+
// The flat keys are preserved alongside the nested projection so that forms whose
|
|
475
|
+
// schema declares the flat keys directly keep working — CrudForm's non-strict
|
|
476
|
+
// schema.safeParse retains whichever representation the schema declares and strips
|
|
477
|
+
// the other. See issue #2503 ("Latent framework gap").
|
|
478
|
+
function collapseDotPathFields(
|
|
479
|
+
source: Record<string, unknown>,
|
|
480
|
+
dotPathFieldIds: ReadonlySet<string>,
|
|
481
|
+
): Record<string, unknown> {
|
|
482
|
+
if (!dotPathFieldIds.size) return source
|
|
483
|
+
let result: Record<string, unknown> | null = null
|
|
484
|
+
for (const fieldId of dotPathFieldIds) {
|
|
485
|
+
if (!Object.prototype.hasOwnProperty.call(source, fieldId)) continue
|
|
486
|
+
if (!result) result = { ...source }
|
|
487
|
+
writeByDotPath(result, fieldId, source[fieldId])
|
|
488
|
+
}
|
|
489
|
+
return result ?? source
|
|
490
|
+
}
|
|
491
|
+
|
|
410
492
|
function readInitialCustomFieldValue(
|
|
411
493
|
source: Record<string, unknown> | undefined,
|
|
412
494
|
key: string,
|
|
@@ -451,6 +533,36 @@ function serializeIssuePath(path: ReadonlyArray<string | number | symbol>): stri
|
|
|
451
533
|
return segments.length > 0 ? segments.join('.') : null
|
|
452
534
|
}
|
|
453
535
|
|
|
536
|
+
function normalizeDirtySnapshotValue(value: unknown): unknown {
|
|
537
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
538
|
+
if (Array.isArray(value)) {
|
|
539
|
+
const normalized = value
|
|
540
|
+
.map((entry) => normalizeDirtySnapshotValue(entry))
|
|
541
|
+
.filter((entry) => entry !== undefined)
|
|
542
|
+
return normalized.length ? normalized : undefined
|
|
543
|
+
}
|
|
544
|
+
if (typeof value !== 'object') return value
|
|
545
|
+
|
|
546
|
+
const prototype = Object.getPrototypeOf(value)
|
|
547
|
+
if (prototype !== Object.prototype && prototype !== null) return value
|
|
548
|
+
|
|
549
|
+
const normalized: Record<string, unknown> = {}
|
|
550
|
+
const record = value as Record<string, unknown>
|
|
551
|
+
for (const key of Object.keys(record).sort()) {
|
|
552
|
+
const nextValue = normalizeDirtySnapshotValue(record[key])
|
|
553
|
+
if (nextValue !== undefined) normalized[key] = nextValue
|
|
554
|
+
}
|
|
555
|
+
return Object.keys(normalized).length ? normalized : undefined
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function createDirtySnapshot(source: Record<string, unknown>): string {
|
|
559
|
+
return JSON.stringify(normalizeDirtySnapshotValue(source) ?? {})
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function createDirtyValueSnapshot(value: unknown): string {
|
|
563
|
+
return JSON.stringify(normalizeDirtySnapshotValue(value) ?? null)
|
|
564
|
+
}
|
|
565
|
+
|
|
454
566
|
const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
455
567
|
layers: Layers,
|
|
456
568
|
tag: Tag,
|
|
@@ -583,6 +695,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
583
695
|
onSubmit,
|
|
584
696
|
onDelete,
|
|
585
697
|
deleteVisible,
|
|
698
|
+
optimisticLockUpdatedAt,
|
|
699
|
+
disableOptimisticLock = false,
|
|
586
700
|
twoColumn = false,
|
|
587
701
|
title,
|
|
588
702
|
backHref,
|
|
@@ -706,6 +820,28 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
706
820
|
)
|
|
707
821
|
|
|
708
822
|
const operation = recordId ? 'update' : 'create'
|
|
823
|
+
|
|
824
|
+
// Resolve the optimistic-lock version used to build the extension header.
|
|
825
|
+
// Explicit `optimisticLockUpdatedAt` (including `null`) always wins. When the
|
|
826
|
+
// prop is absent, auto-derive from the loaded record's `updatedAt`/`updated_at`
|
|
827
|
+
// so every edit CrudForm enforces optimistic locking by default without
|
|
828
|
+
// per-form wiring. Presence of a loaded `updatedAt` — NOT the create/update
|
|
829
|
+
// heuristic — drives this: some edit pages keep the record id outside form
|
|
830
|
+
// values (so `operation` reads as `create`), while a genuine create has no
|
|
831
|
+
// `updatedAt` in `initialValues`. Even a "duplicate"-style create that carries
|
|
832
|
+
// one is harmless — the server ignores the header on inserts (no candidate id).
|
|
833
|
+
// `disableOptimisticLock` always wins; an explicit prop (incl. `null`) wins next.
|
|
834
|
+
const resolvedOptimisticLockUpdatedAt = React.useMemo<string | null | undefined>(() => {
|
|
835
|
+
if (disableOptimisticLock) return null
|
|
836
|
+
if (optimisticLockUpdatedAt !== undefined) return optimisticLockUpdatedAt
|
|
837
|
+
const source = initialValues as Record<string, unknown> | undefined
|
|
838
|
+
const camel = source?.updatedAt
|
|
839
|
+
if (typeof camel === 'string' && camel.length > 0) return camel
|
|
840
|
+
const snake = source?.updated_at
|
|
841
|
+
if (typeof snake === 'string' && snake.length > 0) return snake
|
|
842
|
+
return null
|
|
843
|
+
}, [disableOptimisticLock, optimisticLockUpdatedAt, initialValues])
|
|
844
|
+
|
|
709
845
|
const injectionContext = React.useMemo(() => ({
|
|
710
846
|
formId,
|
|
711
847
|
entityId: primaryEntityId,
|
|
@@ -750,7 +886,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
750
886
|
setHasUnsavedChanges(false)
|
|
751
887
|
return
|
|
752
888
|
}
|
|
753
|
-
const currentSnapshot =
|
|
889
|
+
const currentSnapshot = createDirtySnapshot(values as Record<string, unknown>)
|
|
754
890
|
const dirty = currentSnapshot !== snapshot
|
|
755
891
|
isDirtyRef.current = dirty
|
|
756
892
|
setHasUnsavedChanges(dirty)
|
|
@@ -797,7 +933,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
797
933
|
|
|
798
934
|
const clearDirtyState = React.useCallback((snapshotSource?: Record<string, unknown>) => {
|
|
799
935
|
const source = snapshotSource ?? (valuesRef.current as Record<string, unknown>)
|
|
800
|
-
dirtyBaselineSnapshotRef.current =
|
|
936
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(source)
|
|
937
|
+
dirtyBaselineValuesRef.current = { ...source }
|
|
938
|
+
userEditedFieldIdsRef.current.clear()
|
|
801
939
|
isDirtyRef.current = false
|
|
802
940
|
setHasUnsavedChanges(false)
|
|
803
941
|
}, [])
|
|
@@ -842,7 +980,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
842
980
|
if (!target) return
|
|
843
981
|
if (shouldBypassUnsavedChangesGuardRef.current?.(target)) return
|
|
844
982
|
const baselineSnapshot = dirtyBaselineSnapshotRef.current
|
|
845
|
-
if (baselineSnapshot &&
|
|
983
|
+
if (baselineSnapshot && createDirtySnapshot(valuesRef.current as Record<string, unknown>) === baselineSnapshot) {
|
|
846
984
|
isDirtyRef.current = false
|
|
847
985
|
setHasUnsavedChanges(false)
|
|
848
986
|
return
|
|
@@ -874,7 +1012,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
874
1012
|
return original(data, unused, url)
|
|
875
1013
|
}
|
|
876
1014
|
const baselineSnapshot = dirtyBaselineSnapshotRef.current
|
|
877
|
-
if (baselineSnapshot &&
|
|
1015
|
+
if (baselineSnapshot && createDirtySnapshot(valuesRef.current as Record<string, unknown>) === baselineSnapshot) {
|
|
878
1016
|
isDirtyRef.current = false
|
|
879
1017
|
setHasUnsavedChanges(false)
|
|
880
1018
|
return original(data, unused, url)
|
|
@@ -1183,8 +1321,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1183
1321
|
}
|
|
1184
1322
|
}
|
|
1185
1323
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1324
|
+
const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
|
|
1325
|
+
const mergedDeleteHeaders = {
|
|
1326
|
+
...(injectionRequestHeaders ?? {}),
|
|
1327
|
+
...optimisticLockHeader,
|
|
1328
|
+
}
|
|
1329
|
+
if (Object.keys(mergedDeleteHeaders).length > 0) {
|
|
1330
|
+
await withScopedApiRequestHeaders(mergedDeleteHeaders, async () => {
|
|
1188
1331
|
await onDelete()
|
|
1189
1332
|
})
|
|
1190
1333
|
} else {
|
|
@@ -1228,8 +1371,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1228
1371
|
} catch {
|
|
1229
1372
|
// ignore event dispatch failures
|
|
1230
1373
|
}
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1374
|
+
const optimisticLockConflict = extractOptimisticLockConflict(err)
|
|
1375
|
+
if (optimisticLockConflict) {
|
|
1376
|
+
// Surface on the unified, persistent conflict bar instead of a toast.
|
|
1377
|
+
try { surfaceRecordConflict(err, t) } catch {}
|
|
1378
|
+
} else {
|
|
1379
|
+
const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
|
|
1380
|
+
try { flash(message, 'error') } catch {}
|
|
1381
|
+
}
|
|
1233
1382
|
} finally {
|
|
1234
1383
|
deletingRef.current = false
|
|
1235
1384
|
setPending(false)
|
|
@@ -1634,6 +1783,25 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1634
1783
|
return new globalThis.Map(allFields.map((f) => [f.id, f]))
|
|
1635
1784
|
}, [allFields])
|
|
1636
1785
|
|
|
1786
|
+
// Declared base fields whose id is a dot-path (e.g. `metadata.category`).
|
|
1787
|
+
// Injected and custom (cf) fields already get dot-path/cf-path hydration and
|
|
1788
|
+
// are excluded here so their dedicated handling is preserved. CrudForm hydrates
|
|
1789
|
+
// these flat keys from nested initial values on load and projects them back into
|
|
1790
|
+
// nested objects before schema.safeParse on submit. See issue #2503.
|
|
1791
|
+
const dotPathBaseFieldIds = React.useMemo(() => {
|
|
1792
|
+
const ids = new Set<string>()
|
|
1793
|
+
const cfFieldIds = new Set(cfFields.map((field) => field.id))
|
|
1794
|
+
for (const field of allFields) {
|
|
1795
|
+
const fieldId = field.id
|
|
1796
|
+
if (!fieldId.includes('.')) continue
|
|
1797
|
+
if (injectedFieldIdSet.has(fieldId)) continue
|
|
1798
|
+
if (fieldId.startsWith('cf_') || fieldId.startsWith('cf:')) continue
|
|
1799
|
+
if (cfFieldIds.has(fieldId)) continue
|
|
1800
|
+
ids.add(fieldId)
|
|
1801
|
+
}
|
|
1802
|
+
return ids
|
|
1803
|
+
}, [allFields, injectedFieldIdSet, cfFields])
|
|
1804
|
+
|
|
1637
1805
|
const validateFieldOnBlur = React.useCallback(async (
|
|
1638
1806
|
fieldId: string,
|
|
1639
1807
|
sourceValues?: CrudFormValues<TValues>,
|
|
@@ -1706,7 +1874,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1706
1874
|
for (const injectedId of injectedFieldIdSet) {
|
|
1707
1875
|
delete coreValues[injectedId]
|
|
1708
1876
|
}
|
|
1709
|
-
const result = schema.safeParse(coreValues)
|
|
1877
|
+
const result = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
|
|
1710
1878
|
if (!result.success) {
|
|
1711
1879
|
const schemaFieldErrors: Record<string, string> = {}
|
|
1712
1880
|
result.error.issues.forEach((issue) => {
|
|
@@ -1746,6 +1914,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1746
1914
|
}, [
|
|
1747
1915
|
cfDefinitions,
|
|
1748
1916
|
customEntity,
|
|
1917
|
+
dotPathBaseFieldIds,
|
|
1749
1918
|
fieldById,
|
|
1750
1919
|
formReadOnly,
|
|
1751
1920
|
hiddenBaseFieldIds,
|
|
@@ -2045,14 +2214,46 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2045
2214
|
}
|
|
2046
2215
|
}, [errors, formId])
|
|
2047
2216
|
|
|
2217
|
+
const updateEditedFieldMarker = React.useCallback((
|
|
2218
|
+
id: string,
|
|
2219
|
+
nextValue: unknown,
|
|
2220
|
+
baselineSource: Record<string, unknown> | undefined = dirtyBaselineValuesRef.current,
|
|
2221
|
+
) => {
|
|
2222
|
+
if (
|
|
2223
|
+
baselineSource &&
|
|
2224
|
+
createDirtyValueSnapshot(baselineSource[id]) === createDirtyValueSnapshot(nextValue)
|
|
2225
|
+
) {
|
|
2226
|
+
userEditedFieldIdsRef.current.delete(id)
|
|
2227
|
+
return
|
|
2228
|
+
}
|
|
2229
|
+
userEditedFieldIdsRef.current.add(id)
|
|
2230
|
+
}, [])
|
|
2231
|
+
|
|
2048
2232
|
const setValue = React.useCallback((id: string, nextValue: unknown) => {
|
|
2049
2233
|
let nextData: CrudFormValues<TValues> | null = null
|
|
2234
|
+
let nextDirty: boolean | null = null
|
|
2235
|
+
const currentValue = (valuesRef.current as Record<string, unknown>)[id]
|
|
2236
|
+
if (!Object.is(currentValue, nextValue)) {
|
|
2237
|
+
updateEditedFieldMarker(id, nextValue)
|
|
2238
|
+
}
|
|
2050
2239
|
setValues((prev) => {
|
|
2051
2240
|
if (Object.is(prev[id], nextValue)) return prev
|
|
2241
|
+
const baselineSource = dirtyBaselineValuesRef.current ?? (prev as Record<string, unknown>)
|
|
2242
|
+
updateEditedFieldMarker(id, nextValue, baselineSource)
|
|
2052
2243
|
nextData = { ...prev, [id]: nextValue } as CrudFormValues<TValues>
|
|
2053
2244
|
valuesRef.current = nextData
|
|
2245
|
+
if (!(embedded && !trackDirtyWhenEmbedded)) {
|
|
2246
|
+
const baseline = dirtyBaselineSnapshotRef.current ?? createDirtySnapshot(prev as Record<string, unknown>)
|
|
2247
|
+
dirtyBaselineSnapshotRef.current = baseline
|
|
2248
|
+
dirtyBaselineValuesRef.current = baselineSource
|
|
2249
|
+
nextDirty = createDirtySnapshot(nextData as Record<string, unknown>) !== baseline
|
|
2250
|
+
}
|
|
2054
2251
|
return nextData
|
|
2055
2252
|
})
|
|
2253
|
+
if (nextDirty !== null) {
|
|
2254
|
+
isDirtyRef.current = nextDirty
|
|
2255
|
+
setHasUnsavedChanges(nextDirty)
|
|
2256
|
+
}
|
|
2056
2257
|
const clearedMessages: string[] = []
|
|
2057
2258
|
setErrors((prev) => {
|
|
2058
2259
|
if (!Object.keys(prev).length) return prev
|
|
@@ -2117,7 +2318,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2117
2318
|
}).catch((err) => {
|
|
2118
2319
|
console.error('[CrudForm] Error in onFieldChange:', err)
|
|
2119
2320
|
})
|
|
2120
|
-
}, [extendedInjectionEventsEnabled, flash, t, translateValidationMessage, triggerInjectionEvent])
|
|
2321
|
+
}, [embedded, extendedInjectionEventsEnabled, flash, t, trackDirtyWhenEmbedded, translateValidationMessage, triggerInjectionEvent, updateEditedFieldMarker])
|
|
2121
2322
|
|
|
2122
2323
|
const onBlurRequest = React.useCallback((fieldId: string) => {
|
|
2123
2324
|
void validateFieldOnBlur(fieldId)
|
|
@@ -2145,19 +2346,40 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2145
2346
|
|
|
2146
2347
|
const appliedInitialValuesSnapshotRef = React.useRef<string | undefined>(undefined)
|
|
2147
2348
|
const dirtyBaselineSnapshotRef = React.useRef<string | undefined>(undefined)
|
|
2349
|
+
const dirtyBaselineValuesRef = React.useRef<Record<string, unknown> | undefined>(undefined)
|
|
2350
|
+
const userEditedFieldIdsRef = React.useRef<Set<string>>(new Set())
|
|
2148
2351
|
React.useLayoutEffect(() => {
|
|
2149
2352
|
if (!initialValues) return
|
|
2150
2353
|
const snapshot = JSON.stringify({
|
|
2151
2354
|
initialValues,
|
|
2152
2355
|
injectedFieldIds: injectedFieldDefinitions.map((definition) => definition.id),
|
|
2153
2356
|
customFieldMappings: cfDefinitions.map((definition) => definition.key),
|
|
2357
|
+
dotPathBaseFieldIds: Array.from(dotPathBaseFieldIds),
|
|
2154
2358
|
})
|
|
2155
2359
|
if (appliedInitialValuesSnapshotRef.current === snapshot) return
|
|
2156
2360
|
appliedInitialValuesSnapshotRef.current = snapshot
|
|
2157
2361
|
const initialRecord = initialValues as Record<string, unknown>
|
|
2158
2362
|
let mergedValues: CrudFormValues<TValues> | null = null
|
|
2363
|
+
// Whether the user already has unsaved edits relative to the load-time baseline,
|
|
2364
|
+
// computed from `prev` (React's latest committed values) — NOT from isDirtyRef or
|
|
2365
|
+
// valuesRef, which are both updated in post-render effects and therefore still
|
|
2366
|
+
// hold stale values when this layout effect runs in the SAME render that async
|
|
2367
|
+
// custom-field / injected-field definitions arrive. That lag was the residual
|
|
2368
|
+
// race that let the baseline absorb an in-progress edit under load.
|
|
2369
|
+
let hadUnsavedEdits = false
|
|
2159
2370
|
setValues((prev) => {
|
|
2160
|
-
const
|
|
2371
|
+
const priorBaseline = dirtyBaselineSnapshotRef.current
|
|
2372
|
+
const editedFieldIds = userEditedFieldIdsRef.current
|
|
2373
|
+
hadUnsavedEdits =
|
|
2374
|
+
editedFieldIds.size > 0 ||
|
|
2375
|
+
priorBaseline !== undefined &&
|
|
2376
|
+
createDirtySnapshot(prev as Record<string, unknown>) !== priorBaseline
|
|
2377
|
+
const merged = { ...prev } as CrudFormValues<TValues>
|
|
2378
|
+
const mergedRecord = merged as Record<string, unknown>
|
|
2379
|
+
for (const [key, value] of Object.entries(initialValues as Record<string, unknown>)) {
|
|
2380
|
+
if (editedFieldIds.has(key)) continue
|
|
2381
|
+
mergedRecord[key] = value
|
|
2382
|
+
}
|
|
2161
2383
|
for (const definition of injectedFieldDefinitions) {
|
|
2162
2384
|
if (merged[definition.id] !== undefined) continue
|
|
2163
2385
|
const extracted = readByDotPath(initialRecord, definition.id)
|
|
@@ -2173,11 +2395,29 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2173
2395
|
;(merged as Record<string, unknown>)[targetId] = extracted
|
|
2174
2396
|
}
|
|
2175
2397
|
}
|
|
2398
|
+
for (const fieldId of dotPathBaseFieldIds) {
|
|
2399
|
+
if (merged[fieldId] !== undefined) continue
|
|
2400
|
+
const extracted = readByDotPath(initialRecord, fieldId)
|
|
2401
|
+
if (extracted !== undefined) {
|
|
2402
|
+
;(merged as Record<string, unknown>)[fieldId] = extracted
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2176
2405
|
mergedValues = merged
|
|
2177
2406
|
return mergedValues
|
|
2178
2407
|
})
|
|
2179
|
-
if (mergedValues) {
|
|
2180
|
-
|
|
2408
|
+
if (mergedValues && !hadUnsavedEdits) {
|
|
2409
|
+
// Do not absorb an in-progress edit into the pristine baseline. This effect
|
|
2410
|
+
// re-runs whenever the snapshot changes — which includes custom-field
|
|
2411
|
+
// definitions and injected fields loading ASYNCHRONOUSLY after mount. If the
|
|
2412
|
+
// user has already started editing by the time they arrive, recomputing the
|
|
2413
|
+
// baseline from the current (merged) values would set the baseline equal to
|
|
2414
|
+
// the edited values, silently clearing dirty (header Save disables, the edit
|
|
2415
|
+
// looks pristine) and discarding the unsaved change. Re-establish the baseline
|
|
2416
|
+
// only when there are no pending edits; while dirty, keep the load-time
|
|
2417
|
+
// baseline so the edit stays dirty until save/discard. Root cause of the flaky
|
|
2418
|
+
// optimistic-lock stale-edit conflicts (#2055 / TC-LOCK-OSS-015 / TC-LOCK-OSS-029).
|
|
2419
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2420
|
+
dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
|
|
2181
2421
|
}
|
|
2182
2422
|
if (!extendedInjectionEventsEnabled || !mergedValues) return
|
|
2183
2423
|
let cancelled = false
|
|
@@ -2190,7 +2430,11 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2190
2430
|
)
|
|
2191
2431
|
const transformed = result.data
|
|
2192
2432
|
if (cancelled || !transformed) return
|
|
2193
|
-
|
|
2433
|
+
// As above: never re-apply transformed display data over an in-progress
|
|
2434
|
+
// edit — it would overwrite the user's unsaved changes and reset dirty.
|
|
2435
|
+
if (isDirtyRef.current) return
|
|
2436
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(transformed as Record<string, unknown>)
|
|
2437
|
+
dirtyBaselineValuesRef.current = { ...(transformed as Record<string, unknown>) }
|
|
2194
2438
|
setValues(transformed as CrudFormValues<TValues>)
|
|
2195
2439
|
} catch (err) {
|
|
2196
2440
|
console.error('[CrudForm] Error in transformDisplayData:', err)
|
|
@@ -2203,6 +2447,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2203
2447
|
}, [
|
|
2204
2448
|
cfDefinitions,
|
|
2205
2449
|
customEntity,
|
|
2450
|
+
dotPathBaseFieldIds,
|
|
2206
2451
|
extendedInjectionEventsEnabled,
|
|
2207
2452
|
initialValues,
|
|
2208
2453
|
injectedFieldDefinitions,
|
|
@@ -2259,7 +2504,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2259
2504
|
|
|
2260
2505
|
// Update the dirty baseline so the form doesn't appear dirty from defaults
|
|
2261
2506
|
if (mergedValues) {
|
|
2262
|
-
dirtyBaselineSnapshotRef.current =
|
|
2507
|
+
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2508
|
+
dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
|
|
2263
2509
|
}
|
|
2264
2510
|
}, [isLoading, initialValuesHasId, cfDefinitions, customEntity])
|
|
2265
2511
|
|
|
@@ -2400,7 +2646,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2400
2646
|
|
|
2401
2647
|
let parsedValues: TValues
|
|
2402
2648
|
if (schema) {
|
|
2403
|
-
const res = schema.safeParse(coreValues)
|
|
2649
|
+
const res = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
|
|
2404
2650
|
if (!res.success) {
|
|
2405
2651
|
const fieldErrors: Record<string, string> = {}
|
|
2406
2652
|
res.error.issues.forEach((issue) => {
|
|
@@ -2430,7 +2676,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2430
2676
|
for (const injectedId of injectedFieldIdSet) {
|
|
2431
2677
|
delete projectedCoreValues[injectedId]
|
|
2432
2678
|
}
|
|
2433
|
-
coreSubmitValues = schema
|
|
2679
|
+
coreSubmitValues = schema
|
|
2680
|
+
? schema.parse(collapseDotPathFields(projectedCoreValues, dotPathBaseFieldIds))
|
|
2681
|
+
: (projectedCoreValues as TValues)
|
|
2434
2682
|
if (result.applyToForm) {
|
|
2435
2683
|
setValues(result.data as CrudFormValues<TValues>)
|
|
2436
2684
|
}
|
|
@@ -2518,8 +2766,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2518
2766
|
|
|
2519
2767
|
try {
|
|
2520
2768
|
submitNavigationBypassRef.current = true
|
|
2521
|
-
|
|
2522
|
-
|
|
2769
|
+
const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
|
|
2770
|
+
const mergedSubmitHeaders = {
|
|
2771
|
+
...(injectionRequestHeaders ?? {}),
|
|
2772
|
+
...optimisticLockHeader,
|
|
2773
|
+
}
|
|
2774
|
+
if (Object.keys(mergedSubmitHeaders).length > 0) {
|
|
2775
|
+
await withScopedApiRequestHeaders(mergedSubmitHeaders, async () => {
|
|
2523
2776
|
await onSubmit?.(coreSubmitValues, submitContext)
|
|
2524
2777
|
})
|
|
2525
2778
|
} else {
|
|
@@ -2577,7 +2830,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2577
2830
|
}
|
|
2578
2831
|
}
|
|
2579
2832
|
|
|
2580
|
-
|
|
2833
|
+
const optimisticLockConflict = extractOptimisticLockConflict(err)
|
|
2834
|
+
let displayMessage = optimisticLockConflict
|
|
2835
|
+
? t('ui.forms.flash.recordModified', 'This record was modified by someone else. Refresh and try again.')
|
|
2836
|
+
: typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
|
|
2581
2837
|
if (hasFieldErrors) {
|
|
2582
2838
|
const lowered = displayMessage.toLowerCase()
|
|
2583
2839
|
const highlightedLower = highlightedMessage.toLowerCase()
|
|
@@ -2592,7 +2848,12 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2592
2848
|
displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
|
|
2593
2849
|
}
|
|
2594
2850
|
displayMessage = parseServerMessage(displayMessage)
|
|
2595
|
-
if (
|
|
2851
|
+
if (optimisticLockConflict) {
|
|
2852
|
+
// Primary surface for the conflict is the persistent, error-styled
|
|
2853
|
+
// RecordConflictBanner (unified across all forms). Keep the inline
|
|
2854
|
+
// form error too, but suppress the redundant transient toast.
|
|
2855
|
+
try { surfaceRecordConflict(err, t) } catch {}
|
|
2856
|
+
} else if (!hasFieldErrors) {
|
|
2596
2857
|
flash(displayMessage, 'error')
|
|
2597
2858
|
}
|
|
2598
2859
|
setFormError(displayMessage)
|
|
@@ -3632,74 +3893,11 @@ function TextAreaInput({
|
|
|
3632
3893
|
)
|
|
3633
3894
|
}
|
|
3634
3895
|
|
|
3635
|
-
// Markdown editor
|
|
3896
|
+
// Markdown editor — WYSIWYG MDXEditor (Lexical) wired to the CrudForm value/onChange contract.
|
|
3636
3897
|
type MDProps = { value?: string; onChange: (md: string) => void }
|
|
3637
|
-
const MDEditor = dynamic(async () => {
|
|
3638
|
-
const mod = await import('@uiw/react-md-editor')
|
|
3639
|
-
return mod.default
|
|
3640
|
-
}, { ssr: false }) as React.ComponentType<UiWMDEditorProps>
|
|
3641
|
-
|
|
3642
|
-
type MarkdownPreviewOptions = NonNullable<UiWMDEditorProps['previewOptions']>
|
|
3643
|
-
|
|
3644
|
-
let markdownPreviewOptionsPromise: Promise<MarkdownPreviewOptions> | null = null
|
|
3645
|
-
|
|
3646
|
-
async function loadMarkdownPreviewOptions(): Promise<MarkdownPreviewOptions> {
|
|
3647
|
-
if (!markdownPreviewOptionsPromise) {
|
|
3648
|
-
markdownPreviewOptionsPromise = import('remark-gfm')
|
|
3649
|
-
.then((mod) => ({ remarkPlugins: [mod.default ?? mod] } as MarkdownPreviewOptions))
|
|
3650
|
-
.catch(() => ({} as MarkdownPreviewOptions))
|
|
3651
|
-
}
|
|
3652
|
-
return markdownPreviewOptionsPromise
|
|
3653
|
-
}
|
|
3654
|
-
|
|
3655
|
-
const EMPTY_PREVIEW_OPTIONS: MarkdownPreviewOptions = {}
|
|
3656
3898
|
|
|
3657
3899
|
const MarkdownEditor = React.memo(function MarkdownEditor({ value = '', onChange }: MDProps) {
|
|
3658
|
-
|
|
3659
|
-
const [local, setLocal] = React.useState<string>(value)
|
|
3660
|
-
const [previewOptions, setPreviewOptions] = React.useState<MarkdownPreviewOptions>(EMPTY_PREVIEW_OPTIONS)
|
|
3661
|
-
const typingRef = React.useRef(false)
|
|
3662
|
-
|
|
3663
|
-
React.useEffect(() => {
|
|
3664
|
-
if (!typingRef.current) setLocal(value)
|
|
3665
|
-
}, [value])
|
|
3666
|
-
|
|
3667
|
-
React.useEffect(() => {
|
|
3668
|
-
let mounted = true
|
|
3669
|
-
void loadMarkdownPreviewOptions().then((resolved) => {
|
|
3670
|
-
if (!mounted) return
|
|
3671
|
-
setPreviewOptions(resolved)
|
|
3672
|
-
})
|
|
3673
|
-
return () => {
|
|
3674
|
-
mounted = false
|
|
3675
|
-
}
|
|
3676
|
-
}, [])
|
|
3677
|
-
|
|
3678
|
-
const handleChange = React.useCallback((v?: string) => {
|
|
3679
|
-
typingRef.current = true
|
|
3680
|
-
setLocal(v ?? '')
|
|
3681
|
-
}, [])
|
|
3682
|
-
|
|
3683
|
-
const commit = React.useCallback(() => {
|
|
3684
|
-
if (!typingRef.current) return
|
|
3685
|
-
typingRef.current = false
|
|
3686
|
-
onChange(local)
|
|
3687
|
-
requestAnimationFrame(() => {
|
|
3688
|
-
const ta = containerRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
|
|
3689
|
-
ta?.focus()
|
|
3690
|
-
})
|
|
3691
|
-
}, [local, onChange])
|
|
3692
|
-
|
|
3693
|
-
return (
|
|
3694
|
-
<div ref={containerRef} data-color-mode="light" className="w-full" onBlur={() => commit()}>
|
|
3695
|
-
<MDEditor
|
|
3696
|
-
value={local}
|
|
3697
|
-
height={220}
|
|
3698
|
-
onChange={handleChange}
|
|
3699
|
-
previewOptions={previewOptions}
|
|
3700
|
-
/>
|
|
3701
|
-
</div>
|
|
3702
|
-
)
|
|
3900
|
+
return <MarkdownField value={value} onChange={onChange} />
|
|
3703
3901
|
}, (prev, next) => prev.value === next.value)
|
|
3704
3902
|
|
|
3705
3903
|
// HTML Rich Text editor wrapper for the CrudForm builtin `editor: 'html'`.
|
|
@@ -3870,13 +4068,29 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
|
|
|
3870
4068
|
return (
|
|
3871
4069
|
<Button
|
|
3872
4070
|
key={opt.value}
|
|
4071
|
+
type="button"
|
|
3873
4072
|
variant="ghost"
|
|
3874
4073
|
size="sm"
|
|
3875
4074
|
onClick={() => toggle(opt.value)}
|
|
3876
4075
|
className={`w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? 'bg-muted' : ''}`}
|
|
3877
4076
|
>
|
|
3878
4077
|
<span className="inline-flex items-center gap-2">
|
|
3879
|
-
|
|
4078
|
+
{/* Decorative selection indicator — NOT an interactive Checkbox.
|
|
4079
|
+
The enclosing Button owns the click; a real Radix Checkbox here
|
|
4080
|
+
nests a <button> inside a <button> (hydration error) and its
|
|
4081
|
+
Presence/ref lifecycle drives an infinite update loop when the
|
|
4082
|
+
listbox renders selected values. A plain span mirrors the DS
|
|
4083
|
+
checkbox look without any of that. */}
|
|
4084
|
+
<span
|
|
4085
|
+
aria-hidden="true"
|
|
4086
|
+
className={`inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors ${
|
|
4087
|
+
isSel
|
|
4088
|
+
? 'border-accent-indigo bg-accent-indigo text-accent-indigo-foreground'
|
|
4089
|
+
: 'border-input bg-background'
|
|
4090
|
+
}`}
|
|
4091
|
+
>
|
|
4092
|
+
{isSel ? <Check className="size-3.5" aria-hidden="true" /> : null}
|
|
4093
|
+
</span>
|
|
3880
4094
|
<span>{opt.label}</span>
|
|
3881
4095
|
</span>
|
|
3882
4096
|
</Button>
|
|
@@ -3928,6 +4142,18 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
3928
4142
|
const placeholder = builtin?.placeholder
|
|
3929
4143
|
const rootClassName = wrapperClassName ? `space-y-1 ${wrapperClassName}` : 'space-y-1'
|
|
3930
4144
|
const validateOnWrapperBlur = supportsWrapperBlurValidation(field)
|
|
4145
|
+
const singleSelectValue = Array.isArray(value)
|
|
4146
|
+
? String(value[0] ?? '')
|
|
4147
|
+
: value == null
|
|
4148
|
+
? ''
|
|
4149
|
+
: String(value)
|
|
4150
|
+
const singleSelectOptionsKey = React.useMemo(
|
|
4151
|
+
() => options.map((opt) => `${opt.value}:${opt.label}`).join('\0'),
|
|
4152
|
+
[options],
|
|
4153
|
+
)
|
|
4154
|
+
const singleSelectLabel = singleSelectValue
|
|
4155
|
+
? options.find((option) => option.value === singleSelectValue)?.label
|
|
4156
|
+
: undefined
|
|
3931
4157
|
|
|
3932
4158
|
return (
|
|
3933
4159
|
<div
|
|
@@ -4126,19 +4352,14 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4126
4352
|
)}
|
|
4127
4353
|
{field.type === 'select' && !builtin?.multiple && (
|
|
4128
4354
|
<Select
|
|
4355
|
+
key={`${field.id}:${singleSelectValue}:${singleSelectOptionsKey}`}
|
|
4129
4356
|
// Radix Select MUST be either always-controlled or always-uncontrolled.
|
|
4130
4357
|
// Passing `value={undefined}` on first render and a string later trips
|
|
4131
4358
|
// React's "uncontrolled → controlled" warning and breaks Radix's
|
|
4132
4359
|
// internal state (dropdown flashes / selections no-op). Use empty
|
|
4133
4360
|
// string for "no selection" instead — Radix treats it the same as
|
|
4134
4361
|
// undefined for matching SelectItems but keeps the prop type stable.
|
|
4135
|
-
value={
|
|
4136
|
-
Array.isArray(value)
|
|
4137
|
-
? String(value[0] ?? '')
|
|
4138
|
-
: value == null
|
|
4139
|
-
? ''
|
|
4140
|
-
: String(value)
|
|
4141
|
-
}
|
|
4362
|
+
value={singleSelectValue}
|
|
4142
4363
|
onValueChange={(next) => {
|
|
4143
4364
|
// Custom field clears must be explicit nulls; normal optional
|
|
4144
4365
|
// fields keep the existing undefined clear semantics.
|
|
@@ -4152,7 +4373,9 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4152
4373
|
disabled={disabled}
|
|
4153
4374
|
>
|
|
4154
4375
|
<SelectTrigger data-crud-focus-target="">
|
|
4155
|
-
<SelectValue placeholder={t('ui.forms.select.emptyOption', '—')}
|
|
4376
|
+
<SelectValue placeholder={t('ui.forms.select.emptyOption', '—')}>
|
|
4377
|
+
{singleSelectLabel}
|
|
4378
|
+
</SelectValue>
|
|
4156
4379
|
</SelectTrigger>
|
|
4157
4380
|
<SelectContent>
|
|
4158
4381
|
{!field.required && value != null && value !== '' && (
|