@byline/admin 6.2.1 → 6.4.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 (82) 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 +4 -1
  4. package/dist/fields/code/code-field.js +2 -1
  5. package/dist/fields/datetime/datetime-field.js +5 -2
  6. package/dist/fields/field-renderer.js +2 -1
  7. package/dist/fields/field-services-types.d.ts +23 -0
  8. package/dist/fields/file/file-field.js +6 -4
  9. package/dist/fields/group/group-field.js +8 -3
  10. package/dist/fields/image/image-field.js +6 -4
  11. package/dist/fields/numerical/numerical-field.js +2 -1
  12. package/dist/fields/relation/relation-field.js +8 -2
  13. package/dist/fields/relation/relation-many-field.js +12 -4
  14. package/dist/fields/relation/relation-picker.js +38 -7
  15. package/dist/fields/relation/relation-picker.module.js +3 -0
  16. package/dist/fields/relation/relation-picker_module.css +20 -0
  17. package/dist/fields/select/select-field.js +4 -2
  18. package/dist/fields/text/text-field.js +6 -3
  19. package/dist/fields/text-area/text-area-field.js +6 -3
  20. package/dist/fields/use-field-change-handler.js +24 -6
  21. package/dist/fields/use-field-condition.d.ts +0 -15
  22. package/dist/fields/use-field-condition.js +10 -1
  23. package/dist/forms/form-context.d.ts +3 -12
  24. package/dist/forms/form-context.js +115 -130
  25. package/dist/forms/form-dom-scope.d.ts +34 -0
  26. package/dist/forms/form-dom-scope.js +20 -0
  27. package/dist/forms/form-layout.js +10 -3
  28. package/dist/forms/form-page-chrome.js +14 -24
  29. package/dist/forms/form-renderer.d.ts +2 -1
  30. package/dist/forms/form-renderer.js +13 -56
  31. package/dist/forms/nested-path.d.ts +2 -0
  32. package/dist/forms/nested-path.js +26 -1
  33. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  34. package/dist/forms/use-document-reload-recovery.js +67 -0
  35. package/dist/forms/use-form-submission.d.ts +1 -1
  36. package/dist/forms/use-form-submission.js +45 -27
  37. package/dist/forms/use-form-tabs.d.ts +2 -2
  38. package/dist/forms/use-form-tabs.js +18 -11
  39. package/dist/presentation/tabs.js +2 -1
  40. package/dist/react.d.ts +1 -1
  41. package/package.json +14 -14
  42. package/src/fields/array/array-field.tsx +24 -12
  43. package/src/fields/blocks/blocks-field.tsx +18 -8
  44. package/src/fields/checkbox/checkbox-field.tsx +4 -1
  45. package/src/fields/code/code-field.tsx +2 -1
  46. package/src/fields/datetime/datetime-field.tsx +5 -2
  47. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  48. package/src/fields/field-dom-ids.test.tsx +293 -0
  49. package/src/fields/field-renderer.tsx +2 -1
  50. package/src/fields/field-services-types.ts +25 -0
  51. package/src/fields/file/file-field.tsx +6 -4
  52. package/src/fields/group/group-field.tsx +5 -3
  53. package/src/fields/image/image-field.tsx +6 -4
  54. package/src/fields/numerical/numerical-field.tsx +2 -1
  55. package/src/fields/relation/relation-field.tsx +8 -2
  56. package/src/fields/relation/relation-many-field.tsx +19 -4
  57. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  58. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  59. package/src/fields/relation/relation-picker.module.css +32 -0
  60. package/src/fields/relation/relation-picker.tsx +59 -1
  61. package/src/fields/select/select-field.tsx +4 -2
  62. package/src/fields/text/text-field.tsx +5 -3
  63. package/src/fields/text-area/text-area-field.tsx +5 -3
  64. package/src/fields/use-field-change-handler.ts +43 -9
  65. package/src/fields/use-field-condition.ts +17 -1
  66. package/src/forms/form-context.tsx +180 -234
  67. package/src/forms/form-correctness.test.tsx +560 -0
  68. package/src/forms/form-dom-scope.test.tsx +213 -0
  69. package/src/forms/form-dom-scope.tsx +70 -0
  70. package/src/forms/form-layout.tsx +10 -3
  71. package/src/forms/form-page-chrome.tsx +17 -34
  72. package/src/forms/form-renderer-submit.test.tsx +36 -0
  73. package/src/forms/form-renderer.tsx +26 -64
  74. package/src/forms/form-submit-isolation.test.tsx +247 -0
  75. package/src/forms/nested-path.ts +35 -0
  76. package/src/forms/use-document-reload-recovery.ts +92 -0
  77. package/src/forms/use-form-submission.ts +56 -27
  78. package/src/forms/use-form-tabs.ts +25 -17
  79. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  80. package/src/presentation/tabs.test.tsx +49 -2
  81. package/src/presentation/tabs.tsx +11 -1
  82. package/src/react.ts +2 -0
