@byline/admin 4.16.0 → 4.17.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.
@@ -210,6 +210,16 @@ export function DocumentActions({
210
210
  }
211
211
  }
212
212
 
213
+ // Whether the ellipsis menu has anything to show. Every entry below is
214
+ // conditional, so with no available action the trigger would open an empty
215
+ // menu.
216
+ const hasAnyAction =
217
+ schedulingActions.length > 0 ||
218
+ copyToLocaleAvailable ||
219
+ deleteLocaleAvailable ||
220
+ onDuplicate != null ||
221
+ onDelete != null
222
+
213
223
  const handleOnDelete = () => {
214
224
  setShowDeleteConfirm(false)
215
225
  if (onDelete) {
@@ -298,25 +308,26 @@ export function DocumentActions({
298
308
 
299
309
  return (
300
310
  <>
301
- <DropdownComponent.Root>
302
- <DropdownComponent.Trigger
303
- render={<IconButton variant="text" intent="noeffect" size="sm" />}
304
- >
305
- <EllipsisIcon
306
- className={cx('byline-form-actions-icon', styles.icon)}
307
- width="15px"
308
- height="15px"
309
- />
310
- </DropdownComponent.Trigger>
311
-
312
- <DropdownComponent.Portal>
313
- <DropdownComponent.Content
314
- className={cx('byline-form-actions-menu', styles.menu)}
315
- align="end"
316
- data-side="top"
317
- sideOffset={10}
311
+ {hasAnyAction && (
312
+ <DropdownComponent.Root>
313
+ <DropdownComponent.Trigger
314
+ render={<IconButton variant="text" intent="noeffect" size="sm" />}
318
315
  >
319
- {/*{publishedVersion && (
316
+ <EllipsisIcon
317
+ className={cx('byline-form-actions-icon', styles.icon)}
318
+ width="15px"
319
+ height="15px"
320
+ />
321
+ </DropdownComponent.Trigger>
322
+
323
+ <DropdownComponent.Portal>
324
+ <DropdownComponent.Content
325
+ className={cx('byline-form-actions-menu', styles.menu)}
326
+ align="end"
327
+ data-side="top"
328
+ sideOffset={10}
329
+ >
330
+ {/*{publishedVersion && (
320
331
  <>
321
332
  <DropdownComponent.Item onClick={onUnpublish}>
322
333
  <div className={cx('byline-form-actions-item', styles.item)}>
@@ -329,67 +340,75 @@ export function DocumentActions({
329
340
  <DropdownComponent.Separator />
330
341
  </>
331
342
  )}*/}
332
- {schedulingActions.length > 0 && (
333
- <>
334
- {schedulingActions.map((action) => (
335
- <DropdownComponent.Item key={action.key} onClick={action.onSelect}>
343
+ {schedulingActions.length > 0 && (
344
+ <>
345
+ {schedulingActions.map((action) => (
346
+ <DropdownComponent.Item key={action.key} onClick={action.onSelect}>
347
+ <div className={cx('byline-form-actions-item', styles.item)}>
348
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
349
+ <button type="button">{action.label}</button>
350
+ </span>
351
+ </div>
352
+ </DropdownComponent.Item>
353
+ ))}
354
+ <DropdownComponent.Separator />
355
+ </>
356
+ )}
357
+ {copyToLocaleAvailable && (
358
+ <DropdownComponent.Item onClick={handleOpenCopyToLocale}>
359
+ <div className={cx('byline-form-actions-item', styles.item)}>
360
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
361
+ <button type="button">{t('documentActions.copyToLocaleMenuItem')}</button>
362
+ </span>
363
+ </div>
364
+ </DropdownComponent.Item>
365
+ )}
366
+ {deleteLocaleAvailable && (
367
+ <DropdownComponent.Item onClick={handleOpenDeleteLocale}>
368
+ <div className={cx('byline-form-actions-item', styles.item)}>
369
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
370
+ <button type="button">{t('documentActions.deleteLocale.menuItem')}</button>
371
+ </span>
372
+ </div>
373
+ </DropdownComponent.Item>
374
+ )}
375
+ {onDuplicate && (
376
+ <DropdownComponent.Item onClick={handleOpenDuplicate}>
377
+ <div className={cx('byline-form-actions-item', styles.item)}>
378
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
379
+ <button type="button">{t('common.actions.duplicate')}</button>
380
+ </span>
381
+ </div>
382
+ </DropdownComponent.Item>
383
+ )}
384
+ {onDelete && (
385
+ <>
386
+ <DropdownComponent.Separator />
387
+ <DropdownComponent.Item
388
+ onClick={() => {
389
+ setShowDeleteConfirm(true)
390
+ }}
391
+ >
336
392
  <div className={cx('byline-form-actions-item', styles.item)}>
393
+ <span className={cx('byline-form-actions-item-icon', styles['item-icon'])}>
394
+ <DeleteIcon width="16px" height="16px" />
395
+ </span>
337
396
  <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
338
- <button type="button">{action.label}</button>
397
+ <button
398
+ type="button"
399
+ className={cx('byline-form-actions-delete', styles.delete)}
400
+ >
401
+ {t('common.actions.delete')}
402
+ </button>
339
403
  </span>
340
404
  </div>
341
405
  </DropdownComponent.Item>
342
- ))}
343
- <DropdownComponent.Separator />
344
- </>
345
- )}
346
- {copyToLocaleAvailable && (
347
- <DropdownComponent.Item onClick={handleOpenCopyToLocale}>
348
- <div className={cx('byline-form-actions-item', styles.item)}>
349
- <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
350
- <button type="button">{t('documentActions.copyToLocaleMenuItem')}</button>
351
- </span>
352
- </div>
353
- </DropdownComponent.Item>
354
- )}
355
- {deleteLocaleAvailable && (
356
- <DropdownComponent.Item onClick={handleOpenDeleteLocale}>
357
- <div className={cx('byline-form-actions-item', styles.item)}>
358
- <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
359
- <button type="button">{t('documentActions.deleteLocale.menuItem')}</button>
360
- </span>
361
- </div>
362
- </DropdownComponent.Item>
363
- )}
364
- {onDuplicate && (
365
- <DropdownComponent.Item onClick={handleOpenDuplicate}>
366
- <div className={cx('byline-form-actions-item', styles.item)}>
367
- <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
368
- <button type="button">{t('common.actions.duplicate')}</button>
369
- </span>
370
- </div>
371
- </DropdownComponent.Item>
372
- )}
373
- <DropdownComponent.Separator />
374
- <DropdownComponent.Item
375
- onClick={() => {
376
- setShowDeleteConfirm(true)
377
- }}
378
- >
379
- <div className={cx('byline-form-actions-item', styles.item)}>
380
- <span className={cx('byline-form-actions-item-icon', styles['item-icon'])}>
381
- <DeleteIcon width="16px" height="16px" />
382
- </span>
383
- <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
384
- <button type="button" className={cx('byline-form-actions-delete', styles.delete)}>
385
- {t('common.actions.delete')}
386
- </button>
387
- </span>
388
- </div>
389
- </DropdownComponent.Item>
390
- </DropdownComponent.Content>
391
- </DropdownComponent.Portal>
392
- </DropdownComponent.Root>
406
+ </>
407
+ )}
408
+ </DropdownComponent.Content>
409
+ </DropdownComponent.Portal>
410
+ </DropdownComponent.Root>
411
+ )}
393
412
 
394
413
  <Modal
395
414
  isOpen={showDeleteConfirm}
@@ -0,0 +1,155 @@
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
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
18
+
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
+ Dropdown: {
30
+ Root: Pass,
31
+ Trigger: ({ children }: any) => <div data-testid="actions-trigger">{children}</div>,
32
+ Portal: Pass,
33
+ Content: Pass,
34
+ Item: ({ children, onClick }: any) => (
35
+ <div
36
+ onClick={onClick}
37
+ onKeyDown={(event) => {
38
+ if (event.key === 'Enter' || event.key === ' ') onClick?.(event)
39
+ }}
40
+ role="menuitem"
41
+ tabIndex={0}
42
+ >
43
+ {children}
44
+ </div>
45
+ ),
46
+ Separator: () => <hr />,
47
+ },
48
+ Modal,
49
+ Input: ({ name, value, onChange }: any) => (
50
+ <input name={name} value={value ?? ''} onChange={onChange} />
51
+ ),
52
+ }
53
+ })
54
+
55
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
56
+
57
+ defineAdminConfig({
58
+ i18n: {
59
+ admin: { defaultLocale: 'en', locales: ['en'] },
60
+ content: { defaultLocale: 'en', locales: ['en'] },
61
+ },
62
+ collections: [
63
+ {
64
+ path: 'pages',
65
+ labels: { singular: 'Page', plural: 'Pages' },
66
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
67
+ },
68
+ ],
69
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
70
+ })
71
+
72
+ const fieldServices = {
73
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
74
+ uploadField: async () => ({}),
75
+ } as any
76
+
77
+ let container: HTMLDivElement
78
+ let root: Root
79
+
80
+ beforeEach(() => {
81
+ container = document.createElement('div')
82
+ document.body.appendChild(container)
83
+ root = createRoot(container)
84
+ })
85
+
86
+ afterEach(() => {
87
+ act(() => {
88
+ root.unmount()
89
+ })
90
+ container.remove()
91
+ })
92
+
93
+ const renderInProvider = (element: React.ReactNode) => {
94
+ act(() => {
95
+ root.render(
96
+ <I18nProvider
97
+ bundle={adminTranslations({ locales: ['en'] })}
98
+ activeLocale="en"
99
+ defaultLocale="en"
100
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
101
+ >
102
+ <BylineFieldServicesProvider services={fieldServices}>
103
+ {element}
104
+ </BylineFieldServicesProvider>
105
+ </I18nProvider>
106
+ )
107
+ })
108
+ }
109
+
110
+ import { FormRenderer } from './form-renderer'
111
+
112
+ const baseProps = {
113
+ mode: 'edit' as const,
114
+ fields: [{ name: 'title', label: 'Title', type: 'text' as const }],
115
+ onSubmit: () => {},
116
+ onCancel: () => {},
117
+ collectionPath: 'pages',
118
+ initialData: { id: 'doc-1', path: 'about-us', fields: { title: 'About' } },
119
+ }
120
+
121
+ describe('FormRenderer capabilities', () => {
122
+ const render = (props: Record<string, unknown>) =>
123
+ renderInProvider(<FormRenderer {...(props as any)} />)
124
+
125
+ it('renders the path widget by default when initialData carries a path', () => {
126
+ render(baseProps)
127
+ const widget = container.querySelector('.byline-form-path')
128
+ expect(widget).not.toBeNull()
129
+ const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
130
+ expect(input?.value).toBe('about-us')
131
+ })
132
+
133
+ it('suppresses the path widget when showPath is false', () => {
134
+ render({ ...baseProps, showPath: false })
135
+ expect(container.querySelector('.byline-form-path')).toBeNull()
136
+ expect(container.querySelector('input[name="__systemPath__"]')).toBeNull()
137
+ })
138
+
139
+ it('uses the heading override verbatim instead of create/edit wording', () => {
140
+ render({
141
+ ...baseProps,
142
+ mode: 'create' as const,
143
+ headingLabel: 'Thing',
144
+ heading: 'Site settings',
145
+ })
146
+ const heading = container.querySelector('h1, h2, h3')
147
+ expect(heading?.textContent?.trim()).toBe('Site settings')
148
+ })
149
+
150
+ it('falls back to create wording when no heading override is given', () => {
151
+ render({ ...baseProps, mode: 'create' as const, headingLabel: 'Thing' })
152
+ const heading = container.querySelector('h1, h2, h3')
153
+ expect(heading?.textContent?.trim()).toBe('Create Thing')
154
+ })
155
+ })
@@ -0,0 +1,194 @@
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
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
18
+
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
+ Dropdown: {
30
+ Root: Pass,
31
+ Trigger: ({ children }: any) => <div data-testid="actions-trigger">{children}</div>,
32
+ Portal: Pass,
33
+ Content: Pass,
34
+ Item: ({ children, onClick }: any) => (
35
+ <div
36
+ onClick={onClick}
37
+ onKeyDown={(event) => {
38
+ if (event.key === 'Enter' || event.key === ' ') onClick?.(event)
39
+ }}
40
+ role="menuitem"
41
+ tabIndex={0}
42
+ >
43
+ {children}
44
+ </div>
45
+ ),
46
+ Separator: () => <hr />,
47
+ },
48
+ Modal,
49
+ Input: ({ name, value, onChange }: any) => (
50
+ <input name={name} value={value ?? ''} onChange={onChange} />
51
+ ),
52
+ }
53
+ })
54
+
55
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
56
+
57
+ defineAdminConfig({
58
+ i18n: {
59
+ admin: { defaultLocale: 'en', locales: ['en'] },
60
+ content: { defaultLocale: 'en', locales: ['en'] },
61
+ },
62
+ collections: [
63
+ {
64
+ path: 'pages',
65
+ labels: { singular: 'Page', plural: 'Pages' },
66
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
67
+ },
68
+ ],
69
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
70
+ })
71
+
72
+ const fieldServices = {
73
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
74
+ uploadField: async () => ({}),
75
+ } as any
76
+
77
+ let container: HTMLDivElement
78
+ let root: Root
79
+
80
+ beforeEach(() => {
81
+ container = document.createElement('div')
82
+ document.body.appendChild(container)
83
+ root = createRoot(container)
84
+ })
85
+
86
+ afterEach(() => {
87
+ act(() => {
88
+ root.unmount()
89
+ })
90
+ container.remove()
91
+ })
92
+
93
+ const renderInProvider = (element: React.ReactNode) => {
94
+ act(() => {
95
+ root.render(
96
+ <I18nProvider
97
+ bundle={adminTranslations({ locales: ['en'] })}
98
+ activeLocale="en"
99
+ defaultLocale="en"
100
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
101
+ >
102
+ <BylineFieldServicesProvider services={fieldServices}>
103
+ {element}
104
+ </BylineFieldServicesProvider>
105
+ </I18nProvider>
106
+ )
107
+ })
108
+ }
109
+
110
+ import { FormRenderer } from './form-renderer'
111
+
112
+ const fields = [{ name: 'title', label: 'Title', type: 'text' as const }]
113
+
114
+ const typeIntoTitle = (value: string) => {
115
+ const input = container.querySelector<HTMLInputElement>('input[name="title"]')
116
+ if (input == null) throw new Error('title input not found')
117
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
118
+ act(() => {
119
+ setter?.call(input, value)
120
+ input.dispatchEvent(new Event('input', { bubbles: true }))
121
+ })
122
+ }
123
+
124
+ const submitForm = () => {
125
+ const form = container.querySelector('form')
126
+ if (form == null) throw new Error('form not found')
127
+ act(() => {
128
+ form.requestSubmit()
129
+ })
130
+ }
131
+
132
+ describe('FormRenderer submit contract', () => {
133
+ const render = (props: Record<string, unknown>) =>
134
+ renderInProvider(<FormRenderer {...(props as any)} />)
135
+
136
+ const baseProps = {
137
+ mode: 'create' as const,
138
+ fields,
139
+ onCancel: () => {},
140
+ collectionPath: 'pages',
141
+ }
142
+
143
+ it('becomes dirty once a field is edited', () => {
144
+ render({ ...baseProps, onSubmit: async () => {} })
145
+ expect(container.textContent).toContain('Close')
146
+ typeIntoTitle('Hello')
147
+ expect(container.textContent).toContain('Cancel')
148
+ })
149
+
150
+ it('clears dirty state when onSubmit resolves', async () => {
151
+ const onSubmit = vi.fn(async () => {})
152
+ render({ ...baseProps, onSubmit })
153
+ typeIntoTitle('Hello')
154
+ submitForm()
155
+ await act(async () => {})
156
+ expect(onSubmit).toHaveBeenCalledTimes(1)
157
+ expect(container.textContent).toContain('Close')
158
+ })
159
+
160
+ it('preserves dirty state when onSubmit rejects', async () => {
161
+ const onSubmit = vi.fn(async () => {
162
+ throw new Error('save failed')
163
+ })
164
+ render({ ...baseProps, onSubmit })
165
+ typeIntoTitle('Hello')
166
+ submitForm()
167
+ await act(async () => {})
168
+ expect(onSubmit).toHaveBeenCalledTimes(1)
169
+ expect(container.textContent).toContain('Cancel')
170
+ })
171
+
172
+ it('ignores a second submit while the first is still in flight', async () => {
173
+ let release: () => void = () => {}
174
+ const gate = new Promise<void>((resolve) => {
175
+ release = resolve
176
+ })
177
+ const onSubmit = vi.fn(async () => {
178
+ await gate
179
+ })
180
+ render({ ...baseProps, onSubmit })
181
+ typeIntoTitle('Hello')
182
+ submitForm()
183
+ submitForm()
184
+ // Submission first awaits the field-hook pipeline, so flush that
185
+ // microtask before counting host calls. An immediate assertion here
186
+ // would observe zero calls and would not test duplicate suppression.
187
+ await act(async () => {})
188
+ expect(onSubmit).toHaveBeenCalledTimes(1)
189
+ await act(async () => {
190
+ release()
191
+ })
192
+ expect(onSubmit).toHaveBeenCalledTimes(1)
193
+ })
194
+ })