@byline/admin 6.2.1 → 6.3.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 (55) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout.js +3 -1
  22. package/dist/forms/form-renderer.js +1 -0
  23. package/dist/presentation/tabs.js +2 -1
  24. package/dist/react.d.ts +1 -1
  25. package/package.json +7 -7
  26. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  27. package/src/fields/code/code-field.tsx +2 -1
  28. package/src/fields/datetime/datetime-field.tsx +4 -2
  29. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  30. package/src/fields/field-dom-ids.test.tsx +293 -0
  31. package/src/fields/field-renderer.tsx +2 -1
  32. package/src/fields/field-services-types.ts +25 -0
  33. package/src/fields/file/file-field.tsx +3 -2
  34. package/src/fields/group/group-field.tsx +3 -1
  35. package/src/fields/image/image-field.tsx +3 -2
  36. package/src/fields/numerical/numerical-field.tsx +2 -1
  37. package/src/fields/relation/relation-field.tsx +3 -2
  38. package/src/fields/relation/relation-many-field.tsx +3 -2
  39. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  40. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  41. package/src/fields/relation/relation-picker.module.css +32 -0
  42. package/src/fields/relation/relation-picker.tsx +59 -1
  43. package/src/fields/select/select-field.tsx +3 -2
  44. package/src/fields/text/text-field.tsx +2 -1
  45. package/src/fields/text-area/text-area-field.tsx +2 -1
  46. package/src/forms/form-context.tsx +2 -1
  47. package/src/forms/form-dom-scope.test.tsx +213 -0
  48. package/src/forms/form-dom-scope.tsx +70 -0
  49. package/src/forms/form-layout.tsx +7 -1
  50. package/src/forms/form-renderer.tsx +11 -0
  51. package/src/forms/form-submit-isolation.test.tsx +247 -0
  52. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  53. package/src/presentation/tabs.test.tsx +49 -2
  54. package/src/presentation/tabs.tsx +11 -1
  55. package/src/react.ts +2 -0
