@adatechnology/conversations-ui 0.1.1 → 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 +1 -1
- 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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { createUploadQueue } from './createUploadQueue'
|
|
4
|
+
|
|
5
|
+
function deferred<T = void>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
6
|
+
let resolve!: (value: T) => void
|
|
7
|
+
const promise = new Promise<T>((res) => {
|
|
8
|
+
resolve = res
|
|
9
|
+
})
|
|
10
|
+
return { promise, resolve }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('createUploadQueue', () => {
|
|
14
|
+
it('nunca roda mais que o limite ao mesmo tempo, para 10 itens', async () => {
|
|
15
|
+
const queue = createUploadQueue(3)
|
|
16
|
+
let active = 0
|
|
17
|
+
let maxActive = 0
|
|
18
|
+
const gates = Array.from({ length: 10 }, () => deferred())
|
|
19
|
+
|
|
20
|
+
for (let index = 0; index < 10; index += 1) {
|
|
21
|
+
queue.enqueue(`item-${index}`, async () => {
|
|
22
|
+
active += 1
|
|
23
|
+
maxActive = Math.max(maxActive, active)
|
|
24
|
+
await gates[index]?.promise
|
|
25
|
+
active -= 1
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(active).toBe(3)
|
|
30
|
+
for (const gate of gates) gate.resolve()
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
33
|
+
|
|
34
|
+
expect(maxActive).toBeLessThanOrEqual(3)
|
|
35
|
+
expect(active).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('um retry enfileirado com a fila cheia espera vaga', async () => {
|
|
39
|
+
const queue = createUploadQueue(3)
|
|
40
|
+
const gates = Array.from({ length: 3 }, () => deferred())
|
|
41
|
+
for (let index = 0; index < 3; index += 1) {
|
|
42
|
+
queue.enqueue(`initial-${index}`, async () => {
|
|
43
|
+
await gates[index]?.promise
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let retryStarted = false
|
|
48
|
+
queue.enqueue('retry-item', async () => {
|
|
49
|
+
retryStarted = true
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
53
|
+
expect(retryStarted).toBe(false)
|
|
54
|
+
|
|
55
|
+
gates[0]?.resolve()
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
58
|
+
|
|
59
|
+
expect(retryStarted).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('abortar um item ainda pendente remove da fila sem rodar', async () => {
|
|
63
|
+
const queue = createUploadQueue(1)
|
|
64
|
+
const blocking = deferred()
|
|
65
|
+
queue.enqueue('blocking', async () => {
|
|
66
|
+
await blocking.promise
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
let queuedRan = false
|
|
70
|
+
queue.enqueue('queued', async () => {
|
|
71
|
+
queuedRan = true
|
|
72
|
+
})
|
|
73
|
+
queue.abort('queued')
|
|
74
|
+
|
|
75
|
+
blocking.resolve()
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
78
|
+
|
|
79
|
+
expect(queuedRan).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('abortar um item ativo libera a vaga para o próximo', async () => {
|
|
83
|
+
const queue = createUploadQueue(1)
|
|
84
|
+
let activeAborted = false
|
|
85
|
+
queue.enqueue('active', async (signal) => {
|
|
86
|
+
await new Promise<void>((resolve) => {
|
|
87
|
+
signal.addEventListener('abort', () => {
|
|
88
|
+
activeAborted = true
|
|
89
|
+
resolve()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
let nextRan = false
|
|
95
|
+
queue.enqueue('next', async () => {
|
|
96
|
+
nextRan = true
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
queue.abort('active')
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
102
|
+
|
|
103
|
+
expect(activeAborted).toBe(true)
|
|
104
|
+
expect(nextRan).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fila de upload compartilhada (M1): no máximo `maxConcurrent` itens em voo ao mesmo tempo,
|
|
3
|
+
* qualquer que seja a sequência de chamadas que os enfileirou (anexar novo arquivo, repetir um
|
|
4
|
+
* que falhou). Sem isto, `addAttachmentFiles` e `retryAttachmentUpload` cada um abria sua própria
|
|
5
|
+
* janela de concorrência, e a soma das duas podia passar de 3 uploads simultâneos.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type UploadQueueRunner = (signal: AbortSignal) => Promise<void>
|
|
9
|
+
|
|
10
|
+
export type UploadQueueHandle = {
|
|
11
|
+
/** Enfileira `id`; começa a rodar assim que houver vaga. Reenfileirar um `id` já pendente ou
|
|
12
|
+
* ativo substitui o worker anterior por este. */
|
|
13
|
+
readonly enqueue: (id: string, run: UploadQueueRunner) => void
|
|
14
|
+
/** Aborta `id`: se estiver ativo, aborta o `AbortSignal` dele; se só estiver pendente, remove
|
|
15
|
+
* da fila sem nunca chegar a rodar. */
|
|
16
|
+
readonly abort: (id: string) => void
|
|
17
|
+
/** Aborta tudo — ativo e pendente — e esvazia a fila. */
|
|
18
|
+
readonly abortAll: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createUploadQueue(maxConcurrent: number): UploadQueueHandle {
|
|
22
|
+
const pending: string[] = []
|
|
23
|
+
const runners = new Map<string, UploadQueueRunner>()
|
|
24
|
+
const controllers = new Map<string, AbortController>()
|
|
25
|
+
let activeCount = 0
|
|
26
|
+
|
|
27
|
+
function pump(): void {
|
|
28
|
+
while (activeCount < maxConcurrent && pending.length > 0) {
|
|
29
|
+
const id = pending.shift() as string
|
|
30
|
+
const run = runners.get(id)
|
|
31
|
+
runners.delete(id)
|
|
32
|
+
if (!run) continue
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
controllers.set(id, controller)
|
|
35
|
+
activeCount += 1
|
|
36
|
+
void run(controller.signal).finally(() => {
|
|
37
|
+
controllers.delete(id)
|
|
38
|
+
activeCount -= 1
|
|
39
|
+
pump()
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function enqueue(id: string, run: UploadQueueRunner): void {
|
|
45
|
+
if (controllers.has(id)) return
|
|
46
|
+
runners.set(id, run)
|
|
47
|
+
if (!pending.includes(id)) pending.push(id)
|
|
48
|
+
pump()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function abort(id: string): void {
|
|
52
|
+
const controller = controllers.get(id)
|
|
53
|
+
if (controller) {
|
|
54
|
+
controller.abort()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const index = pending.indexOf(id)
|
|
58
|
+
if (index >= 0) pending.splice(index, 1)
|
|
59
|
+
runners.delete(id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function abortAll(): void {
|
|
63
|
+
for (const controller of controllers.values()) controller.abort()
|
|
64
|
+
pending.length = 0
|
|
65
|
+
runners.clear()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { enqueue, abort, abortAll }
|
|
69
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { QuickRepliesWorkspace } from './QuickRepliesWorkspace'
|
|
2
|
+
export type { QuickRepliesWorkspaceProps } from './QuickRepliesWorkspace'
|
|
3
|
+
export { QuickRepliesPicker } from './QuickRepliesPicker'
|
|
4
|
+
export type { QuickRepliesPickerProps } from './QuickRepliesPicker'
|
|
5
|
+
export type { QuickRepliesWorkspaceLabels, QuickRepliesPickerLabels } from './labels'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vocabulário das telas de mensagens prontas. Sobrescrevível campo a campo — mesma convenção de
|
|
3
|
+
* `documents/labels.ts` e `workspace/labels.ts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface QuickRepliesPickerLabels {
|
|
7
|
+
readonly loading: string
|
|
8
|
+
readonly error: string
|
|
9
|
+
readonly empty: string
|
|
10
|
+
readonly noResults: string
|
|
11
|
+
/** Contagem ao lado do clipe na linha (QR-32): "2 anexos". */
|
|
12
|
+
readonly attachmentsCount: (count: number) => string
|
|
13
|
+
/** Mostrado na linha quando a mensagem tem anexo, mas o produto não sabe mandá-lo (QR-33). */
|
|
14
|
+
readonly attachmentsUnavailable: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_QUICK_REPLIES_PICKER_LABELS: QuickRepliesPickerLabels = {
|
|
18
|
+
loading: 'Carregando mensagens prontas…',
|
|
19
|
+
error: 'Não foi possível carregar as mensagens prontas.',
|
|
20
|
+
empty: 'Nenhuma mensagem pronta cadastrada ainda.',
|
|
21
|
+
noResults: 'Nenhuma mensagem encontrada.',
|
|
22
|
+
attachmentsCount: (count) => `${count} ${count === 1 ? 'anexo' : 'anexos'}`,
|
|
23
|
+
attachmentsUnavailable: 'Este produto não envia os anexos desta mensagem — só o texto entra.',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QuickRepliesWorkspaceLabels {
|
|
27
|
+
readonly title: string
|
|
28
|
+
readonly subtitle: (total: number) => string
|
|
29
|
+
readonly searchPlaceholder: string
|
|
30
|
+
readonly loading: string
|
|
31
|
+
readonly failure: string
|
|
32
|
+
readonly empty: string
|
|
33
|
+
readonly noResults: string
|
|
34
|
+
readonly create: string
|
|
35
|
+
readonly edit: string
|
|
36
|
+
readonly remove: string
|
|
37
|
+
readonly removeConfirm: (title: string) => string
|
|
38
|
+
readonly save: string
|
|
39
|
+
readonly cancel: string
|
|
40
|
+
readonly columnTitle: string
|
|
41
|
+
readonly columnShortcut: string
|
|
42
|
+
readonly columnBody: string
|
|
43
|
+
readonly columnActions: string
|
|
44
|
+
readonly fieldTitle: string
|
|
45
|
+
readonly fieldTitleInvalid: string
|
|
46
|
+
readonly fieldShortcut: string
|
|
47
|
+
readonly fieldShortcutHint: string
|
|
48
|
+
readonly fieldShortcutInvalid: string
|
|
49
|
+
readonly fieldBody: string
|
|
50
|
+
readonly fieldBodyInvalid: string
|
|
51
|
+
readonly insertVariable: string
|
|
52
|
+
readonly shortcutTaken: string
|
|
53
|
+
readonly readOnlyNotice: string
|
|
54
|
+
readonly saveError: string
|
|
55
|
+
/** Sem `createQuickReply`/`updateQuickReply` na porta — a tela não deveria nem oferecer o botão,
|
|
56
|
+
* mas o formulário ainda precisa de um texto caso chame `submit` de outro jeito. */
|
|
57
|
+
readonly saveUnavailable: string
|
|
58
|
+
readonly saving: string
|
|
59
|
+
readonly deleting: string
|
|
60
|
+
/** Seção de anexos (QR-31) — só aparece com `uploadQuickReplyAttachment` na porta. */
|
|
61
|
+
readonly attachmentsTitle: string
|
|
62
|
+
readonly attachmentsAdd: string
|
|
63
|
+
readonly attachmentsEmpty: string
|
|
64
|
+
readonly attachmentUploading: (percent: number) => string
|
|
65
|
+
/** @deprecated Sem uso: não existe um "processando" observável entre a resposta do upload e o
|
|
66
|
+
* item entrar em `editing.attachments` — as duas atualizações de estado acontecem no mesmo
|
|
67
|
+
* commit do React. Mantido no tipo só para não quebrar quem já customiza este campo. */
|
|
68
|
+
readonly attachmentProcessing: string
|
|
69
|
+
readonly attachmentRetry: string
|
|
70
|
+
readonly attachmentRemove: string
|
|
71
|
+
readonly attachmentCancel: string
|
|
72
|
+
readonly attachmentMoveUp: string
|
|
73
|
+
readonly attachmentMoveDown: string
|
|
74
|
+
readonly attachmentLimitReached: string
|
|
75
|
+
readonly attachmentTooLarge: (filename: string) => string
|
|
76
|
+
readonly saveBlockedUploading: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS: QuickRepliesWorkspaceLabels = {
|
|
80
|
+
title: 'Mensagens prontas',
|
|
81
|
+
subtitle: (total) => `${total} ${total === 1 ? 'mensagem cadastrada' : 'mensagens cadastradas'}`,
|
|
82
|
+
searchPlaceholder: 'Buscar por título, atalho ou texto',
|
|
83
|
+
loading: 'Carregando mensagens prontas…',
|
|
84
|
+
failure: 'Não foi possível carregar as mensagens prontas.',
|
|
85
|
+
empty: 'Nenhuma mensagem pronta cadastrada ainda.',
|
|
86
|
+
noResults: 'Nenhuma mensagem encontrada para a busca.',
|
|
87
|
+
create: 'Nova mensagem',
|
|
88
|
+
edit: 'Editar',
|
|
89
|
+
remove: 'Excluir',
|
|
90
|
+
removeConfirm: (title) => `Excluir a mensagem "${title}"?`,
|
|
91
|
+
save: 'Salvar',
|
|
92
|
+
cancel: 'Cancelar',
|
|
93
|
+
columnTitle: 'Título',
|
|
94
|
+
columnShortcut: 'Atalho',
|
|
95
|
+
columnBody: 'Texto',
|
|
96
|
+
columnActions: 'Ações',
|
|
97
|
+
fieldTitle: 'Título',
|
|
98
|
+
fieldTitleInvalid: 'Digite um título de até 40 caracteres.',
|
|
99
|
+
fieldShortcut: 'Atalho',
|
|
100
|
+
fieldShortcutHint: 'Letras minúsculas, números e hífen, sem espaço nem acento.',
|
|
101
|
+
fieldShortcutInvalid: 'Atalho inválido — só letras minúsculas, números e hífen, até 20 caracteres.',
|
|
102
|
+
fieldBody: 'Texto',
|
|
103
|
+
fieldBodyInvalid: 'Digite um texto de até 1000 caracteres.',
|
|
104
|
+
insertVariable: 'Inserir variável',
|
|
105
|
+
shortcutTaken: 'Esse atalho já está em uso — escolha outro.',
|
|
106
|
+
readOnlyNotice: 'Você só pode consultar as mensagens prontas.',
|
|
107
|
+
saveError: 'Não foi possível salvar a mensagem.',
|
|
108
|
+
saveUnavailable: 'Salvar mensagens prontas não está disponível.',
|
|
109
|
+
saving: 'Salvando…',
|
|
110
|
+
deleting: 'Excluindo…',
|
|
111
|
+
attachmentsTitle: 'Anexos',
|
|
112
|
+
attachmentsAdd: 'Adicionar anexo',
|
|
113
|
+
attachmentsEmpty: 'Nenhum anexo — até 10 arquivos.',
|
|
114
|
+
attachmentUploading: (percent) => `Enviando… ${percent}%`,
|
|
115
|
+
attachmentProcessing: 'Processando…',
|
|
116
|
+
attachmentRetry: 'Tentar de novo',
|
|
117
|
+
attachmentRemove: 'Remover anexo',
|
|
118
|
+
attachmentCancel: 'Cancelar envio',
|
|
119
|
+
attachmentMoveUp: 'Mover para cima',
|
|
120
|
+
attachmentMoveDown: 'Mover para baixo',
|
|
121
|
+
attachmentLimitReached: 'Limite de 10 anexos por mensagem.',
|
|
122
|
+
attachmentTooLarge: (filename) => `${filename}: excede o tamanho máximo para o tipo de arquivo.`,
|
|
123
|
+
saveBlockedUploading: 'Aguardando o envio dos anexos…',
|
|
124
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mensagem pronta cadastrada pelo produto e oferecida no composer.
|
|
3
|
+
*
|
|
4
|
+
* Difere do `QuickReply` dos chips (`MessageComposer`): aquela vive em código no host; esta vem da
|
|
5
|
+
* API, tem atalho para o `/` e é editada pela tela de cadastro.
|
|
6
|
+
*/
|
|
7
|
+
export type QuickReply = {
|
|
8
|
+
readonly id: string
|
|
9
|
+
/** Até 40 caracteres — é o que o atendente lê na lista. */
|
|
10
|
+
readonly title: string
|
|
11
|
+
/** Casa `^[a-z0-9-]{1,20}$`: é digitado depois do `/`, sem acento nem espaço. */
|
|
12
|
+
readonly shortcut: string
|
|
13
|
+
/** Até 1000 caracteres, com `{{marcador}}` onde entra o dado da conversa. */
|
|
14
|
+
readonly body: string
|
|
15
|
+
/** Ausente em host sem anexos: o contrato continua o mesmo para quem não os oferece. */
|
|
16
|
+
readonly attachments?: readonly QuickReplyAttachment[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Teto de anexos por mensagem pronta — o mesmo que a API aplica, para a tela recusar antes. */
|
|
20
|
+
export const QUICK_REPLY_ATTACHMENT_LIMIT = 10
|
|
21
|
+
|
|
22
|
+
/** Arquivo já guardado pelo host; o `uploadId` é a única chave que o pacote devolve ao servidor. */
|
|
23
|
+
export type QuickReplyAttachment = {
|
|
24
|
+
readonly uploadId: string
|
|
25
|
+
readonly filename: string
|
|
26
|
+
readonly mimeType: string
|
|
27
|
+
readonly sizeBytes: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Item da fila do composer. `local` ainda não subiu (veio do clipe); `stored` já está no servidor
|
|
32
|
+
* (veio de uma mensagem pronta) e é enviado por referência, sem baixar e subir de novo.
|
|
33
|
+
*/
|
|
34
|
+
export type QueuedAttachment =
|
|
35
|
+
| { readonly kind: 'local'; readonly localId: string; readonly file: File }
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: 'stored'
|
|
38
|
+
readonly uploadId: string
|
|
39
|
+
readonly filename: string
|
|
40
|
+
readonly mimeType: string
|
|
41
|
+
readonly sizeBytes: number
|
|
42
|
+
/** Carregada sob demanda só para imagem — a lista não pode abrir N URLs assinadas de uma vez. */
|
|
43
|
+
readonly previewUrl?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resultado por arquivo: o envio em lote falha parcialmente, e só o que falhou fica na fila. */
|
|
47
|
+
export type StoredAttachmentSendResult = {
|
|
48
|
+
readonly uploadId: string
|
|
49
|
+
readonly status: 'sent' | 'failed' | 'skipped'
|
|
50
|
+
readonly errorCode?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** O que a tela de cadastro manda para criar ou atualizar; o `id` é do servidor. */
|
|
54
|
+
export type QuickReplyInput = {
|
|
55
|
+
readonly title: string
|
|
56
|
+
readonly shortcut: string
|
|
57
|
+
readonly body: string
|
|
58
|
+
/** Ordem do cadastro é a ordem de envio; ausente não mexe nos anexos já gravados. */
|
|
59
|
+
readonly attachmentUploadIds?: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dado da conversa que um texto pode citar. Uma lista só alimenta a prévia, a inserção e o botão de
|
|
64
|
+
* variáveis — antes eram duas props de formato diferente, e as telas divergiam por isso.
|
|
65
|
+
*/
|
|
66
|
+
export type ConversationVariable = {
|
|
67
|
+
readonly id: string
|
|
68
|
+
/** Rótulo do botão "Inserir variável" (ex.: "Nome do cliente"). */
|
|
69
|
+
readonly label: string
|
|
70
|
+
/** Como aparece no texto: `{{nome}}`. */
|
|
71
|
+
readonly marker: string
|
|
72
|
+
/** Vazio quando a conversa não tem o dado: a variável não é oferecida. */
|
|
73
|
+
readonly value: string
|
|
74
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { mapWithConcurrencyLimit, moveAttachment, validateAttachmentFiles } from './quickReplyAttachmentUpload'
|
|
4
|
+
|
|
5
|
+
function file(name: string, type: string, sizeBytes: number): File {
|
|
6
|
+
return new File([new Uint8Array(sizeBytes)], name, { type })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('validateAttachmentFiles', () => {
|
|
10
|
+
it('aceita dentro do teto e do tamanho por tipo', () => {
|
|
11
|
+
const result = validateAttachmentFiles([file('a.png', 'image/png', 10)], 0)
|
|
12
|
+
expect(result.accepted.map((f) => f.name)).toEqual(['a.png'])
|
|
13
|
+
expect(result.rejected).toEqual([])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('recusa por tamanho sem subir', () => {
|
|
17
|
+
const big = file('big.png', 'image/png', 6 * 1024 * 1024)
|
|
18
|
+
const result = validateAttachmentFiles([big], 0)
|
|
19
|
+
expect(result.accepted).toEqual([])
|
|
20
|
+
expect(result.rejected).toEqual([{ file: big, reason: 'size' }])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('recusa a partir do teto de 10, mantendo os anteriores', () => {
|
|
24
|
+
const files = Array.from({ length: 3 }, (_, index) => file(`f${index}.pdf`, 'application/pdf', 10))
|
|
25
|
+
const result = validateAttachmentFiles(files, 9)
|
|
26
|
+
expect(result.accepted.map((f) => f.name)).toEqual(['f0.pdf'])
|
|
27
|
+
expect(result.rejected.map((r) => r.file.name)).toEqual(['f1.pdf', 'f2.pdf'])
|
|
28
|
+
expect(result.rejected.every((r) => r.reason === 'limit')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('aceita limites do host', () => {
|
|
32
|
+
const result = validateAttachmentFiles([file('a.png', 'image/png', 3)], 0, {
|
|
33
|
+
document: 1,
|
|
34
|
+
image: 2,
|
|
35
|
+
audio: 1,
|
|
36
|
+
video: 1,
|
|
37
|
+
})
|
|
38
|
+
expect(result.rejected).toEqual([{ file: expect.anything(), reason: 'size' }])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('moveAttachment', () => {
|
|
43
|
+
it('move um item para frente e para trás', () => {
|
|
44
|
+
expect(moveAttachment(['a', 'b', 'c'], 0, 1)).toEqual(['b', 'a', 'c'])
|
|
45
|
+
expect(moveAttachment(['a', 'b', 'c'], 2, -1)).toEqual(['a', 'c', 'b'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('não faz nada nas bordas', () => {
|
|
49
|
+
expect(moveAttachment(['a', 'b'], 0, -1)).toEqual(['a', 'b'])
|
|
50
|
+
expect(moveAttachment(['a', 'b'], 1, 1)).toEqual(['a', 'b'])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('mapWithConcurrencyLimit', () => {
|
|
55
|
+
it('preserva a ordem do resultado mesmo com término fora de ordem', async () => {
|
|
56
|
+
const order = [30, 10, 20]
|
|
57
|
+
const result = await mapWithConcurrencyLimit(order, 3, async (delay) => {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
59
|
+
return delay
|
|
60
|
+
})
|
|
61
|
+
expect(result).toEqual([30, 10, 20])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('nunca roda mais que o limite ao mesmo tempo', async () => {
|
|
65
|
+
let current = 0
|
|
66
|
+
let max = 0
|
|
67
|
+
await mapWithConcurrencyLimit([1, 2, 3, 4, 5, 6], 3, async (item) => {
|
|
68
|
+
current += 1
|
|
69
|
+
max = Math.max(max, current)
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
71
|
+
current -= 1
|
|
72
|
+
return item
|
|
73
|
+
})
|
|
74
|
+
expect(max).toBeLessThanOrEqual(3)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funções puras do upload de anexo na tela de cadastro (QR-31): validação antes de subir,
|
|
3
|
+
* reordenação e o limitador de concorrência. Sem estado de React — testáveis sem montar o hook.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { canAddAttachments, resolveMaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
7
|
+
import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
8
|
+
|
|
9
|
+
export type AttachmentFileRejection = { readonly file: File; readonly reason: 'limit' | 'size' }
|
|
10
|
+
|
|
11
|
+
export type ValidateAttachmentFilesResult = {
|
|
12
|
+
readonly accepted: readonly File[]
|
|
13
|
+
readonly rejected: readonly AttachmentFileRejection[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Valida o teto de 10 e o tamanho por tipo ANTES de subir (QR-31) — nunca gasta um upload inteiro
|
|
18
|
+
* num arquivo que já ia ser recusado. Processa em ordem: um arquivo que estoura o teto no meio do
|
|
19
|
+
* lote não impede os anteriores de entrar, só ele e os que vêm depois.
|
|
20
|
+
*/
|
|
21
|
+
export function validateAttachmentFiles(
|
|
22
|
+
files: readonly File[],
|
|
23
|
+
currentCount: number,
|
|
24
|
+
limits?: MaxAttachmentSizeBytes,
|
|
25
|
+
): ValidateAttachmentFilesResult {
|
|
26
|
+
const accepted: File[] = []
|
|
27
|
+
const rejected: AttachmentFileRejection[] = []
|
|
28
|
+
let count = currentCount
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
if (!canAddAttachments(count, 1)) {
|
|
31
|
+
rejected.push({ file, reason: 'limit' })
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
const maxSize = resolveMaxAttachmentSizeBytes(file.type, limits)
|
|
35
|
+
if (file.size > maxSize) {
|
|
36
|
+
rejected.push({ file, reason: 'size' })
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
accepted.push(file)
|
|
40
|
+
count += 1
|
|
41
|
+
}
|
|
42
|
+
return { accepted, rejected }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Move um item uma posição — usado pelos botões de reordenar (acessíveis por teclado). Fora da
|
|
46
|
+
* faixa, devolve a mesma lista em vez de estourar. */
|
|
47
|
+
export function moveAttachment<T>(list: readonly T[], index: number, direction: -1 | 1): readonly T[] {
|
|
48
|
+
const target = index + direction
|
|
49
|
+
if (index < 0 || index >= list.length || target < 0 || target >= list.length) return list
|
|
50
|
+
const next = [...list]
|
|
51
|
+
const [item] = next.splice(index, 1)
|
|
52
|
+
next.splice(target, 0, item as T)
|
|
53
|
+
return next
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Roda `worker` sobre `items` com no máximo `limit` em paralelo (QR-31: até 3 uploads ao mesmo
|
|
58
|
+
* tempo). O resultado preserva a ordem de `items` — a posição *i* é sempre o resultado do item *i*,
|
|
59
|
+
* mesmo que o item *i+1* termine primeiro.
|
|
60
|
+
*/
|
|
61
|
+
export async function mapWithConcurrencyLimit<TItem, TResult>(
|
|
62
|
+
items: readonly TItem[],
|
|
63
|
+
limit: number,
|
|
64
|
+
worker: (item: TItem, index: number) => Promise<TResult>,
|
|
65
|
+
): Promise<TResult[]> {
|
|
66
|
+
const results: TResult[] = new Array(items.length)
|
|
67
|
+
let nextIndex = 0
|
|
68
|
+
|
|
69
|
+
async function runNext(): Promise<void> {
|
|
70
|
+
const index = nextIndex
|
|
71
|
+
nextIndex += 1
|
|
72
|
+
if (index >= items.length) return
|
|
73
|
+
const item = items[index] as TItem
|
|
74
|
+
results[index] = await worker(item, index)
|
|
75
|
+
await runNext()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const workerCount = Math.max(0, Math.min(limit, items.length))
|
|
79
|
+
await Promise.all(Array.from({ length: workerCount }, () => runNext()))
|
|
80
|
+
return results
|
|
81
|
+
}
|