@byline/admin 6.3.0 → 6.5.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.
Files changed (62) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +1 -0
  4. package/dist/fields/datetime/datetime-field.js +1 -0
  5. package/dist/fields/file/file-field.js +3 -2
  6. package/dist/fields/group/group-field.js +5 -2
  7. package/dist/fields/image/image-field.js +3 -2
  8. package/dist/fields/relation/relation-field.js +5 -0
  9. package/dist/fields/relation/relation-many-field.js +9 -2
  10. package/dist/fields/select/select-field.js +1 -0
  11. package/dist/fields/text/text-field.js +4 -2
  12. package/dist/fields/text-area/text-area-field.js +4 -2
  13. package/dist/fields/use-field-change-handler.js +24 -6
  14. package/dist/fields/use-field-condition.d.ts +0 -15
  15. package/dist/fields/use-field-condition.js +10 -1
  16. package/dist/forms/form-context.d.ts +3 -12
  17. package/dist/forms/form-context.js +111 -129
  18. package/dist/forms/form-layout.js +7 -2
  19. package/dist/forms/form-page-chrome.js +14 -24
  20. package/dist/forms/form-renderer.d.ts +2 -1
  21. package/dist/forms/form-renderer.js +12 -56
  22. package/dist/forms/nested-path.d.ts +2 -0
  23. package/dist/forms/nested-path.js +26 -1
  24. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  25. package/dist/forms/use-document-reload-recovery.js +67 -0
  26. package/dist/forms/use-form-submission.d.ts +1 -1
  27. package/dist/forms/use-form-submission.js +45 -27
  28. package/dist/forms/use-form-tabs.d.ts +2 -2
  29. package/dist/forms/use-form-tabs.js +18 -11
  30. package/dist/modules/admin-account/components/change-password.js +6 -0
  31. package/dist/modules/admin-users/components/create.js +4 -3
  32. package/dist/modules/admin-users/components/set-password.js +4 -0
  33. package/dist/modules/auth/components/sign-in-form.js +4 -3
  34. package/package.json +16 -16
  35. package/src/fields/array/array-field.tsx +24 -12
  36. package/src/fields/blocks/blocks-field.tsx +18 -8
  37. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  38. package/src/fields/datetime/datetime-field.tsx +1 -0
  39. package/src/fields/file/file-field.tsx +3 -2
  40. package/src/fields/group/group-field.tsx +2 -2
  41. package/src/fields/image/image-field.tsx +3 -2
  42. package/src/fields/relation/relation-field.tsx +5 -0
  43. package/src/fields/relation/relation-many-field.tsx +16 -2
  44. package/src/fields/select/select-field.tsx +1 -0
  45. package/src/fields/text/text-field.tsx +3 -2
  46. package/src/fields/text-area/text-area-field.tsx +3 -2
  47. package/src/fields/use-field-change-handler.ts +43 -9
  48. package/src/fields/use-field-condition.ts +17 -1
  49. package/src/forms/form-context.tsx +178 -233
  50. package/src/forms/form-correctness.test.tsx +560 -0
  51. package/src/forms/form-layout.tsx +3 -2
  52. package/src/forms/form-page-chrome.tsx +17 -34
  53. package/src/forms/form-renderer-submit.test.tsx +36 -0
  54. package/src/forms/form-renderer.tsx +15 -64
  55. package/src/forms/nested-path.ts +35 -0
  56. package/src/forms/use-document-reload-recovery.ts +92 -0
  57. package/src/forms/use-form-submission.ts +56 -27
  58. package/src/forms/use-form-tabs.ts +25 -17
  59. package/src/modules/admin-account/components/change-password.tsx +6 -0
  60. package/src/modules/admin-users/components/create.tsx +4 -3
  61. package/src/modules/admin-users/components/set-password.tsx +4 -0
  62. package/src/modules/auth/components/sign-in-form.tsx +4 -3
