@adatechnology/conversations-ui 0.2.0 → 0.3.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/{chunk-DKPXKQGC.js → chunk-HMCOTZAX.js} +18 -1
- package/dist/{chunk-NHQDQJL2.js → chunk-L4OE4ORQ.js} +18 -19
- package/dist/{chunk-WCBDXZ3X.js → chunk-PJNR6UMN.js} +73 -8
- package/dist/flows/index.js +32 -28
- package/dist/index.d.ts +42 -3
- package/dist/index.js +586 -295
- package/dist/preview/index.js +2 -2
- package/package.json +1 -1
- package/src/InteractiveMessage.tsx +13 -13
- package/src/MessageText.tsx +1 -3
- package/src/blockFormattingNesting.test.tsx +47 -0
- package/src/flows/FlowWhatsAppPreview.tsx +40 -42
- package/src/index.ts +3 -0
- package/src/lib/WhatsAppMessagePreview.tsx +32 -0
- package/src/lib/composer-formatting.ts +8 -0
- package/src/lib/whatsapp-formatting.test.tsx +65 -2
- package/src/lib/whatsapp-formatting.tsx +110 -13
- package/src/quickReplies/QuickRepliesWorkspace.tsx +249 -164
- package/src/quickReplies/QuickReplyFormattingToolbar.tsx +62 -0
- package/src/quickReplies/QuickReplyWhatsAppPreview.tsx +57 -0
- package/src/quickReplies/labels.ts +27 -1
- package/src/quickReplies/quickReplyAttachments.test.ts +71 -0
- package/src/quickReplies/quickReplyAttachments.ts +39 -0
- package/src/quickReplies/quickReplyFormatting.test.tsx +336 -0
- package/src/quickReplies/quickReplyFormatting.ts +232 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +1 -1
- package/src/workspace/useComposerAttachmentRetry.ts +14 -0
- package/src/workspace/useComposerQueue.ts +17 -8
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
attachmentKey,
|
|
6
6
|
canAddAttachments,
|
|
7
7
|
excludeRetryingItems,
|
|
8
|
+
hasSentEveryStoredUpload,
|
|
8
9
|
orderOutgoingItems,
|
|
9
10
|
queuedAttachmentsFromQuickReply,
|
|
10
11
|
resolveIdempotencyKey,
|
|
@@ -12,6 +13,8 @@ import {
|
|
|
12
13
|
resolveRetryOutcome,
|
|
13
14
|
retryStoredAttachments,
|
|
14
15
|
sendQueuedMessage,
|
|
16
|
+
shouldResetRetryKey,
|
|
17
|
+
type IdempotencyKeyState,
|
|
15
18
|
} from './quickReplyAttachments'
|
|
16
19
|
import type { QueuedAttachment } from './quickReply.types'
|
|
17
20
|
|
|
@@ -261,6 +264,74 @@ describe('resolveIdempotencyKey', () => {
|
|
|
261
264
|
})
|
|
262
265
|
})
|
|
263
266
|
|
|
267
|
+
describe('hasSentEveryStoredUpload', () => {
|
|
268
|
+
it('true quando não havia guardado nenhum nesta tentativa', () => {
|
|
269
|
+
expect(hasSentEveryStoredUpload([], [])).toBe(true)
|
|
270
|
+
expect(hasSentEveryStoredUpload([], ['a'])).toBe(true)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('true quando todo uploadId da tentativa saiu', () => {
|
|
274
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], ['a', 'b'])).toBe(true)
|
|
275
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], ['b', 'a'])).toBe(true)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('true mesmo com chave extra em sentAttachmentKeys (ex: anexo local também enviado)', () => {
|
|
279
|
+
expect(hasSentEveryStoredUpload(['a'], ['a', 'local-1'])).toBe(true)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('falso quando um uploadId falhou ou foi pulado', () => {
|
|
283
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], ['a'])).toBe(false)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('falso quando nada saiu', () => {
|
|
287
|
+
expect(hasSentEveryStoredUpload(['a', 'b'], [])).toBe(false)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('shouldResetRetryKey', () => {
|
|
292
|
+
const stateA: IdempotencyKeyState = { key: 'key-a', uploadIds: ['upload-1'] }
|
|
293
|
+
const stateB: IdempotencyKeyState = { key: 'key-b', uploadIds: ['upload-1'] }
|
|
294
|
+
|
|
295
|
+
it('descarta quando o item saiu e o ref ainda é o mesmo da tentativa (duas tentativas seguidas do mesmo uploadId recebem chaves diferentes)', () => {
|
|
296
|
+
expect(
|
|
297
|
+
shouldResetRetryKey({
|
|
298
|
+
current: stateA,
|
|
299
|
+
attempted: stateA,
|
|
300
|
+
sentAttachmentKeys: ['upload-1'],
|
|
301
|
+
uploadId: 'upload-1',
|
|
302
|
+
}),
|
|
303
|
+
).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('mantém a chave quando o retry falhou (uploadId não está em sentAttachmentKeys)', () => {
|
|
307
|
+
expect(
|
|
308
|
+
shouldResetRetryKey({ current: stateA, attempted: stateA, sentAttachmentKeys: [], uploadId: 'upload-1' }),
|
|
309
|
+
).toBe(false)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('mantém a chave quando um retry concorrente já trocou o ref por identidade', () => {
|
|
313
|
+
expect(
|
|
314
|
+
shouldResetRetryKey({
|
|
315
|
+
current: stateB,
|
|
316
|
+
attempted: stateA,
|
|
317
|
+
sentAttachmentKeys: ['upload-1'],
|
|
318
|
+
uploadId: 'upload-1',
|
|
319
|
+
}),
|
|
320
|
+
).toBe(false)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('mantém a chave quando o ref já foi limpo (undefined) por outra resolução', () => {
|
|
324
|
+
expect(
|
|
325
|
+
shouldResetRetryKey({
|
|
326
|
+
current: undefined,
|
|
327
|
+
attempted: stateA,
|
|
328
|
+
sentAttachmentKeys: ['upload-1'],
|
|
329
|
+
uploadId: 'upload-1',
|
|
330
|
+
}),
|
|
331
|
+
).toBe(false)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
264
335
|
describe('retryStoredAttachments', () => {
|
|
265
336
|
it('reenvia só o uploadId pedido, sem tocar no resto da fila', async () => {
|
|
266
337
|
const calls: { uploadIds: readonly string[]; idempotencyKey: string }[] = []
|
|
@@ -112,6 +112,45 @@ function sameUploadIds(a: readonly string[], b: readonly string[]): boolean {
|
|
|
112
112
|
return a.every((id, index) => id === b[index])
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Decide se a chave de idempotência pode ser descartada depois de um envio (M3-bug): todo
|
|
117
|
+
* `uploadId` da tentativa saiu com sucesso. `setState` com updater NÃO roda de forma síncrona
|
|
118
|
+
* dentro do handler — uma variável `let` atualizada por ele e lida logo em seguida sempre lê o
|
|
119
|
+
* valor antigo. Esta decisão usa só os parâmetros da própria tentativa (nunca o estado da fila),
|
|
120
|
+
* então não depende de nenhum `setState` ter aplicado.
|
|
121
|
+
*/
|
|
122
|
+
export function hasSentEveryStoredUpload(
|
|
123
|
+
storedUploadIds: readonly string[],
|
|
124
|
+
sentAttachmentKeys: readonly string[],
|
|
125
|
+
): boolean {
|
|
126
|
+
if (storedUploadIds.length === 0) return true
|
|
127
|
+
const sentKeys = new Set(sentAttachmentKeys)
|
|
128
|
+
return storedUploadIds.every((uploadId) => sentKeys.has(uploadId))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type ShouldResetRetryKeyParams = {
|
|
132
|
+
/** Valor corrente do ref no momento da checagem — lido depois do `await`. */
|
|
133
|
+
readonly current: IdempotencyKeyState | undefined
|
|
134
|
+
/** Estado usado NESTA tentativa, capturado antes do `await`. */
|
|
135
|
+
readonly attempted: IdempotencyKeyState
|
|
136
|
+
readonly sentAttachmentKeys: readonly string[]
|
|
137
|
+
readonly uploadId: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Decide se a chave de idempotência do retry avulso pode ser descartada (M3-retry-bug): o item
|
|
142
|
+
* saiu com sucesso E ninguém trocou o ref por identidade desde então. A checagem de identidade
|
|
143
|
+
* (`current === attempted`) importa porque um retry avulso reusa `uploadId` de itens guardados
|
|
144
|
+
* (mensagens prontas) — um segundo retry do MESMO `uploadId` pode começar e sobrescrever o ref
|
|
145
|
+
* antes desta tentativa terminar; sem a checagem, o `undefined` desta tentativa apagaria a chave
|
|
146
|
+
* da tentativa mais nova, e o próximo envio dela reusaria uma chave já consumida pelo servidor.
|
|
147
|
+
*/
|
|
148
|
+
export function shouldResetRetryKey(params: ShouldResetRetryKeyParams): boolean {
|
|
149
|
+
const { current, attempted, sentAttachmentKeys, uploadId } = params
|
|
150
|
+
if (!sentAttachmentKeys.includes(uploadId)) return false
|
|
151
|
+
return current === attempted
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
export type SendQueuedMessageParams = {
|
|
116
155
|
readonly text: string
|
|
117
156
|
readonly queue: readonly QueuedAttachment[]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
|
|
5
|
+
import { FORMATTING_ACTION, WHATSAPP_MARKER_BY_ACTION } from '../lib/composer-formatting'
|
|
6
|
+
import { FlowWhatsAppPreview } from '../flows/FlowWhatsAppPreview'
|
|
7
|
+
import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './labels'
|
|
8
|
+
import { QuickRepliesWorkspace } from './QuickRepliesWorkspace'
|
|
9
|
+
import { QuickReplyFormattingToolbar } from './QuickReplyFormattingToolbar'
|
|
10
|
+
import { QuickReplyWhatsAppPreview, resolvePreviewVariables } from './QuickReplyWhatsAppPreview'
|
|
11
|
+
import {
|
|
12
|
+
changedRange,
|
|
13
|
+
computeFormattingEdit,
|
|
14
|
+
toggleLinePrefix,
|
|
15
|
+
formattingActionForShortcut,
|
|
16
|
+
wrapSelection,
|
|
17
|
+
type FormattingShortcutEvent,
|
|
18
|
+
} from './quickReplyFormatting'
|
|
19
|
+
|
|
20
|
+
describe('wrapSelection — regras do WhatsApp', () => {
|
|
21
|
+
it('deixa o espaço das pontas fora do marcador', () => {
|
|
22
|
+
expect(wrapSelection({ text: 'Olá mundo ', start: 3, end: 10, marker: '*' })).toEqual({
|
|
23
|
+
text: 'Olá *mundo* ',
|
|
24
|
+
selectionStart: 5,
|
|
25
|
+
selectionEnd: 10,
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('desliga quando o marcador está dentro da seleção', () => {
|
|
30
|
+
expect(wrapSelection({ text: 'a *b* c', start: 2, end: 5, marker: '*' })).toEqual({
|
|
31
|
+
text: 'a b c',
|
|
32
|
+
selectionStart: 2,
|
|
33
|
+
selectionEnd: 3,
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('desliga quando o marcador está logo fora da seleção', () => {
|
|
38
|
+
expect(wrapSelection({ text: 'a ```b``` c', start: 5, end: 6, marker: '```' })).toEqual({
|
|
39
|
+
text: 'a b c',
|
|
40
|
+
selectionStart: 2,
|
|
41
|
+
selectionEnd: 3,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('formata cada linha não vazia separadamente', () => {
|
|
46
|
+
expect(wrapSelection({ text: 'um\n\n dois ', start: 0, end: 10, marker: '_' })).toEqual({
|
|
47
|
+
text: '_um_\n\n _dois_ ',
|
|
48
|
+
selectionStart: 0,
|
|
49
|
+
selectionEnd: 14,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('desliga em todas as linhas quando todas já estão formatadas', () => {
|
|
54
|
+
expect(wrapSelection({ text: '~um~\n~dois~', start: 0, end: 11, marker: '~' }).text).toBe('um\ndois')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('só espaço selecionado cai no par vazio', () => {
|
|
58
|
+
expect(wrapSelection({ text: 'a b', start: 1, end: 3, marker: '*' }).text).toBe('a* *b')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('computeFormattingEdit', () => {
|
|
63
|
+
it('aplica a notação da ação e devolve a seleção nova', () => {
|
|
64
|
+
expect(
|
|
65
|
+
computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'bold', maximumLength: 10 }),
|
|
66
|
+
).toEqual({ text: '*oi*', selectionStart: 1, selectionEnd: 3 })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('não aplica quando passaria do limite', () => {
|
|
70
|
+
expect(
|
|
71
|
+
computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'monospace', maximumLength: 7 }),
|
|
72
|
+
).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('aceita chegar exatamente ao limite', () => {
|
|
76
|
+
expect(
|
|
77
|
+
computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'italic', maximumLength: 4 })
|
|
78
|
+
?.text,
|
|
79
|
+
).toBe('_oi_')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('desligar nunca esbarra no limite', () => {
|
|
83
|
+
expect(
|
|
84
|
+
computeFormattingEdit({ text: '*oi*', selectionStart: 0, selectionEnd: 4, action: 'bold', maximumLength: 4 })
|
|
85
|
+
?.text,
|
|
86
|
+
).toBe('oi')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('formattingActionForShortcut', () => {
|
|
91
|
+
const base: FormattingShortcutEvent = {
|
|
92
|
+
key: 'b',
|
|
93
|
+
ctrlKey: true,
|
|
94
|
+
metaKey: false,
|
|
95
|
+
shiftKey: false,
|
|
96
|
+
altKey: false,
|
|
97
|
+
isComposing: false,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('Ctrl/Cmd+B é negrito, Ctrl/Cmd+I é itálico', () => {
|
|
101
|
+
expect(formattingActionForShortcut(base)).toBe('bold')
|
|
102
|
+
expect(formattingActionForShortcut({ ...base, ctrlKey: false, metaKey: true, key: 'I' })).toBe('italic')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('ignora sem modificador, com shift, alt, composição ou outra tecla', () => {
|
|
106
|
+
expect(formattingActionForShortcut({ ...base, ctrlKey: false })).toBeUndefined()
|
|
107
|
+
expect(formattingActionForShortcut({ ...base, shiftKey: true })).toBeUndefined()
|
|
108
|
+
expect(formattingActionForShortcut({ ...base, altKey: true })).toBeUndefined()
|
|
109
|
+
expect(formattingActionForShortcut({ ...base, isComposing: true })).toBeUndefined()
|
|
110
|
+
expect(formattingActionForShortcut({ ...base, key: 'u' })).toBeUndefined()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('changedRange', () => {
|
|
115
|
+
it('isola a inserção', () => {
|
|
116
|
+
expect(changedRange('a b', 'a *b*')).toEqual({ start: 2, end: 3, insertedText: '*b*' })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('isola a remoção', () => {
|
|
120
|
+
expect(changedRange('*b*', '*b')).toEqual({ start: 2, end: 3, insertedText: '' })
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const LABELS = DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS
|
|
125
|
+
|
|
126
|
+
describe('wrapSelection', () => {
|
|
127
|
+
it('envolve a seleção e mantém o trecho selecionado', () => {
|
|
128
|
+
expect(wrapSelection({ text: 'Olá mundo', start: 4, end: 9, marker: '*' })).toEqual({
|
|
129
|
+
text: 'Olá *mundo*',
|
|
130
|
+
selectionStart: 5,
|
|
131
|
+
selectionEnd: 10,
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('sem seleção, abre o par com o cursor no meio', () => {
|
|
136
|
+
expect(wrapSelection({ text: 'Olá ', start: 4, end: 4, marker: '_' })).toEqual({
|
|
137
|
+
text: 'Olá __',
|
|
138
|
+
selectionStart: 5,
|
|
139
|
+
selectionEnd: 5,
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it.each([
|
|
144
|
+
[FORMATTING_ACTION.BOLD, '*oi*'],
|
|
145
|
+
[FORMATTING_ACTION.ITALIC, '_oi_'],
|
|
146
|
+
[FORMATTING_ACTION.STRIKETHROUGH, '~oi~'],
|
|
147
|
+
[FORMATTING_ACTION.MONOSPACE, '```oi```'],
|
|
148
|
+
])('%s usa a notação do WhatsApp', (action, expected) => {
|
|
149
|
+
const marker = WHATSAPP_MARKER_BY_ACTION[action]
|
|
150
|
+
expect(wrapSelection({ text: 'oi', start: 0, end: 2, marker }).text).toBe(expected)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('QuickReplyWhatsAppPreview', () => {
|
|
155
|
+
it('renderiza negrito, itálico, tachado e monoespaçado', () => {
|
|
156
|
+
const markup = renderToStaticMarkup(<QuickReplyWhatsAppPreview body={'*a* _b_ ~c~ ```d```'} labels={LABELS} />)
|
|
157
|
+
expect(markup).toContain('<strong>a</strong>')
|
|
158
|
+
expect(markup).toContain('<em>b</em>')
|
|
159
|
+
expect(markup).toMatch(/<(del|s)>c<\/(del|s)>/)
|
|
160
|
+
expect(markup).toMatch(/<code[^>]*>d<\/code>/)
|
|
161
|
+
expect(markup).toContain(LABELS.previewTitle)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('sem texto, mostra o aviso de corpo vazio', () => {
|
|
165
|
+
expect(renderToStaticMarkup(<QuickReplyWhatsAppPreview body="" labels={LABELS} />)).toContain(
|
|
166
|
+
LABELS.previewEmptyBody,
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('lista os anexos pelo nome do arquivo', () => {
|
|
171
|
+
const markup = renderToStaticMarkup(
|
|
172
|
+
<QuickReplyWhatsAppPreview
|
|
173
|
+
body="Segue"
|
|
174
|
+
attachments={[{ uploadId: 'u1', filename: 'contrato.pdf', mimeType: 'application/pdf', sizeBytes: 10 }]}
|
|
175
|
+
labels={LABELS}
|
|
176
|
+
/>,
|
|
177
|
+
)
|
|
178
|
+
expect(markup).toContain('contrato.pdf')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('resolvePreviewVariables', () => {
|
|
183
|
+
const variables = [
|
|
184
|
+
{ id: 'name', label: 'Nome do cliente', marker: '{{nome}}', value: 'Maria' },
|
|
185
|
+
{ id: 'city', label: 'Cidade', marker: '{{cidade}}', value: '' },
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
it('usa o exemplo, cai no rótulo e deixa marcador desconhecido como está', () => {
|
|
189
|
+
expect(resolvePreviewVariables('Oi {{nome}} de {{cidade}} {{cpf}}', variables)).toBe('Oi Maria de Cidade {{cpf}}')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('ignora variável sem marcador', () => {
|
|
193
|
+
expect(resolvePreviewVariables('Oi', [{ id: 'x', label: 'X', marker: '', value: 'Y' }])).toBe('Oi')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('QuickReplyFormattingToolbar', () => {
|
|
198
|
+
it('expõe os quatro botões com nome acessível', () => {
|
|
199
|
+
const markup = renderToStaticMarkup(<QuickReplyFormattingToolbar labels={LABELS} onFormat={() => {}} />)
|
|
200
|
+
for (const label of [LABELS.formatBold, LABELS.formatItalic, LABELS.formatStrikethrough, LABELS.formatMonospace]) {
|
|
201
|
+
expect(markup).toContain(`aria-label="${label}"`)
|
|
202
|
+
}
|
|
203
|
+
expect(markup).toContain('role="toolbar"')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('o clique entrega a ação ao dono do campo', () => {
|
|
207
|
+
const received: string[] = []
|
|
208
|
+
const element = QuickReplyFormattingToolbar({ labels: LABELS, onFormat: (action) => received.push(action) })
|
|
209
|
+
const buttons = element.props.children as { props: { onClick: () => void } }[]
|
|
210
|
+
for (const button of buttons) button.props.onClick()
|
|
211
|
+
expect(received).toEqual([
|
|
212
|
+
'bold',
|
|
213
|
+
'italic',
|
|
214
|
+
'strikethrough',
|
|
215
|
+
'monospace',
|
|
216
|
+
'inlineCode',
|
|
217
|
+
'bulletedList',
|
|
218
|
+
'numberedList',
|
|
219
|
+
'quote',
|
|
220
|
+
])
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('QuickRepliesWorkspace com labels antigas', () => {
|
|
225
|
+
it('aceita labels parciais sem as chaves novas', () => {
|
|
226
|
+
expect(() => renderToStaticMarkup(<QuickRepliesWorkspace api={{}} labels={{ title: 'X' }} />)).not.toThrow()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('FlowWhatsAppPreview sobre o balão compartilhado', () => {
|
|
231
|
+
it('mantém corpo formatado, botões e aviso de modo', () => {
|
|
232
|
+
const markup = renderToStaticMarkup(
|
|
233
|
+
<FlowWhatsAppPreview
|
|
234
|
+
body="*Oi*"
|
|
235
|
+
options={[
|
|
236
|
+
['1', 'Sim'],
|
|
237
|
+
['2', 'Não'],
|
|
238
|
+
]}
|
|
239
|
+
/>,
|
|
240
|
+
)
|
|
241
|
+
expect(markup).toContain('bg-[#e5ddd5]')
|
|
242
|
+
expect(markup).toContain('<strong>Oi</strong>')
|
|
243
|
+
expect(markup).toContain('Sim')
|
|
244
|
+
expect(markup).toContain('Não')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('sem corpo nem opções, mostra só o placeholder, fora do balão', () => {
|
|
248
|
+
expect(renderToStaticMarkup(<FlowWhatsAppPreview body="" />)).not.toContain('bg-[#e5ddd5]')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('toggleLinePrefix', () => {
|
|
253
|
+
it('põe o prefixo na linha do cursor e move o cursor junto', () => {
|
|
254
|
+
expect(toggleLinePrefix({ text: 'a\nbc', start: 3, end: 3, action: 'bulletedList' })).toEqual({
|
|
255
|
+
text: 'a\n- bc',
|
|
256
|
+
selectionStart: 5,
|
|
257
|
+
selectionEnd: 5,
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('numera cada linha selecionada em sequência, pulando a linha em branco', () => {
|
|
262
|
+
expect(toggleLinePrefix({ text: 'um\n\ndois\ntrês', start: 1, end: 10, action: 'numberedList' })).toEqual({
|
|
263
|
+
text: '1. um\n\n2. dois\n3. três',
|
|
264
|
+
selectionStart: 0,
|
|
265
|
+
selectionEnd: 22,
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('tira o prefixo de todas quando todas já têm', () => {
|
|
270
|
+
expect(toggleLinePrefix({ text: '> a\n> b', start: 0, end: 7, action: 'quote' }).text).toBe('a\nb')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('completa as que faltam quando só algumas têm', () => {
|
|
274
|
+
expect(toggleLinePrefix({ text: '- a\nb', start: 0, end: 5, action: 'bulletedList' }).text).toBe('- a\n- b')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('linha vazia com cursor ganha o prefixo', () => {
|
|
278
|
+
expect(toggleLinePrefix({ text: '', start: 0, end: 0, action: 'quote' })).toEqual({
|
|
279
|
+
text: '> ',
|
|
280
|
+
selectionStart: 2,
|
|
281
|
+
selectionEnd: 2,
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('seleção terminando na quebra não puxa a linha de baixo', () => {
|
|
286
|
+
expect(toggleLinePrefix({ text: 'a\nb', start: 0, end: 2, action: 'bulletedList' }).text).toBe('- a\nb')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('computeFormattingEdit com as ações de linha e código', () => {
|
|
291
|
+
it('código inline usa crase simples', () => {
|
|
292
|
+
expect(
|
|
293
|
+
computeFormattingEdit({ text: 'x', selectionStart: 0, selectionEnd: 1, action: 'inlineCode', maximumLength: 10 })
|
|
294
|
+
?.text,
|
|
295
|
+
).toBe('`x`')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('prefixo de linha respeita o limite', () => {
|
|
299
|
+
expect(
|
|
300
|
+
computeFormattingEdit({
|
|
301
|
+
text: 'a\nb',
|
|
302
|
+
selectionStart: 0,
|
|
303
|
+
selectionEnd: 3,
|
|
304
|
+
action: 'bulletedList',
|
|
305
|
+
maximumLength: 6,
|
|
306
|
+
}),
|
|
307
|
+
).toBeUndefined()
|
|
308
|
+
expect(
|
|
309
|
+
computeFormattingEdit({
|
|
310
|
+
text: 'a\nb',
|
|
311
|
+
selectionStart: 0,
|
|
312
|
+
selectionEnd: 3,
|
|
313
|
+
action: 'bulletedList',
|
|
314
|
+
maximumLength: 7,
|
|
315
|
+
})?.text,
|
|
316
|
+
).toBe('- a\n- b')
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe('QuickRepliesWorkspace — ações da tabela no padrão do pacote', () => {
|
|
321
|
+
// As linhas só chegam depois do carregamento assíncrono; o render estático não as mostra, então o
|
|
322
|
+
// contrato é conferido no fonte, como em flows/workspaceContract.test.ts.
|
|
323
|
+
const source = readFileSync(new URL('./QuickRepliesWorkspace.tsx', import.meta.url), 'utf8')
|
|
324
|
+
|
|
325
|
+
it('editar e excluir são cv-header-icon com Pencil e Trash2, nome acessível com o título', () => {
|
|
326
|
+
expect(source).toContain('className="cv-header-icon"')
|
|
327
|
+
expect(source).toContain('<Pencil size={14}')
|
|
328
|
+
expect(source).toContain('<Trash2 size={14}')
|
|
329
|
+
expect(source).toContain('aria-label={`${text.edit}: ${quickReply.title}`}')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('confirmação usa cv-header-action--danger e o criar usa --primary', () => {
|
|
333
|
+
expect(source).toContain('cv-header-action cv-header-action--danger')
|
|
334
|
+
expect(source).toContain('cv-header-action cv-header-action--primary')
|
|
335
|
+
})
|
|
336
|
+
})
|