@byline/admin 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/array/array-field.d.ts +14 -0
- package/dist/fields/array/array-field.js +177 -0
- package/dist/fields/array/array-field.module.js +11 -0
- package/dist/fields/array/array-field_module.css +32 -0
- package/dist/fields/blocks/blocks-field.d.ts +13 -0
- package/dist/fields/blocks/blocks-field.js +245 -0
- package/dist/fields/blocks/blocks-field.module.js +26 -0
- package/dist/fields/blocks/blocks-field_module.css +107 -0
- package/dist/fields/checkbox/checkbox-field.d.ts +16 -0
- package/dist/fields/checkbox/checkbox-field.js +28 -0
- package/dist/fields/checkbox/checkbox-field.module.js +6 -0
- package/dist/fields/checkbox/checkbox-field_module.css +4 -0
- package/dist/fields/column-formatter.d.ts +20 -0
- package/dist/fields/column-formatter.js +15 -0
- package/dist/fields/date-time-formatter.d.ts +16 -0
- package/dist/fields/date-time-formatter.js +8 -0
- package/dist/fields/datetime/datetime-field.d.ts +16 -0
- package/dist/fields/datetime/datetime-field.js +37 -0
- package/dist/fields/datetime/datetime-field.module.js +5 -0
- package/dist/fields/datetime/datetime-field_module.css +4 -0
- package/dist/fields/draggable-context-menu.d.ts +6 -0
- package/dist/fields/draggable-context-menu.js +85 -0
- package/dist/fields/draggable-context-menu.module.js +15 -0
- package/dist/fields/draggable-context-menu_module.css +91 -0
- package/dist/fields/field-helpers.d.ts +26 -0
- package/dist/fields/field-helpers.js +50 -0
- package/dist/fields/field-renderer.d.ts +37 -0
- package/dist/fields/field-renderer.js +206 -0
- package/dist/fields/field-renderer.module.js +8 -0
- package/dist/fields/field-renderer_module.css +11 -0
- package/dist/fields/field-services-context.d.ts +16 -0
- package/dist/fields/field-services-context.js +13 -0
- package/dist/fields/field-services-types.d.ts +63 -0
- package/dist/fields/field-services-types.js +1 -0
- package/dist/fields/file/file-field.d.ts +19 -0
- package/dist/fields/file/file-field.js +225 -0
- package/dist/fields/file/file-field.module.js +18 -0
- package/dist/fields/file/file-field_module.css +131 -0
- package/dist/fields/file/file-upload-field.d.ts +21 -0
- package/dist/fields/file/file-upload-field.js +130 -0
- package/dist/fields/file/file-upload-field.module.js +15 -0
- package/dist/fields/file/file-upload-field_module.css +74 -0
- package/dist/fields/group/group-field.d.ts +15 -0
- package/dist/fields/group/group-field.js +59 -0
- package/dist/fields/group/group-field.module.js +9 -0
- package/dist/fields/group/group-field_module.css +27 -0
- package/dist/fields/image/image-field.d.ts +19 -0
- package/dist/fields/image/image-field.js +241 -0
- package/dist/fields/image/image-field.module.js +22 -0
- package/dist/fields/image/image-field_module.css +121 -0
- package/dist/fields/image/image-upload-field.d.ts +21 -0
- package/dist/fields/image/image-upload-field.js +190 -0
- package/dist/fields/image/image-upload-field.module.js +19 -0
- package/dist/fields/image/image-upload-field_module.css +92 -0
- package/dist/fields/local-date-time.d.ts +27 -0
- package/dist/fields/local-date-time.js +49 -0
- package/dist/fields/locale-badge.d.ts +18 -0
- package/dist/fields/locale-badge.js +10 -0
- package/dist/fields/locale-badge.module.js +5 -0
- package/dist/fields/locale-badge_module.css +27 -0
- package/dist/fields/numerical/numerical-field.d.ts +18 -0
- package/dist/fields/numerical/numerical-field.js +74 -0
- package/dist/fields/relation/relation-display.d.ts +40 -0
- package/dist/fields/relation/relation-display.js +58 -0
- package/dist/fields/relation/relation-display.module.js +9 -0
- package/dist/fields/relation/relation-display_module.css +21 -0
- package/dist/fields/relation/relation-field.d.ts +18 -0
- package/dist/fields/relation/relation-field.js +138 -0
- package/dist/fields/relation/relation-field.module.js +13 -0
- package/dist/fields/relation/relation-field_module.css +62 -0
- package/dist/fields/relation/relation-picker.d.ts +49 -0
- package/dist/fields/relation/relation-picker.js +236 -0
- package/dist/fields/relation/relation-picker.module.js +26 -0
- package/dist/fields/relation/relation-picker_module.css +124 -0
- package/dist/fields/relation/relation-summary.d.ts +31 -0
- package/dist/fields/relation/relation-summary.js +50 -0
- package/dist/fields/relation/relation-summary.module.js +11 -0
- package/dist/fields/relation/relation-summary_module.css +37 -0
- package/dist/fields/select/select-field.d.ts +16 -0
- package/dist/fields/select/select-field.js +50 -0
- package/dist/fields/select/select-field.module.js +5 -0
- package/dist/fields/select/select-field_module.css +4 -0
- package/dist/fields/sortable-item.d.ts +15 -0
- package/dist/fields/sortable-item.js +81 -0
- package/dist/fields/sortable-item.module.js +22 -0
- package/dist/fields/sortable-item_module.css +124 -0
- package/dist/fields/text/text-field.d.ts +20 -0
- package/dist/fields/text/text-field.js +104 -0
- package/dist/fields/text/text-field.module.js +6 -0
- package/dist/fields/text/text-field_module.css +5 -0
- package/dist/fields/text-area/text-area-field.d.ts +20 -0
- package/dist/fields/text-area/text-area-field.js +105 -0
- package/dist/fields/text-area/text-area-field.module.js +6 -0
- package/dist/fields/text-area/text-area-field_module.css +5 -0
- package/dist/fields/use-field-change-handler.d.ts +23 -0
- package/dist/fields/use-field-change-handler.js +52 -0
- package/dist/forms/document-actions.d.ts +48 -0
- package/dist/forms/document-actions.js +475 -0
- package/dist/forms/document-actions.module.js +34 -0
- package/dist/forms/document-actions_module.css +118 -0
- package/dist/forms/form-context.d.ts +89 -0
- package/dist/forms/form-context.js +466 -0
- package/dist/forms/form-renderer.d.ts +98 -0
- package/dist/forms/form-renderer.js +597 -0
- package/dist/forms/form-renderer.module.js +46 -0
- package/dist/forms/form-renderer_module.css +245 -0
- package/dist/forms/navigation-guard.d.ts +54 -0
- package/dist/forms/navigation-guard.js +22 -0
- package/dist/forms/path-widget.d.ts +36 -0
- package/dist/forms/path-widget.js +116 -0
- package/dist/forms/path-widget.module.js +8 -0
- package/dist/forms/path-widget_module.css +29 -0
- package/dist/forms/upload-executor.d.ts +57 -0
- package/dist/forms/upload-executor.js +94 -0
- package/dist/lib/translate-validation-error.d.ts +36 -0
- package/dist/lib/translate-validation-error.js +11 -0
- package/dist/modules/admin-account/commands.d.ts +2 -1
- package/dist/modules/admin-account/commands.js +13 -2
- package/dist/modules/admin-account/components/change-password.js +45 -36
- package/dist/modules/admin-account/components/container.js +185 -134
- package/dist/modules/admin-account/components/preferences.d.ts +8 -0
- package/dist/modules/admin-account/components/preferences.js +152 -0
- package/dist/modules/admin-account/components/preferences.module.js +11 -0
- package/dist/modules/admin-account/components/preferences_module.css +41 -0
- package/dist/modules/admin-account/components/update.js +50 -31
- package/dist/modules/admin-account/index.d.ts +3 -3
- package/dist/modules/admin-account/index.js +2 -2
- package/dist/modules/admin-account/schemas.d.ts +4 -0
- package/dist/modules/admin-account/schemas.js +4 -1
- package/dist/modules/admin-account/service.d.ts +1 -0
- package/dist/modules/admin-account/service.js +8 -0
- package/dist/modules/admin-permissions/components/inspector.js +31 -41
- package/dist/modules/admin-roles/components/create.js +43 -26
- package/dist/modules/admin-roles/components/permissions.js +26 -35
- package/dist/modules/admin-roles/components/update.js +26 -16
- package/dist/modules/admin-users/components/create.js +60 -40
- package/dist/modules/admin-users/components/roles.js +9 -15
- package/dist/modules/admin-users/components/set-password.js +30 -31
- package/dist/modules/admin-users/components/update.js +58 -39
- package/dist/modules/admin-users/dto.js +1 -0
- package/dist/modules/admin-users/repository.d.ts +17 -0
- package/dist/modules/admin-users/schemas.d.ts +4 -0
- package/dist/modules/admin-users/schemas.js +6 -2
- package/dist/modules/auth/components/sign-in-form.js +10 -8
- package/dist/presentation/group.d.ts +27 -0
- package/dist/presentation/group.js +14 -0
- package/dist/presentation/group.module.js +6 -0
- package/dist/presentation/group_module.css +19 -0
- package/dist/presentation/row.d.ts +25 -0
- package/dist/presentation/row.js +8 -0
- package/dist/presentation/row.module.js +5 -0
- package/dist/presentation/row_module.css +18 -0
- package/dist/presentation/tabs.d.ts +25 -0
- package/dist/presentation/tabs.js +39 -0
- package/dist/presentation/tabs.module.js +10 -0
- package/dist/presentation/tabs_module.css +68 -0
- package/dist/react.d.ts +66 -0
- package/dist/react.js +36 -0
- package/dist/services/admin-services-types.d.ts +16 -0
- package/dist/widgets/diff-viewer/diff-modal.d.ts +22 -0
- package/dist/widgets/diff-viewer/diff-modal.js +149 -0
- package/dist/widgets/diff-viewer/diff-modal.module.js +14 -0
- package/dist/widgets/diff-viewer/diff-modal_module.css +56 -0
- package/dist/widgets/status-badge/status-badge.d.ts +25 -0
- package/dist/widgets/status-badge/status-badge.js +37 -0
- package/dist/widgets/status-badge/status-badge.module.js +7 -0
- package/dist/widgets/status-badge/status-badge_module.css +20 -0
- package/package.json +14 -4
- package/src/fields/array/array-field.module.css +48 -0
- package/src/fields/array/array-field.tsx +267 -0
- package/src/fields/blocks/blocks-field.module.css +148 -0
- package/src/fields/blocks/blocks-field.tsx +323 -0
- package/src/fields/checkbox/checkbox-field.module.css +4 -0
- package/src/fields/checkbox/checkbox-field.tsx +54 -0
- package/src/fields/column-formatter.tsx +31 -0
- package/src/fields/date-time-formatter.tsx +22 -0
- package/src/fields/datetime/datetime-field.module.css +13 -0
- package/src/fields/datetime/datetime-field.tsx +54 -0
- package/src/fields/draggable-context-menu.module.css +127 -0
- package/src/fields/draggable-context-menu.tsx +87 -0
- package/src/fields/field-helpers.ts +69 -0
- package/src/fields/field-renderer.module.css +22 -0
- package/src/fields/field-renderer.tsx +288 -0
- package/src/fields/field-services-context.tsx +35 -0
- package/src/fields/field-services-types.ts +68 -0
- package/src/fields/file/file-field.module.css +153 -0
- package/src/fields/file/file-field.tsx +286 -0
- package/src/fields/file/file-upload-field.module.css +101 -0
- package/src/fields/file/file-upload-field.tsx +187 -0
- package/src/fields/group/group-field.module.css +43 -0
- package/src/fields/group/group-field.tsx +84 -0
- package/src/fields/image/image-field.module.css +155 -0
- package/src/fields/image/image-field.tsx +306 -0
- package/src/fields/image/image-upload-field.module.css +123 -0
- package/src/fields/image/image-upload-field.tsx +276 -0
- package/src/fields/local-date-time.tsx +88 -0
- package/src/fields/locale-badge.module.css +37 -0
- package/src/fields/locale-badge.tsx +32 -0
- package/src/fields/numerical/numerical-field.tsx +114 -0
- package/src/fields/relation/relation-display.module.css +36 -0
- package/src/fields/relation/relation-display.tsx +130 -0
- package/src/fields/relation/relation-field.module.css +83 -0
- package/src/fields/relation/relation-field.tsx +211 -0
- package/src/fields/relation/relation-picker.module.css +168 -0
- package/src/fields/relation/relation-picker.tsx +326 -0
- package/src/fields/relation/relation-summary.module.css +55 -0
- package/src/fields/relation/relation-summary.tsx +123 -0
- package/src/fields/select/select-field.module.css +13 -0
- package/src/fields/select/select-field.tsx +61 -0
- package/src/fields/sortable-item.module.css +167 -0
- package/src/fields/sortable-item.tsx +106 -0
- package/src/fields/text/text-field.module.css +13 -0
- package/src/fields/text/text-field.tsx +146 -0
- package/src/fields/text-area/text-area-field.module.css +13 -0
- package/src/fields/text-area/text-area-field.tsx +147 -0
- package/src/fields/use-field-change-handler.ts +112 -0
- package/src/forms/document-actions.module.css +160 -0
- package/src/forms/document-actions.tsx +482 -0
- package/src/forms/form-context.tsx +704 -0
- package/src/forms/form-renderer.module.css +321 -0
- package/src/forms/form-renderer.tsx +891 -0
- package/src/forms/navigation-guard.tsx +98 -0
- package/src/forms/path-widget.module.css +41 -0
- package/src/forms/path-widget.test.tsx +217 -0
- package/src/forms/path-widget.tsx +183 -0
- package/src/forms/upload-executor.ts +192 -0
- package/src/lib/translate-validation-error.ts +56 -0
- package/src/modules/admin-account/commands.ts +13 -0
- package/src/modules/admin-account/components/change-password.tsx +46 -31
- package/src/modules/admin-account/components/container.tsx +83 -38
- package/src/modules/admin-account/components/preferences.module.css +60 -0
- package/src/modules/admin-account/components/preferences.tsx +203 -0
- package/src/modules/admin-account/components/update.tsx +53 -27
- package/src/modules/admin-account/index.ts +3 -0
- package/src/modules/admin-account/schemas.ts +13 -0
- package/src/modules/admin-account/service.ts +12 -0
- package/src/modules/admin-permissions/components/inspector.tsx +22 -14
- package/src/modules/admin-roles/components/create.tsx +51 -23
- package/src/modules/admin-roles/components/permissions.tsx +25 -21
- package/src/modules/admin-roles/components/update.tsx +37 -19
- package/src/modules/admin-users/components/create.tsx +63 -34
- package/src/modules/admin-users/components/roles.tsx +9 -8
- package/src/modules/admin-users/components/set-password.tsx +34 -28
- package/src/modules/admin-users/components/update.tsx +58 -36
- package/src/modules/admin-users/dto.ts +1 -0
- package/src/modules/admin-users/repository.ts +17 -0
- package/src/modules/admin-users/schemas.ts +12 -0
- package/src/modules/auth/components/sign-in-form.tsx +14 -8
- package/src/presentation/group.module.css +41 -0
- package/src/presentation/group.tsx +40 -0
- package/src/presentation/row.module.css +32 -0
- package/src/presentation/row.tsx +33 -0
- package/src/presentation/tabs.module.css +107 -0
- package/src/presentation/tabs.tsx +84 -0
- package/src/react.ts +84 -0
- package/src/services/admin-services-types.ts +18 -0
- package/src/widgets/diff-viewer/diff-modal.module.css +79 -0
- package/src/widgets/diff-viewer/diff-modal.tsx +186 -0
- package/src/widgets/status-badge/status-badge.module.css +31 -0
- package/src/widgets/status-badge/status-badge.tsx +71 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type React from 'react'
|
|
12
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
|
|
15
|
+
import { normalizeHooks } from '@byline/core'
|
|
16
|
+
import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
|
|
17
|
+
import { get as getNestedValue, set as setNestedValue } from 'lodash-es'
|
|
18
|
+
|
|
19
|
+
interface FormError {
|
|
20
|
+
field: string
|
|
21
|
+
message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Represents a file that has been selected but not yet uploaded.
|
|
26
|
+
* The file is held locally until form submission.
|
|
27
|
+
*/
|
|
28
|
+
export interface PendingUpload {
|
|
29
|
+
/** The actual File object to upload */
|
|
30
|
+
file: File
|
|
31
|
+
/** Blob URL for local preview (must be revoked on cleanup) */
|
|
32
|
+
previewUrl: string
|
|
33
|
+
/** The collection path for the upload endpoint */
|
|
34
|
+
collectionPath: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type FieldListener = (value: any) => void
|
|
38
|
+
type ErrorsListener = (errors: FormError[]) => void
|
|
39
|
+
type MetaListener = () => void
|
|
40
|
+
type SystemPathListener = (value: string | null) => void
|
|
41
|
+
type FieldUploadingListener = (uploading: boolean) => void
|
|
42
|
+
|
|
43
|
+
interface FormContextType {
|
|
44
|
+
setFieldValue: (name: string, value: any) => void
|
|
45
|
+
setFieldStore: (name: string, value: any) => void
|
|
46
|
+
getFieldValue: (name: string) => any
|
|
47
|
+
getFieldValues: () => Record<string, any>
|
|
48
|
+
getPatches: () => DocumentPatch[]
|
|
49
|
+
appendPatch: (patch: DocumentPatch) => void
|
|
50
|
+
resetPatches: () => void
|
|
51
|
+
hasChanges: () => boolean
|
|
52
|
+
resetHasChanges: () => void
|
|
53
|
+
runFieldHooks: (fields: Field[]) => Promise<FormError[]>
|
|
54
|
+
validateForm: (fields: Field[]) => FormError[]
|
|
55
|
+
errors: FormError[]
|
|
56
|
+
getErrors: () => FormError[]
|
|
57
|
+
clearErrors: () => void
|
|
58
|
+
setFieldError: (field: string, message: string) => void
|
|
59
|
+
clearFieldError: (field: string) => void
|
|
60
|
+
isDirty: (fieldName: string) => boolean
|
|
61
|
+
subscribeField: (name: string, listener: FieldListener) => () => void
|
|
62
|
+
subscribeErrors: (listener: ErrorsListener) => () => void
|
|
63
|
+
subscribeMeta: (listener: MetaListener) => () => void
|
|
64
|
+
// Pending uploads (deferred until save)
|
|
65
|
+
addPendingUpload: (fieldPath: string, upload: PendingUpload) => void
|
|
66
|
+
removePendingUpload: (fieldPath: string) => void
|
|
67
|
+
getPendingUploads: () => Map<string, PendingUpload>
|
|
68
|
+
hasPendingUploads: () => boolean
|
|
69
|
+
clearPendingUploads: () => void
|
|
70
|
+
// Per-field upload-in-flight tracking. Mirrors the pending-uploads map but
|
|
71
|
+
// for the window during which the upload-executor is actively transporting
|
|
72
|
+
// a given fieldPath, so widgets can render a localised spinner/overlay.
|
|
73
|
+
setFieldUploading: (fieldPath: string, uploading: boolean) => void
|
|
74
|
+
getIsFieldUploading: (fieldPath: string) => boolean
|
|
75
|
+
subscribeFieldUploading: (fieldPath: string, listener: FieldUploadingListener) => () => void
|
|
76
|
+
// System-managed `path` slot (persisted in `byline_document_paths`),
|
|
77
|
+
// edited by the path widget. `null` means the widget will fall back
|
|
78
|
+
// to live-derived preview / the server-side default; a non-null value
|
|
79
|
+
// is sent verbatim to the server.
|
|
80
|
+
getSystemPath: () => string | null
|
|
81
|
+
setSystemPath: (value: string | null) => void
|
|
82
|
+
subscribeSystemPath: (listener: SystemPathListener) => () => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const FormContext = createContext<FormContextType | null>(null)
|
|
86
|
+
|
|
87
|
+
export const useFormContext = () => {
|
|
88
|
+
const context = useContext(FormContext)
|
|
89
|
+
if (context == null) {
|
|
90
|
+
throw new Error('useFormContext must be used within a FormProvider')
|
|
91
|
+
}
|
|
92
|
+
return context
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const FormProvider = ({
|
|
96
|
+
children,
|
|
97
|
+
initialData = {},
|
|
98
|
+
}: {
|
|
99
|
+
children: React.ReactNode
|
|
100
|
+
initialData?: Record<string, any>
|
|
101
|
+
}) => {
|
|
102
|
+
const fieldValues = useRef<Record<string, any>>(
|
|
103
|
+
JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
|
|
104
|
+
)
|
|
105
|
+
const initialValues = useRef<Record<string, any>>(initialData?.fields ?? initialData)
|
|
106
|
+
const errorsRef = useRef<FormError[]>([])
|
|
107
|
+
const dirtyFields = useRef<Set<string>>(new Set())
|
|
108
|
+
const patchesRef = useRef<DocumentPatch[]>([])
|
|
109
|
+
const pendingUploadsRef = useRef<Map<string, PendingUpload>>(new Map())
|
|
110
|
+
const uploadingFieldsRef = useRef<Set<string>>(new Set())
|
|
111
|
+
const uploadingListenersRef = useRef<Map<string, Set<FieldUploadingListener>>>(new Map())
|
|
112
|
+
|
|
113
|
+
const fieldListeners = useRef<Map<string, Set<FieldListener>>>(new Map())
|
|
114
|
+
const errorListeners = useRef<Set<ErrorsListener>>(new Set())
|
|
115
|
+
const metaListeners = useRef<Set<MetaListener>>(new Set())
|
|
116
|
+
|
|
117
|
+
// System path slot — initialised from the loaded version's top-level
|
|
118
|
+
// `path` (edit mode) or `null` (create mode). Edits via `setSystemPath`
|
|
119
|
+
// mark the form dirty so the Save button enables.
|
|
120
|
+
const systemPathRef = useRef<string | null>(
|
|
121
|
+
typeof initialData?.path === 'string' && (initialData.path as string).length > 0
|
|
122
|
+
? (initialData.path as string)
|
|
123
|
+
: null
|
|
124
|
+
)
|
|
125
|
+
const initialSystemPath = useRef<string | null>(systemPathRef.current)
|
|
126
|
+
const systemPathListeners = useRef<Set<SystemPathListener>>(new Set())
|
|
127
|
+
|
|
128
|
+
const subscribeField = useCallback((name: string, listener: FieldListener) => {
|
|
129
|
+
if (!fieldListeners.current.has(name)) {
|
|
130
|
+
fieldListeners.current.set(name, new Set())
|
|
131
|
+
}
|
|
132
|
+
fieldListeners.current.get(name)?.add(listener)
|
|
133
|
+
return () => {
|
|
134
|
+
const listeners = fieldListeners.current.get(name)
|
|
135
|
+
if (listeners) {
|
|
136
|
+
listeners.delete(listener)
|
|
137
|
+
if (listeners.size === 0) {
|
|
138
|
+
fieldListeners.current.delete(name)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
const subscribeErrors = useCallback((listener: ErrorsListener) => {
|
|
145
|
+
errorListeners.current.add(listener)
|
|
146
|
+
return () => {
|
|
147
|
+
errorListeners.current.delete(listener)
|
|
148
|
+
}
|
|
149
|
+
}, [])
|
|
150
|
+
|
|
151
|
+
const subscribeMeta = useCallback((listener: MetaListener) => {
|
|
152
|
+
metaListeners.current.add(listener)
|
|
153
|
+
return () => {
|
|
154
|
+
metaListeners.current.delete(listener)
|
|
155
|
+
}
|
|
156
|
+
}, [])
|
|
157
|
+
|
|
158
|
+
const notifyFieldListeners = useCallback((name: string, value: any) => {
|
|
159
|
+
const listeners = fieldListeners.current.get(name)
|
|
160
|
+
if (listeners) {
|
|
161
|
+
listeners.forEach((listener) => {
|
|
162
|
+
listener(value)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
const notifyErrorListeners = useCallback(() => {
|
|
168
|
+
errorListeners.current.forEach((listener) => {
|
|
169
|
+
listener(errorsRef.current)
|
|
170
|
+
})
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
const notifyMetaListeners = useCallback(() => {
|
|
174
|
+
metaListeners.current.forEach((listener) => {
|
|
175
|
+
listener()
|
|
176
|
+
})
|
|
177
|
+
}, [])
|
|
178
|
+
|
|
179
|
+
const updateFieldStoreInternal = useCallback(
|
|
180
|
+
(name: string, value: any) => {
|
|
181
|
+
const newFieldValues = { ...fieldValues.current }
|
|
182
|
+
|
|
183
|
+
// Keep nested path values up to date for generic usage and patches.
|
|
184
|
+
setNestedValue(newFieldValues, name, value)
|
|
185
|
+
|
|
186
|
+
fieldValues.current = newFieldValues
|
|
187
|
+
dirtyFields.current.add(name)
|
|
188
|
+
|
|
189
|
+
notifyFieldListeners(name, value)
|
|
190
|
+
notifyMetaListeners()
|
|
191
|
+
},
|
|
192
|
+
[notifyFieldListeners, notifyMetaListeners]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const setFieldStore = useCallback(
|
|
196
|
+
(name: string, value: any) => {
|
|
197
|
+
updateFieldStoreInternal(name, value)
|
|
198
|
+
},
|
|
199
|
+
[updateFieldStoreInternal]
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const setFieldValue = useCallback(
|
|
203
|
+
(name: string, value: any) => {
|
|
204
|
+
updateFieldStoreInternal(name, value)
|
|
205
|
+
|
|
206
|
+
const patch: FieldSetPatch = {
|
|
207
|
+
kind: 'field.set',
|
|
208
|
+
path: name,
|
|
209
|
+
value,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Optimization: Coalesce consecutive field.set patches for the same path
|
|
213
|
+
const lastPatch = patchesRef.current[patchesRef.current.length - 1]
|
|
214
|
+
if (lastPatch && lastPatch.kind === 'field.set' && lastPatch.path === name) {
|
|
215
|
+
const newPatches = [...patchesRef.current]
|
|
216
|
+
newPatches[newPatches.length - 1] = patch
|
|
217
|
+
patchesRef.current = newPatches
|
|
218
|
+
} else {
|
|
219
|
+
patchesRef.current = [...patchesRef.current, patch]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Clear field-specific errors when value changes
|
|
223
|
+
if (errorsRef.current.some((error) => error.field === name)) {
|
|
224
|
+
errorsRef.current = errorsRef.current.filter((error) => error.field !== name)
|
|
225
|
+
notifyErrorListeners()
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
[updateFieldStoreInternal, notifyErrorListeners]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
const getFieldValues = useCallback(() => fieldValues.current, [])
|
|
232
|
+
|
|
233
|
+
const getPatches = useCallback(() => patchesRef.current, [])
|
|
234
|
+
const appendPatch = useCallback(
|
|
235
|
+
(patch: DocumentPatch) => {
|
|
236
|
+
patchesRef.current = [...patchesRef.current, patch]
|
|
237
|
+
// Mark a generic dirty flag so hasChanges() becomes true even
|
|
238
|
+
// for patches that don't correspond to a specific field.set.
|
|
239
|
+
dirtyFields.current.add('__patch__')
|
|
240
|
+
notifyMetaListeners()
|
|
241
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
242
|
+
// eslint-disable-next-line no-console
|
|
243
|
+
console.debug('FormContext.appendPatch', { patch, dirtyCount: dirtyFields.current.size })
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
[notifyMetaListeners]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const getFieldValue = useCallback((name: string) => {
|
|
250
|
+
const dirty = dirtyFields.current.has(name)
|
|
251
|
+
const currentValue = getNestedValue(fieldValues.current, name)
|
|
252
|
+
|
|
253
|
+
if (currentValue !== undefined) {
|
|
254
|
+
return currentValue
|
|
255
|
+
}
|
|
256
|
+
if (!dirty) {
|
|
257
|
+
return getNestedValue(initialValues.current, name)
|
|
258
|
+
}
|
|
259
|
+
return undefined
|
|
260
|
+
}, [])
|
|
261
|
+
|
|
262
|
+
const hasChanges = useCallback(() => {
|
|
263
|
+
return dirtyFields.current.size > 0
|
|
264
|
+
}, [])
|
|
265
|
+
|
|
266
|
+
const resetHasChanges = useCallback(() => {
|
|
267
|
+
dirtyFields.current.clear()
|
|
268
|
+
patchesRef.current = []
|
|
269
|
+
initialSystemPath.current = systemPathRef.current
|
|
270
|
+
notifyMetaListeners()
|
|
271
|
+
}, [notifyMetaListeners])
|
|
272
|
+
|
|
273
|
+
const isDirty = useCallback((fieldName: string) => {
|
|
274
|
+
return dirtyFields.current.has(fieldName)
|
|
275
|
+
}, [])
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// System path slot
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
const getSystemPath = useCallback(() => systemPathRef.current, [])
|
|
282
|
+
|
|
283
|
+
const setSystemPath = useCallback(
|
|
284
|
+
(value: string | null) => {
|
|
285
|
+
systemPathRef.current = value
|
|
286
|
+
if (value !== initialSystemPath.current) {
|
|
287
|
+
dirtyFields.current.add('__systemPath__')
|
|
288
|
+
} else {
|
|
289
|
+
dirtyFields.current.delete('__systemPath__')
|
|
290
|
+
}
|
|
291
|
+
systemPathListeners.current.forEach((listener) => {
|
|
292
|
+
listener(value)
|
|
293
|
+
})
|
|
294
|
+
notifyMetaListeners()
|
|
295
|
+
},
|
|
296
|
+
[notifyMetaListeners]
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
const subscribeSystemPath = useCallback((listener: SystemPathListener) => {
|
|
300
|
+
systemPathListeners.current.add(listener)
|
|
301
|
+
return () => {
|
|
302
|
+
systemPathListeners.current.delete(listener)
|
|
303
|
+
}
|
|
304
|
+
}, [])
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Pending uploads (deferred until save)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
const addPendingUpload = useCallback(
|
|
311
|
+
(fieldPath: string, upload: PendingUpload) => {
|
|
312
|
+
// If there's an existing pending upload for this path, revoke its blob URL
|
|
313
|
+
const existing = pendingUploadsRef.current.get(fieldPath)
|
|
314
|
+
if (existing) {
|
|
315
|
+
URL.revokeObjectURL(existing.previewUrl)
|
|
316
|
+
}
|
|
317
|
+
pendingUploadsRef.current.set(fieldPath, upload)
|
|
318
|
+
dirtyFields.current.add(fieldPath)
|
|
319
|
+
notifyMetaListeners()
|
|
320
|
+
},
|
|
321
|
+
[notifyMetaListeners]
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const removePendingUpload = useCallback(
|
|
325
|
+
(fieldPath: string) => {
|
|
326
|
+
const existing = pendingUploadsRef.current.get(fieldPath)
|
|
327
|
+
if (existing) {
|
|
328
|
+
URL.revokeObjectURL(existing.previewUrl)
|
|
329
|
+
pendingUploadsRef.current.delete(fieldPath)
|
|
330
|
+
notifyMetaListeners()
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
[notifyMetaListeners]
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
const getPendingUploads = useCallback(() => {
|
|
337
|
+
return new Map(pendingUploadsRef.current)
|
|
338
|
+
}, [])
|
|
339
|
+
|
|
340
|
+
const hasPendingUploads = useCallback(() => {
|
|
341
|
+
return pendingUploadsRef.current.size > 0
|
|
342
|
+
}, [])
|
|
343
|
+
|
|
344
|
+
const clearPendingUploads = useCallback(() => {
|
|
345
|
+
// Revoke all blob URLs to prevent memory leaks
|
|
346
|
+
for (const upload of pendingUploadsRef.current.values()) {
|
|
347
|
+
URL.revokeObjectURL(upload.previewUrl)
|
|
348
|
+
}
|
|
349
|
+
pendingUploadsRef.current.clear()
|
|
350
|
+
}, [])
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Per-field upload-in-flight tracking
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
const setFieldUploading = useCallback((fieldPath: string, uploading: boolean) => {
|
|
357
|
+
if (uploading) {
|
|
358
|
+
if (uploadingFieldsRef.current.has(fieldPath)) return
|
|
359
|
+
uploadingFieldsRef.current.add(fieldPath)
|
|
360
|
+
} else {
|
|
361
|
+
if (!uploadingFieldsRef.current.has(fieldPath)) return
|
|
362
|
+
uploadingFieldsRef.current.delete(fieldPath)
|
|
363
|
+
}
|
|
364
|
+
uploadingListenersRef.current.get(fieldPath)?.forEach((listener) => {
|
|
365
|
+
listener(uploading)
|
|
366
|
+
})
|
|
367
|
+
}, [])
|
|
368
|
+
|
|
369
|
+
const getIsFieldUploading = useCallback((fieldPath: string) => {
|
|
370
|
+
return uploadingFieldsRef.current.has(fieldPath)
|
|
371
|
+
}, [])
|
|
372
|
+
|
|
373
|
+
const subscribeFieldUploading = useCallback(
|
|
374
|
+
(fieldPath: string, listener: FieldUploadingListener) => {
|
|
375
|
+
let listeners = uploadingListenersRef.current.get(fieldPath)
|
|
376
|
+
if (!listeners) {
|
|
377
|
+
listeners = new Set()
|
|
378
|
+
uploadingListenersRef.current.set(fieldPath, listeners)
|
|
379
|
+
}
|
|
380
|
+
listeners.add(listener)
|
|
381
|
+
return () => {
|
|
382
|
+
const set = uploadingListenersRef.current.get(fieldPath)
|
|
383
|
+
if (set) {
|
|
384
|
+
set.delete(listener)
|
|
385
|
+
if (set.size === 0) {
|
|
386
|
+
uploadingListenersRef.current.delete(fieldPath)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
[]
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
// Cleanup blob URLs on unmount
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
return () => {
|
|
397
|
+
for (const upload of pendingUploadsRef.current.values()) {
|
|
398
|
+
URL.revokeObjectURL(upload.previewUrl)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}, [])
|
|
402
|
+
|
|
403
|
+
const validateForm = useCallback(
|
|
404
|
+
(fields: Field[]): FormError[] => {
|
|
405
|
+
const formErrors: FormError[] = []
|
|
406
|
+
const data = getFieldValues()
|
|
407
|
+
|
|
408
|
+
for (const field of fields) {
|
|
409
|
+
const value = getFieldValue(field.name)
|
|
410
|
+
|
|
411
|
+
// Required field validation
|
|
412
|
+
if (!field.optional && (value == null || value === '')) {
|
|
413
|
+
formErrors.push({
|
|
414
|
+
field: field.name,
|
|
415
|
+
message: `${field.label} is required`,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Type-specific validation
|
|
420
|
+
if (value != null && value !== '') {
|
|
421
|
+
switch (field.type) {
|
|
422
|
+
case 'text':
|
|
423
|
+
if (typeof value !== 'string') {
|
|
424
|
+
formErrors.push({
|
|
425
|
+
field: field.name,
|
|
426
|
+
message: `${field.label} must be text`,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
break
|
|
430
|
+
case 'checkbox':
|
|
431
|
+
if (typeof value !== 'boolean') {
|
|
432
|
+
formErrors.push({
|
|
433
|
+
field: field.name,
|
|
434
|
+
message: `${field.label} must be true or false`,
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
break
|
|
438
|
+
case 'select':
|
|
439
|
+
if ('options' in field && field.options) {
|
|
440
|
+
const validValues = field.options.map((opt) => opt.value)
|
|
441
|
+
if (!validValues.includes(value)) {
|
|
442
|
+
formErrors.push({
|
|
443
|
+
field: field.name,
|
|
444
|
+
message: `${field.label} must be one of: ${validValues.join(', ')}`,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
break
|
|
449
|
+
case 'datetime':
|
|
450
|
+
if (value instanceof Date === false && typeof value !== 'string') {
|
|
451
|
+
formErrors.push({
|
|
452
|
+
field: field.name,
|
|
453
|
+
message: `${field.label} must be a valid date`,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
break
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Custom validate function — applies to all field types including structure fields.
|
|
461
|
+
if (field.validate) {
|
|
462
|
+
const error = field.validate(value, data)
|
|
463
|
+
if (error) {
|
|
464
|
+
formErrors.push({ field: field.name, message: error })
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
errorsRef.current = formErrors
|
|
470
|
+
notifyErrorListeners()
|
|
471
|
+
return formErrors
|
|
472
|
+
},
|
|
473
|
+
[getFieldValue, getFieldValues, notifyErrorListeners]
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
const clearErrors = useCallback(() => {
|
|
477
|
+
errorsRef.current = []
|
|
478
|
+
notifyErrorListeners()
|
|
479
|
+
}, [notifyErrorListeners])
|
|
480
|
+
|
|
481
|
+
const setFieldError = useCallback(
|
|
482
|
+
(field: string, message: string) => {
|
|
483
|
+
// Replace any existing error for this field, or add a new one
|
|
484
|
+
const filtered = errorsRef.current.filter((e) => e.field !== field)
|
|
485
|
+
filtered.push({ field, message })
|
|
486
|
+
errorsRef.current = filtered
|
|
487
|
+
notifyErrorListeners()
|
|
488
|
+
},
|
|
489
|
+
[notifyErrorListeners]
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
const clearFieldError = useCallback(
|
|
493
|
+
(field: string) => {
|
|
494
|
+
if (errorsRef.current.some((e) => e.field === field)) {
|
|
495
|
+
errorsRef.current = errorsRef.current.filter((e) => e.field !== field)
|
|
496
|
+
notifyErrorListeners()
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
[notifyErrorListeners]
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Run `beforeValidate` hooks for every top-level field that defines one.
|
|
504
|
+
* Called at submit time, before `validateForm()`. Hooks may return
|
|
505
|
+
* `{ value }` to auto-populate a field, or `{ error }` to block submit.
|
|
506
|
+
*/
|
|
507
|
+
const runFieldHooks = useCallback(
|
|
508
|
+
async (fields: Field[]): Promise<FormError[]> => {
|
|
509
|
+
const hookErrors: FormError[] = []
|
|
510
|
+
const data = { ...fieldValues.current }
|
|
511
|
+
|
|
512
|
+
for (const field of fields) {
|
|
513
|
+
const fns = normalizeHooks(field.hooks?.beforeValidate)
|
|
514
|
+
if (fns.length === 0) continue
|
|
515
|
+
|
|
516
|
+
const path = field.name
|
|
517
|
+
const value = getFieldValue(path)
|
|
518
|
+
|
|
519
|
+
const ctx: FieldHookContext = {
|
|
520
|
+
value,
|
|
521
|
+
previousValue: value,
|
|
522
|
+
data,
|
|
523
|
+
path,
|
|
524
|
+
field,
|
|
525
|
+
operation: 'submit',
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
for (const fn of fns) {
|
|
530
|
+
const result = (await fn(ctx)) as FieldBeforeChangeResult | undefined
|
|
531
|
+
if (result?.error) {
|
|
532
|
+
hookErrors.push({ field: path, message: result.error })
|
|
533
|
+
}
|
|
534
|
+
if (result?.value !== undefined) {
|
|
535
|
+
// Auto-populate: write the derived value into the store
|
|
536
|
+
setFieldValue(path, result.value)
|
|
537
|
+
// Keep ctx and data snapshot in sync for subsequent hooks
|
|
538
|
+
ctx.value = result.value
|
|
539
|
+
data[path] = result.value
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch (err) {
|
|
543
|
+
const message = err instanceof Error ? err.message : 'Unexpected hook error'
|
|
544
|
+
hookErrors.push({ field: path, message })
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (hookErrors.length > 0) {
|
|
549
|
+
errorsRef.current = [...errorsRef.current, ...hookErrors]
|
|
550
|
+
notifyErrorListeners()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return hookErrors
|
|
554
|
+
},
|
|
555
|
+
[getFieldValue, setFieldValue, notifyErrorListeners]
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<FormContext.Provider
|
|
560
|
+
value={{
|
|
561
|
+
setFieldValue,
|
|
562
|
+
setFieldStore,
|
|
563
|
+
getFieldValue,
|
|
564
|
+
getFieldValues,
|
|
565
|
+
getPatches,
|
|
566
|
+
appendPatch,
|
|
567
|
+
resetPatches: () => {
|
|
568
|
+
patchesRef.current = []
|
|
569
|
+
},
|
|
570
|
+
hasChanges,
|
|
571
|
+
resetHasChanges,
|
|
572
|
+
runFieldHooks,
|
|
573
|
+
validateForm,
|
|
574
|
+
errors: errorsRef.current,
|
|
575
|
+
getErrors: () => errorsRef.current,
|
|
576
|
+
clearErrors,
|
|
577
|
+
setFieldError,
|
|
578
|
+
clearFieldError,
|
|
579
|
+
isDirty,
|
|
580
|
+
subscribeField,
|
|
581
|
+
subscribeErrors,
|
|
582
|
+
subscribeMeta,
|
|
583
|
+
addPendingUpload,
|
|
584
|
+
removePendingUpload,
|
|
585
|
+
getPendingUploads,
|
|
586
|
+
hasPendingUploads,
|
|
587
|
+
clearPendingUploads,
|
|
588
|
+
setFieldUploading,
|
|
589
|
+
getIsFieldUploading,
|
|
590
|
+
subscribeFieldUploading,
|
|
591
|
+
getSystemPath,
|
|
592
|
+
setSystemPath,
|
|
593
|
+
subscribeSystemPath,
|
|
594
|
+
}}
|
|
595
|
+
>
|
|
596
|
+
{children}
|
|
597
|
+
</FormContext.Provider>
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Subscribe to the system `path` slot edited by the path widget.
|
|
603
|
+
* Returns the current value (or `null` when no override is set).
|
|
604
|
+
*/
|
|
605
|
+
export const useSystemPath = (): string | null => {
|
|
606
|
+
const { getSystemPath, subscribeSystemPath } = useFormContext()
|
|
607
|
+
const [value, setValue] = useState<string | null>(() => getSystemPath())
|
|
608
|
+
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
return subscribeSystemPath((next) => setValue(next))
|
|
611
|
+
}, [subscribeSystemPath])
|
|
612
|
+
|
|
613
|
+
return value
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export const useFormStore = () => {
|
|
617
|
+
return useFormContext()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export const useFieldError = (name: string) => {
|
|
621
|
+
const { getErrors, subscribeErrors } = useFormContext()
|
|
622
|
+
// Seed from the live errors ref via getErrors() rather than the context's
|
|
623
|
+
// `errors` snapshot — the snapshot is bound at FormProvider's first render
|
|
624
|
+
// and goes stale as soon as validateForm replaces errorsRef.current. Fields
|
|
625
|
+
// mounted after validation has already run (e.g. switching to a tab whose
|
|
626
|
+
// error badge is non-zero) would otherwise initialise to undefined and miss
|
|
627
|
+
// the existing error until something else fires notifyErrorListeners.
|
|
628
|
+
const [error, setError] = useState<string | undefined>(
|
|
629
|
+
() => getErrors().find((e) => e.field === name)?.message
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
const unsubscribe = subscribeErrors((currentErrors) => {
|
|
634
|
+
const fieldError = currentErrors.find((e) => e.field === name)
|
|
635
|
+
setError(fieldError?.message)
|
|
636
|
+
})
|
|
637
|
+
return unsubscribe
|
|
638
|
+
}, [subscribeErrors, name])
|
|
639
|
+
|
|
640
|
+
return error
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export const useFormMeta = () => {
|
|
644
|
+
const { hasChanges, subscribeMeta } = useFormContext()
|
|
645
|
+
const [hasChangesValue, setHasChangesValue] = useState(hasChanges())
|
|
646
|
+
|
|
647
|
+
useEffect(() => {
|
|
648
|
+
const unsubscribe = subscribeMeta(() => {
|
|
649
|
+
setHasChangesValue(hasChanges())
|
|
650
|
+
})
|
|
651
|
+
return unsubscribe
|
|
652
|
+
}, [subscribeMeta, hasChanges])
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
hasChanges: hasChangesValue,
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export const useIsDirty = (name: string) => {
|
|
660
|
+
const { isDirty, subscribeMeta } = useFormContext()
|
|
661
|
+
const [dirty, setDirty] = useState(isDirty(name))
|
|
662
|
+
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
const unsubscribe = subscribeMeta(() => {
|
|
665
|
+
setDirty(isDirty(name))
|
|
666
|
+
})
|
|
667
|
+
return unsubscribe
|
|
668
|
+
}, [subscribeMeta, isDirty, name])
|
|
669
|
+
|
|
670
|
+
return dirty
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export const useFieldValue = <T = any>(name: string): T | undefined => {
|
|
674
|
+
const { getFieldValue, subscribeField } = useFormContext()
|
|
675
|
+
const [value, setValue] = useState<T | undefined>(() => getFieldValue(name))
|
|
676
|
+
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
const unsubscribe = subscribeField(name, (nextValue) => {
|
|
679
|
+
setValue(nextValue)
|
|
680
|
+
})
|
|
681
|
+
return unsubscribe
|
|
682
|
+
}, [subscribeField, name])
|
|
683
|
+
|
|
684
|
+
return value
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Subscribe to a single field's upload-in-flight state. Returns `true` while
|
|
689
|
+
* the form orchestrator is actively transporting this field's pending upload
|
|
690
|
+
* (between the `setFieldUploading(path, true)` and the matching `false`
|
|
691
|
+
* emitted by the upload executor's progress callback).
|
|
692
|
+
*/
|
|
693
|
+
export const useIsFieldUploading = (fieldPath: string): boolean => {
|
|
694
|
+
const { getIsFieldUploading, subscribeFieldUploading } = useFormContext()
|
|
695
|
+
const [uploading, setUploading] = useState<boolean>(() => getIsFieldUploading(fieldPath))
|
|
696
|
+
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
return subscribeFieldUploading(fieldPath, (next) => {
|
|
699
|
+
setUploading(next)
|
|
700
|
+
})
|
|
701
|
+
}, [subscribeFieldUploading, fieldPath])
|
|
702
|
+
|
|
703
|
+
return uploading
|
|
704
|
+
}
|