@@ -32,6 +32,7 @@ import {
32
32
  useIsDirty,
33
33
  useIsFieldUploading,
34
34
  } from '../../forms/form-context'
35
+ import { useScopedDomId } from '../../forms/form-dom-scope'
35
36
  import { useFieldChangeHandler } from '../use-field-change-handler'
36
37
  import styles from './image-field.module.css'
37
38
  import { ImageUploadField } from './image-upload-field'
@@ -103,6 +104,7 @@ export const ImageField = ({
103
104
 
104
105
  // Handle remove, including cleanup of pending uploads
105
106
  const handleRemove = () => {
107
+ if (field.readOnly) return
106
108
  if (isPending) {
107
109
  removePendingUpload(fieldPath)
108
110
  }
@@ -114,7 +116,7 @@ export const ImageField = ({
114
116
  const [lightboxOpen, setLightboxOpen] = useState(false)
115
117
  const canOpenLightbox = !isPending && !!incomingValue?.storageUrl
116
118
 
117
- const htmlId = fieldPath
119
+ const htmlId = useScopedDomId(fieldPath)
118
120
 
119
121
  return (
120
122
  <div className={`byline-field-image ${field.name}`}>
@@ -136,7 +138,7 @@ export const ImageField = ({
136
138
  >
137
139
  {t('fields.upload.requireSavedDocument')}
138
140
  </div>
139
- ) : collectionPath ? (
141
+ ) : collectionPath && !field.readOnly ? (
140
142
  <ImageUploadField
141
143
  field={field}
142
144
  collectionPath={collectionPath}
@@ -169,7 +171,7 @@ export const ImageField = ({
169
171
  intent="noeffect"
170
172
  onClick={handleRemove}
171
173
  size="xs"
172
- disabled={isUploading}
174
+ disabled={isUploading || field.readOnly}
173
175
  aria-label={t('fields.image.removeAriaLabel')}
174
176
  >
175
177
  <CloseIcon width="15px" height="15px" />
@@ -297,7 +299,7 @@ export const ImageField = ({
297
299
 
298
300
  {field.helpText && <HelpText text={field.helpText} />}
299
301
 
300
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
302
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
301
303
 
302
304
  {canOpenLightbox && incomingValue?.storageUrl && (
303
305
  <ImageLightbox
@@ -20,6 +20,7 @@ import { normalizeNumericValue } from '@byline/core'
20
20
  import { Input } from '@byline/ui/react'
21
21
 
22
22
  import { useFieldError, useFieldValue } from '../../forms/form-context'
23
+ import { useScopedDomId } from '../../forms/form-dom-scope'
23
24
 
24
25
  type NumericalValue = string | number | null
25
26
 
@@ -48,7 +49,7 @@ export const NumericalField = ({
48
49
  const fieldValue = useFieldValue<NumericalValue | undefined>(fieldPath)
49
50
  const incomingValue =
50
51
  value !== undefined ? value : fieldValue !== undefined ? fieldValue : (defaultValue ?? null)
51
- const htmlId = id ?? fieldPath
52
+ const htmlId = useScopedDomId(fieldPath, id)
52
53
  const canonicalDisplay = incomingValue == null ? '' : String(incomingValue)
53
54
  const [displayValue, setDisplayValue] = useState(canonicalDisplay)
54
55
  const [isEditing, setIsEditing] = useState(false)
@@ -28,6 +28,7 @@ import {
28
28
  import cx from 'clsx'
29
29
 
30
30
  import { useFieldError, useFieldValue } from '../../forms/form-context'
31
+ import { useScopedDomId } from '../../forms/form-dom-scope'
31
32
  import styles from './relation-field.module.css'
32
33
  import { RelationPicker } from './relation-picker'
33
34
  import { RelationSummary } from './relation-summary'
@@ -65,7 +66,7 @@ export const RelationField = ({
65
66
  path,
66
67
  }: RelationFieldProps) => {
67
68
  const fieldPath = path ?? field.name
68
- const htmlId = id ?? fieldPath
69
+ const htmlId = useScopedDomId(fieldPath, id)
69
70
  const fieldError = useFieldError(fieldPath)
70
71
  const fieldValue = useFieldValue<IncomingRelationValue | null | undefined>(fieldPath)
71
72
 
@@ -103,6 +104,7 @@ export const RelationField = ({
103
104
  targetCollectionId: string
104
105
  record?: Record<string, any>
105
106
  }) => {
107
+ if (field.readOnly) return
106
108
  setPickerOpen(false)
107
109
  if (selection.record) {
108
110
  setPickedRecord({ id: selection.targetDocumentId, record: selection.record })
@@ -116,6 +118,7 @@ export const RelationField = ({
116
118
  }
117
119
 
118
120
  const handleRemove = () => {
121
+ if (field.readOnly) return
119
122
  setPickedRecord(null)
120
123
  onChange?.(null)
121
124
  }
@@ -167,6 +170,7 @@ export const RelationField = ({
167
170
  />
168
171
  <div className={cx('byline-field-relation-actions', styles.actions)}>
169
172
  <IconButton
173
+ disabled={field.readOnly}
170
174
  id={htmlId}
171
175
  type="button"
172
176
  intent="noeffect"
@@ -179,6 +183,7 @@ export const RelationField = ({
179
183
  <EditIcon width="15px" height="15px" />
180
184
  </IconButton>
181
185
  <IconButton
186
+ disabled={field.readOnly}
182
187
  type="button"
183
188
  intent="noeffect"
184
189
  size="xs"
@@ -193,6 +198,7 @@ export const RelationField = ({
193
198
  </div>
194
199
  ) : (
195
200
  <Button
201
+ disabled={field.readOnly}
196
202
  id={htmlId}
197
203
  size="xs"
198
204
  variant="outlined"
@@ -208,7 +214,7 @@ export const RelationField = ({
208
214
 
209
215
  {field.helpText != null && <HelpText text={field.helpText} />}
210
216
 
211
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
217
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
212
218
 
213
219
  {!isUnknown && (
214
220
  <RelationPicker
@@ -32,6 +32,7 @@ import {
32
32
  import cx from 'clsx'
33
33
 
34
34
  import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
35
+ import { useScopedDomId } from '../../forms/form-dom-scope'
35
36
  import styles from './relation-field.module.css'
36
37
  import { RelationPicker, type RelationPickerSelection } from './relation-picker'
37
38
  import { RelationSummary } from './relation-summary'
@@ -60,12 +61,20 @@ interface RelationManyTileProps {
60
61
  id: string
61
62
  children: ReactNode
62
63
  onRemove: () => void
64
+ readOnly?: boolean
63
65
  removeAriaLabel: string
64
66
  }
65
67
 
66
- const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationManyTileProps) => {
68
+ const RelationManyTile = ({
69
+ id,
70
+ children,
71
+ onRemove,
72
+ removeAriaLabel,
73
+ readOnly,
74
+ }: RelationManyTileProps) => {
67
75
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
68
76
  id,
77
+ disabled: readOnly,
69
78
  transition: { duration: 250, easing: 'cubic-bezier(0, 0.2, 0.2, 1)' },
70
79
  })
71
80
 
@@ -86,6 +95,7 @@ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationM
86
95
  )}
87
96
  >
88
97
  <button
98
+ disabled={readOnly}
89
99
  type="button"
90
100
  className={cx('byline-field-relation-many-grip', styles['many-grip'])}
91
101
  {...attributes}
@@ -95,6 +105,7 @@ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationM
95
105
  </button>
96
106
  <div className={cx('byline-field-relation-many-body', styles['many-body'])}>{children}</div>
97
107
  <IconButton
108
+ disabled={readOnly}
98
109
  type="button"
99
110
  size="xs"
100
111
  intent="noeffect"
@@ -117,7 +128,7 @@ interface RelationManyFieldProps {
117
128
 
118
129
  export const RelationManyField = ({ field, defaultValue, id, path }: RelationManyFieldProps) => {
119
130
  const fieldPath = path ?? field.name
120
- const htmlId = id ?? fieldPath
131
+ const htmlId = useScopedDomId(fieldPath, id)
121
132
  const { t } = useTranslation('byline-admin')
122
133
  const fieldError = useFieldError(fieldPath)
123
134
  const { getFieldValue, setFieldValue } = useFormContext()
@@ -151,6 +162,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
151
162
  }
152
163
 
153
164
  const handleAddMany = (selections: RelationPickerSelection[]) => {
165
+ if (field.readOnly) return
154
166
  setPickerOpen(false)
155
167
  const current = currentArray()
156
168
  // Dedup the batch against the current array — a target may appear at most
@@ -177,6 +189,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
177
189
  }
178
190
 
179
191
  const handleRemove = (targetDocumentId: string) => {
192
+ if (field.readOnly) return
180
193
  setFieldValue(
181
194
  fieldPath,
182
195
  currentArray().filter((v) => v.targetDocumentId !== targetDocumentId)
@@ -190,7 +203,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
190
203
  moveFromIndex: number
191
204
  moveToIndex: number
192
205
  }) => {
193
- if (moveFromIndex === moveToIndex) return
206
+ if (field.readOnly || moveFromIndex === moveToIndex) return
194
207
  setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex))
195
208
  }
196
209
 
@@ -234,6 +247,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
234
247
  >
235
248
  {items.map((value) => (
236
249
  <RelationManyTile
250
+ readOnly={field.readOnly}
237
251
  key={value.targetDocumentId}
238
252
  id={value.targetDocumentId}
239
253
  onRemove={() => handleRemove(value.targetDocumentId)}
@@ -253,6 +267,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
253
267
 
254
268
  <div className={cx('byline-field-relation-many-add', styles['many-add'])}>
255
269
  <Button
270
+ disabled={field.readOnly}
256
271
  id={htmlId}
257
272
  size="xs"
258
273
  variant="outlined"
@@ -268,7 +283,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
268
283
 
269
284
  {field.helpText != null && <HelpText text={field.helpText} />}
270
285
 
271
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
286
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
272
287
 
273
288
  <RelationPicker
274
289
  multiple
@@ -0,0 +1,203 @@
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 { act } from 'react'
10
+
11
+ import { defineAdminConfig } from '@byline/core'
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createRoot, type Root } from 'react-dom/client'
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16
+
17
+ // base-ui's Modal portals into a floating layer jsdom cannot position, and the
18
+ // contract under test is what the picker puts *into* the modal.
19
+ vi.mock('@byline/ui/react', async (importOriginal) => {
20
+ const actual = await importOriginal<Record<string, unknown>>()
21
+ const Pass = ({ children }: any) => <div>{children}</div>
22
+ const Modal: any = ({ children, isOpen }: any) => (isOpen ? <div>{children}</div> : null)
23
+ Modal.Container = Pass
24
+ Modal.Header = Pass
25
+ Modal.Content = Pass
26
+ Modal.Actions = Pass
27
+ return {
28
+ ...actual,
29
+ Modal,
30
+ Search: ({ placeholder }: any) => <input aria-label="search" placeholder={placeholder} />,
31
+ LoaderRing: () => <span />,
32
+ }
33
+ })
34
+
35
+ import { BylineFieldServicesProvider } from '../field-services-context'
36
+ import { RelationPicker } from './relation-picker'
37
+
38
+ const globalWithAct = globalThis as any
39
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
40
+
41
+ defineAdminConfig({
42
+ i18n: {
43
+ admin: { defaultLocale: 'en', locales: ['en'] },
44
+ content: { defaultLocale: 'en', locales: ['en'] },
45
+ },
46
+ collections: [
47
+ {
48
+ path: 'media',
49
+ labels: { singular: 'Media item', plural: 'Media' },
50
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
51
+ },
52
+ ],
53
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
54
+ })
55
+
56
+ const targetDefinition = {
57
+ path: 'media',
58
+ labels: { singular: 'Media item', plural: 'Media' },
59
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
60
+ } as any
61
+
62
+ const emptyList = async () => ({
63
+ docs: [],
64
+ meta: { totalPages: 1 },
65
+ included: { collection: { id: 'collection-1' } },
66
+ })
67
+
68
+ let container: HTMLDivElement
69
+ let root: Root
70
+
71
+ beforeEach(() => {
72
+ container = document.createElement('div')
73
+ document.body.appendChild(container)
74
+ root = createRoot(container)
75
+ })
76
+
77
+ afterEach(() => {
78
+ act(() => {
79
+ root.unmount()
80
+ })
81
+ container.remove()
82
+ })
83
+
84
+ const render = async (services: Record<string, unknown>, props: Record<string, unknown> = {}) => {
85
+ await act(async () => {
86
+ root.render(
87
+ <I18nProvider
88
+ bundle={adminTranslations({ locales: ['en'] })}
89
+ activeLocale="en"
90
+ defaultLocale="en"
91
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
92
+ >
93
+ <BylineFieldServicesProvider
94
+ services={
95
+ { getCollectionDocuments: emptyList, uploadField: async () => ({}), ...services } as any
96
+ }
97
+ >
98
+ <RelationPicker
99
+ targetCollectionPath="media"
100
+ targetDefinition={targetDefinition}
101
+ displayField="title"
102
+ isOpen={true}
103
+ onSelect={() => {}}
104
+ onDismiss={() => {}}
105
+ {...(props as any)}
106
+ />
107
+ </BylineFieldServicesProvider>
108
+ </I18nProvider>
109
+ )
110
+ })
111
+ }
112
+
113
+ const createLink = () =>
114
+ container.querySelector<HTMLAnchorElement>('.byline-field-relation-picker-create')
115
+
116
+ const bothCapabilities = {
117
+ canCreateInCollection: () => true,
118
+ getCreateDocumentUrl: (path: string) => `/control-panel/collections/${path}/create`,
119
+ }
120
+
121
+ describe('relation picker create affordance', () => {
122
+ it('is absent when the host wires no capabilities', async () => {
123
+ await render({})
124
+
125
+ expect(createLink()).toBeNull()
126
+ })
127
+
128
+ /**
129
+ * Permission is unknowable without `canCreateInCollection`, and offering a link
130
+ * that leads to a refusal is worse than offering none.
131
+ */
132
+ it('is absent when only the url capability is wired', async () => {
133
+ await render({ getCreateDocumentUrl: (path: string) => `/x/${path}/create` })
134
+
135
+ expect(createLink()).toBeNull()
136
+ })
137
+
138
+ it('is absent when only the permission capability is wired', async () => {
139
+ await render({ canCreateInCollection: () => true })
140
+
141
+ expect(createLink()).toBeNull()
142
+ })
143
+
144
+ it('is absent when the viewer may not create in the target collection', async () => {
145
+ await render({ ...bothCapabilities, canCreateInCollection: () => false })
146
+
147
+ expect(createLink()).toBeNull()
148
+ })
149
+
150
+ it('asks about the target collection, not some other one', async () => {
151
+ const canCreateInCollection = vi.fn(() => true)
152
+ await render({ ...bothCapabilities, canCreateInCollection })
153
+
154
+ expect(canCreateInCollection).toHaveBeenCalledWith('media')
155
+ })
156
+
157
+ it('renders the host-supplied url when permitted', async () => {
158
+ await render(bothCapabilities)
159
+
160
+ expect(createLink()?.getAttribute('href')).toBe('/control-panel/collections/media/create')
161
+ })
162
+
163
+ /**
164
+ * A new tab is the whole point: the parent editor and this picker stay mounted
165
+ * with their unsaved state. `noopener` because the opened page must not reach
166
+ * back into this one through `window.opener`.
167
+ */
168
+ it('opens in a new tab without handing over a window reference', async () => {
169
+ await render(bothCapabilities)
170
+
171
+ expect(createLink()?.getAttribute('target')).toBe('_blank')
172
+ expect(createLink()?.getAttribute('rel')).toContain('noopener')
173
+ })
174
+
175
+ it('names the target collection so the reader knows what they are creating', async () => {
176
+ await render(bothCapabilities)
177
+
178
+ expect(createLink()?.textContent).toContain('Media item')
179
+ })
180
+
181
+ /**
182
+ * An anchor rather than a button calling `window.open`: keyboard and
183
+ * middle-click work for free, and popup blockers leave it alone.
184
+ */
185
+ it('is a real link, reachable by keyboard', async () => {
186
+ await render(bothCapabilities)
187
+
188
+ const link = createLink()
189
+ expect(link?.tagName).toBe('A')
190
+ expect(link?.getAttribute('href')).toBeTruthy()
191
+ })
192
+
193
+ it('does not dismiss the picker when followed', async () => {
194
+ const onDismiss = vi.fn()
195
+ await render(bothCapabilities, { onDismiss })
196
+
197
+ act(() => {
198
+ createLink()?.click()
199
+ })
200
+
201
+ expect(onDismiss).not.toHaveBeenCalled()
202
+ })
203
+ })