@byline/richtext-lexical 6.4.0 → 6.6.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/field/extensions/code-highlight/code-highlight-extension.js +2 -1
- package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
- package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
- package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
- package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
- package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
- package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
- package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
- package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
- package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
- package/dist/field/extensions/inline-image/populate.d.ts +22 -0
- package/dist/field/extensions/inline-image/populate.js +19 -1
- package/dist/field/extensions/inline-image/types.d.ts +7 -0
- package/dist/field/extensions/link/link-href.d.ts +30 -0
- package/dist/field/extensions/link/link-href.js +14 -0
- package/dist/field/extensions/link/link-modal.js +96 -288
- package/dist/field/extensions/link/link-target-fields.css +59 -0
- package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
- package/dist/field/extensions/link/link-target-fields.js +227 -0
- package/dist/field/extensions/link/link-target-state.d.ts +54 -0
- package/dist/field/extensions/link/link-target-state.js +70 -0
- package/dist/field/extensions/link/populate.d.ts +15 -1
- package/dist/field/extensions/link/populate.js +55 -50
- package/dist/field/markdown/lexical-to-markdown.js +6 -1
- package/dist/field/plugins/toolbar-plugin/index.js +2 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +4 -2
- package/dist/validate/createEmptyEditorState.d.ts +2 -2
- package/dist/validate/hasText.d.ts +2 -2
- package/package.json +25 -24
- package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
- package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
- package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
- package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
- package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
- package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
- package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
- package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
- package/src/field/extensions/inline-image/node-types.ts +19 -0
- package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
- package/src/field/extensions/inline-image/populate.ts +44 -0
- package/src/field/extensions/inline-image/types.ts +7 -0
- package/src/field/extensions/link/link-href.test.node.ts +78 -0
- package/src/field/extensions/link/link-href.ts +52 -0
- package/src/field/extensions/link/link-modal.tsx +86 -325
- package/src/field/extensions/link/link-target-fields.css +81 -0
- package/src/field/extensions/link/link-target-fields.tsx +275 -0
- package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
- package/src/field/extensions/link/link-target-state.ts +158 -0
- package/src/field/extensions/link/populate.ts +94 -67
- package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
- package/src/field/markdown/lexical-to-markdown.ts +10 -1
- package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
- package/src/server.ts +11 -5
- package/src/validate/createEmptyEditorState.ts +2 -7
- package/src/validate/hasText.test.ts +1 -2
- package/src/validate/hasText.ts +2 -9
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The "where does this point?" sub-form, shared by the link modal and the
|
|
13
|
+
* inline-image modal.
|
|
14
|
+
*
|
|
15
|
+
* Purely presentational — every decision lives in `link-target-state.ts`,
|
|
16
|
+
* which is where the rules are tested. This file owns the markup, the
|
|
17
|
+
* picker's open/closed state, and nothing else.
|
|
18
|
+
*
|
|
19
|
+
* `idPrefix` is required rather than defaulted: two of these can be
|
|
20
|
+
* mounted in one document (an image modal over a link modal), and
|
|
21
|
+
* duplicate element ids break the `Label`/`htmlFor` association that
|
|
22
|
+
* screen readers depend on.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type * as React from 'react'
|
|
26
|
+
import { useMemo, useState } from 'react'
|
|
27
|
+
|
|
28
|
+
import { RelationPicker } from '@byline/admin/react'
|
|
29
|
+
import type { MultiCollectionDefinition } from '@byline/core'
|
|
30
|
+
import { getCollectionDefinition, isSingleton } from '@byline/core'
|
|
31
|
+
import {
|
|
32
|
+
Button,
|
|
33
|
+
Checkbox,
|
|
34
|
+
CloseIcon,
|
|
35
|
+
EditIcon,
|
|
36
|
+
IconButton,
|
|
37
|
+
Input,
|
|
38
|
+
Label,
|
|
39
|
+
RadioGroup,
|
|
40
|
+
RadioGroupItem,
|
|
41
|
+
Select,
|
|
42
|
+
type SelectValue,
|
|
43
|
+
} from '@byline/ui/react'
|
|
44
|
+
|
|
45
|
+
import type { LinkTargetState } from './link-target-state'
|
|
46
|
+
|
|
47
|
+
import './link-target-fields.css'
|
|
48
|
+
|
|
49
|
+
export interface LinkTargetFieldsProps {
|
|
50
|
+
idPrefix: string
|
|
51
|
+
state: LinkTargetState
|
|
52
|
+
onChange: (next: LinkTargetState) => void
|
|
53
|
+
/** Collections with `linksInEditor: true`. Empty disables the picker. */
|
|
54
|
+
linkable: MultiCollectionDefinition[]
|
|
55
|
+
/** Offer a "no link" choice. See `LinkTargetOptions.allowNone`. */
|
|
56
|
+
allowNone: boolean
|
|
57
|
+
/** Validation message from `validateLinkTarget`, if the form was submitted. */
|
|
58
|
+
error?: string | null
|
|
59
|
+
/** Label above the kind radio. */
|
|
60
|
+
legend?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const LinkTargetFields: React.FC<LinkTargetFieldsProps> = ({
|
|
64
|
+
idPrefix,
|
|
65
|
+
state,
|
|
66
|
+
onChange,
|
|
67
|
+
linkable,
|
|
68
|
+
allowNone,
|
|
69
|
+
error,
|
|
70
|
+
legend = 'Link',
|
|
71
|
+
}) => {
|
|
72
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
73
|
+
|
|
74
|
+
const targetDefinition = state.targetCollection
|
|
75
|
+
? getCollectionDefinition(state.targetCollection)
|
|
76
|
+
: null
|
|
77
|
+
const targetDef: MultiCollectionDefinition | null =
|
|
78
|
+
targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null
|
|
79
|
+
|
|
80
|
+
const collectionItems: SelectValue<string>[] = useMemo(
|
|
81
|
+
() => linkable.map((c) => ({ label: c.labels.singular, value: c.path })),
|
|
82
|
+
[linkable]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const pickedLabel: string | null = useMemo(() => {
|
|
86
|
+
if (state.kind !== 'internal' || !state.picked) return null
|
|
87
|
+
const title = state.picked.document?.title
|
|
88
|
+
if (typeof title === 'string' && title.length > 0) return title
|
|
89
|
+
const pickedDefinition = getCollectionDefinition(state.picked.targetCollectionPath)
|
|
90
|
+
const pickedDef =
|
|
91
|
+
pickedDefinition != null && !isSingleton(pickedDefinition) ? pickedDefinition : null
|
|
92
|
+
const short = state.picked.targetDocumentId.slice(0, 8)
|
|
93
|
+
return `${pickedDef?.labels.singular ?? state.picked.targetCollectionPath} · ${short}…`
|
|
94
|
+
}, [state.kind, state.picked])
|
|
95
|
+
|
|
96
|
+
const handlePickerSelect = (selection: {
|
|
97
|
+
targetDocumentId: string
|
|
98
|
+
targetCollectionId: string
|
|
99
|
+
record?: Record<string, any>
|
|
100
|
+
}) => {
|
|
101
|
+
setPickerOpen(false)
|
|
102
|
+
const targetCollection = state.targetCollection as string
|
|
103
|
+
// Normalise the picked record into a small `{ title, path }` envelope —
|
|
104
|
+
// everything a renderer needs to build a link without a round-trip.
|
|
105
|
+
// `useAsTitle` is always in the picker projection; `path` is top-level
|
|
106
|
+
// metadata on every list response.
|
|
107
|
+
const titleField = getCollectionDefinition(targetCollection)?.useAsTitle
|
|
108
|
+
const title = titleField ? selection.record?.fields?.[titleField] : undefined
|
|
109
|
+
const path = selection.record?.path
|
|
110
|
+
const document: Record<string, any> = {}
|
|
111
|
+
if (typeof title === 'string' && title.length > 0) document.title = title
|
|
112
|
+
if (typeof path === 'string' && path.length > 0) document.path = path
|
|
113
|
+
onChange({
|
|
114
|
+
...state,
|
|
115
|
+
picked: {
|
|
116
|
+
targetDocumentId: selection.targetDocumentId,
|
|
117
|
+
targetCollectionId: selection.targetCollectionId,
|
|
118
|
+
targetCollectionPath: targetCollection,
|
|
119
|
+
document: Object.keys(document).length > 0 ? document : undefined,
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<div className="flex flex-col gap-3">
|
|
127
|
+
<Label
|
|
128
|
+
id={`${idPrefix}-kind-label`}
|
|
129
|
+
htmlFor={`${idPrefix}-kind`}
|
|
130
|
+
className="text-sm font-medium"
|
|
131
|
+
label={legend}
|
|
132
|
+
/>
|
|
133
|
+
<RadioGroup
|
|
134
|
+
id={`${idPrefix}-kind`}
|
|
135
|
+
name={`${idPrefix}-kind`}
|
|
136
|
+
aria-labelledby={`${idPrefix}-kind-label`}
|
|
137
|
+
direction="row"
|
|
138
|
+
value={state.kind}
|
|
139
|
+
onValueChange={(value) => onChange({ ...state, kind: value as LinkTargetState['kind'] })}
|
|
140
|
+
>
|
|
141
|
+
{allowNone && (
|
|
142
|
+
<RadioGroupItem id={`${idPrefix}-kind-none`} value="none" label="No link" />
|
|
143
|
+
)}
|
|
144
|
+
{linkable.length > 0 && (
|
|
145
|
+
<RadioGroupItem id={`${idPrefix}-kind-internal`} value="internal" label="Document" />
|
|
146
|
+
)}
|
|
147
|
+
<RadioGroupItem id={`${idPrefix}-kind-custom`} value="custom" label="Custom URL" />
|
|
148
|
+
</RadioGroup>
|
|
149
|
+
|
|
150
|
+
{state.kind === 'custom' && (
|
|
151
|
+
<Input
|
|
152
|
+
id={`${idPrefix}-url`}
|
|
153
|
+
name="url"
|
|
154
|
+
label="URL"
|
|
155
|
+
placeholder="https://example.com or /path"
|
|
156
|
+
value={state.url}
|
|
157
|
+
error={error != null}
|
|
158
|
+
errorText={error ?? undefined}
|
|
159
|
+
onChange={(e) => onChange({ ...state, url: e.target.value })}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{state.kind === 'internal' && (
|
|
164
|
+
<div className="flex flex-col gap-3">
|
|
165
|
+
{linkable.length > 1 && (
|
|
166
|
+
<div>
|
|
167
|
+
<Label
|
|
168
|
+
id={`${idPrefix}-collection-label`}
|
|
169
|
+
htmlFor={`${idPrefix}-collection`}
|
|
170
|
+
className="mb-1"
|
|
171
|
+
label="Target collection"
|
|
172
|
+
/>
|
|
173
|
+
<Select<string>
|
|
174
|
+
size="sm"
|
|
175
|
+
items={collectionItems}
|
|
176
|
+
placeholder="Target collection"
|
|
177
|
+
value={state.targetCollection ?? undefined}
|
|
178
|
+
onValueChange={(value) => {
|
|
179
|
+
if (value == null) return
|
|
180
|
+
// Browsing other collections is exploratory — the
|
|
181
|
+
// current pick survives until the picker replaces it.
|
|
182
|
+
onChange({ ...state, targetCollection: value })
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
<div>
|
|
189
|
+
<Label
|
|
190
|
+
id={`${idPrefix}-document-label`}
|
|
191
|
+
htmlFor={`${idPrefix}-document`}
|
|
192
|
+
className="mb-1"
|
|
193
|
+
label="Target document"
|
|
194
|
+
/>
|
|
195
|
+
{/*
|
|
196
|
+
Frame → tile → icon actions, matching the admin relation
|
|
197
|
+
field. A chosen document is a summary line with edit and
|
|
198
|
+
remove icons, not a text button whose label changes; an
|
|
199
|
+
empty one is a single outlined select button.
|
|
200
|
+
*/}
|
|
201
|
+
<div className="byline-link-target-frame">
|
|
202
|
+
{state.picked ? (
|
|
203
|
+
<div className="byline-link-target-tile">
|
|
204
|
+
<span className="byline-link-target-summary">
|
|
205
|
+
<span className="byline-link-target-kind">
|
|
206
|
+
{targetDef?.labels.singular ?? state.picked.targetCollectionPath}
|
|
207
|
+
</span>
|
|
208
|
+
<span className="byline-link-target-value">{pickedLabel}</span>
|
|
209
|
+
</span>
|
|
210
|
+
<span className="byline-link-target-actions">
|
|
211
|
+
<IconButton
|
|
212
|
+
id={`${idPrefix}-document`}
|
|
213
|
+
type="button"
|
|
214
|
+
intent="noeffect"
|
|
215
|
+
size="xs"
|
|
216
|
+
aria-label={`Change ${targetDef?.labels.singular ?? 'document'}`}
|
|
217
|
+
onClick={() => setPickerOpen(true)}
|
|
218
|
+
disabled={!state.targetCollection}
|
|
219
|
+
>
|
|
220
|
+
<EditIcon width="15px" height="15px" />
|
|
221
|
+
</IconButton>
|
|
222
|
+
<IconButton
|
|
223
|
+
type="button"
|
|
224
|
+
intent="noeffect"
|
|
225
|
+
size="xs"
|
|
226
|
+
aria-label={`Remove ${targetDef?.labels.singular ?? 'document'}`}
|
|
227
|
+
onClick={() => onChange({ ...state, picked: null })}
|
|
228
|
+
>
|
|
229
|
+
<CloseIcon width="15px" height="15px" />
|
|
230
|
+
</IconButton>
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
) : (
|
|
234
|
+
<Button
|
|
235
|
+
id={`${idPrefix}-document`}
|
|
236
|
+
size="xs"
|
|
237
|
+
variant="outlined"
|
|
238
|
+
intent="noeffect"
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={() => setPickerOpen(true)}
|
|
241
|
+
disabled={!state.targetCollection}
|
|
242
|
+
>
|
|
243
|
+
{`Select ${targetDef?.labels.singular ?? 'document'}`}
|
|
244
|
+
</Button>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{error && <span className="text-xs text-red-400">{error}</span>}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{state.kind !== 'none' && (
|
|
254
|
+
<Checkbox
|
|
255
|
+
id={`${idPrefix}-new-tab`}
|
|
256
|
+
name="newTab"
|
|
257
|
+
label="Open in new tab"
|
|
258
|
+
checked={state.newTab}
|
|
259
|
+
onCheckedChange={(checked) => onChange({ ...state, newTab: checked === true })}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{state.kind === 'internal' && state.targetCollection && (
|
|
265
|
+
<RelationPicker
|
|
266
|
+
targetCollectionPath={state.targetCollection}
|
|
267
|
+
targetDefinition={targetDef}
|
|
268
|
+
isOpen={pickerOpen}
|
|
269
|
+
onSelect={handlePickerSelect}
|
|
270
|
+
onDismiss={() => setPickerOpen(false)}
|
|
271
|
+
/>
|
|
272
|
+
)}
|
|
273
|
+
</>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* The link-target form's state machine, extracted from `LinkModal` so both
|
|
11
|
+
* it and the inline-image modal drive the same rules — and so those rules
|
|
12
|
+
* are testable without rendering React (this repo carries no React
|
|
13
|
+
* testing library; the JSX shell is verified in the browser).
|
|
14
|
+
*
|
|
15
|
+
* Two callers with one asymmetry: a `LinkNode` always has a target, so
|
|
16
|
+
* `allowNone` is false there. An inline image's target is optional, so
|
|
17
|
+
* `allowNone` is true and `toLinkAttributes` can return undefined — which
|
|
18
|
+
* is how an editor removes a link.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from 'vitest'
|
|
22
|
+
|
|
23
|
+
import { fromLinkAttributes, toLinkAttributes, validateLinkTarget } from './link-target-state'
|
|
24
|
+
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
25
|
+
import type { LinkAttributes } from '.'
|
|
26
|
+
|
|
27
|
+
const linkable = [
|
|
28
|
+
{ path: 'pages', labels: { singular: 'Page', plural: 'Pages' } },
|
|
29
|
+
{ path: 'posts', labels: { singular: 'Post', plural: 'Posts' } },
|
|
30
|
+
] as any[]
|
|
31
|
+
|
|
32
|
+
const picked: DocumentRelation = {
|
|
33
|
+
targetDocumentId: 'doc-1',
|
|
34
|
+
targetCollectionId: 'coll-pages',
|
|
35
|
+
targetCollectionPath: 'pages',
|
|
36
|
+
document: { title: 'About Us', path: '/pages/about' },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const internalLink: LinkAttributes = {
|
|
40
|
+
linkType: 'internal',
|
|
41
|
+
newTab: false,
|
|
42
|
+
targetDocumentId: 'doc-1',
|
|
43
|
+
targetCollectionId: 'coll-pages',
|
|
44
|
+
targetCollectionPath: 'pages',
|
|
45
|
+
document: { title: 'About Us', path: '/pages/about' },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const customLink: LinkAttributes = {
|
|
49
|
+
linkType: 'custom',
|
|
50
|
+
url: 'https://example.com/report',
|
|
51
|
+
newTab: true,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The empty state is reached through the module's one entry point. */
|
|
55
|
+
const empty = (linkable: any[], options: { allowNone: boolean }) =>
|
|
56
|
+
fromLinkAttributes(undefined, linkable as never, options)
|
|
57
|
+
|
|
58
|
+
describe('the starting state', () => {
|
|
59
|
+
it('starts on "none" when the caller allows an absent target', () => {
|
|
60
|
+
expect(empty(linkable, { allowNone: true }).kind).toBe('none')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('starts on "internal" when a target is required and collections are linkable', () => {
|
|
64
|
+
expect(empty(linkable, { allowNone: false }).kind).toBe('internal')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('falls back to "custom" when no collection opts into the editor picker', () => {
|
|
68
|
+
expect(empty([], { allowNone: false }).kind).toBe('custom')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('seeds the picker onto the first linkable collection', () => {
|
|
72
|
+
expect(empty(linkable, { allowNone: false }).targetCollection).toBe('pages')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('fromLinkAttributes', () => {
|
|
77
|
+
it('reads an absent link as "none" when that is allowed', () => {
|
|
78
|
+
expect(fromLinkAttributes(undefined, linkable, { allowNone: true }).kind).toBe('none')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('loads a custom URL', () => {
|
|
82
|
+
const state = fromLinkAttributes(customLink, linkable, { allowNone: true })
|
|
83
|
+
expect(state.kind).toBe('custom')
|
|
84
|
+
expect(state.url).toBe('https://example.com/report')
|
|
85
|
+
expect(state.newTab).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('loads an internal relation and points the picker at its collection', () => {
|
|
89
|
+
const state = fromLinkAttributes(internalLink, linkable, { allowNone: true })
|
|
90
|
+
expect(state.kind).toBe('internal')
|
|
91
|
+
expect(state.picked).toEqual(picked)
|
|
92
|
+
expect(state.targetCollection).toBe('pages')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// LinkModal's existing behaviour: the toolbar inserts a placeholder link
|
|
96
|
+
// with `https://`, which means "the user has not chosen yet" — so prefer
|
|
97
|
+
// the document picker rather than showing a URL box pre-filled with junk.
|
|
98
|
+
it.each(['', 'https://'])(
|
|
99
|
+
'treats the placeholder url %j as undecided and prefers the picker',
|
|
100
|
+
(url) => {
|
|
101
|
+
const state = fromLinkAttributes({ linkType: 'custom', url }, linkable, { allowNone: false })
|
|
102
|
+
expect(state.kind).toBe('internal')
|
|
103
|
+
expect(state.url).toBe('')
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
it('does not surface the placeholder url in the input', () => {
|
|
108
|
+
const state = fromLinkAttributes({ linkType: 'custom', url: 'https://' }, [], {
|
|
109
|
+
allowNone: false,
|
|
110
|
+
})
|
|
111
|
+
expect(state.kind).toBe('custom')
|
|
112
|
+
expect(state.url).toBe('')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('toLinkAttributes', () => {
|
|
117
|
+
it('returns undefined for "none" — this is how a link is removed', () => {
|
|
118
|
+
const state = empty(linkable, { allowNone: true })
|
|
119
|
+
expect(toLinkAttributes(state)).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('builds a custom link', () => {
|
|
123
|
+
const state = {
|
|
124
|
+
...empty([], { allowNone: true }),
|
|
125
|
+
kind: 'custom' as const,
|
|
126
|
+
url: 'https://example.com/report',
|
|
127
|
+
newTab: true,
|
|
128
|
+
}
|
|
129
|
+
expect(toLinkAttributes(state)).toEqual(customLink)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('builds an internal link carrying the picked relation envelope', () => {
|
|
133
|
+
const state = {
|
|
134
|
+
...empty(linkable, { allowNone: true }),
|
|
135
|
+
kind: 'internal' as const,
|
|
136
|
+
picked,
|
|
137
|
+
newTab: false,
|
|
138
|
+
}
|
|
139
|
+
expect(toLinkAttributes(state)).toEqual(internalLink)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns undefined for an internal selection with nothing picked', () => {
|
|
143
|
+
const state = {
|
|
144
|
+
...empty(linkable, { allowNone: true }),
|
|
145
|
+
kind: 'internal' as const,
|
|
146
|
+
}
|
|
147
|
+
expect(toLinkAttributes(state)).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it.each([customLink, internalLink])('round-trips %#', (link) => {
|
|
151
|
+
expect(toLinkAttributes(fromLinkAttributes(link, linkable, { allowNone: true }))).toEqual(link)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('validateLinkTarget', () => {
|
|
156
|
+
it('accepts "none"', () => {
|
|
157
|
+
expect(validateLinkTarget(empty(linkable, { allowNone: true }))).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it.each(['https://example.com', '/docs/getting-started', 'mailto:a@example.com'])(
|
|
161
|
+
'accepts %s as a custom target',
|
|
162
|
+
(url) => {
|
|
163
|
+
const state = {
|
|
164
|
+
...empty([], { allowNone: true }),
|
|
165
|
+
kind: 'custom' as const,
|
|
166
|
+
url,
|
|
167
|
+
}
|
|
168
|
+
expect(validateLinkTarget(state)).toBeNull()
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
it('rejects an empty custom URL', () => {
|
|
173
|
+
const state = {
|
|
174
|
+
...empty([], { allowNone: true }),
|
|
175
|
+
kind: 'custom' as const,
|
|
176
|
+
url: '',
|
|
177
|
+
}
|
|
178
|
+
expect(validateLinkTarget(state)).toBeTruthy()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('rejects an internal selection with no document picked', () => {
|
|
182
|
+
const state = {
|
|
183
|
+
...empty(linkable, { allowNone: true }),
|
|
184
|
+
kind: 'internal' as const,
|
|
185
|
+
}
|
|
186
|
+
expect(validateLinkTarget(state)).toBeTruthy()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('accepts an internal selection once a document is picked', () => {
|
|
190
|
+
const state = {
|
|
191
|
+
...empty(linkable, { allowNone: true }),
|
|
192
|
+
kind: 'internal' as const,
|
|
193
|
+
picked,
|
|
194
|
+
}
|
|
195
|
+
expect(validateLinkTarget(state)).toBeNull()
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* The state machine behind "where does this link point?" — shared by the
|
|
11
|
+
* link modal and the inline-image modal so both offer the same choice of
|
|
12
|
+
* a Byline document or an arbitrary URL, with the same validation.
|
|
13
|
+
*
|
|
14
|
+
* React-free on purpose. The JSX shell lives in `link-target-fields.tsx`;
|
|
15
|
+
* everything that decides anything lives here, where it can be tested
|
|
16
|
+
* without a DOM.
|
|
17
|
+
*
|
|
18
|
+
* The one asymmetry between callers is `allowNone`. A `LinkNode` exists
|
|
19
|
+
* only because it has a target, so its form always resolves to a link. An
|
|
20
|
+
* inline image's target is optional, so its form adds a "no link" choice
|
|
21
|
+
* and `toLinkAttributes` may return `undefined` — which is precisely how
|
|
22
|
+
* an editor removes a link that is already there.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { MultiCollectionDefinition } from '@byline/core'
|
|
26
|
+
|
|
27
|
+
import { validateUrl } from '../../utils/url'
|
|
28
|
+
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
29
|
+
import type { LinkAttributes } from '.'
|
|
30
|
+
|
|
31
|
+
export interface LinkTargetState {
|
|
32
|
+
/** Narrow with `LinkTargetState['kind']` rather than a separate alias. */
|
|
33
|
+
kind: 'none' | 'custom' | 'internal'
|
|
34
|
+
/** Custom-URL text. Meaningful only when `kind === 'custom'`. */
|
|
35
|
+
url: string
|
|
36
|
+
newTab: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Which collection the picker is currently pointed at — UI state only,
|
|
39
|
+
* and deliberately independent of `picked.targetCollectionPath` so the
|
|
40
|
+
* user can browse other collections without losing their selection.
|
|
41
|
+
*/
|
|
42
|
+
targetCollection: string | null
|
|
43
|
+
/** The chosen document. Meaningful only when `kind === 'internal'`. */
|
|
44
|
+
picked: DocumentRelation | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LinkTargetOptions {
|
|
48
|
+
/** Whether "no link at all" is a valid outcome. */
|
|
49
|
+
allowNone: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A link the toolbar inserted as a placeholder, before the editor has
|
|
54
|
+
* chosen anything. Shown as an empty form rather than a URL box pre-filled
|
|
55
|
+
* with `https://`.
|
|
56
|
+
*/
|
|
57
|
+
function isPlaceholderUrl(url: string | undefined): boolean {
|
|
58
|
+
return url == null || url === '' || url === 'https://'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The starting state for a form with nothing chosen yet. Module-private:
|
|
63
|
+
* `fromLinkAttributes(undefined, …)` returns exactly this, so callers have
|
|
64
|
+
* one way in rather than two that must agree.
|
|
65
|
+
*/
|
|
66
|
+
function emptyLinkTargetState(
|
|
67
|
+
linkable: MultiCollectionDefinition[],
|
|
68
|
+
options: LinkTargetOptions
|
|
69
|
+
): LinkTargetState {
|
|
70
|
+
const firstLinkable = linkable[0]?.path ?? null
|
|
71
|
+
const kind: LinkTargetState['kind'] = options.allowNone
|
|
72
|
+
? 'none'
|
|
73
|
+
: linkable.length > 0
|
|
74
|
+
? 'internal'
|
|
75
|
+
: 'custom'
|
|
76
|
+
return {
|
|
77
|
+
kind,
|
|
78
|
+
url: '',
|
|
79
|
+
newTab: false,
|
|
80
|
+
targetCollection: firstLinkable,
|
|
81
|
+
picked: null,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fromLinkAttributes(
|
|
86
|
+
link: LinkAttributes | undefined,
|
|
87
|
+
linkable: MultiCollectionDefinition[],
|
|
88
|
+
options: LinkTargetOptions
|
|
89
|
+
): LinkTargetState {
|
|
90
|
+
const base = emptyLinkTargetState(linkable, options)
|
|
91
|
+
if (link == null) return base
|
|
92
|
+
|
|
93
|
+
if (link.linkType === 'internal') {
|
|
94
|
+
const picked: DocumentRelation = {
|
|
95
|
+
targetDocumentId: link.targetDocumentId,
|
|
96
|
+
targetCollectionId: link.targetCollectionId,
|
|
97
|
+
targetCollectionPath: link.targetCollectionPath,
|
|
98
|
+
document: link.document,
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
kind: 'internal',
|
|
103
|
+
newTab: link.newTab ?? false,
|
|
104
|
+
targetCollection: picked.targetCollectionPath || base.targetCollection,
|
|
105
|
+
picked,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Custom. An undecided placeholder prefers the document picker when any
|
|
110
|
+
// collection offers one; otherwise it stays on the (empty) URL field.
|
|
111
|
+
const placeholder = isPlaceholderUrl(link.url)
|
|
112
|
+
const kind: LinkTargetState['kind'] = placeholder && linkable.length > 0 ? 'internal' : 'custom'
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
kind,
|
|
116
|
+
url: placeholder ? '' : (link.url ?? ''),
|
|
117
|
+
newTab: link.newTab ?? false,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Collapse the form back to a `LinkAttributes`, or `undefined` when there
|
|
123
|
+
* is no usable target. Callers that require a link should run
|
|
124
|
+
* `validateLinkTarget` first — this function reports "nothing to store"
|
|
125
|
+
* rather than "the user made a mistake".
|
|
126
|
+
*/
|
|
127
|
+
export function toLinkAttributes(state: LinkTargetState): LinkAttributes | undefined {
|
|
128
|
+
if (state.kind === 'none') return undefined
|
|
129
|
+
if (state.kind === 'internal') {
|
|
130
|
+
if (state.picked == null) return undefined
|
|
131
|
+
return {
|
|
132
|
+
linkType: 'internal',
|
|
133
|
+
newTab: state.newTab,
|
|
134
|
+
targetDocumentId: state.picked.targetDocumentId,
|
|
135
|
+
targetCollectionId: state.picked.targetCollectionId,
|
|
136
|
+
targetCollectionPath: state.picked.targetCollectionPath,
|
|
137
|
+
document: state.picked.document,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (state.url.length === 0) return undefined
|
|
141
|
+
return { linkType: 'custom', url: state.url, newTab: state.newTab }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Returns a human-readable problem, or `null` when the form is usable. */
|
|
145
|
+
export function validateLinkTarget(state: LinkTargetState): string | null {
|
|
146
|
+
if (state.kind === 'none') return null
|
|
147
|
+
if (state.kind === 'internal') {
|
|
148
|
+
return state.picked == null ? 'Pick a target document' : null
|
|
149
|
+
}
|
|
150
|
+
if (state.url.length === 0) {
|
|
151
|
+
return 'Enter a URL or a root-relative path starting with /'
|
|
152
|
+
}
|
|
153
|
+
// Root-relative paths bypass `validateUrl`, which expects a scheme.
|
|
154
|
+
if (!state.url.startsWith('/') && !validateUrl(state.url)) {
|
|
155
|
+
return 'Enter a valid URL or a root-relative path starting with /'
|
|
156
|
+
}
|
|
157
|
+
return null
|
|
158
|
+
}
|