@startsimpli/ui 0.4.142 → 0.4.143
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/package.json +36 -10
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mounted editor (bd startsim-sjuai).
|
|
3
|
+
*
|
|
4
|
+
* What must hold: editing mode reports the whole string to the host;
|
|
5
|
+
* suggesting mode NEVER does (nothing may PATCH the field) and instead posts
|
|
6
|
+
* the whole proposed text against the base version on a pause, on blur and on
|
|
7
|
+
* leaving the mode; a view-only reviewer can suggest but not edit; marks are
|
|
8
|
+
* reachable by keyboard and announced in words; and the transport is the
|
|
9
|
+
* injected client, called by the hook, never fetched by the component.
|
|
10
|
+
*/
|
|
11
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
12
|
+
import { createRef } from 'react'
|
|
13
|
+
import { EditorSelection } from '@codemirror/state'
|
|
14
|
+
|
|
15
|
+
import { TrackedTextEditor, type TrackedTextEditorHandle } from '../TrackedTextEditor'
|
|
16
|
+
import { useTrackChanges } from '../useTrackChanges'
|
|
17
|
+
import type { BlameSpan, Suggestion, TrackActor, TrackChangesClient } from '../types'
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
// jsdom has no layout; CodeMirror measures text with these.
|
|
21
|
+
const rect = { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }
|
|
22
|
+
Range.prototype.getBoundingClientRect = () => rect as DOMRect
|
|
23
|
+
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const bo: TrackActor = { sub: 'bo', label: 'bo@x.io', kind: 'person', kindSource: 'claim' }
|
|
27
|
+
const bot: TrackActor = { sub: 'svc', label: 'svc:n8n', kind: 'machine', kindSource: 'claim' }
|
|
28
|
+
|
|
29
|
+
const VALUE = 'Hello world. A second sentence.'
|
|
30
|
+
|
|
31
|
+
function sugg(id: string, quote: string, replacement: string, author = bo): Suggestion {
|
|
32
|
+
const from = VALUE.indexOf(quote)
|
|
33
|
+
return { id, setId: 'set', from, to: from + quote.length, quote, replacement, status: 'open', anchorState: 'mapped', author, createdAt: null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function typeAt(handle: TrackedTextEditorHandle, at: number, insert: string, to = at) {
|
|
37
|
+
act(() => {
|
|
38
|
+
handle.view!.dispatch({ changes: { from: at, to, insert }, userEvent: 'input.type' })
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('editing mode', () => {
|
|
43
|
+
it('reports every edit as the whole stored string and never proposes', () => {
|
|
44
|
+
const onChange = jest.fn()
|
|
45
|
+
const onPropose = jest.fn()
|
|
46
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
47
|
+
render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={3} currentActorSub="ada" onChange={onChange} onPropose={onPropose} />)
|
|
48
|
+
typeAt(ref.current!, VALUE.length, ' More.')
|
|
49
|
+
expect(onChange).toHaveBeenLastCalledWith(VALUE + ' More.')
|
|
50
|
+
expect(onPropose).not.toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not echo a host value change back as an edit', () => {
|
|
54
|
+
const onChange = jest.fn()
|
|
55
|
+
const { rerender } = render(<TrackedTextEditor label="Blog" value={VALUE} version={3} currentActorSub="ada" onChange={onChange} />)
|
|
56
|
+
rerender(<TrackedTextEditor label="Blog" value={'Intro. ' + VALUE} version={4} currentActorSub="ada" onChange={onChange} />)
|
|
57
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('suggesting mode', () => {
|
|
62
|
+
beforeEach(() => jest.useFakeTimers())
|
|
63
|
+
afterEach(() => jest.useRealTimers())
|
|
64
|
+
|
|
65
|
+
function mountSuggesting(extra: Partial<React.ComponentProps<typeof TrackedTextEditor>> = {}) {
|
|
66
|
+
const onChange = jest.fn()
|
|
67
|
+
const onPropose = jest.fn().mockResolvedValue(undefined)
|
|
68
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
69
|
+
const utils = render(
|
|
70
|
+
<TrackedTextEditor
|
|
71
|
+
ref={ref}
|
|
72
|
+
label="Blog"
|
|
73
|
+
value={VALUE}
|
|
74
|
+
version={7}
|
|
75
|
+
currentActorSub="ada"
|
|
76
|
+
defaultMode="suggesting"
|
|
77
|
+
onChange={onChange}
|
|
78
|
+
onPropose={onPropose}
|
|
79
|
+
{...extra}
|
|
80
|
+
/>,
|
|
81
|
+
)
|
|
82
|
+
return { ...utils, ref, onChange, onPropose }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('never reports an edit to the host, and posts the whole proposed text after a 5 s pause', async () => {
|
|
86
|
+
const { ref, onChange, onPropose } = mountSuggesting()
|
|
87
|
+
typeAt(ref.current!, 6, 'big ')
|
|
88
|
+
act(() => jest.advanceTimersByTime(4900))
|
|
89
|
+
expect(onPropose).not.toHaveBeenCalled()
|
|
90
|
+
typeAt(ref.current!, 0, '>> ')
|
|
91
|
+
act(() => jest.advanceTimersByTime(4900))
|
|
92
|
+
expect(onPropose).not.toHaveBeenCalled() // the pause restarts on each keystroke
|
|
93
|
+
await act(async () => {
|
|
94
|
+
jest.advanceTimersByTime(200)
|
|
95
|
+
})
|
|
96
|
+
expect(onPropose).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(onPropose).toHaveBeenCalledWith('>> Hello big world. A second sentence.', 7)
|
|
98
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('posts on blur, and not again when nothing changed', async () => {
|
|
102
|
+
const { ref, onPropose } = mountSuggesting()
|
|
103
|
+
typeAt(ref.current!, 0, 'X')
|
|
104
|
+
await act(async () => {
|
|
105
|
+
await ref.current!.flush()
|
|
106
|
+
})
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await ref.current!.flush()
|
|
109
|
+
})
|
|
110
|
+
expect(onPropose).toHaveBeenCalledTimes(1)
|
|
111
|
+
typeAt(ref.current!, 0, 'Y')
|
|
112
|
+
await act(async () => {
|
|
113
|
+
fireEvent.blur(ref.current!.view!.contentDOM) // the blur alone posts
|
|
114
|
+
})
|
|
115
|
+
expect(onPropose).toHaveBeenCalledTimes(2)
|
|
116
|
+
expect(onPropose).toHaveBeenLastCalledWith('YX' + VALUE, 7)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('does not post when the proposal equals the base text', async () => {
|
|
120
|
+
const { ref, onPropose } = mountSuggesting()
|
|
121
|
+
typeAt(ref.current!, 0, 'X')
|
|
122
|
+
typeAt(ref.current!, 0, '', 1)
|
|
123
|
+
await act(async () => {
|
|
124
|
+
jest.advanceTimersByTime(6000)
|
|
125
|
+
await ref.current!.flush()
|
|
126
|
+
})
|
|
127
|
+
expect(onPropose).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('posts the pending proposal when switching back to editing, then shows the stored text', async () => {
|
|
131
|
+
const onModeChange = jest.fn()
|
|
132
|
+
const { ref, onPropose, onChange } = mountSuggesting({ onModeChange })
|
|
133
|
+
typeAt(ref.current!, 0, 'X')
|
|
134
|
+
await act(async () => {
|
|
135
|
+
fireEvent.click(screen.getByRole('button', { name: 'Editing' }))
|
|
136
|
+
})
|
|
137
|
+
expect(onPropose).toHaveBeenCalledWith('X' + VALUE, 7)
|
|
138
|
+
expect(onModeChange).toHaveBeenCalledWith('editing')
|
|
139
|
+
expect(ref.current!.view!.state.doc.toString()).toBe(VALUE)
|
|
140
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('keeps the proposal and says so when the post fails', async () => {
|
|
144
|
+
const onPropose = jest.fn().mockRejectedValue(new Error('network down'))
|
|
145
|
+
const { ref } = mountSuggesting({ onPropose })
|
|
146
|
+
typeAt(ref.current!, 0, 'X')
|
|
147
|
+
await act(async () => {
|
|
148
|
+
await ref.current!.flush()
|
|
149
|
+
})
|
|
150
|
+
expect(ref.current!.proposedText()).toBe('X' + VALUE)
|
|
151
|
+
expect(screen.getByText(/not sent/i)).toBeInTheDocument()
|
|
152
|
+
// and retries next time
|
|
153
|
+
onPropose.mockResolvedValue(undefined)
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await ref.current!.flush()
|
|
156
|
+
})
|
|
157
|
+
expect(onPropose).toHaveBeenCalledTimes(2)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('shows local inserts and deletions as ins/del marks', () => {
|
|
161
|
+
const { ref, container } = mountSuggesting()
|
|
162
|
+
typeAt(ref.current!, 0, 'New ')
|
|
163
|
+
const at = 'New '.length + VALUE.indexOf('second ')
|
|
164
|
+
typeAt(ref.current!, at, '', at + 'second '.length)
|
|
165
|
+
expect(container.querySelector('ins.cm-tc-local-ins')?.textContent).toBe('New ')
|
|
166
|
+
expect(container.querySelector('del.cm-tc-local-del')?.textContent).toBe('second ')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('who may do what', () => {
|
|
171
|
+
it('a reviewer who cannot edit is in suggesting mode and cannot switch to editing', () => {
|
|
172
|
+
const onChange = jest.fn()
|
|
173
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
174
|
+
render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={1} currentActorSub="v" canEdit={false} onChange={onChange} onPropose={jest.fn()} />)
|
|
175
|
+
expect(screen.getByRole('button', { name: 'Editing' })).toBeDisabled()
|
|
176
|
+
expect(screen.getByRole('button', { name: 'Suggesting' })).toHaveAttribute('aria-pressed', 'true')
|
|
177
|
+
typeAt(ref.current!, 0, 'X')
|
|
178
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('cannot start suggesting while there are unsaved edits', () => {
|
|
182
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
183
|
+
render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={1} currentActorSub="a" onChange={jest.fn()} onPropose={jest.fn()} />)
|
|
184
|
+
typeAt(ref.current!, 0, 'X')
|
|
185
|
+
expect(screen.getByRole('button', { name: 'Suggesting' })).toBeDisabled()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('cannot suggest on a record with no version', () => {
|
|
189
|
+
render(<TrackedTextEditor label="Blog" value={VALUE} version={null} currentActorSub="a" onChange={jest.fn()} onPropose={jest.fn()} />)
|
|
190
|
+
expect(screen.getByRole('button', { name: 'Suggesting' })).toBeDisabled()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('accessibility', () => {
|
|
195
|
+
const blame: BlameSpan[] = [
|
|
196
|
+
{ id: 'blame:0', from: 0, to: 12, author: bot, unknownReason: null, reason: null, version: 2, at: null, via: 'edit', acceptedBy: null, restoredFrom: null },
|
|
197
|
+
{ id: 'blame:12', from: 12, to: VALUE.length, author: null, unknownReason: 'before history began 2026-10-06', reason: 'before_history', version: null, at: null, via: 'unknown', acceptedBy: null, restoredFrom: null },
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
it('labels the text area and tells a keyboard user how to reach the marks', () => {
|
|
201
|
+
render(<TrackedTextEditor label="Blog (markdown)" value={VALUE} version={1} currentActorSub="a" />)
|
|
202
|
+
const box = screen.getByRole('textbox', { name: 'Blog (markdown)' })
|
|
203
|
+
expect(box).toHaveAttribute('aria-multiline', 'true')
|
|
204
|
+
const help = document.getElementById(box.getAttribute('aria-describedby')!)
|
|
205
|
+
expect(help?.textContent).toMatch(/F8/)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('F8 moves to the next suggestion and announces it in words', () => {
|
|
209
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
210
|
+
const onActiveChange = jest.fn()
|
|
211
|
+
render(
|
|
212
|
+
<TrackedTextEditor
|
|
213
|
+
ref={ref}
|
|
214
|
+
label="Blog"
|
|
215
|
+
value={VALUE}
|
|
216
|
+
version={1}
|
|
217
|
+
currentActorSub="a"
|
|
218
|
+
suggestions={[sugg('s1', 'world', 'planet'), sugg('s2', 'second', '2nd')]}
|
|
219
|
+
onActiveChange={onActiveChange}
|
|
220
|
+
/>,
|
|
221
|
+
)
|
|
222
|
+
act(() => {
|
|
223
|
+
fireEvent.keyDown(ref.current!.view!.contentDOM, { key: 'F8', code: 'F8', keyCode: 119 })
|
|
224
|
+
})
|
|
225
|
+
expect(screen.getByRole('status')).toHaveTextContent('Suggestion by bo@x.io: replace “world” with “planet”')
|
|
226
|
+
expect(onActiveChange).toHaveBeenLastCalledWith(expect.objectContaining({ id: 's1' }))
|
|
227
|
+
act(() => {
|
|
228
|
+
fireEvent.keyDown(ref.current!.view!.contentDOM, { key: 'F8', code: 'F8', keyCode: 119 })
|
|
229
|
+
})
|
|
230
|
+
expect(screen.getByRole('status')).toHaveTextContent('replace “second” with “2nd”')
|
|
231
|
+
const s = ref.current!.view!.state
|
|
232
|
+
expect(s.sliceDoc(s.selection.main.from, s.selection.main.to)).toBe('second')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('names a machine author and an unknown span’s reason, on the mark and when reached', () => {
|
|
236
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
237
|
+
const { container } = render(
|
|
238
|
+
<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={1} currentActorSub="a" blame={blame} defaultShowAuthors />,
|
|
239
|
+
)
|
|
240
|
+
const machine = container.querySelector('[data-tc-kind="blame"][data-actor-kind="machine"]')!
|
|
241
|
+
expect(machine.getAttribute('title')).toMatch(/svc:n8n \(machine\)/)
|
|
242
|
+
const unknown = container.querySelector('[data-tc-kind="blame"][data-actor-kind="unknown"]')!
|
|
243
|
+
expect(unknown.getAttribute('title')).toBe('Author unknown: before history began 2026-10-06')
|
|
244
|
+
act(() => {
|
|
245
|
+
ref.current!.view!.dispatch({ selection: EditorSelection.cursor(20) })
|
|
246
|
+
})
|
|
247
|
+
expect(screen.getByRole('status')).toHaveTextContent('Author unknown: before history began 2026-10-06')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('draws suggestion marks as del/ins elements with the description as title', () => {
|
|
251
|
+
const { container } = render(
|
|
252
|
+
<TrackedTextEditor label="Blog" value={VALUE} version={1} currentActorSub="a" suggestions={[sugg('s1', 'world', 'planet')]} />,
|
|
253
|
+
)
|
|
254
|
+
const del = container.querySelector('del.cm-tc-sugg-del')!
|
|
255
|
+
expect(del.textContent).toBe('world')
|
|
256
|
+
expect(container.querySelector('ins.cm-tc-sugg-ins')?.textContent).toBe('planet')
|
|
257
|
+
expect(del.getAttribute('title')).toBe('Suggestion by bo@x.io: replace “world” with “planet”')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('the authors toggle is a pressed/unpressed button and hides blame by default', () => {
|
|
261
|
+
const { container } = render(<TrackedTextEditor label="Blog" value={VALUE} version={1} currentActorSub="a" blame={blame} />)
|
|
262
|
+
expect(container.querySelector('[data-tc-kind="blame"]')).toBeNull()
|
|
263
|
+
const toggle = screen.getByRole('button', { name: 'Show authors' })
|
|
264
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
265
|
+
fireEvent.click(toggle)
|
|
266
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
|
267
|
+
expect(container.querySelector('[data-tc-kind="blame"]')).not.toBeNull()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('sizes its toolbar on its own container, not the viewport', () => {
|
|
271
|
+
const { container } = render(<TrackedTextEditor label="Blog" value={VALUE} version={1} currentActorSub="a" />)
|
|
272
|
+
const root = container.querySelector('[data-testid="tracked-text-editor"]')!
|
|
273
|
+
expect(root.className).toMatch(/@container/)
|
|
274
|
+
// a viewport breakpoint would be a bare `sm:`/`md:`; container variants are `@md:`
|
|
275
|
+
expect(container.innerHTML).not.toMatch(/[\s"](sm|md|lg|xl):/)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('selection anchors for comments', () => {
|
|
280
|
+
it('returns the selection in stored-string coordinates with context', () => {
|
|
281
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
282
|
+
render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={1} currentActorSub="a" onChange={jest.fn()} />)
|
|
283
|
+
// Unsaved typing before the selection must not shift the anchor.
|
|
284
|
+
typeAt(ref.current!, 0, 'Typed. ')
|
|
285
|
+
const at = 'Typed. '.length + VALUE.indexOf('second')
|
|
286
|
+
act(() => {
|
|
287
|
+
ref.current!.view!.dispatch({ selection: EditorSelection.range(at, at + 6) })
|
|
288
|
+
})
|
|
289
|
+
expect(ref.current!.selectionAnchor()).toEqual({
|
|
290
|
+
from: VALUE.indexOf('second'),
|
|
291
|
+
to: VALUE.indexOf('second') + 6,
|
|
292
|
+
quote: 'second',
|
|
293
|
+
prefix: 'Hello world. A ',
|
|
294
|
+
suffix: ' sentence.',
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('useTrackChanges with an injected client', () => {
|
|
300
|
+
function Host({ client, value, version }: { client: TrackChangesClient; value: string; version: number }) {
|
|
301
|
+
const tc = useTrackChanges({ client, field: 'blog', value, version })
|
|
302
|
+
return (
|
|
303
|
+
<>
|
|
304
|
+
<TrackedTextEditor
|
|
305
|
+
label="Blog"
|
|
306
|
+
value={value}
|
|
307
|
+
version={version}
|
|
308
|
+
currentActorSub="ada"
|
|
309
|
+
blame={tc.blame}
|
|
310
|
+
suggestions={tc.suggestions}
|
|
311
|
+
threads={tc.threads}
|
|
312
|
+
notices={tc.notices}
|
|
313
|
+
onPropose={tc.propose}
|
|
314
|
+
defaultShowAuthors
|
|
315
|
+
/>
|
|
316
|
+
<output data-testid="count">{tc.suggestions.length}</output>
|
|
317
|
+
</>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
it('reads through the client, converts emoji offsets, and paints on the right words', async () => {
|
|
322
|
+
const value = '🚀 Launch day. Big news.'
|
|
323
|
+
const cp = (s: string) => Array.from(value.slice(0, value.indexOf(s))).length
|
|
324
|
+
const client: TrackChangesClient = {
|
|
325
|
+
blame: jest.fn().mockResolvedValue({ version: 2, spans: [{ from: 0, to: cp(' Big'), actor_label: 'ada@x', actor_kind: 'person' }] }),
|
|
326
|
+
listSuggestions: jest.fn().mockResolvedValue({ version: 2, results: [{ id: 's1', anchor: { from: cp('Big'), to: cp('Big') + 3, quote: 'Big' }, replacement: 'Huge', actor_label: 'bo@x' }] }),
|
|
327
|
+
listComments: jest.fn().mockResolvedValue([]),
|
|
328
|
+
}
|
|
329
|
+
const { container } = render(<Host client={client} value={value} version={2} />)
|
|
330
|
+
await waitFor(() => expect(container.querySelector('del.cm-tc-sugg-del')?.textContent).toBe('Big'))
|
|
331
|
+
expect(client.blame).toHaveBeenCalledWith({ field: 'blog' })
|
|
332
|
+
expect(container.querySelector('[data-tc-kind="blame"]')?.textContent).toBe('🚀 Launch day.')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('says so when a read is refused, instead of showing nothing', async () => {
|
|
336
|
+
const client: TrackChangesClient = {
|
|
337
|
+
blame: jest.fn().mockRejectedValue(Object.assign(new Error('nope'), { status: 404 })),
|
|
338
|
+
listSuggestions: jest.fn().mockResolvedValue([]),
|
|
339
|
+
}
|
|
340
|
+
render(<Host client={client} value={VALUE} version={1} />)
|
|
341
|
+
expect(await screen.findByText('You cannot see who wrote each passage for this field.')).toBeInTheDocument()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('does not paint a response for a different version', async () => {
|
|
345
|
+
const client: TrackChangesClient = {
|
|
346
|
+
listSuggestions: jest.fn().mockResolvedValue({ version: 9, results: [{ id: 's1', anchor: { from: 0, to: 5, quote: 'Hello' }, replacement: 'Hi' }] }),
|
|
347
|
+
}
|
|
348
|
+
render(<Host client={client} value={VALUE} version={1} />)
|
|
349
|
+
await waitFor(() => expect(client.listSuggestions).toHaveBeenCalled())
|
|
350
|
+
await act(async () => {})
|
|
351
|
+
expect(screen.getByTestId('count')).toHaveTextContent('0')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('posts a proposal with field and base version, then re-reads the suggestions', async () => {
|
|
355
|
+
jest.useFakeTimers()
|
|
356
|
+
const client: TrackChangesClient = {
|
|
357
|
+
listSuggestions: jest.fn().mockResolvedValue([]),
|
|
358
|
+
propose: jest.fn().mockResolvedValue({}),
|
|
359
|
+
}
|
|
360
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
361
|
+
function H() {
|
|
362
|
+
const tc = useTrackChanges({ client, field: 'linkedin', value: VALUE, version: 5 })
|
|
363
|
+
return (
|
|
364
|
+
<TrackedTextEditor ref={ref} label="LinkedIn" language="plain" value={VALUE} version={5} currentActorSub="ada" defaultMode="suggesting" suggestions={tc.suggestions} onPropose={tc.propose} />
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
render(<H />)
|
|
368
|
+
typeAt(ref.current!, 0, 'Hi. ')
|
|
369
|
+
await act(async () => {
|
|
370
|
+
await ref.current!.flush()
|
|
371
|
+
})
|
|
372
|
+
expect(client.propose).toHaveBeenCalledWith({ field: 'linkedin', base_version: 5, proposed: 'Hi. ' + VALUE })
|
|
373
|
+
await act(async () => {})
|
|
374
|
+
expect(client.listSuggestions).toHaveBeenCalledTimes(2)
|
|
375
|
+
jest.useRealTimers()
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe('late-arriving own suggestions', () => {
|
|
380
|
+
it('loads the reviewer’s own open suggestions into the proposal when they arrive after suggesting started', () => {
|
|
381
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
382
|
+
const ada: TrackActor = { sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' }
|
|
383
|
+
const props = { label: 'Blog', value: VALUE, version: 4, currentActorSub: 'ada', defaultMode: 'suggesting' as const, onPropose: jest.fn() }
|
|
384
|
+
const { rerender } = render(<TrackedTextEditor ref={ref} {...props} suggestions={[]} />)
|
|
385
|
+
expect(ref.current!.proposedText()).toBe(VALUE)
|
|
386
|
+
rerender(<TrackedTextEditor ref={ref} {...props} suggestions={[sugg('m1', 'world', 'planet', ada)]} />)
|
|
387
|
+
expect(ref.current!.proposedText()).toBe('Hello planet. A second sentence.')
|
|
388
|
+
expect(props.onPropose).not.toHaveBeenCalled()
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
describe('the stored string round-trips through the mounted editor', () => {
|
|
393
|
+
it('hands back CRLF, tabs and emoji byte for byte', () => {
|
|
394
|
+
const value = '# Title\r\n\r\n\tIndented 🚀 中文\r\n'
|
|
395
|
+
const onChange = jest.fn()
|
|
396
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
397
|
+
render(<TrackedTextEditor ref={ref} label="Blog" value={value} version={1} currentActorSub="a" onChange={onChange} />)
|
|
398
|
+
typeAt(ref.current!, value.length, 'x')
|
|
399
|
+
typeAt(ref.current!, value.length, '', value.length + 1)
|
|
400
|
+
expect(onChange).toHaveBeenLastCalledWith(value)
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
describe('switching into suggesting mode', () => {
|
|
405
|
+
it('does not post the unchanged text when the switch is clicked from a focused editor', async () => {
|
|
406
|
+
const onPropose = jest.fn().mockResolvedValue(undefined)
|
|
407
|
+
const ref = createRef<TrackedTextEditorHandle>()
|
|
408
|
+
render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={2} currentActorSub="ada" onChange={jest.fn()} onPropose={onPropose} />)
|
|
409
|
+
act(() => ref.current!.view!.focus())
|
|
410
|
+
const button = screen.getByRole('button', { name: 'Suggesting' })
|
|
411
|
+
await act(async () => {
|
|
412
|
+
button.focus() // the editor blurs, as a real click does
|
|
413
|
+
fireEvent.click(button)
|
|
414
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
415
|
+
})
|
|
416
|
+
expect(ref.current!.view!.state.doc.toString()).toBe(VALUE)
|
|
417
|
+
await act(async () => {
|
|
418
|
+
await ref.current!.flush()
|
|
419
|
+
})
|
|
420
|
+
expect(onPropose).not.toHaveBeenCalled()
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('every page is read, because a proposal supersedes ALL of the author’s open suggestions', () => {
|
|
425
|
+
it('follows next_cursor to the end for suggestions and comments', async () => {
|
|
426
|
+
const listSuggestions = jest.fn(async (q: { field: string; cursor?: string }) =>
|
|
427
|
+
q.cursor
|
|
428
|
+
? { next_cursor: null, results: [{ id: 's2', anchor: { from: 13, to: 14, quote: 'A' }, position: { from: 13, to: 14, state: 'mapped' }, replacement: 'One', author: { sub: 'bo' } }] }
|
|
429
|
+
: { next_cursor: 'c2', results: [{ id: 's1', anchor: { from: 0, to: 5, quote: 'Hello' }, position: { from: 0, to: 5, state: 'mapped' }, replacement: 'Hi', author: { sub: 'bo' } }] },
|
|
430
|
+
)
|
|
431
|
+
const listComments = jest.fn(async (q: { field: string; cursor?: string }) =>
|
|
432
|
+
q.cursor ? { next_cursor: null, results: [{ id: 't2', comments: [] }] } : { next_cursor: 'x', results: [{ id: 't1', comments: [] }] },
|
|
433
|
+
)
|
|
434
|
+
function H() {
|
|
435
|
+
const tc = useTrackChanges({ client: { listSuggestions, listComments }, field: 'blog', value: VALUE, version: 1 })
|
|
436
|
+
return <output data-testid="n">{`${tc.suggestions.map((s) => s.id).join(',')}|${tc.threads.map((t) => t.id).join(',')}`}</output>
|
|
437
|
+
}
|
|
438
|
+
render(<H />)
|
|
439
|
+
await waitFor(() => expect(screen.getByTestId('n')).toHaveTextContent('s1,s2|t1,t2'))
|
|
440
|
+
expect(listSuggestions).toHaveBeenLastCalledWith({ field: 'blog', cursor: 'c2' })
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
describe('own suggestions that cannot be loaded into the proposal', () => {
|
|
445
|
+
const ada: TrackActor = { sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' }
|
|
446
|
+
const orphan: Suggestion = { ...sugg('o1', 'world', 'planet', ada), anchorState: 'orphaned', from: 0, to: 0 }
|
|
447
|
+
|
|
448
|
+
it('warns only about live ones the proposal cannot carry, which the next post supersedes', () => {
|
|
449
|
+
const overlapping = [sugg('m1', 'Hello world', 'Hi there', ada), sugg('m2', 'world', 'earth', ada), sugg('m3', 'Hello', 'Hey', ada)]
|
|
450
|
+
render(
|
|
451
|
+
<TrackedTextEditor label="Blog" value={VALUE} version={1} currentActorSub="ada" defaultMode="suggesting" onPropose={jest.fn()} suggestions={[orphan, ...overlapping]} />,
|
|
452
|
+
)
|
|
453
|
+
expect(screen.getByText('2 of your earlier suggestions overlap another of yours and could not be placed; your next change will replace them.')).toBeInTheDocument()
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('does not warn about an orphaned one: the server keeps it open (kept_orphaned, start-simpli-api #508)', () => {
|
|
457
|
+
render(
|
|
458
|
+
<TrackedTextEditor label="Blog" value={VALUE} version={1} currentActorSub="ada" defaultMode="suggesting" onPropose={jest.fn()} suggestions={[orphan]} />,
|
|
459
|
+
)
|
|
460
|
+
expect(screen.queryByText(/your next change will replace/)).toBeNull()
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe('blame with history off on the type', () => {
|
|
465
|
+
it('shows the server’s reason instead of an empty authorship view', async () => {
|
|
466
|
+
const client: TrackChangesClient = {
|
|
467
|
+
blame: jest.fn().mockResolvedValue({ version: 1, offset_unit: 'utf16', recorded: false, spans: null, detail: 'History is turned off for this type.' }),
|
|
468
|
+
}
|
|
469
|
+
function H() {
|
|
470
|
+
const tc = useTrackChanges({ client, field: 'blog', value: VALUE, version: 1 })
|
|
471
|
+
return <TrackedTextEditor label="Blog" value={VALUE} version={1} currentActorSub="a" blame={tc.blame} notices={tc.notices} />
|
|
472
|
+
}
|
|
473
|
+
render(<H />)
|
|
474
|
+
expect(await screen.findByText('Who wrote each passage is not recorded: History is turned off for this type.')).toBeInTheDocument()
|
|
475
|
+
})
|
|
476
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentence a screen reader hears, and a mouse user sees as a tooltip, for
|
|
3
|
+
* each mark (bd startsim-sjuai). One function for both, so the visual and the
|
|
4
|
+
* spoken account of a span can never disagree.
|
|
5
|
+
*
|
|
6
|
+
* Follows ActorIdentity's rule: a machine is ALWAYS named as a machine, a
|
|
7
|
+
* claimed person is not decorated, and `unknown` is stated calmly with its
|
|
8
|
+
* reason rather than as an error, because most of today's text predates the
|
|
9
|
+
* revision trail (design startsim-xyn25 section 0: 168 of 183 drafts have no
|
|
10
|
+
* revision at all).
|
|
11
|
+
*/
|
|
12
|
+
import { actorLabel } from '../ActorIdentity';
|
|
13
|
+
import type { BlameSpan, CommentThread, Suggestion, TrackActor } from './types';
|
|
14
|
+
|
|
15
|
+
const QUOTE_MAX = 60;
|
|
16
|
+
|
|
17
|
+
export function quoted(text: string): string {
|
|
18
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
19
|
+
const cut = flat.length > QUOTE_MAX ? `${flat.slice(0, QUOTE_MAX - 1)}…` : flat;
|
|
20
|
+
return `“${cut}”`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** "Ada (ada@x.io)" is ActorIdentity's job visually; spoken, the label alone
|
|
24
|
+
* plus the kind when it carries information. */
|
|
25
|
+
export function who(actor: TrackActor | null | undefined, fallback = 'someone'): string {
|
|
26
|
+
const label = actorLabel({ name: actor?.label }) || fallback;
|
|
27
|
+
if (actor?.kind === 'machine') {
|
|
28
|
+
return `${label} (machine${actor.kindSource === 'inferred' ? ', inferred' : ''})`;
|
|
29
|
+
}
|
|
30
|
+
if (actor?.kind === 'person' && actor.kindSource === 'inferred') return `${label} (person, inferred)`;
|
|
31
|
+
return label;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function when(at: string | null): string {
|
|
35
|
+
if (!at) return '';
|
|
36
|
+
const d = new Date(at);
|
|
37
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
38
|
+
return `, ${d.toISOString().slice(0, 10)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function describeBlame(s: BlameSpan): string {
|
|
42
|
+
if (!s.author) return `Author unknown: ${s.unknownReason ?? 'not recorded'}`;
|
|
43
|
+
if (s.via === 'accepted_suggestion') {
|
|
44
|
+
const by = s.acceptedBy ? `, accepted by ${who(s.acceptedBy)}` : '';
|
|
45
|
+
return `Suggested by ${who(s.author)}${by}${when(s.at)}`;
|
|
46
|
+
}
|
|
47
|
+
if (s.via === 'restore') {
|
|
48
|
+
const from = s.restoredFrom != null ? ` from version ${s.restoredFrom}` : '';
|
|
49
|
+
return `Restored by ${who(s.author)}${from}${when(s.at)}`;
|
|
50
|
+
}
|
|
51
|
+
return `Written by ${who(s.author)}${when(s.at)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function describeChange(quote: string, replacement: string): string {
|
|
55
|
+
if (quote && replacement) return `replace ${quoted(quote)} with ${quoted(replacement)}`;
|
|
56
|
+
if (replacement) return `insert ${quoted(replacement)}`;
|
|
57
|
+
return `delete ${quoted(quote)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function describeSuggestion(s: Suggestion): string {
|
|
61
|
+
const moved = s.anchorState === 'reanchored' ? ' (the text around it changed)' : '';
|
|
62
|
+
return `Suggestion by ${who(s.author)}: ${describeChange(s.quote, s.replacement)}${moved}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function describeLocal(deleted: string, inserted: string): string {
|
|
66
|
+
return `Your suggestion: ${describeChange(deleted, inserted)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function describeThread(t: CommentThread): string {
|
|
70
|
+
const root = t.comments[0];
|
|
71
|
+
const replies = t.comments.length - 1;
|
|
72
|
+
const tail = replies > 0 ? ` (${replies} ${replies === 1 ? 'reply' : 'replies'})` : '';
|
|
73
|
+
if (!root) return `Comment on ${quoted(t.quote)}${tail}`;
|
|
74
|
+
const body = t.redacted || root.body == null ? (root.deleted ? 'deleted comment' : 'comment hidden') : quoted(root.body);
|
|
75
|
+
return `Comment by ${who(root.author)}: ${body}${tail}`;
|
|
76
|
+
}
|