@estiva-app/ui 0.51.0 → 0.53.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.
@@ -0,0 +1,268 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The composer triggers (CON-27, moved from Peek's `mention.tsx` and
4
+ * `slashCommands.tsx`): each opens its list at the caret, and a pick inserts
5
+ * the chip the body is written from.
6
+ */
7
+ import { act } from 'react'
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
9
+ import { render, screen, cleanup, within } from '@testing-library/react'
10
+ import type { Editor } from '@tiptap/core'
11
+ import { EditorContent, useEditor } from '@tiptap/react'
12
+ import StarterKit from '@tiptap/starter-kit'
13
+ import {
14
+ CaptionedReference,
15
+ PersonMention,
16
+ ReferenceTrigger,
17
+ SlashCommands,
18
+ UrgentPersonMention,
19
+ filterPeople,
20
+ filterReferences,
21
+ filterSlashSections,
22
+ formatSection,
23
+ typeTrigger,
24
+ urgentPubkeys,
25
+ type CaptionedItem,
26
+ type MentionPerson,
27
+ type SlashSection,
28
+ } from './ComposerTriggers'
29
+
30
+ const KEY = 'b'.repeat(64)
31
+ const PEOPLE: MentionPerson[] = [
32
+ { id: 'ada', name: 'Ada Lovelace', description: 'Engineering', pubkey: KEY },
33
+ { id: 'bea', name: 'Bea', pubkey: null },
34
+ ...['Cy', 'Di', 'Ed', 'Flo', 'Gus'].map((name) => ({ id: name.toLowerCase(), name })),
35
+ ]
36
+ const ITEMS: CaptionedItem[] = Array.from({ length: 8 }, (_, i) => ({
37
+ id: `${i}`.repeat(64),
38
+ label: i === 7 ? 'Bea' : 'Ada',
39
+ snippet: `line ${i}`,
40
+ uri: `nostr:nevent1example${i}`,
41
+ search: `**line** ${i}`,
42
+ }))
43
+ const APP_SECTION: SlashSection = {
44
+ label: 'Shortcuts',
45
+ commands: [{ key: 'shortcut-@', row: { label: 'Mention', shortcut: '@' }, keywords: ['@', 'Mention a person'], run: typeTrigger('@') }],
46
+ }
47
+
48
+ // An app's own name for the chip, as Peek's `messageMention`.
49
+ const Chip = CaptionedReference.extend({ name: 'messageMention' })
50
+
51
+ let editor: Editor | null = null
52
+
53
+ function Composer() {
54
+ const made = useEditor({
55
+ extensions: [
56
+ StarterKit,
57
+ PersonMention.configure({ people: () => PEOPLE }),
58
+ UrgentPersonMention.configure({ people: () => PEOPLE }),
59
+ Chip,
60
+ ReferenceTrigger.configure({ items: () => ITEMS, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things' }),
61
+ SlashCommands.configure({ sections: [formatSection(), APP_SECTION] }),
62
+ ],
63
+ })
64
+ editor = made
65
+ return <EditorContent editor={made} />
66
+ }
67
+
68
+ async function type(text: string) {
69
+ await act(async () => {
70
+ editor!.chain().focus().insertContent(text).run()
71
+ })
72
+ }
73
+
74
+ async function press(key: string) {
75
+ await act(async () => {
76
+ editor!.view.someProp('handleKeyDown', (handle) => handle(editor!.view, new KeyboardEvent('keydown', { key })))
77
+ })
78
+ }
79
+
80
+ // The highlighted row also draws its Enter hint; the label is what is compared.
81
+ const options = () => within(screen.getByRole('listbox')).getAllByRole('option').map((one) => one.textContent?.replace('↩ Enter', ''))
82
+ const nodes = () => {
83
+ const found: { type: string; attrs: Record<string, unknown> }[] = []
84
+ editor!.state.doc.descendants((node) => {
85
+ if (node.isInline && !node.isText) found.push({ type: node.type.name, attrs: node.attrs })
86
+ })
87
+ return found
88
+ }
89
+
90
+ // jsdom lays nothing out, so it has no `scrollIntoView`; the highlighted row calls it.
91
+ beforeAll(() => {
92
+ Element.prototype.scrollIntoView = vi.fn()
93
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
94
+ })
95
+
96
+ afterEach(() => {
97
+ editor?.destroy()
98
+ editor = null
99
+ cleanup()
100
+ })
101
+
102
+ describe('@ and !@', () => {
103
+ it('lists the first six people, narrowed by name, and a pick inserts a chip with their key', async () => {
104
+ render(<Composer />)
105
+ await type('@')
106
+ expect(screen.getByRole('listbox', { name: 'People' })).toBeTruthy()
107
+ expect(options()).toHaveLength(6)
108
+ await type('ada')
109
+ expect(options()).toHaveLength(1)
110
+ await press('Enter')
111
+ expect(nodes()).toEqual([{ type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }])
112
+ expect(editor!.getHTML()).toContain('data-mention="true"')
113
+ expect(screen.queryByRole('listbox')).toBeNull()
114
+ })
115
+
116
+ it('opens the urgent list on !@, and the document reports whose key it names', async () => {
117
+ render(<Composer />)
118
+ await type('!@')
119
+ expect(screen.getByRole('listbox', { name: 'Urgent mention' })).toBeTruthy()
120
+ await press('Enter')
121
+ expect(nodes()[0]).toEqual({ type: 'urgentMention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } })
122
+ expect(urgentPubkeys(editor!)).toEqual([KEY])
123
+ })
124
+
125
+ it('keeps a person with no key, who has nothing to carry urgently', () => {
126
+ expect(filterPeople(PEOPLE, 'BE')).toEqual([PEOPLE[1]])
127
+ expect(filterPeople(PEOPLE, '')).toHaveLength(6)
128
+ })
129
+ })
130
+
131
+ describe('[', () => {
132
+ it('lists the last six references and inserts the app-named chip with its uri', async () => {
133
+ render(<Composer />)
134
+ await type('[')
135
+ expect(screen.getByRole('listbox', { name: 'Things' })).toBeTruthy()
136
+ expect(options()).toEqual(['Ada: line 2', 'Ada: line 3', 'Ada: line 4', 'Ada: line 5', 'Ada: line 6', 'Bea: line 7'])
137
+ await press('Enter')
138
+ expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[2].id, label: 'Ada', snippet: 'line 2', uri: ITEMS[2].uri } }])
139
+ expect(editor!.getHTML()).toContain('data-message-mention="true"')
140
+ })
141
+
142
+ it('draws the app’s sections in order, skips an empty one, and redraws the open list when told', async () => {
143
+ const listeners = new Set<() => void>()
144
+ let late: CaptionedItem[] = []
145
+ const sections = (query: string) => [
146
+ { label: 'Recent', items: ITEMS.slice(0, 2).filter((item) => item.snippet.includes(query)) },
147
+ { label: 'Empty', items: [] },
148
+ { label: 'Found', items: late },
149
+ ]
150
+ function Sectioned() {
151
+ const made = useEditor({
152
+ extensions: [
153
+ StarterKit,
154
+ Chip,
155
+ ReferenceTrigger.configure({
156
+ items: () => [],
157
+ sections,
158
+ subscribe: (listener) => {
159
+ listeners.add(listener)
160
+ return () => listeners.delete(listener)
161
+ },
162
+ nodeName: 'messageMention',
163
+ ariaLabel: 'Things',
164
+ sectionLabel: 'Things',
165
+ }),
166
+ ],
167
+ })
168
+ editor = made
169
+ return <EditorContent editor={made} />
170
+ }
171
+ render(<Sectioned />)
172
+ await type('[')
173
+ expect(options()).toEqual(['Ada: line 0', 'Ada: line 1'])
174
+ expect(screen.getByText('Recent')).toBeTruthy()
175
+ expect(screen.queryByText('Empty')).toBeNull()
176
+ expect(listeners.size).toBe(1)
177
+
178
+ // The person arrows to a row, then a late answer lands: their row stays highlighted.
179
+ await press('ArrowDown')
180
+ late = [{ id: 'f'.repeat(64), label: 'Plan', snippet: 'Q3 roadmap', uri: 'nostr:naddr1example', description: 'Planning' }]
181
+ await act(async () => listeners.forEach((listener) => listener()))
182
+ expect(options()).toEqual(['Ada: line 0', 'Ada: line 1', 'Plan: Q3 roadmapPlanning'])
183
+ expect(screen.getByText('Found')).toBeTruthy()
184
+ await press('Enter')
185
+ expect(nodes().map((node) => node.attrs.id)).toEqual([ITEMS[1].id])
186
+ expect(listeners.size).toBe(0)
187
+
188
+ // A row that arrived late is picked like any other.
189
+ await type('[')
190
+ await press('ArrowDown')
191
+ await press('ArrowDown')
192
+ await press('Enter')
193
+ expect(nodes()[1]).toEqual({ type: 'messageMention', attrs: { id: 'f'.repeat(64), label: 'Plan', snippet: 'Q3 roadmap', uri: 'nostr:naddr1example' } })
194
+
195
+ await type('[')
196
+ expect(listeners.size).toBe(1)
197
+ await press('Escape')
198
+ expect(screen.queryByRole('listbox')).toBeNull()
199
+ // Escape closes the list and stops listening.
200
+ expect(listeners.size).toBe(0)
201
+ })
202
+
203
+ it('matches who said it, or the text it searches', () => {
204
+ expect(filterReferences(ITEMS, 'bea').map((item) => item.id)).toEqual([ITEMS[7].id])
205
+ expect(filterReferences(ITEMS, '**line** 3').map((item) => item.id)).toEqual([ITEMS[3].id])
206
+ })
207
+ })
208
+
209
+ describe('a chip through HTML (copy and paste)', () => {
210
+ it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => {
211
+ render(<Composer />)
212
+ await act(async () => {
213
+ editor!.commands.setContent([
214
+ {
215
+ type: 'paragraph',
216
+ content: [
217
+ { type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } },
218
+ { type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } },
219
+ ],
220
+ },
221
+ ])
222
+ })
223
+ const html = editor!.getHTML()
224
+ await act(async () => {
225
+ editor!.commands.setContent(html)
226
+ })
227
+ expect(nodes()).toEqual([
228
+ { type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } },
229
+ { type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } },
230
+ ])
231
+ await act(async () => {
232
+ editor!.commands.setContent(
233
+ '<p><span data-urgent-mention="true" data-id="x" data-label="Ada" data-pubkey="not-a-key">@Ada</span>' +
234
+ '<span data-message-mention="true" data-id="y" data-label="Ada" data-uri="javascript:alert(1)">Ada</span></p>',
235
+ )
236
+ })
237
+ const [person, reference] = nodes()
238
+ expect([person.type, person.attrs.label, person.attrs.pubkey]).toEqual(['urgentMention', 'Ada', null])
239
+ expect([reference.type, reference.attrs.uri]).toEqual(['messageMention', null])
240
+ })
241
+ })
242
+
243
+ describe('/', () => {
244
+ it('opens Format first, then the app’s sections, and runs the chosen row', async () => {
245
+ render(<Composer />)
246
+ await type('/')
247
+ expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy()
248
+ expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code`', 'Bulleted list-', 'Numbered list1.', 'Mention@'])
249
+ await type('head')
250
+ await press('Enter')
251
+ expect(editor!.getJSON().content?.[0]).toEqual({ type: 'heading', attrs: { level: 1 } })
252
+ })
253
+
254
+ it('narrows on a label, an id or a keyword, and keeps an emptied section', () => {
255
+ const sections = [formatSection(), APP_SECTION]
256
+ const labels = (q: string) => filterSlashSections(sections, q).map((s) => s.commands.map((c) => c.row.label))
257
+ expect(labels('list')).toEqual([['Bulleted list', 'Numbered list'], []])
258
+ expect(labels('mention a person')).toEqual([[], ['Mention']])
259
+ expect(labels('HEAD')).toEqual([['Heading', 'Subheading'], []])
260
+ })
261
+
262
+ it('makes code a block when the app asks for one', async () => {
263
+ render(<Composer />)
264
+ const code = formatSection('block').commands.find((c) => c.key === 'format-code')!
265
+ await act(async () => code.run(editor!))
266
+ expect(editor!.getJSON().content?.[0].type).toBe('codeBlock')
267
+ })
268
+ })