@@ -33,7 +33,8 @@ import {
33
33
  useScheduledPublication,
34
34
  } from './scheduled-publication-control'
35
35
  import { computeStatusTransitions } from './status-transitions'
36
- import { useFormSubmission } from './use-form-submission'
36
+ import { useDocumentReloadRecovery } from './use-document-reload-recovery'
37
+ import { type SystemFieldsSubmitPayload, useFormSubmission } from './use-form-submission'
37
38
  import type { DocumentActionsLocaleOption } from './document-actions'
38
39
  import type { UseNavigationGuard } from './navigation-guard'
39
40
 
@@ -63,7 +64,7 @@ export interface FormRendererProps {
63
64
  onReloadDocument?: () => void | Promise<void>
64
65
  mode: 'create' | 'edit'
65
66
  fields: Field[]
66
- onSubmit: (data: any) => void | Promise<void>
67
+ onSubmit: (data: SystemFieldsSubmitPayload) => void | Promise<void>
67
68
  onCancel: () => void
68
69
  onStatusChange?: (nextStatus: string) => Promise<void>
69
70
  onUnpublish?: () => Promise<void>
@@ -238,9 +239,6 @@ const FormContent = ({
238
239
 
239
240
  const [hasChanges, setHasChanges] = useState(hasChangesFn())
240
241
  const [statusBusy, setStatusBusy] = useState(false)
241
- const formRef = useRef<HTMLFormElement>(null)
242
- const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
243
- const restoreFocusAfterBusyRef = useRef(false)
244
242
  // Block-only "save first" guard. Set true when the editor triggers a
245
243
  // guarded action (status change, duplicate, copy-to-locale) while the form
246
244
  // is dirty — those actions operate on the saved version, so unsaved edits
@@ -332,26 +330,6 @@ const FormContent = ({
332
330
  // The guard hook is injected by the consuming framework (prop > context > no-op fallback).
333
331
  const guardFromContext = useNavigationGuardAdapter()
334
332
  const useGuard = useNavigationGuardProp ?? guardFromContext
335
- const [discarding, setDiscarding] = useState(false)
336
- const [reloadFailed, setReloadFailed] = useState(false)
337
- const warningRef = useRef<HTMLDivElement>(null)
338
- const mutationBlockedRef = useRef(mutationsBlocked)
339
- mutationBlockedRef.current = mutationsBlocked || discarding
340
- const guard = useGuard(hasChanges && !discarding)
341
- useEffect(() => {
342
- if (mutationIssue) warningRef.current?.focus()
343
- }, [mutationIssue])
344
- useEffect(() => {
345
- if (!discarding) return
346
- // Let the guard's beforeunload listener detach before the explicit discard.
347
- Promise.resolve()
348
- .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
349
- .catch(() => {
350
- setDiscarding(false)
351
- setReloadFailed(true)
352
- })
353
- }, [discarding, onReloadDocument])
354
-
355
333
  // Compute available status transitions
356
334
  const currentStatus = initialData?.status
357
335
  const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(
@@ -364,14 +342,6 @@ const FormContent = ({
364
342
  return subscribeMeta(() => setHasChanges(hasChangesFn()))
365
343
  }, [subscribeMeta, hasChangesFn])
366
344
 
367
- const captureFocusBeforeBusy = useCallback(() => {
368
- if (focusBeforeBusyRef.current != null) return
369
- const activeElement = document.activeElement
370
- if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
371
- focusBeforeBusyRef.current = activeElement
372
- restoreFocusAfterBusyRef.current = true
373
- }, [])
374
-
375
345
  // One save at a time: validate -> upload -> (confirm) -> submit. Admission is
376
346
  // decided synchronously inside the hook, so two Saves in the same turn cannot
377
347
  // both get through. `phase` is what the UI renders.
@@ -381,40 +351,24 @@ const FormContent = ({
381
351
  documentId: mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
382
352
  advertiseLocales,
383
353
  onSubmit,
384
- isBlocked: () => mutationBlockedRef.current,
385
- onBeforeBusy: captureFocusBeforeBusy,
354
+ isBlocked: (): boolean => recovery.mutationBlockedRef.current,
355
+ onBeforeBusy: (): void => recovery.captureFocusBeforeBusy(),
356
+ })
357
+ const recovery = useDocumentReloadRecovery({
358
+ mutationsBlocked,
359
+ mutationIssue,
360
+ onReloadDocument,
361
+ isBusy: submission.isBusy,
386
362
  })
363
+ const { formRef, warningRef, mutationBlockedRef, discarding, reloadFailed, requestDiscard } =
364
+ recovery
365
+ const guard = useGuard(hasChanges && !discarding)
387
366
  const isUploading = submission.phase.kind === 'uploading'
388
367
  const isSubmitting = submission.phase.kind === 'submitting'
389
368
  const isBusy = submission.isBusy
390
369
  const pendingSystemFieldsSubmit =
391
370
  submission.phase.kind === 'confirmingSystemFields' ? submission.phase.payload : null
392
371
 
393
- // `inert` removes the active control from the tab order while a save is in
394
- // flight. Restore the editor's position after React has removed `inert`;
395
- // when the original control became disabled, fall back to the first usable
396
- // form control instead of leaving focus on <body>.
397
- useEffect(() => {
398
- if (isBusy || !restoreFocusAfterBusyRef.current) return
399
- restoreFocusAfterBusyRef.current = false
400
-
401
- if (mutationIssue) {
402
- warningRef.current?.focus()
403
- focusBeforeBusyRef.current = null
404
- return
405
- }
406
- const original = focusBeforeBusyRef.current
407
- focusBeforeBusyRef.current = null
408
- const originalCanReceiveFocus =
409
- original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
410
- const target = originalCanReceiveFocus
411
- ? original
412
- : formRef.current?.querySelector<HTMLElement>(
413
- 'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
414
- )
415
- target?.focus({ preventScroll: true })
416
- }, [isBusy, mutationIssue])
417
-
418
372
  const handleCancel = () => {
419
373
  if (onCancel && typeof onCancel === 'function') {
420
374
  onCancel()
@@ -498,10 +452,7 @@ const FormContent = ({
498
452
  warningRef={warningRef}
499
453
  discarding={discarding}
500
454
  reloadFailed={reloadFailed}
501
- onDiscardRequested={() => {
502
- setReloadFailed(false)
503
- setDiscarding(true)
504
- }}
455
+ onDiscardRequested={requestDiscard}
505
456
  scheduling={scheduling}
506
457
  restoreWarnings={restoreWarnings}
507
458
  />
@@ -146,3 +146,38 @@ export function set<T extends object>(object: T, path: string, value: unknown):
146
146
  setWithResult(object, path, value)
147
147
  return object
148
148
  }
149
+
150
+ /** Clone only the containers along a write path, preserving other snapshots. */
151
+ export function withValue<T extends object>(
152
+ object: T,
153
+ path: string,
154
+ value: unknown
155
+ ): T | undefined {
156
+ const parsed = parseInstancePath(path)
157
+ if (!parsed.ok || !hasExistingIdTargets(object, path)) return undefined
158
+ const result: any = Array.isArray(object) ? [...object] : { ...object }
159
+ let current = result
160
+ for (const [index, segment] of parsed.segments.entries()) {
161
+ const key =
162
+ segment.kind === 'field'
163
+ ? segment.name
164
+ : segment.kind === 'index'
165
+ ? segment.index
166
+ : segment.kind === 'id'
167
+ ? selectId(current, segment.id)
168
+ : undefined
169
+ if (key === undefined || key === -1) return undefined
170
+ if (index === parsed.segments.length - 1) {
171
+ current[key] = value
172
+ return result
173
+ }
174
+ const existing = current[key]
175
+ current[key] = Array.isArray(existing)
176
+ ? [...existing]
177
+ : existing != null && typeof existing === 'object'
178
+ ? { ...existing }
179
+ : newContainer(parsed.segments[index + 1])
180
+ current = current[key]
181
+ }
182
+ return undefined
183
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { useCallback, useEffect, useRef, useState } from 'react'
10
+
11
+ /** Reload admission and focus recovery remain synchronous at event boundaries. */
12
+ export function useDocumentReloadRecovery({
13
+ mutationsBlocked,
14
+ mutationIssue,
15
+ onReloadDocument,
16
+ isBusy,
17
+ }: {
18
+ mutationsBlocked: boolean
19
+ mutationIssue?: string | null
20
+ onReloadDocument?: () => void | Promise<void>
21
+ isBusy: boolean
22
+ }) {
23
+ const formRef = useRef<HTMLFormElement>(null)
24
+ const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
25
+ const restoreFocusAfterBusyRef = useRef(false)
26
+ const [discarding, setDiscarding] = useState(false)
27
+ const [reloadFailed, setReloadFailed] = useState(false)
28
+ const warningRef = useRef<HTMLDivElement>(null)
29
+ const mutationBlockedRef = useRef(mutationsBlocked)
30
+ mutationBlockedRef.current = mutationsBlocked || discarding
31
+ useEffect(() => {
32
+ if (mutationIssue) warningRef.current?.focus()
33
+ }, [mutationIssue])
34
+ useEffect(() => {
35
+ if (!discarding) return
36
+ // Let the guard's beforeunload listener detach before the explicit discard.
37
+ Promise.resolve()
38
+ .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
39
+ .catch(() => {
40
+ setDiscarding(false)
41
+ setReloadFailed(true)
42
+ })
43
+ }, [discarding, onReloadDocument])
44
+
45
+ const captureFocusBeforeBusy = useCallback(() => {
46
+ if (focusBeforeBusyRef.current != null) return
47
+ const activeElement = document.activeElement
48
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
49
+ focusBeforeBusyRef.current = activeElement
50
+ restoreFocusAfterBusyRef.current = true
51
+ }, [])
52
+
53
+ // `inert` removes the active control from the tab order while a save is in
54
+ // flight. Restore the editor's position after React has removed `inert`;
55
+ // when the original control became disabled, fall back to the first usable
56
+ // form control instead of leaving focus on <body>.
57
+ useEffect(() => {
58
+ if (isBusy || !restoreFocusAfterBusyRef.current) return
59
+ restoreFocusAfterBusyRef.current = false
60
+
61
+ if (mutationIssue) {
62
+ warningRef.current?.focus()
63
+ focusBeforeBusyRef.current = null
64
+ return
65
+ }
66
+ const original = focusBeforeBusyRef.current
67
+ focusBeforeBusyRef.current = null
68
+ const originalCanReceiveFocus =
69
+ original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
70
+ const target = originalCanReceiveFocus
71
+ ? original
72
+ : formRef.current?.querySelector<HTMLElement>(
73
+ 'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
74
+ )
75
+ target?.focus({ preventScroll: true })
76
+ }, [isBusy, mutationIssue])
77
+
78
+ const requestDiscard = useCallback(() => {
79
+ mutationBlockedRef.current = true
80
+ setReloadFailed(false)
81
+ setDiscarding(true)
82
+ }, [])
83
+ return {
84
+ formRef,
85
+ warningRef,
86
+ mutationBlockedRef,
87
+ discarding,
88
+ reloadFailed,
89
+ requestDiscard,
90
+ captureFocusBeforeBusy,
91
+ }
92
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { useCallback, useRef, useState } from 'react'
12
12
 
13
- import type { Field } from '@byline/core'
13
+ import { type Field, getDocumentFieldValidationDetails } from '@byline/core'
14
14
  import type { DocumentPatch } from '@byline/core/patches'
15
15
  import { useTranslation } from '@byline/i18n/react'
16
16
 
@@ -105,7 +105,9 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
105
105
  setFieldValue,
106
106
  setFieldError,
107
107
  getPendingUploads,
108
- clearPendingUploads,
108
+ removePendingUpload,
109
+ clearFieldError,
110
+ waitForFieldChanges,
109
111
  setFieldUploading,
110
112
  } = useFormContext()
111
113
  const { t } = useTranslation('byline-admin')
@@ -113,6 +115,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
113
115
 
114
116
  const [phase, setPhase] = useState<SubmissionPhase>(IDLE)
115
117
  const inFlightRef = useRef(false)
118
+ const confirmationRef = useRef<SystemFieldsSubmitPayload | null>(null)
116
119
 
117
120
  const isBusy = phase.kind === 'uploading' || phase.kind === 'submitting'
118
121
 
@@ -136,14 +139,16 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
136
139
  try {
137
140
  await onSubmit(payload)
138
141
  resetHasChanges()
139
- } catch {
142
+ } catch (error) {
143
+ const validation = getDocumentFieldValidationDetails(error)
144
+ for (const issue of validation?.issues ?? []) setFieldError(issue.field, issue.message)
140
145
  // Intentionally swallowed here — the host has already reported the
141
146
  // failure to the user. Dirty state is preserved by not resetting.
142
147
  } finally {
143
148
  setPhase(IDLE)
144
149
  }
145
150
  },
146
- [isBlocked, onBeforeBusy, onSubmit, resetHasChanges]
151
+ [isBlocked, onBeforeBusy, onSubmit, resetHasChanges, setFieldError]
147
152
  )
148
153
 
149
154
  const runUploads = useCallback(async (): Promise<boolean> => {
@@ -170,28 +175,31 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
170
175
  }
171
176
  )
172
177
 
178
+ // Adopt each success before reporting partial failure, so retries only
179
+ // transport failed files. Do not overwrite a selection replaced in flight.
180
+ for (const [fieldPath, storedFile] of uploadResult.successful) {
181
+ if (getPendingUploads().get(fieldPath) !== pendingUploads.get(fieldPath)) continue
182
+ setFieldValue(fieldPath, storedFile)
183
+ clearFieldError(fieldPath)
184
+ removePendingUpload(fieldPath)
185
+ }
173
186
  if (!uploadResult.allSucceeded) {
174
- for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
175
- setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
187
+ for (const [fieldPath, errorMessage] of uploadResult.errors) {
188
+ if (getPendingUploads().get(fieldPath) === pendingUploads.get(fieldPath)) {
189
+ setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
190
+ }
176
191
  }
177
- console.error('One or more uploads failed:', uploadResult.errors)
178
192
  return false
179
193
  }
180
-
181
- // Replace pending StoredFileValues with real ones in form data
182
- for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
183
- setFieldValue(fieldPath, storedFile)
184
- }
185
-
186
- // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
187
- clearPendingUploads()
194
+ if (getPendingUploads().size > 0) return false
188
195
  return true
189
196
  } catch (err) {
190
197
  console.error('Upload execution error:', err)
191
198
  return false
192
199
  }
193
200
  }, [
194
- clearPendingUploads,
201
+ removePendingUpload,
202
+ clearFieldError,
195
203
  documentId,
196
204
  fields,
197
205
  getFieldValues,
@@ -210,8 +218,8 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
210
218
  return {
211
219
  reason,
212
220
  payload: {
213
- data: getFieldValues(),
214
- patches: getPatches(),
221
+ data: structuredClone(getFieldValues()),
222
+ patches: structuredClone(getPatches()),
215
223
  contentDirty,
216
224
  pathDirty,
217
225
  systemPath: getSystemPath(),
@@ -230,21 +238,28 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
230
238
 
231
239
  const submit = useCallback(async () => {
232
240
  if (isBlocked()) return
233
- if (inFlightRef.current) return
241
+ if (inFlightRef.current || confirmationRef.current) return
234
242
  inFlightRef.current = true
235
243
  try {
236
244
  setPhase({ kind: 'validating' })
237
245
 
238
246
  // Run field-level beforeValidate hooks (submit-time), then validate
239
- const hookErrors = await runFieldHooks(fields)
240
- const formErrors = validateForm(fields)
241
- const allErrors = [...hookErrors, ...formErrors]
242
-
243
- if (allErrors.length > 0) {
244
- console.error('Form validation failed:', allErrors)
247
+ await waitForFieldChanges()
248
+ if (isBlocked()) {
245
249
  setPhase(IDLE)
246
250
  return
247
251
  }
252
+ // Metadata-only saves do not create a content version and must remain
253
+ // available even when an older document lacks newly required fields.
254
+ if (mode !== 'edit' || getDirtyBreakdown().reason !== 'direct-write') {
255
+ const hookErrors = await runFieldHooks(fields)
256
+ const formErrors = validateForm(fields, hookErrors)
257
+ if (formErrors.length > 0) {
258
+ console.error('Form validation failed:', formErrors)
259
+ setPhase(IDLE)
260
+ return
261
+ }
262
+ }
248
263
 
249
264
  if (isBlocked()) {
250
265
  setPhase(IDLE)
@@ -263,6 +278,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
263
278
  // so confirm it before saving. Create mode writes everything as part of
264
279
  // the initial version, so no confirmation applies there.
265
280
  if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
281
+ confirmationRef.current = payload
266
282
  setPhase({ kind: 'confirmingSystemFields', payload })
267
283
  return
268
284
  }
@@ -271,13 +287,25 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
271
287
  } finally {
272
288
  inFlightRef.current = false
273
289
  }
274
- }, [buildPayload, deliver, fields, isBlocked, mode, runUploads, runFieldHooks, validateForm])
290
+ }, [
291
+ buildPayload,
292
+ getDirtyBreakdown,
293
+ deliver,
294
+ fields,
295
+ isBlocked,
296
+ mode,
297
+ runUploads,
298
+ runFieldHooks,
299
+ validateForm,
300
+ waitForFieldChanges,
301
+ ])
275
302
 
276
303
  const confirmSystemFields = useCallback(async () => {
277
- if (phase.kind !== 'confirmingSystemFields') return
304
+ if (phase.kind !== 'confirmingSystemFields' || confirmationRef.current !== phase.payload) return
278
305
  if (inFlightRef.current) return
279
306
  inFlightRef.current = true
280
307
  const { payload } = phase
308
+ confirmationRef.current = null
281
309
  try {
282
310
  await deliver(payload)
283
311
  } finally {
@@ -286,6 +314,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
286
314
  }, [deliver, phase])
287
315
 
288
316
  const cancelSystemFields = useCallback(() => {
317
+ confirmationRef.current = null
289
318
  setPhase((current) => (current.kind === 'confirmingSystemFields' ? IDLE : current))
290
319
  }, [])
291
320
 
@@ -8,9 +8,9 @@
8
8
  * Copyright (c) Infonomic Company Limited
9
9
  */
10
10
 
11
- import { useEffect, useMemo, useState } from 'react'
11
+ import { useCallback, useMemo, useSyncExternalStore } from 'react'
12
12
 
13
- import type { TabDefinition, TabSetDefinition } from '@byline/core'
13
+ import { parseInstancePath, type TabDefinition, type TabSetDefinition } from '@byline/core'
14
14
 
15
15
  import { useFormContext } from './form-context'
16
16
 
@@ -42,27 +42,35 @@ export interface UseFormTabsResult {
42
42
  */
43
43
  export function useFormTabs(
44
44
  fieldToTabPath: Map<string, { tabSetName: string; tabName: string }>,
45
- activeTabBySet: Record<string, string>
45
+ activeTabBySet: Record<string, string>,
46
+ tabSets: TabSetDefinition[]
46
47
  ): UseFormTabsResult {
47
- const { errors: initialErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
48
-
49
- const [errors, setErrors] = useState(initialErrors)
50
- useEffect(() => {
51
- return subscribeErrors((newErrors) => setErrors(newErrors))
52
- }, [subscribeErrors])
53
-
54
- // Live form data, so TabDefinition.condition re-evaluates per keystroke.
55
- const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
56
- useEffect(() => {
57
- return subscribeMeta(() => setFormData(getFieldValues()))
58
- }, [subscribeMeta, getFieldValues])
48
+ const { getErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
49
+ const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors)
50
+ const conditionalTabs = useMemo(
51
+ () => tabSets.flatMap((set) => set.tabs.filter((tab) => tab.condition)),
52
+ [tabSets]
53
+ )
54
+ const snapshot = useCallback(
55
+ () => JSON.stringify(conditionalTabs.map((tab) => Boolean(tab.condition?.(getFieldValues())))),
56
+ [conditionalTabs, getFieldValues]
57
+ )
58
+ const subscribe = useCallback(
59
+ (notify: () => void) => (conditionalTabs.length ? subscribeMeta(notify) : () => {}),
60
+ [conditionalTabs, subscribeMeta]
61
+ )
62
+ useSyncExternalStore(subscribe, snapshot, snapshot)
59
63
 
60
64
  // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
61
65
  // Each tab bar consumes its own slice.
62
66
  const errorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
63
67
  const result: Record<string, Record<string, number>> = {}
64
68
  for (const err of errors) {
65
- const path = fieldToTabPath.get(err.field)
69
+ const parsed = parseInstancePath(err.field)
70
+ const root = parsed.ok ? parsed.segments[0] : undefined
71
+ const path =
72
+ fieldToTabPath.get(err.field) ??
73
+ (root?.kind === 'field' ? fieldToTabPath.get(root.name) : undefined)
66
74
  if (!path) continue
67
75
  result[path.tabSetName] ??= {}
68
76
  result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
@@ -71,7 +79,7 @@ export function useFormTabs(
71
79
  }, [errors, fieldToTabPath])
72
80
 
73
81
  const resolve = (set: TabSetDefinition): ResolvedTabSet => {
74
- const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
82
+ const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(getFieldValues()))
75
83
  const requested = activeTabBySet[set.name] ?? ''
76
84
  const activeTabName =
77
85
  visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
@@ -167,6 +167,8 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
167
167
  error={field.state.meta.errors.length > 0}
168
168
  errorText={firstError(field.state.meta.errors)}
169
169
  autoComplete="current-password"
170
+ showPasswordLabel={t('common.actions.showPassword')}
171
+ hidePasswordLabel={t('common.actions.hidePassword')}
170
172
  required
171
173
  />
172
174
  )}
@@ -184,6 +186,8 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
184
186
  error={field.state.meta.errors.length > 0}
185
187
  errorText={translateValidationError(t, firstError(field.state.meta.errors))}
186
188
  autoComplete="new-password"
189
+ showPasswordLabel={t('common.actions.showPassword')}
190
+ hidePasswordLabel={t('common.actions.hidePassword')}
187
191
  required
188
192
  />
189
193
  )}
@@ -201,6 +205,8 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
201
205
  error={field.state.meta.errors.length > 0}
202
206
  errorText={firstError(field.state.meta.errors)}
203
207
  autoComplete="new-password"
208
+ showPasswordLabel={t('common.actions.showPassword')}
209
+ hidePasswordLabel={t('common.actions.hidePassword')}
204
210
  required
205
211
  />
206
212
  )}
@@ -28,7 +28,7 @@ import { revalidateLogic, useForm } from '@tanstack/react-form-start'
28
28
 
29
29
  import { passwordSchema } from '@byline/core/validation'
30
30
  import { useTranslation } from '@byline/i18n/react'
31
- import { Alert, Button, Checkbox, Input, LoaderEllipsis } from '@byline/ui/react'
31
+ import { Alert, Button, Checkbox, Input, InputPassword, LoaderEllipsis } from '@byline/ui/react'
32
32
  import cx from 'clsx'
33
33
  import { z } from 'zod'
34
34
 
@@ -237,11 +237,10 @@ export function CreateAdminUser({ onClose, onSuccess }: CreateAdminUserProps) {
237
237
 
238
238
  <form.Field name="password">
239
239
  {(field) => (
240
- <Input
240
+ <InputPassword
241
241
  label={t('adminUsers.create.fields.password')}
242
242
  id="new-password"
243
243
  name={field.name}
244
- type="password"
245
244
  value={field.state.value}
246
245
  onBlur={field.handleBlur}
247
246
  onChange={(e) => field.handleChange(e.currentTarget.value)}
@@ -249,6 +248,8 @@ export function CreateAdminUser({ onClose, onSuccess }: CreateAdminUserProps) {
249
248
  errorText={translateValidationError(t, firstError(field.state.meta.errors))}
250
249
  helpText={t('adminUsers.create.fields.passwordHelp')}
251
250
  autoComplete="new-password"
251
+ showPasswordLabel={t('common.actions.showPassword')}
252
+ hidePasswordLabel={t('common.actions.hidePassword')}
252
253
  required
253
254
  />
254
255
  )}
@@ -131,6 +131,8 @@ export function SetPassword({ user, onClose, onSuccess }: SetPasswordProps) {
131
131
  error={field.state.meta.errors.length > 0}
132
132
  errorText={translateValidationError(t, firstError(field.state.meta.errors))}
133
133
  autoComplete="new-password"
134
+ showPasswordLabel={t('common.actions.showPassword')}
135
+ hidePasswordLabel={t('common.actions.hidePassword')}
134
136
  required
135
137
  />
136
138
  )}
@@ -148,6 +150,8 @@ export function SetPassword({ user, onClose, onSuccess }: SetPasswordProps) {
148
150
  error={field.state.meta.errors.length > 0}
149
151
  errorText={firstError(field.state.meta.errors)}
150
152
  autoComplete="new-password"
153
+ showPasswordLabel={t('common.actions.showPassword')}
154
+ hidePasswordLabel={t('common.actions.hidePassword')}
151
155
  required
152
156
  />
153
157
  )}
@@ -27,7 +27,7 @@ import { type FormEvent, useState } from 'react'
27
27
 
28
28
  import { getAdminConfig } from '@byline/core'
29
29
  import { useTranslation } from '@byline/i18n/react'
30
- import { Alert, Button, Card, Input, LoaderEllipsis } from '@byline/ui/react'
30
+ import { Alert, Button, Card, Input, InputPassword, LoaderEllipsis } from '@byline/ui/react'
31
31
  import cx from 'clsx'
32
32
 
33
33
  import { useBylineAdminServices } from '../../../services/admin-services-context.js'
@@ -111,12 +111,13 @@ export function SignInForm({ redirectTo, homeUrl }: SignInFormProps) {
111
111
  onChange={(event) => setEmail(event.currentTarget.value)}
112
112
  disabled={pending}
113
113
  />
114
- <Input
114
+ <InputPassword
115
115
  label={t('common.fields.password')}
116
116
  id="password"
117
117
  name="password"
118
- type="password"
119
118
  autoComplete="current-password"
119
+ showPasswordLabel={t('common.actions.showPassword')}
120
+ hidePasswordLabel={t('common.actions.hidePassword')}
120
121
  required
121
122
  value={password}
122
123
  onChange={(event) => setPassword(event.currentTarget.value)}