@@ -0,0 +1,153 @@
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 type { AdminResourceConfig, Field } from '@byline/core'
12
+ import { defineAdminConfig } from '@byline/core'
13
+ import { adminTranslations } from '@byline/i18n/admin'
14
+ import { I18nProvider } from '@byline/i18n/react'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
17
+
18
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
19
+ import { FormProvider } from '../forms/form-context'
20
+ import { FormLayout } from '../forms/form-layout'
21
+
22
+ // Deliberately not the `;(globalThis as any)` idiom used elsewhere in this
23
+ // suite: Biome's import sorting can move an import across that leading
24
+ // semicolon, and without semicolons the result parses as a call on the import.
25
+ const globalWithAct = globalThis as any
26
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
27
+
28
+ defineAdminConfig({
29
+ i18n: {
30
+ admin: { defaultLocale: 'en', locales: ['en'] },
31
+ content: { defaultLocale: 'en', locales: ['en'] },
32
+ },
33
+ collections: [
34
+ {
35
+ path: 'pages',
36
+ labels: { singular: 'Page', plural: 'Pages' },
37
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
38
+ },
39
+ ],
40
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
41
+ })
42
+
43
+ const fields: Field[] = [
44
+ { name: 'title', label: 'Title', type: 'text' },
45
+ { name: 'seoTitle', label: 'SEO title', type: 'text' },
46
+ ]
47
+
48
+ /**
49
+ * Both forms declare a tab set under the same name, which is the ordinary case:
50
+ * two forms over one collection share its admin configuration verbatim.
51
+ */
52
+ const adminConfig = {
53
+ layout: { main: ['main'] },
54
+ tabSets: [
55
+ {
56
+ name: 'main',
57
+ tabs: [
58
+ { name: 'general', label: 'General', fields: ['title'] },
59
+ { name: 'seo', label: 'SEO', fields: ['seoTitle'] },
60
+ ],
61
+ },
62
+ ],
63
+ } as unknown as AdminResourceConfig
64
+
65
+ let container: HTMLDivElement
66
+ let root: Root
67
+
68
+ beforeEach(() => {
69
+ container = document.createElement('div')
70
+ document.body.appendChild(container)
71
+ root = createRoot(container)
72
+ })
73
+
74
+ afterEach(() => {
75
+ act(() => {
76
+ root.unmount()
77
+ })
78
+ container.remove()
79
+ })
80
+
81
+ const Form = ({ name }: { name: string }) => (
82
+ <form aria-label={name}>
83
+ <FormProvider initialData={{ fields: {} }} collectionPath="pages">
84
+ <FormLayout
85
+ fields={fields}
86
+ adminConfig={adminConfig}
87
+ activeTabBySet={{ main: 'general' }}
88
+ onTabChange={() => {}}
89
+ />
90
+ </FormProvider>
91
+ </form>
92
+ )
93
+
94
+ const renderTwoForms = () => {
95
+ act(() => {
96
+ root.render(
97
+ <I18nProvider
98
+ bundle={adminTranslations({ locales: ['en'] })}
99
+ activeLocale="en"
100
+ defaultLocale="en"
101
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
102
+ >
103
+ <BylineFieldServicesProvider
104
+ services={
105
+ {
106
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
107
+ uploadField: async () => ({}),
108
+ } as any
109
+ }
110
+ >
111
+ <Form name="parent" />
112
+ <Form name="child" />
113
+ </BylineFieldServicesProvider>
114
+ </I18nProvider>
115
+ )
116
+ })
117
+ }
118
+
119
+ describe('tab DOM ids across concurrently mounted forms', () => {
120
+ it('keeps tab trigger ids distinct across two forms with a "main" tab set', () => {
121
+ renderTwoForms()
122
+
123
+ const triggers = Array.from(container.querySelectorAll('[role="tab"]')).map((n) => n.id)
124
+
125
+ expect(triggers.length).toBeGreaterThan(0)
126
+ expect(triggers.every((id) => id !== '')).toBe(true)
127
+ expect(new Set(triggers).size).toBe(triggers.length)
128
+ })
129
+
130
+ it('labels each tab panel from a trigger in its own form', () => {
131
+ renderTwoForms()
132
+
133
+ const panels = Array.from(container.querySelectorAll('[role="tabpanel"]'))
134
+ expect(panels).toHaveLength(2)
135
+
136
+ for (const panel of panels) {
137
+ const owner = document.getElementById(panel.getAttribute('aria-labelledby') as string)
138
+ expect(owner).not.toBeNull()
139
+ // Resolution is document-wide, so an unscoped id points the second form's
140
+ // panel at the first form's trigger.
141
+ expect(owner?.closest('form')).toBe(panel.closest('form'))
142
+ }
143
+ })
144
+
145
+ it('keeps panel ids distinct so aria-controls cannot cross forms', () => {
146
+ renderTwoForms()
147
+
148
+ const panelIds = Array.from(container.querySelectorAll('[role="tabpanel"]')).map((n) => n.id)
149
+
150
+ expect(panelIds.every((id) => id !== '')).toBe(true)
151
+ expect(new Set(panelIds).size).toBe(panelIds.length)
152
+ })
153
+ })
@@ -51,7 +51,7 @@ import { AdminTabs } from './tabs'
51
51
  * report overflow. Drive the two widths the component reads off its stable
52
52
  * override handles.
53
53
  */
54
- const layout = { container: 0, row: 0 }
54
+ const layout = { container: 0, row: 0, rowReads: 0 }
55
55
 
56
56
  function installPointerStubs() {
57
57
  // jsdom implements neither pointer capture nor scrollTo; the component guards
@@ -96,7 +96,11 @@ function installLayoutStubs() {
96
96
  Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
97
97
  configurable: true,
98
98
  get(this: HTMLElement) {
99
- return this.classList.contains('byline-admin-tablist') ? layout.row : 0
99
+ if (!this.classList.contains('byline-admin-tablist')) return 0
100
+ // Counted, not just answered: in a browser this getter is what forces
101
+ // the synchronous layout, so the count is the cost under test.
102
+ layout.rowReads += 1
103
+ return layout.row
100
104
  },
101
105
  })
102
106
  }
