@byline/admin 4.15.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.
- package/dist/abilities.js +2 -0
- package/dist/fields/relation/relation-field.js +3 -2
- package/dist/fields/relation/relation-many-field.js +3 -2
- package/dist/fields/relation/relation-picker.d.ts +2 -2
- package/dist/fields/relation/relation-summary.d.ts +2 -2
- package/dist/forms/document-actions.js +30 -25
- package/dist/forms/form-renderer.d.ts +17 -3
- package/dist/forms/form-renderer.js +18 -9
- package/dist/forms/tree-placement-widget.js +3 -2
- package/dist/forms/use-form-layout.d.ts +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modules/analytics/abilities.d.ts +14 -0
- package/dist/modules/analytics/abilities.js +21 -0
- package/dist/modules/analytics/components/dashboard.d.ts +20 -0
- package/dist/modules/analytics/components/dashboard.js +308 -0
- package/dist/modules/analytics/components/dashboard.module.js +29 -0
- package/dist/modules/analytics/components/dashboard.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/dashboard_module.css +268 -0
- package/dist/modules/analytics/components/timeseries.d.ts +59 -0
- package/dist/modules/analytics/components/timeseries.js +253 -0
- package/dist/modules/analytics/components/timeseries.module.js +17 -0
- package/dist/modules/analytics/components/timeseries_module.css +126 -0
- package/dist/modules/analytics/index.d.ts +9 -0
- package/dist/modules/analytics/index.js +1 -0
- package/dist/modules/analytics/types.d.ts +20 -0
- package/dist/modules/analytics/types.js +1 -0
- package/package.json +21 -11
- package/src/abilities.ts +2 -0
- package/src/fields/relation/relation-field.tsx +5 -3
- package/src/fields/relation/relation-many-field.tsx +5 -3
- package/src/fields/relation/relation-picker.tsx +2 -2
- package/src/fields/relation/relation-summary.tsx +2 -2
- package/src/forms/document-actions.test.tsx +157 -0
- package/src/forms/document-actions.tsx +93 -74
- package/src/forms/form-renderer-capabilities.test.tsx +155 -0
- package/src/forms/form-renderer-submit.test.tsx +194 -0
- package/src/forms/form-renderer.tsx +59 -25
- package/src/forms/tree-placement-widget.tsx +3 -2
- package/src/forms/use-form-layout.ts +3 -3
- package/src/index.ts +1 -0
- package/src/modules/analytics/abilities.ts +33 -0
- package/src/modules/analytics/components/dashboard.module.css +330 -0
- package/src/modules/analytics/components/dashboard.test.node.ts +193 -0
- package/src/modules/analytics/components/dashboard.tsx +371 -0
- package/src/modules/analytics/components/timeseries.module.css +151 -0
- package/src/modules/analytics/components/timeseries.tsx +332 -0
- package/src/modules/analytics/index.ts +14 -0
- package/src/modules/analytics/types.ts +31 -0
- package/dist/forms/__probe-nested.test.node.d.ts +0 -1
- package/dist/forms/__probe-two.test.node.d.ts +0 -1
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
// Spread the REAL uikit and override only the three primitives that make
|
|
20
|
+
// assertions hard. A factory mock replaces the whole module, so anything not
|
|
21
|
+
// returned becomes `undefined` and React throws "Element type is invalid" on
|
|
22
|
+
// mount — FormRenderer alone pulls in Alert, Button, and ComboButton
|
|
23
|
+
// (`form-renderer.tsx:24`), and its field/presentation subtree pulls in more.
|
|
24
|
+
// `@byline/ui` ships a built `dist` plus a `development` source condition, so
|
|
25
|
+
// `importOriginal()` resolves without a prior build step.
|
|
26
|
+
vi.mock('@byline/ui/react', async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<Record<string, unknown>>()
|
|
28
|
+
const Pass = ({ children }: any) => <div>{children}</div>
|
|
29
|
+
const Modal: any = ({ children, isOpen }: any) => (isOpen ? <div>{children}</div> : null)
|
|
30
|
+
Modal.Container = Pass
|
|
31
|
+
Modal.Header = Pass
|
|
32
|
+
Modal.Content = Pass
|
|
33
|
+
Modal.Actions = Pass
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
Dropdown: {
|
|
37
|
+
Root: Pass,
|
|
38
|
+
Trigger: ({ children }: any) => <div data-testid="actions-trigger">{children}</div>,
|
|
39
|
+
Portal: Pass,
|
|
40
|
+
Content: Pass,
|
|
41
|
+
Item: ({ children, onClick }: any) => (
|
|
42
|
+
<div
|
|
43
|
+
onClick={onClick}
|
|
44
|
+
onKeyDown={(event) => {
|
|
45
|
+
if (event.key === 'Enter' || event.key === ' ') onClick?.(event)
|
|
46
|
+
}}
|
|
47
|
+
role="menuitem"
|
|
48
|
+
tabIndex={0}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
Separator: () => <hr />,
|
|
54
|
+
},
|
|
55
|
+
Modal,
|
|
56
|
+
// Overridden so `input[name=...]` is a stable selector for typing into
|
|
57
|
+
// fields; every other uikit export comes through from `actual`.
|
|
58
|
+
Input: ({ name, value, onChange }: any) => (
|
|
59
|
+
<input name={name} value={value ?? ''} onChange={onChange} />
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Silences React's "not configured to support act(...)" warning. Copied from
|
|
65
|
+
// `path-widget.test.tsx:76`.
|
|
66
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
67
|
+
|
|
68
|
+
// FormRenderer calls `getAdminConfig()` unconditionally (`form-renderer.tsx:279`)
|
|
69
|
+
// to resolve the path widget's slugifier. The *use* of the result is gated on
|
|
70
|
+
// `useAsPath`, but the call is not — with no config registered it throws
|
|
71
|
+
// "Byline has not been configured yet" and the mount fails. `AdminConfig`
|
|
72
|
+
// requires only `i18n` and `collections`, so register a real one rather than
|
|
73
|
+
// mocking `@byline/core`; it lives on a global, so module scope runs it once.
|
|
74
|
+
defineAdminConfig({
|
|
75
|
+
i18n: {
|
|
76
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
77
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
78
|
+
},
|
|
79
|
+
collections: [
|
|
80
|
+
{
|
|
81
|
+
path: 'pages',
|
|
82
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
83
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// FormRenderer also calls `useBylineFieldServices()` unconditionally
|
|
90
|
+
// (`form-renderer.tsx:271`), which throws when the provider is absent. Only
|
|
91
|
+
// `getCollectionDocuments` and `uploadField` are required members; the tree
|
|
92
|
+
// functions are optional. Cast the stub — these tests never invoke it, and
|
|
93
|
+
// pinning the real signatures here would couple them to unrelated drift.
|
|
94
|
+
const fieldServices = {
|
|
95
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
96
|
+
uploadField: async () => ({}),
|
|
97
|
+
} as any
|
|
98
|
+
|
|
99
|
+
let container: HTMLDivElement
|
|
100
|
+
let root: Root
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
container = document.createElement('div')
|
|
104
|
+
document.body.appendChild(container)
|
|
105
|
+
root = createRoot(container)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
act(() => {
|
|
110
|
+
root.unmount()
|
|
111
|
+
})
|
|
112
|
+
container.remove()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const renderInProvider = (element: React.ReactNode) => {
|
|
116
|
+
act(() => {
|
|
117
|
+
root.render(
|
|
118
|
+
<I18nProvider
|
|
119
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
120
|
+
activeLocale="en"
|
|
121
|
+
defaultLocale="en"
|
|
122
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
123
|
+
>
|
|
124
|
+
<BylineFieldServicesProvider services={fieldServices}>
|
|
125
|
+
{element}
|
|
126
|
+
</BylineFieldServicesProvider>
|
|
127
|
+
</I18nProvider>
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
import { DocumentActions } from './document-actions'
|
|
133
|
+
|
|
134
|
+
describe('DocumentActions', () => {
|
|
135
|
+
const render = (props: Record<string, unknown>) =>
|
|
136
|
+
renderInProvider(<DocumentActions {...(props as any)} />)
|
|
137
|
+
|
|
138
|
+
it('hides the Delete item when no onDelete handler is supplied', () => {
|
|
139
|
+
render({})
|
|
140
|
+
expect(container.textContent).not.toContain('Delete')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('renders the Delete item when an onDelete handler is supplied', () => {
|
|
144
|
+
render({ onDelete: async () => {} })
|
|
145
|
+
expect(container.textContent).toContain('Delete')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('renders no trigger at all when no actions are available', () => {
|
|
149
|
+
render({})
|
|
150
|
+
expect(container.querySelector('[data-testid="actions-trigger"]')).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('renders the trigger when at least one action is available', () => {
|
|
154
|
+
render({ onDuplicate: async () => {} })
|
|
155
|
+
expect(container.querySelector('[data-testid="actions-trigger"]')).not.toBeNull()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -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
|
-
|
|
302
|
-
<DropdownComponent.
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
})
|