@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.
- package/dist/fields/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout.js +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout.tsx +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- 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={`${
|
|
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
|
|
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
|
|
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={`${
|
|
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 = ({
|
|
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
|
|
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={`${
|
|
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
|
+
})
|