@@ -135,6 +139,7 @@ describe('AdminTabs', () => {
135
139
  observed.length = 0
136
140
  layout.container = 1000
137
141
  layout.row = 400
142
+ layout.rowReads = 0
138
143
  onChange = vi.fn()
139
144
  container = document.createElement('div')
140
145
  document.body.appendChild(container)
@@ -367,6 +372,48 @@ describe('AdminTabs', () => {
367
372
  expect(observedClasses.some((c) => c.includes('byline-admin-tabs '))).toBe(true)
368
373
  })
369
374
 
375
+ /**
376
+ * The tab array arrives with a fresh identity on every render of the form
377
+ * layout: `useFormTabs.resolve()` filters `set.tabs` each time it is called,
378
+ * and the live form data it filters against updates per keystroke. Keying the
379
+ * measurement effect on that identity made every unrelated render tear down
380
+ * the ResizeObserver and force a synchronous layout read — measured in Chrome
381
+ * against a seeded `pages` document as one forced read per keystroke, and two
382
+ * on an editor mount.
383
+ */
384
+ it('does not re-measure when the parent re-renders with an equal tab array', () => {
385
+ render()
386
+ const readsAfterMount = layout.rowReads
387
+ const observedAfterMount = observed.length
388
+
389
+ render({ tabs: TABS.map((tab) => ({ ...tab })) })
390
+
391
+ expect(layout.rowReads).toBe(readsAfterMount)
392
+ expect(observed.length).toBe(observedAfterMount)
393
+ })
394
+
395
+ it('re-measures when a tab is added or removed', () => {
396
+ render()
397
+ const readsAfterMount = layout.rowReads
398
+
399
+ render({ tabs: [...TABS, { name: 'indexing', label: 'Indexing' }] })
400
+
401
+ expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
402
+ })
403
+
404
+ /**
405
+ * A label carries the width, so a translation change has to re-measure even
406
+ * though the tab names are untouched.
407
+ */
408
+ it('re-measures when a label changes', () => {
409
+ render()
410
+ const readsAfterMount = layout.rowReads
411
+
412
+ render({ tabs: TABS.map((tab, i) => (i === 0 ? { ...tab, label: 'Détails' } : tab)) })
413
+
414
+ expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
415
+ })
416
+
370
417
  // ─── Drag-to-scroll (desktop) ─────────────────────────────────────────
371
418
 
372
419
  const viewport = () =>
@@ -124,6 +124,16 @@ export const AdminTabs = ({
124
124
 
125
125
  const [strip, setStrip] = useState<StripState>(INITIAL_STRIP_STATE)
126
126
 
127
+ // The measurement effect is keyed on what the tabs *are*, not on the identity
128
+ // of the array carrying them. A form layout rebuilds that array on every
129
+ // render — `useFormTabs.resolve()` filters `set.tabs` against live form data,
130
+ // so a new array arrives per keystroke — and re-running the effect for one of
131
+ // those renders would disconnect the observer and force a synchronous layout
132
+ // read on a tree the browser has just invalidated. Labels are part of the key
133
+ // because the label is what carries the width: a translation change has to
134
+ // re-measure, a keystroke elsewhere in the form does not.
135
+ const tabsKey = tabs.map((tab) => `${tab.name}:${tab.label}`).join('\u0000')
136
+
127
137
  const measure = useCallback(() => {
128
138
  const container = containerRef.current
129
139
  const row = rowRef.current
@@ -162,7 +172,7 @@ export const AdminTabs = ({
162
172
  observer.observe(container)
163
173
  observer.observe(row)
164
174
  return () => observer.disconnect()
165
- }, [measure, tabs])
175
+ }, [measure, tabsKey])
166
176
 
167
177
  const reveal = useCallback((name: string) => {
168
178
  const viewport = viewportRef.current
package/src/react.ts CHANGED
@@ -81,10 +81,12 @@ export * from './widgets/status-badge/status-badge.js'
81
81
  // Field-side service contract types.
82
82
  export type {
83
83
  BylineFieldServices,
84
+ CanCreateInCollectionFn,
84
85
  CollectionListDoc,
85
86
  CollectionListParams,
86
87
  CollectionListResponse,
87
88
  GetCollectionDocumentsFn,
89
+ GetCreateDocumentUrlFn,
88
90
  GetTreeAncestorsFn,
89
91
  GetTreeParentFn,
90
92
  PlaceTreeNodeFn,