@adatechnology/conversations-ui 0.1.0 → 0.2.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/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
- package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
- package/dist/index.d.ts +304 -9
- package/dist/index.js +2166 -509
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +147 -1
- package/dist/styles.css +135 -0
- package/package.json +2 -2
- package/src/MessageComposer.test.tsx +14 -0
- package/src/MessageComposer.tsx +327 -73
- package/src/RichMessageComposer.test.tsx +22 -4
- package/src/RichMessageComposer.tsx +674 -370
- package/src/index.ts +28 -1
- package/src/preview/createMockConversationsApi.ts +184 -0
- package/src/providers/types.ts +34 -0
- package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
- package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
- package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
- package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
- package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
- package/src/quickReplies/createUploadQueue.test.ts +106 -0
- package/src/quickReplies/createUploadQueue.ts +69 -0
- package/src/quickReplies/index.ts +5 -0
- package/src/quickReplies/labels.ts +124 -0
- package/src/quickReplies/quickReply.types.ts +74 -0
- package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
- package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
- package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
- package/src/quickReplies/quickReplyAttachments.ts +289 -0
- package/src/quickReplies/quickReplySearch.test.ts +88 -0
- package/src/quickReplies/quickReplySearch.ts +104 -0
- package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
- package/src/quickReplies/quickReplyShortcut.ts +46 -0
- package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
- package/src/quickReplies/resolveConversationVariables.ts +41 -0
- package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
- package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
- package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
- package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
- package/src/styles.css +87 -0
- package/src/workspace/ConversationPane.tsx +137 -73
- package/src/workspace/ConversationsWorkspace.tsx +16 -1
- package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
- package/src/workspace/QueuedAttachmentsList.tsx +198 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/labels.ts +14 -0
- package/src/workspace/useComposerAttachmentRetry.ts +155 -0
- package/src/workspace/useComposerQueue.ts +220 -0
package/src/index.ts
CHANGED
|
@@ -254,11 +254,38 @@ export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
|
254
254
|
export type { ConversationHeaderUtility } from './ConversationHeader'
|
|
255
255
|
export { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
|
|
256
256
|
export type { QuickReply } from './MessageComposer'
|
|
257
|
+
// `QuickReply` já nomeia o chip; a mensagem pronta cadastrada sai como `SavedQuickReply` para não quebrar hosts.
|
|
258
|
+
export type {
|
|
259
|
+
QuickReply as SavedQuickReply,
|
|
260
|
+
QuickReplyInput,
|
|
261
|
+
QuickReplyAttachment,
|
|
262
|
+
QueuedAttachment,
|
|
263
|
+
StoredAttachmentSendResult,
|
|
264
|
+
ConversationVariable,
|
|
265
|
+
} from './quickReplies/quickReply.types'
|
|
266
|
+
|
|
267
|
+
// Só o que o host precisa para montar a fila e ler seus tetos. O resto (validação de arquivo,
|
|
268
|
+
// ordenação de envio, limite de concorrência de upload) é orquestração interna do pacote — expô-lo
|
|
269
|
+
// convida o host a reimplementar o pipeline por fora em vez de usar `ConversationPane` (L2).
|
|
270
|
+
export { QUICK_REPLY_ATTACHMENT_LIMIT } from './quickReplies/quickReply.types'
|
|
271
|
+
export {
|
|
272
|
+
DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
|
|
273
|
+
resolveMaxAttachmentSizeBytes,
|
|
274
|
+
queuedAttachmentsFromQuickReply,
|
|
275
|
+
} from './quickReplies/quickReplyAttachments'
|
|
276
|
+
export type { MaxAttachmentSizeBytes, OutgoingItems } from './quickReplies/quickReplyAttachments'
|
|
277
|
+
export type { AttachmentFileRejection, ValidateAttachmentFilesResult } from './quickReplies/quickReplyAttachmentUpload'
|
|
278
|
+
export { QuickRepliesWorkspace, QuickRepliesPicker } from './quickReplies'
|
|
279
|
+
export type { QuickRepliesWorkspaceProps, QuickRepliesPickerProps } from './quickReplies'
|
|
280
|
+
export { DEFAULT_QUICK_REPLIES_PICKER_LABELS, DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './quickReplies/labels'
|
|
281
|
+
export type { QuickRepliesPickerLabels, QuickRepliesWorkspaceLabels } from './quickReplies/labels'
|
|
282
|
+
export { normalizeForSearch, filterQuickReplies, highlightMatch } from './quickReplies/quickReplySearch'
|
|
283
|
+
export type { FilterQuickRepliesParams, MatchSegment } from './quickReplies/quickReplySearch'
|
|
257
284
|
|
|
258
285
|
// Tela de atendimento completa. Fica no export principal — e não num subpath — porque é a
|
|
259
286
|
// composição padrão do pacote: quem instala conversas quer esta tela, e as peças continuam
|
|
260
287
|
// exportadas ao lado para quem precisar montar outra.
|
|
261
|
-
export { ConversationsWorkspace, ConversationPane, ConversationsInboxList } from './workspace'
|
|
288
|
+
export { ConversationsWorkspace, ConversationPane, ConversationsInboxList, QueuedAttachmentsList } from './workspace'
|
|
262
289
|
export { useConversationsInbox, CONVERSATIONS_PER_PAGE, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './workspace'
|
|
263
290
|
export type {
|
|
264
291
|
ConversationsWorkspaceProps,
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ConversationsApi,
|
|
17
17
|
ListConversationsParams,
|
|
18
18
|
} from '../providers/types'
|
|
19
|
+
import type { QuickReply, QuickReplyInput } from '../quickReplies/quickReply.types'
|
|
19
20
|
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
20
21
|
import { previewFileBase64, previewFileUrl } from './previewMediaSource'
|
|
21
22
|
import type { PreviewStore } from './previewStore'
|
|
@@ -36,6 +37,38 @@ const PREVIEW_TEMPLATES: readonly ConversationTemplate[] = [
|
|
|
36
37
|
{ name: 'promocao_taxa', language: 'pt_BR', status: 'PENDING', category: 'MARKETING' },
|
|
37
38
|
]
|
|
38
39
|
|
|
40
|
+
const PREVIEW_QUICK_REPLIES: QuickReply[] = [
|
|
41
|
+
{ id: '1', title: 'Boas-vindas', shortcut: 'ola', body: 'Olá {{nome}}, bem-vindo!' },
|
|
42
|
+
{
|
|
43
|
+
id: '2',
|
|
44
|
+
title: 'Lista de documentos',
|
|
45
|
+
shortcut: 'documentos',
|
|
46
|
+
body: 'Segue a lista de documentos necessários para prosseguir com sua solicitação.',
|
|
47
|
+
attachments: [
|
|
48
|
+
{ uploadId: 'upload-1', filename: 'Checklist de documentos.pdf', mimeType: 'application/pdf', sizeBytes: 245000 },
|
|
49
|
+
{ uploadId: 'upload-2', filename: 'Tabela de taxas.png', mimeType: 'image/png', sizeBytes: 125000 },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: '3',
|
|
54
|
+
title: 'Prazo de análise',
|
|
55
|
+
shortcut: 'prazo',
|
|
56
|
+
body: 'Sua solicitação está em análise e o resultado sairá em até 48 horas.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: '4',
|
|
60
|
+
title: 'Agendar ligação',
|
|
61
|
+
shortcut: 'ligacao',
|
|
62
|
+
body: 'Olá {{nome}}, você gostaria de agendar uma ligação comigo? Que horas funcionam melhor para você?',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: '5',
|
|
66
|
+
title: 'Encerramento',
|
|
67
|
+
shortcut: 'tchau',
|
|
68
|
+
body: 'Obrigado {{nome_completo}}, foi um prazer atender você. Qualquer dúvida, é só chamar!',
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
39
72
|
/**
|
|
40
73
|
* O mock satisfaz `ConversationsApi`, mas com o retorno de `fetchConversations` ESTREITADO para a
|
|
41
74
|
* forma paginada. Sem isto o contrato — que aceita array ou página — obrigaria todo consumidor do
|
|
@@ -267,5 +300,156 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
267
300
|
listTemplates(): Promise<ConversationTemplate[]> {
|
|
268
301
|
return withLatency(() => [...PREVIEW_TEMPLATES])
|
|
269
302
|
},
|
|
303
|
+
|
|
304
|
+
listQuickReplies(searchParams?: { search?: string }): Promise<QuickReply[]> {
|
|
305
|
+
return withLatency(() => {
|
|
306
|
+
let results = [...PREVIEW_QUICK_REPLIES]
|
|
307
|
+
if (searchParams?.search) {
|
|
308
|
+
const term = searchParams.search.toLowerCase()
|
|
309
|
+
results = results.filter(
|
|
310
|
+
(qr) =>
|
|
311
|
+
qr.title.toLowerCase().includes(term) ||
|
|
312
|
+
qr.shortcut.toLowerCase().includes(term) ||
|
|
313
|
+
qr.body.toLowerCase().includes(term),
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
return results
|
|
317
|
+
})
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
createQuickReply(input: QuickReplyInput): Promise<QuickReply> {
|
|
321
|
+
return withLatency(() => {
|
|
322
|
+
// Check for duplicate shortcut
|
|
323
|
+
if (PREVIEW_QUICK_REPLIES.some((qr) => qr.shortcut === input.shortcut)) {
|
|
324
|
+
const error = new Error('Atalho já existe') as unknown as {
|
|
325
|
+
code?: string
|
|
326
|
+
details?: unknown[]
|
|
327
|
+
}
|
|
328
|
+
error.code = 'QUICK_REPLY_SHORTCUT_TAKEN'
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
331
|
+
const newQuickReply: QuickReply = {
|
|
332
|
+
id: String(Date.now()),
|
|
333
|
+
title: input.title,
|
|
334
|
+
shortcut: input.shortcut,
|
|
335
|
+
body: input.body,
|
|
336
|
+
}
|
|
337
|
+
PREVIEW_QUICK_REPLIES.push(newQuickReply)
|
|
338
|
+
return newQuickReply
|
|
339
|
+
})
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
updateQuickReply(id: string, input: QuickReplyInput): Promise<QuickReply> {
|
|
343
|
+
return withLatency(() => {
|
|
344
|
+
const index = PREVIEW_QUICK_REPLIES.findIndex((qr) => qr.id === id)
|
|
345
|
+
if (index === -1) {
|
|
346
|
+
throw new Error('Mensagem pronta não encontrada')
|
|
347
|
+
}
|
|
348
|
+
// Check for duplicate shortcut (excluding current item)
|
|
349
|
+
if (PREVIEW_QUICK_REPLIES.some((qr) => qr.id !== id && qr.shortcut === input.shortcut)) {
|
|
350
|
+
const error = new Error('Atalho já existe') as unknown as {
|
|
351
|
+
code?: string
|
|
352
|
+
details?: unknown[]
|
|
353
|
+
}
|
|
354
|
+
error.code = 'QUICK_REPLY_SHORTCUT_TAKEN'
|
|
355
|
+
throw error
|
|
356
|
+
}
|
|
357
|
+
const updated: QuickReply = {
|
|
358
|
+
id,
|
|
359
|
+
title: input.title,
|
|
360
|
+
shortcut: input.shortcut,
|
|
361
|
+
body: input.body,
|
|
362
|
+
}
|
|
363
|
+
PREVIEW_QUICK_REPLIES[index] = updated
|
|
364
|
+
return updated
|
|
365
|
+
})
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
deleteQuickReply(id: string): Promise<void> {
|
|
369
|
+
return withLatency(() => {
|
|
370
|
+
const index = PREVIEW_QUICK_REPLIES.findIndex((qr) => qr.id === id)
|
|
371
|
+
if (index !== -1) {
|
|
372
|
+
PREVIEW_QUICK_REPLIES.splice(index, 1)
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
uploadQuickReplyAttachment(
|
|
378
|
+
file: File,
|
|
379
|
+
options: { readonly onProgress: (progress: number) => void; readonly signal: AbortSignal },
|
|
380
|
+
): Promise<{ uploadId: string; filename: string; mimeType: string; sizeBytes: number }> {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
if (options.signal.aborted) {
|
|
383
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const abortHandler = () => {
|
|
388
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
389
|
+
}
|
|
390
|
+
options.signal.addEventListener('abort', abortHandler)
|
|
391
|
+
|
|
392
|
+
// Simulação de progresso ao longo de ~1.5s (1500ms)
|
|
393
|
+
const steps = 6
|
|
394
|
+
const stepDuration = 1500 / steps
|
|
395
|
+
let step = 0
|
|
396
|
+
|
|
397
|
+
const progressInterval = setInterval(() => {
|
|
398
|
+
if (options.signal.aborted) {
|
|
399
|
+
clearInterval(progressInterval)
|
|
400
|
+
options.signal.removeEventListener('abort', abortHandler)
|
|
401
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
step += 1
|
|
406
|
+
const progress = step / steps
|
|
407
|
+
if (progress <= 1) {
|
|
408
|
+
options.onProgress(Math.round(progress * 100))
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (step >= steps) {
|
|
412
|
+
clearInterval(progressInterval)
|
|
413
|
+
options.signal.removeEventListener('abort', abortHandler)
|
|
414
|
+
resolve({
|
|
415
|
+
uploadId: `upload-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
416
|
+
filename: file.name,
|
|
417
|
+
mimeType: file.type,
|
|
418
|
+
sizeBytes: file.size,
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
}, stepDuration)
|
|
422
|
+
})
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
sendStoredAttachments(params: {
|
|
426
|
+
conversationId: string
|
|
427
|
+
uploadIds: readonly string[]
|
|
428
|
+
idempotencyKey: string
|
|
429
|
+
}): Promise<{ results: readonly { uploadId: string; status: 'sent' | 'failed' | 'skipped' }[] }> {
|
|
430
|
+
return withLatency(() => {
|
|
431
|
+
const results: { uploadId: string; status: 'sent' | 'failed' | 'skipped' }[] = []
|
|
432
|
+
|
|
433
|
+
for (let index = 0; index < params.uploadIds.length; index += 1) {
|
|
434
|
+
const uploadId = params.uploadIds[index] as string
|
|
435
|
+
// Simular falha para uploads cuja nome no preview contém "falha", e pular os seguintes
|
|
436
|
+
const isFailed = uploadId.includes('falha')
|
|
437
|
+
if (isFailed) {
|
|
438
|
+
results.push({ uploadId, status: 'failed' })
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Se o anterior falhou, pular este
|
|
443
|
+
if (index > 0 && results[index - 1]?.status === 'failed') {
|
|
444
|
+
results.push({ uploadId, status: 'skipped' })
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
results.push({ uploadId, status: 'sent' })
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { results }
|
|
452
|
+
})
|
|
453
|
+
},
|
|
270
454
|
}
|
|
271
455
|
}
|
package/src/providers/types.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QuickReply,
|
|
3
|
+
QuickReplyAttachment,
|
|
4
|
+
QuickReplyInput,
|
|
5
|
+
StoredAttachmentSendResult,
|
|
6
|
+
} from '../quickReplies/quickReply.types'
|
|
1
7
|
import type { MessagePayload, MessageTranscription } from '../types'
|
|
2
8
|
import type { ConversationChannel } from '../conversationChannel'
|
|
3
9
|
|
|
@@ -173,6 +179,34 @@ export interface ConversationsApi {
|
|
|
173
179
|
markAllRead?(): Promise<void>
|
|
174
180
|
listTemplates?(): Promise<ConversationTemplate[]>
|
|
175
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Mensagens prontas do produto. Ausente, nada de mensagens prontas aparece; sem `create`,
|
|
184
|
+
* `update` e `delete` a tela de cadastro fica só leitura — a presença da função é a capacidade.
|
|
185
|
+
*/
|
|
186
|
+
listQuickReplies?(params?: { search?: string }): Promise<QuickReply[]>
|
|
187
|
+
createQuickReply?(input: QuickReplyInput): Promise<QuickReply>
|
|
188
|
+
updateQuickReply?(id: string, input: QuickReplyInput): Promise<QuickReply>
|
|
189
|
+
deleteQuickReply?(id: string): Promise<void>
|
|
190
|
+
/**
|
|
191
|
+
* Sobe um anexo de mensagem pronta. O pacote não sabe COMO o host sobe (URL assinada, multipart):
|
|
192
|
+
* só precisa de progresso e cancelamento. Ausente, a tela de cadastro não oferece anexos.
|
|
193
|
+
* A miniatura de imagem reusa `getDocumentUrl(uploadId, 'inline')` — não há porta própria.
|
|
194
|
+
*/
|
|
195
|
+
uploadQuickReplyAttachment?(
|
|
196
|
+
file: File,
|
|
197
|
+
options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
|
|
198
|
+
): Promise<QuickReplyAttachment>
|
|
199
|
+
/**
|
|
200
|
+
* Envia por referência anexos já guardados. A chave de idempotência protege o reenvio depois de
|
|
201
|
+
* queda de rede; o resultado vem por arquivo porque a falha é parcial. Ausente, mensagem pronta
|
|
202
|
+
* com anexo insere só o texto.
|
|
203
|
+
*/
|
|
204
|
+
sendStoredAttachments?(params: {
|
|
205
|
+
conversationId: string
|
|
206
|
+
uploadIds: readonly string[]
|
|
207
|
+
idempotencyKey: string
|
|
208
|
+
}): Promise<{ results: readonly StoredAttachmentSendResult[] }>
|
|
209
|
+
|
|
176
210
|
/**
|
|
177
211
|
* Transcrição completa gerada pelo servidor. Existe ao lado de `buildTranscriptText`, que monta
|
|
178
212
|
* a partir das mensagens já em memória: a tela costuma ter só a última página carregada, e
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seção de anexos do formulário de cadastro (QR-31), extraída de `QuickRepliesWorkspace`: lista de
|
|
3
|
+
* anexos já subidos (com reordenação), uploads em voo e recusas de arquivo. Some inteira quando o
|
|
4
|
+
* host não oferece `uploadQuickReplyAttachment` — a mesma regra de capacidade do resto do pacote.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useRef } from 'react'
|
|
8
|
+
import { Paperclip, X } from 'lucide-react'
|
|
9
|
+
import { formatFileSize } from '../lib/format'
|
|
10
|
+
import type { PendingAttachmentUpload } from './useQuickReplyAttachmentUploads'
|
|
11
|
+
import type { AttachmentFileRejection } from './quickReplyAttachmentUpload'
|
|
12
|
+
import type { QuickReplyAttachment } from './quickReply.types'
|
|
13
|
+
import type { QuickRepliesWorkspaceLabels } from './labels'
|
|
14
|
+
|
|
15
|
+
export type AttachmentsFormSectionProps = {
|
|
16
|
+
readonly labels: QuickRepliesWorkspaceLabels
|
|
17
|
+
readonly attachments: readonly QuickReplyAttachment[]
|
|
18
|
+
readonly pendingUploads: readonly PendingAttachmentUpload[]
|
|
19
|
+
readonly attachmentRejections: readonly AttachmentFileRejection[]
|
|
20
|
+
readonly onAddFiles: (files: FileList) => void
|
|
21
|
+
readonly onRetryUpload: (localId: string) => void
|
|
22
|
+
readonly onCancelUpload: (localId: string) => void
|
|
23
|
+
readonly onRemoveAttachment: (uploadId: string) => void
|
|
24
|
+
readonly onMoveAttachment: (index: number, direction: -1 | 1) => void
|
|
25
|
+
readonly onDismissRejections: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function AttachmentsFormSection({
|
|
29
|
+
labels: text,
|
|
30
|
+
attachments,
|
|
31
|
+
pendingUploads,
|
|
32
|
+
attachmentRejections,
|
|
33
|
+
onAddFiles,
|
|
34
|
+
onRetryUpload,
|
|
35
|
+
onCancelUpload,
|
|
36
|
+
onRemoveAttachment,
|
|
37
|
+
onMoveAttachment,
|
|
38
|
+
onDismissRejections,
|
|
39
|
+
}: AttachmentsFormSectionProps) {
|
|
40
|
+
const attachmentFileInputRef = useRef<HTMLInputElement>(null)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-2">
|
|
44
|
+
<span className="block text-sm font-medium">{text.attachmentsTitle}</span>
|
|
45
|
+
|
|
46
|
+
{attachments.length === 0 && pendingUploads.length === 0 ? (
|
|
47
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">{text.attachmentsEmpty}</p>
|
|
48
|
+
) : (
|
|
49
|
+
<ul className="space-y-1">
|
|
50
|
+
{attachments.map((attachment, index) => (
|
|
51
|
+
<li
|
|
52
|
+
key={attachment.uploadId}
|
|
53
|
+
className="flex items-center gap-2 rounded-md border border-gray-200 px-2 py-1.5 text-xs dark:border-gray-700"
|
|
54
|
+
>
|
|
55
|
+
<Paperclip aria-hidden="true" className="h-3.5 w-3.5 flex-none text-gray-400" />
|
|
56
|
+
<span className="min-w-0 flex-1 truncate">{attachment.filename}</span>
|
|
57
|
+
<span className="flex-none text-gray-400">{formatFileSize(attachment.sizeBytes)}</span>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
disabled={index === 0}
|
|
61
|
+
aria-label={text.attachmentMoveUp}
|
|
62
|
+
onClick={() => onMoveAttachment(index, -1)}
|
|
63
|
+
className="flex-none disabled:opacity-30"
|
|
64
|
+
>
|
|
65
|
+
↑
|
|
66
|
+
</button>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
disabled={index === attachments.length - 1}
|
|
70
|
+
aria-label={text.attachmentMoveDown}
|
|
71
|
+
onClick={() => onMoveAttachment(index, 1)}
|
|
72
|
+
className="flex-none disabled:opacity-30"
|
|
73
|
+
>
|
|
74
|
+
↓
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
aria-label={text.attachmentRemove}
|
|
79
|
+
onClick={() => onRemoveAttachment(attachment.uploadId)}
|
|
80
|
+
className="flex-none text-red-600 dark:text-red-400"
|
|
81
|
+
>
|
|
82
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
83
|
+
</button>
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
{pendingUploads.map((pending) => (
|
|
87
|
+
<li
|
|
88
|
+
key={pending.localId}
|
|
89
|
+
className="flex items-center gap-2 rounded-md border border-gray-200 px-2 py-1.5 text-xs dark:border-gray-700"
|
|
90
|
+
aria-busy={pending.status === 'uploading'}
|
|
91
|
+
aria-live="polite"
|
|
92
|
+
>
|
|
93
|
+
<Paperclip aria-hidden="true" className="h-3.5 w-3.5 flex-none text-gray-400" />
|
|
94
|
+
<span className="min-w-0 flex-1 truncate">{pending.file.name}</span>
|
|
95
|
+
{pending.status === 'uploading' ? (
|
|
96
|
+
<span className="flex-none text-gray-500 dark:text-gray-400">
|
|
97
|
+
{text.attachmentUploading(Math.round(pending.progress * 100))}
|
|
98
|
+
</span>
|
|
99
|
+
) : (
|
|
100
|
+
<>
|
|
101
|
+
<span role="alert" className="flex-none text-red-600 dark:text-red-400">
|
|
102
|
+
{pending.error}
|
|
103
|
+
</span>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => onRetryUpload(pending.localId)}
|
|
107
|
+
className="flex-none font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
108
|
+
>
|
|
109
|
+
{text.attachmentRetry}
|
|
110
|
+
</button>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
aria-label={text.attachmentCancel}
|
|
116
|
+
onClick={() => onCancelUpload(pending.localId)}
|
|
117
|
+
className="flex-none text-gray-400"
|
|
118
|
+
>
|
|
119
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
120
|
+
</button>
|
|
121
|
+
</li>
|
|
122
|
+
))}
|
|
123
|
+
</ul>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{attachmentRejections.length > 0 ? (
|
|
127
|
+
<div role="alert" className="space-y-0.5 text-xs text-red-600 dark:text-red-400">
|
|
128
|
+
{attachmentRejections.map((rejection, index) => (
|
|
129
|
+
<p key={index}>
|
|
130
|
+
{rejection.reason === 'limit'
|
|
131
|
+
? text.attachmentLimitReached
|
|
132
|
+
: text.attachmentTooLarge(rejection.file.name)}
|
|
133
|
+
</p>
|
|
134
|
+
))}
|
|
135
|
+
<button type="button" onClick={onDismissRejections} className="hover:underline">
|
|
136
|
+
{text.cancel}
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
) : null}
|
|
140
|
+
|
|
141
|
+
<input
|
|
142
|
+
ref={attachmentFileInputRef}
|
|
143
|
+
type="file"
|
|
144
|
+
multiple
|
|
145
|
+
hidden
|
|
146
|
+
onChange={(event) => {
|
|
147
|
+
if (event.target.files) onAddFiles(event.target.files)
|
|
148
|
+
event.target.value = ''
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={() => attachmentFileInputRef.current?.click()}
|
|
154
|
+
className="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
155
|
+
>
|
|
156
|
+
{text.attachmentsAdd}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { QuickRepliesPicker } from './QuickRepliesPicker'
|
|
5
|
+
import type { QuickReply } from './quickReply.types'
|
|
6
|
+
|
|
7
|
+
const ITEMS: QuickReply[] = [
|
|
8
|
+
{ id: '1', title: 'Saudação', shortcut: 'ola', body: 'Olá!' },
|
|
9
|
+
{ id: '2', title: 'Pedido de documento', shortcut: 'doc', body: 'Envie o RG.' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const GREETING_WITH_VARIABLE: QuickReply = {
|
|
13
|
+
id: '3',
|
|
14
|
+
title: 'Boas-vindas',
|
|
15
|
+
shortcut: 'bv',
|
|
16
|
+
body: 'Olá {{nome}}, bem-vindo!',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('QuickRepliesPicker', () => {
|
|
20
|
+
it('mostra o estado vazio quando não há mensagens cadastradas', () => {
|
|
21
|
+
const markup = renderToStaticMarkup(
|
|
22
|
+
<QuickRepliesPicker
|
|
23
|
+
id="qr"
|
|
24
|
+
items={[]}
|
|
25
|
+
search=""
|
|
26
|
+
highlightedIndex={0}
|
|
27
|
+
isLoading={false}
|
|
28
|
+
onHover={() => {}}
|
|
29
|
+
onSelect={() => {}}
|
|
30
|
+
/>,
|
|
31
|
+
)
|
|
32
|
+
expect(markup).toContain('Nenhuma mensagem pronta cadastrada ainda.')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('mostra o estado de nenhum resultado quando a busca não acha nada', () => {
|
|
36
|
+
const markup = renderToStaticMarkup(
|
|
37
|
+
<QuickRepliesPicker
|
|
38
|
+
id="qr"
|
|
39
|
+
items={[]}
|
|
40
|
+
search="boleto"
|
|
41
|
+
highlightedIndex={0}
|
|
42
|
+
isLoading={false}
|
|
43
|
+
onHover={() => {}}
|
|
44
|
+
onSelect={() => {}}
|
|
45
|
+
/>,
|
|
46
|
+
)
|
|
47
|
+
expect(markup).toContain('Nenhuma mensagem encontrada.')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('mostra o carregamento antes da lista', () => {
|
|
51
|
+
const markup = renderToStaticMarkup(
|
|
52
|
+
<QuickRepliesPicker
|
|
53
|
+
id="qr"
|
|
54
|
+
items={[]}
|
|
55
|
+
search=""
|
|
56
|
+
highlightedIndex={0}
|
|
57
|
+
isLoading
|
|
58
|
+
onHover={() => {}}
|
|
59
|
+
onSelect={() => {}}
|
|
60
|
+
/>,
|
|
61
|
+
)
|
|
62
|
+
expect(markup).toContain('Carregando mensagens prontas')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('marca a opção destacada com aria-selected', () => {
|
|
66
|
+
const markup = renderToStaticMarkup(
|
|
67
|
+
<QuickRepliesPicker
|
|
68
|
+
id="qr"
|
|
69
|
+
items={ITEMS}
|
|
70
|
+
search=""
|
|
71
|
+
highlightedIndex={1}
|
|
72
|
+
isLoading={false}
|
|
73
|
+
onHover={() => {}}
|
|
74
|
+
onSelect={() => {}}
|
|
75
|
+
/>,
|
|
76
|
+
)
|
|
77
|
+
expect(markup).toContain('id="qr-option-1"')
|
|
78
|
+
expect(markup).toMatch(/id="qr-option-1"[^>]*aria-selected="true"/)
|
|
79
|
+
expect(markup).toMatch(/id="qr-option-0"[^>]*aria-selected="false"/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('destaca o trecho buscado sem produzir HTML a partir do texto cadastrado', () => {
|
|
83
|
+
const markup = renderToStaticMarkup(
|
|
84
|
+
<QuickRepliesPicker
|
|
85
|
+
id="qr"
|
|
86
|
+
items={ITEMS}
|
|
87
|
+
search="doc"
|
|
88
|
+
highlightedIndex={0}
|
|
89
|
+
isLoading={false}
|
|
90
|
+
onHover={() => {}}
|
|
91
|
+
onSelect={() => {}}
|
|
92
|
+
/>,
|
|
93
|
+
)
|
|
94
|
+
expect(markup).toContain('<mark')
|
|
95
|
+
expect(markup).toContain('Pedido de ')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('mostra a prévia do corpo com a variável já resolvida (QR-04), nunca o marcador cru', () => {
|
|
99
|
+
const markup = renderToStaticMarkup(
|
|
100
|
+
<QuickRepliesPicker
|
|
101
|
+
id="qr"
|
|
102
|
+
items={[GREETING_WITH_VARIABLE]}
|
|
103
|
+
search=""
|
|
104
|
+
highlightedIndex={0}
|
|
105
|
+
isLoading={false}
|
|
106
|
+
onHover={() => {}}
|
|
107
|
+
onSelect={() => {}}
|
|
108
|
+
variables={{ nome: 'João' }}
|
|
109
|
+
/>,
|
|
110
|
+
)
|
|
111
|
+
expect(markup).toContain('Olá João, bem-vindo!')
|
|
112
|
+
expect(markup).not.toContain('{{nome}}')
|
|
113
|
+
})
|
|
114
|
+
})
|