@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.
Files changed (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2166 -509
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +155 -0
  49. 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
+ }