@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,222 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
insertAtCursor,
|
|
5
|
+
submitQuickReply,
|
|
6
|
+
validateQuickReplyInput,
|
|
7
|
+
type QuickRepliesWorkspaceApi,
|
|
8
|
+
type QuickRepliesWorkspaceEditing,
|
|
9
|
+
} from './useQuickRepliesWorkspace'
|
|
10
|
+
import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './labels'
|
|
11
|
+
import type { QuickReply } from './quickReply.types'
|
|
12
|
+
|
|
13
|
+
const LABELS = DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS
|
|
14
|
+
|
|
15
|
+
const VALID_EDITING: QuickRepliesWorkspaceEditing = {
|
|
16
|
+
id: null,
|
|
17
|
+
title: 'Saudação',
|
|
18
|
+
shortcut: 'ola',
|
|
19
|
+
body: 'Olá!',
|
|
20
|
+
attachments: [],
|
|
21
|
+
}
|
|
22
|
+
const SAVED: QuickReply = { id: '1', title: 'Saudação', shortcut: 'ola', body: 'Olá!' }
|
|
23
|
+
|
|
24
|
+
describe('validateQuickReplyInput', () => {
|
|
25
|
+
it('aceita título, atalho e corpo dentro do limite', () => {
|
|
26
|
+
expect(validateQuickReplyInput({ title: 'Saudação', shortcut: 'ola', body: 'Olá!' }, LABELS)).toEqual({})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rejeita título vazio ou maior que 40 caracteres', () => {
|
|
30
|
+
expect(validateQuickReplyInput({ title: '', shortcut: 'ola', body: 'Olá!' }, LABELS).title).toBeDefined()
|
|
31
|
+
expect(
|
|
32
|
+
validateQuickReplyInput({ title: 'x'.repeat(41), shortcut: 'ola', body: 'Olá!' }, LABELS).title,
|
|
33
|
+
).toBeDefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('rejeita atalho com maiúscula, espaço ou acento', () => {
|
|
37
|
+
expect(validateQuickReplyInput({ title: 'T', shortcut: 'Ola', body: 'B' }, LABELS).shortcut).toBeDefined()
|
|
38
|
+
expect(validateQuickReplyInput({ title: 'T', shortcut: 'ol a', body: 'B' }, LABELS).shortcut).toBeDefined()
|
|
39
|
+
expect(validateQuickReplyInput({ title: 'T', shortcut: 'olá', body: 'B' }, LABELS).shortcut).toBeDefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('aceita atalho com hífen e número', () => {
|
|
43
|
+
expect(validateQuickReplyInput({ title: 'T', shortcut: 'doc-2', body: 'B' }, LABELS).shortcut).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('rejeita corpo vazio ou maior que 1000 caracteres', () => {
|
|
47
|
+
expect(validateQuickReplyInput({ title: 'T', shortcut: 'ola', body: '' }, LABELS).body).toBeDefined()
|
|
48
|
+
expect(validateQuickReplyInput({ title: 'T', shortcut: 'ola', body: 'x'.repeat(1001) }, LABELS).body).toBeDefined()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('submitQuickReply', () => {
|
|
53
|
+
it('cria quando id é null e chama createQuickReply', async () => {
|
|
54
|
+
const api: QuickRepliesWorkspaceApi = { createQuickReply: async () => SAVED }
|
|
55
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
56
|
+
expect(result).toEqual({ outcome: 'saved', quickReply: SAVED })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('atualiza quando há id e chama updateQuickReply com ele', async () => {
|
|
60
|
+
let calledWith: string | undefined
|
|
61
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
62
|
+
updateQuickReply: async (id) => {
|
|
63
|
+
calledWith = id
|
|
64
|
+
return SAVED
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
const result = await submitQuickReply({ api, editing: { ...VALID_EDITING, id: '1' }, labels: LABELS })
|
|
68
|
+
expect(result).toEqual({ outcome: 'saved', quickReply: SAVED })
|
|
69
|
+
expect(calledWith).toBe('1')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('manda attachmentUploadIds só quando o host tem a porta de upload (QR-30)', async () => {
|
|
73
|
+
let receivedInput: unknown
|
|
74
|
+
const withUpload: QuickRepliesWorkspaceApi = {
|
|
75
|
+
createQuickReply: async (input) => {
|
|
76
|
+
receivedInput = input
|
|
77
|
+
return SAVED
|
|
78
|
+
},
|
|
79
|
+
uploadQuickReplyAttachment: async () => ({ uploadId: 'x', filename: 'x', mimeType: 'x', sizeBytes: 1 }),
|
|
80
|
+
}
|
|
81
|
+
const editingWithAttachments = {
|
|
82
|
+
...VALID_EDITING,
|
|
83
|
+
attachments: [{ uploadId: 'a', filename: 'a.pdf', mimeType: 'application/pdf', sizeBytes: 1 }],
|
|
84
|
+
}
|
|
85
|
+
await submitQuickReply({ api: withUpload, editing: editingWithAttachments, labels: LABELS })
|
|
86
|
+
expect((receivedInput as { attachmentUploadIds?: readonly string[] }).attachmentUploadIds).toEqual(['a'])
|
|
87
|
+
|
|
88
|
+
const withoutUpload: QuickRepliesWorkspaceApi = {
|
|
89
|
+
createQuickReply: async (input) => {
|
|
90
|
+
receivedInput = input
|
|
91
|
+
return SAVED
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
await submitQuickReply({ api: withoutUpload, editing: editingWithAttachments, labels: LABELS })
|
|
95
|
+
expect((receivedInput as { attachmentUploadIds?: readonly string[] }).attachmentUploadIds).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('não chama a api quando a validação falha', async () => {
|
|
99
|
+
let called = false
|
|
100
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
101
|
+
createQuickReply: async () => {
|
|
102
|
+
called = true
|
|
103
|
+
return SAVED
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
const result = await submitQuickReply({ api, editing: { ...VALID_EDITING, title: '' }, labels: LABELS })
|
|
107
|
+
expect(result.outcome).toBe('invalid')
|
|
108
|
+
expect(called).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('409 com code QUICK_REPLY_SHORTCUT_TAKEN vira erro no campo atalho', async () => {
|
|
112
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
113
|
+
createQuickReply: async () => {
|
|
114
|
+
throw { code: 'QUICK_REPLY_SHORTCUT_TAKEN' }
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
118
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: LABELS.shortcutTaken } })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('error.details[] com vários campos aponta cada um no seu campo', async () => {
|
|
122
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
123
|
+
createQuickReply: async () => {
|
|
124
|
+
throw {
|
|
125
|
+
details: [
|
|
126
|
+
{ field: 'title', message: 'Título repetido' },
|
|
127
|
+
{ field: 'shortcut', message: 'Atalho em uso' },
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
133
|
+
expect(result).toEqual({
|
|
134
|
+
outcome: 'rejected',
|
|
135
|
+
fieldErrors: { title: 'Título repetido', shortcut: 'Atalho em uso' },
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('erro sem details vira o rótulo do formulário, nunca a mensagem crua da exceção', async () => {
|
|
140
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
141
|
+
createQuickReply: async () => {
|
|
142
|
+
throw new Error('ECONNRESET: socket hang up')
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
146
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: {}, formError: LABELS.saveError })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('409 com code aninhado em error.response.data.error (axios) vira erro no atalho', async () => {
|
|
150
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
151
|
+
createQuickReply: async () => {
|
|
152
|
+
throw {
|
|
153
|
+
response: {
|
|
154
|
+
data: {
|
|
155
|
+
error: {
|
|
156
|
+
code: 'QUICK_REPLY_SHORTCUT_TAKEN',
|
|
157
|
+
message: 'Atalho já existe',
|
|
158
|
+
details: [{ field: 'shortcut', message: 'Atalho já existe' }],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
166
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: 'Atalho já existe' } })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('erro aninhado em error.error (envelope { error: {...} }) vira erro no atalho', async () => {
|
|
170
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
171
|
+
createQuickReply: async () => {
|
|
172
|
+
throw {
|
|
173
|
+
error: {
|
|
174
|
+
code: 'QUICK_REPLY_SHORTCUT_TAKEN',
|
|
175
|
+
message: 'Atalho já existe',
|
|
176
|
+
details: [{ field: 'shortcut', message: 'Atalho já existe' }],
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
182
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: 'Atalho já existe' } })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('erro aninhado em error.body.error vira erro no atalho', async () => {
|
|
186
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
187
|
+
createQuickReply: async () => {
|
|
188
|
+
throw { body: { error: { code: 'QUICK_REPLY_SHORTCUT_TAKEN' } } }
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
192
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: { shortcut: LABELS.shortcutTaken } })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('erro que se referencia (error.error === error) não trava em recursão infinita', async () => {
|
|
196
|
+
const circular: Record<string, unknown> = {}
|
|
197
|
+
circular.error = circular
|
|
198
|
+
const api: QuickRepliesWorkspaceApi = {
|
|
199
|
+
createQuickReply: async () => {
|
|
200
|
+
throw circular
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
const result = await submitQuickReply({ api, editing: VALID_EDITING, labels: LABELS })
|
|
204
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: {}, formError: LABELS.saveError })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('editando sem updateQuickReply na porta, rejeita com saveUnavailable em vez de fingir sucesso', async () => {
|
|
208
|
+
const api: QuickRepliesWorkspaceApi = {}
|
|
209
|
+
const result = await submitQuickReply({ api, editing: { ...VALID_EDITING, id: '1' }, labels: LABELS })
|
|
210
|
+
expect(result).toEqual({ outcome: 'rejected', fieldErrors: {}, formError: LABELS.saveUnavailable })
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('insertAtCursor', () => {
|
|
215
|
+
it('insere o marcador na posição do cursor', () => {
|
|
216
|
+
expect(insertAtCursor('Olá !', 4, 4, '{{nome}}')).toEqual({ text: 'Olá {{nome}}!', caret: 12 })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('troca a seleção pelo marcador', () => {
|
|
220
|
+
expect(insertAtCursor('Olá NOME!', 4, 8, '{{nome}}')).toEqual({ text: 'Olá {{nome}}!', caret: 12 })
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { filterQuickReplies } from './quickReplySearch'
|
|
3
|
+
import { moveAttachment } from './quickReplyAttachmentUpload'
|
|
4
|
+
import { useQuickReplyAttachmentUploads } from './useQuickReplyAttachmentUploads'
|
|
5
|
+
import type { PendingAttachmentUpload } from './useQuickReplyAttachmentUploads'
|
|
6
|
+
import type { AttachmentFileRejection } from './quickReplyAttachmentUpload'
|
|
7
|
+
import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
8
|
+
import type { QuickReply, QuickReplyAttachment, QuickReplyInput } from './quickReply.types'
|
|
9
|
+
import type { QuickRepliesWorkspaceLabels } from './labels'
|
|
10
|
+
|
|
11
|
+
export type { PendingAttachmentUpload } from './useQuickReplyAttachmentUploads'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Portas que a tela de cadastro precisa. `create`/`update`/`delete` ausentes não impedem a leitura
|
|
15
|
+
* — só tiram a ação correspondente, a mesma regra de capacidade do resto do pacote.
|
|
16
|
+
*/
|
|
17
|
+
export interface QuickRepliesWorkspaceApi {
|
|
18
|
+
readonly listQuickReplies?: (params?: { search?: string }) => Promise<QuickReply[]>
|
|
19
|
+
readonly createQuickReply?: (input: QuickReplyInput) => Promise<QuickReply>
|
|
20
|
+
readonly updateQuickReply?: (id: string, input: QuickReplyInput) => Promise<QuickReply>
|
|
21
|
+
readonly deleteQuickReply?: (id: string) => Promise<void>
|
|
22
|
+
/** Sem esta porta, a seção de anexos não aparece — nem no formulário. */
|
|
23
|
+
readonly uploadQuickReplyAttachment?: (
|
|
24
|
+
file: File,
|
|
25
|
+
options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
|
|
26
|
+
) => Promise<QuickReplyAttachment>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type QuickReplyFormField = 'title' | 'shortcut' | 'body'
|
|
30
|
+
export type QuickRepliesWorkspaceFieldErrors = Partial<Record<QuickReplyFormField, string>>
|
|
31
|
+
|
|
32
|
+
export type QuickRepliesWorkspaceEditing = {
|
|
33
|
+
/** `null` é a criação; presente é edição do registro com este id. */
|
|
34
|
+
readonly id: string | null
|
|
35
|
+
readonly title: string
|
|
36
|
+
readonly shortcut: string
|
|
37
|
+
readonly body: string
|
|
38
|
+
/** Já subidos — a ordem daqui é a ordem de envio salva (QR-30). */
|
|
39
|
+
readonly attachments: readonly QuickReplyAttachment[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TITLE_MAX_LENGTH = 40
|
|
43
|
+
const BODY_MAX_LENGTH = 1000
|
|
44
|
+
const SHORTCUT_PATTERN = /^[a-z0-9-]{1,20}$/
|
|
45
|
+
|
|
46
|
+
/** Casca genérica de erro de API com `code` e `details[]` (`apis.md`) — sem acoplar a um cliente HTTP específico. */
|
|
47
|
+
type ApiErrorShape = { readonly code?: unknown; readonly details?: unknown }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Desembrulha a casca `{ code, details }` de onde quer que o cliente HTTP do host a tenha
|
|
51
|
+
* pendurado. `apis.md` define o envelope `{ error: { code, message, details } }`, mas cada cliente
|
|
52
|
+
* (fetch cru, axios, o `Error` que o `ConversationsProvider` relança) expõe o objeto lançado de um
|
|
53
|
+
* jeito diferente — `error.error`, `error.response.data.error` (axios) ou `error.body.error`
|
|
54
|
+
* (alguns wrappers de fetch). Sem isto, a rejeição real do servidor nunca chega ao formulário e o
|
|
55
|
+
* operador só vê "não foi possível salvar", mesmo quando a API já mandou o campo certo.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_ERROR_ENVELOPE_DEPTH = 3
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Desce no máximo `MAX_ERROR_ENVELOPE_DEPTH` níveis: um erro que se referencia (`error.error ===
|
|
61
|
+
* error`, comum em `Error` customizado com `cause` circular) faria a recursão nunca terminar.
|
|
62
|
+
*/
|
|
63
|
+
function apiErrorEnvelopeOf(error: unknown, depth = 0): ApiErrorShape | undefined {
|
|
64
|
+
if (depth >= MAX_ERROR_ENVELOPE_DEPTH) return undefined
|
|
65
|
+
if (!error || typeof error !== 'object') return undefined
|
|
66
|
+
const candidate = error as Record<string, unknown>
|
|
67
|
+
if ('code' in candidate || 'details' in candidate) return candidate as ApiErrorShape
|
|
68
|
+
const nested =
|
|
69
|
+
(candidate.error as unknown) ??
|
|
70
|
+
(candidate.response as Record<string, unknown> | undefined)?.data ??
|
|
71
|
+
(candidate.body as unknown)
|
|
72
|
+
if (nested && typeof nested === 'object') return apiErrorEnvelopeOf(nested, depth + 1)
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fieldErrorsOf(error: unknown): QuickRepliesWorkspaceFieldErrors {
|
|
77
|
+
const envelope = apiErrorEnvelopeOf(error)
|
|
78
|
+
if (!envelope) return {}
|
|
79
|
+
const details = envelope.details
|
|
80
|
+
if (!Array.isArray(details)) return {}
|
|
81
|
+
const result: QuickRepliesWorkspaceFieldErrors = {}
|
|
82
|
+
for (const detail of details) {
|
|
83
|
+
if (!detail || typeof detail !== 'object') continue
|
|
84
|
+
const field = (detail as { field?: unknown }).field
|
|
85
|
+
const message = (detail as { message?: unknown }).message
|
|
86
|
+
if (typeof field === 'string' && typeof message === 'string' && isFormField(field)) {
|
|
87
|
+
result[field] = message
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isFormField(value: string): value is QuickReplyFormField {
|
|
94
|
+
return value === 'title' || value === 'shortcut' || value === 'body'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function errorCodeOf(error: unknown): string | undefined {
|
|
98
|
+
const code = apiErrorEnvelopeOf(error)?.code
|
|
99
|
+
return typeof code === 'string' ? code : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Regras de formato — as mesmas do backend (`QR-08`), verificadas antes de gastar uma chamada. */
|
|
103
|
+
export function validateQuickReplyInput(
|
|
104
|
+
input: QuickReplyInput,
|
|
105
|
+
labels: QuickRepliesWorkspaceLabels,
|
|
106
|
+
): QuickRepliesWorkspaceFieldErrors {
|
|
107
|
+
const errors: QuickRepliesWorkspaceFieldErrors = {}
|
|
108
|
+
if (!input.title.trim() || input.title.length > TITLE_MAX_LENGTH) errors.title = labels.fieldTitleInvalid
|
|
109
|
+
if (!SHORTCUT_PATTERN.test(input.shortcut)) errors.shortcut = labels.fieldShortcutInvalid
|
|
110
|
+
if (!input.body.trim() || input.body.length > BODY_MAX_LENGTH) errors.body = labels.fieldBodyInvalid
|
|
111
|
+
return errors
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type SubmitQuickReplyParams = {
|
|
115
|
+
readonly api: QuickRepliesWorkspaceApi
|
|
116
|
+
readonly editing: QuickRepliesWorkspaceEditing
|
|
117
|
+
readonly labels: QuickRepliesWorkspaceLabels
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type SubmitQuickReplyResult =
|
|
121
|
+
| { readonly outcome: 'saved'; readonly quickReply: QuickReply }
|
|
122
|
+
| { readonly outcome: 'invalid'; readonly fieldErrors: QuickRepliesWorkspaceFieldErrors }
|
|
123
|
+
| {
|
|
124
|
+
readonly outcome: 'rejected'
|
|
125
|
+
readonly fieldErrors: QuickRepliesWorkspaceFieldErrors
|
|
126
|
+
readonly formError?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Toda a lógica de salvar num único lugar testável sem montar o hook: valida, chama a porta certa
|
|
131
|
+
* (criar ou atualizar) e traduz a rejeição — 409 com `code: 'QUICK_REPLY_SHORTCUT_TAKEN'` ou
|
|
132
|
+
* `error.details[]` — no campo que errou.
|
|
133
|
+
*/
|
|
134
|
+
export async function submitQuickReply({
|
|
135
|
+
api,
|
|
136
|
+
editing,
|
|
137
|
+
labels,
|
|
138
|
+
}: SubmitQuickReplyParams): Promise<SubmitQuickReplyResult> {
|
|
139
|
+
const input: QuickReplyInput = {
|
|
140
|
+
title: editing.title.trim(),
|
|
141
|
+
shortcut: editing.shortcut.trim(),
|
|
142
|
+
body: editing.body,
|
|
143
|
+
// Só manda a lista quando o host oferece a porta de upload — ausente, `attachmentUploadIds`
|
|
144
|
+
// some do payload e o backend não mexe nos anexos já gravados (contrato do tipo).
|
|
145
|
+
...(api.uploadQuickReplyAttachment
|
|
146
|
+
? { attachmentUploadIds: editing.attachments.map((attachment) => attachment.uploadId) }
|
|
147
|
+
: {}),
|
|
148
|
+
}
|
|
149
|
+
const validationErrors = validateQuickReplyInput(input, labels)
|
|
150
|
+
if (Object.keys(validationErrors).length > 0) return { outcome: 'invalid', fieldErrors: validationErrors }
|
|
151
|
+
|
|
152
|
+
const editingId = editing.id
|
|
153
|
+
if (editingId === null && !api.createQuickReply) {
|
|
154
|
+
return { outcome: 'rejected', fieldErrors: {}, formError: labels.saveUnavailable }
|
|
155
|
+
}
|
|
156
|
+
if (editingId !== null && !api.updateQuickReply) {
|
|
157
|
+
return { outcome: 'rejected', fieldErrors: {}, formError: labels.saveUnavailable }
|
|
158
|
+
}
|
|
159
|
+
const save =
|
|
160
|
+
editingId === null
|
|
161
|
+
? (api.createQuickReply as NonNullable<QuickRepliesWorkspaceApi['createQuickReply']>)
|
|
162
|
+
: (input: QuickReplyInput) =>
|
|
163
|
+
(api.updateQuickReply as NonNullable<QuickRepliesWorkspaceApi['updateQuickReply']>)(editingId, input)
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const quickReply = await save(input)
|
|
167
|
+
if (!quickReply) return { outcome: 'invalid', fieldErrors: {} }
|
|
168
|
+
return { outcome: 'saved', quickReply }
|
|
169
|
+
} catch (caught: unknown) {
|
|
170
|
+
const detailErrors = fieldErrorsOf(caught)
|
|
171
|
+
const code = errorCodeOf(caught)
|
|
172
|
+
const shortcutMessage =
|
|
173
|
+
detailErrors.shortcut ?? (code === 'QUICK_REPLY_SHORTCUT_TAKEN' ? labels.shortcutTaken : undefined)
|
|
174
|
+
if (shortcutMessage || Object.keys(detailErrors).length > 0) {
|
|
175
|
+
return {
|
|
176
|
+
outcome: 'rejected',
|
|
177
|
+
fieldErrors: { ...detailErrors, ...(shortcutMessage ? { shortcut: shortcutMessage } : {}) },
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Nunca `caught.message` cru: é texto de exceção interna (rede, parsing), não algo que o
|
|
181
|
+
// operador deva ler — a mensagem exibida é sempre o rótulo do host (QR-10).
|
|
182
|
+
return {
|
|
183
|
+
outcome: 'rejected',
|
|
184
|
+
fieldErrors: {},
|
|
185
|
+
formError: labels.saveError,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type InsertAtCursorResult = {
|
|
191
|
+
readonly text: string
|
|
192
|
+
readonly caret: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Insere `marker` na posição do cursor (ou troca a seleção) — usado pelo botão "Inserir variável". */
|
|
196
|
+
export function insertAtCursor(text: string, start: number, end: number, marker: string): InsertAtCursorResult {
|
|
197
|
+
return { text: text.slice(0, start) + marker + text.slice(end), caret: start + marker.length }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type UseQuickRepliesWorkspaceParams = {
|
|
201
|
+
readonly api: QuickRepliesWorkspaceApi
|
|
202
|
+
readonly labels: QuickRepliesWorkspaceLabels
|
|
203
|
+
/** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
|
|
204
|
+
readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type UseQuickRepliesWorkspaceResult = {
|
|
208
|
+
readonly quickReplies: readonly QuickReply[]
|
|
209
|
+
readonly filtered: readonly QuickReply[]
|
|
210
|
+
readonly isLoading: boolean
|
|
211
|
+
readonly loadError: string | undefined
|
|
212
|
+
readonly search: string
|
|
213
|
+
readonly setSearch: (value: string) => void
|
|
214
|
+
/** Sem `createQuickReply`, a tela só mostra a tabela — nada de criar, editar ou excluir. */
|
|
215
|
+
readonly readOnly: boolean
|
|
216
|
+
readonly canEdit: boolean
|
|
217
|
+
readonly canDelete: boolean
|
|
218
|
+
readonly editing: QuickRepliesWorkspaceEditing | undefined
|
|
219
|
+
readonly startCreate: () => void
|
|
220
|
+
readonly startEdit: (quickReply: QuickReply) => void
|
|
221
|
+
readonly cancelEdit: () => void
|
|
222
|
+
readonly updateField: (field: QuickReplyFormField, value: string) => void
|
|
223
|
+
readonly fieldErrors: QuickRepliesWorkspaceFieldErrors
|
|
224
|
+
readonly isSaving: boolean
|
|
225
|
+
readonly saveError: string | undefined
|
|
226
|
+
readonly submit: () => Promise<void>
|
|
227
|
+
readonly remove: (id: string) => Promise<void>
|
|
228
|
+
/** Excluindo agora — para o botão da linha mostrar o spinner certo (QR-47). */
|
|
229
|
+
readonly deletingId: string | undefined
|
|
230
|
+
/** Sem `uploadQuickReplyAttachment`, a seção de anexos não aparece no formulário (QR-31). */
|
|
231
|
+
readonly hasAttachmentsCapability: boolean
|
|
232
|
+
readonly pendingUploads: readonly PendingAttachmentUpload[]
|
|
233
|
+
readonly addAttachmentFiles: (files: FileList | readonly File[]) => void
|
|
234
|
+
readonly retryAttachmentUpload: (localId: string) => void
|
|
235
|
+
readonly cancelAttachmentUpload: (localId: string) => void
|
|
236
|
+
readonly removeAttachment: (uploadId: string) => void
|
|
237
|
+
readonly moveAttachmentAt: (index: number, direction: -1 | 1) => void
|
|
238
|
+
readonly attachmentRejections: readonly AttachmentFileRejection[]
|
|
239
|
+
readonly dismissAttachmentRejections: () => void
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Estado da tela de cadastro: lista, busca, formulário de criação/edição, anexos e exclusão. */
|
|
243
|
+
export function useQuickRepliesWorkspace({
|
|
244
|
+
api,
|
|
245
|
+
labels,
|
|
246
|
+
attachmentSizeLimits,
|
|
247
|
+
}: UseQuickRepliesWorkspaceParams): UseQuickRepliesWorkspaceResult {
|
|
248
|
+
const [quickReplies, setQuickReplies] = useState<QuickReply[]>([])
|
|
249
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
250
|
+
const [loadError, setLoadError] = useState<string | undefined>(undefined)
|
|
251
|
+
const [search, setSearch] = useState('')
|
|
252
|
+
const [editing, setEditing] = useState<QuickRepliesWorkspaceEditing | undefined>(undefined)
|
|
253
|
+
const [fieldErrors, setFieldErrors] = useState<QuickRepliesWorkspaceFieldErrors>({})
|
|
254
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
255
|
+
const [saveError, setSaveError] = useState<string | undefined>(undefined)
|
|
256
|
+
const [deletingId, setDeletingId] = useState<string | undefined>(undefined)
|
|
257
|
+
|
|
258
|
+
const uploadAttachment = useCallback((attachment: QuickReplyAttachment): void => {
|
|
259
|
+
setEditing((current) => (current ? { ...current, attachments: [...current.attachments, attachment] } : current))
|
|
260
|
+
}, [])
|
|
261
|
+
|
|
262
|
+
const {
|
|
263
|
+
pendingUploads,
|
|
264
|
+
attachmentRejections,
|
|
265
|
+
addAttachmentFiles,
|
|
266
|
+
retryAttachmentUpload,
|
|
267
|
+
cancelAttachmentUpload,
|
|
268
|
+
dismissAttachmentRejections,
|
|
269
|
+
abortAllUploads,
|
|
270
|
+
} = useQuickReplyAttachmentUploads({
|
|
271
|
+
...(api.uploadQuickReplyAttachment ? { upload: api.uploadQuickReplyAttachment } : {}),
|
|
272
|
+
labels,
|
|
273
|
+
...(attachmentSizeLimits ? { attachmentSizeLimits } : {}),
|
|
274
|
+
attachmentsCount: editing?.attachments.length ?? 0,
|
|
275
|
+
onUploaded: uploadAttachment,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!api.listQuickReplies) return
|
|
280
|
+
let cancelled = false
|
|
281
|
+
setIsLoading(true)
|
|
282
|
+
setLoadError(undefined)
|
|
283
|
+
api
|
|
284
|
+
.listQuickReplies()
|
|
285
|
+
.then((result) => {
|
|
286
|
+
if (!cancelled) setQuickReplies(result)
|
|
287
|
+
})
|
|
288
|
+
.catch((caught: unknown) => {
|
|
289
|
+
if (!cancelled) setLoadError(caught instanceof Error ? caught.message : labels.failure)
|
|
290
|
+
})
|
|
291
|
+
.finally(() => {
|
|
292
|
+
if (!cancelled) setIsLoading(false)
|
|
293
|
+
})
|
|
294
|
+
return () => {
|
|
295
|
+
cancelled = true
|
|
296
|
+
}
|
|
297
|
+
}, [api.listQuickReplies])
|
|
298
|
+
|
|
299
|
+
const filtered = filterQuickReplies({ quickReplies, search })
|
|
300
|
+
|
|
301
|
+
const startCreate = useCallback(() => {
|
|
302
|
+
abortAllUploads()
|
|
303
|
+
dismissAttachmentRejections()
|
|
304
|
+
setEditing({ id: null, title: '', shortcut: '', body: '', attachments: [] })
|
|
305
|
+
setFieldErrors({})
|
|
306
|
+
setSaveError(undefined)
|
|
307
|
+
}, [abortAllUploads, dismissAttachmentRejections])
|
|
308
|
+
|
|
309
|
+
const startEdit = useCallback(
|
|
310
|
+
(quickReply: QuickReply) => {
|
|
311
|
+
abortAllUploads()
|
|
312
|
+
dismissAttachmentRejections()
|
|
313
|
+
setEditing({
|
|
314
|
+
id: quickReply.id,
|
|
315
|
+
title: quickReply.title,
|
|
316
|
+
shortcut: quickReply.shortcut,
|
|
317
|
+
body: quickReply.body,
|
|
318
|
+
attachments: quickReply.attachments ?? [],
|
|
319
|
+
})
|
|
320
|
+
setFieldErrors({})
|
|
321
|
+
setSaveError(undefined)
|
|
322
|
+
},
|
|
323
|
+
[abortAllUploads, dismissAttachmentRejections],
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
const cancelEdit = useCallback(() => {
|
|
327
|
+
abortAllUploads()
|
|
328
|
+
dismissAttachmentRejections()
|
|
329
|
+
setEditing(undefined)
|
|
330
|
+
setFieldErrors({})
|
|
331
|
+
setSaveError(undefined)
|
|
332
|
+
}, [abortAllUploads, dismissAttachmentRejections])
|
|
333
|
+
|
|
334
|
+
const updateField = useCallback((field: QuickReplyFormField, value: string) => {
|
|
335
|
+
setEditing((current) => (current ? { ...current, [field]: value } : current))
|
|
336
|
+
// Editar o campo limpa o erro dele — a mensagem antiga não deve sobreviver à correção.
|
|
337
|
+
setFieldErrors((current) => {
|
|
338
|
+
if (!(field in current)) return current
|
|
339
|
+
const next = { ...current }
|
|
340
|
+
delete next[field]
|
|
341
|
+
return next
|
|
342
|
+
})
|
|
343
|
+
}, [])
|
|
344
|
+
|
|
345
|
+
const submit = useCallback(async () => {
|
|
346
|
+
// Rede de segurança: o botão já fica desabilitado com upload em voo (QR-47), mas salvar antes
|
|
347
|
+
// da resposta chegar mandaria a mensagem sem o anexo que o operador acabou de anexar.
|
|
348
|
+
if (!editing || pendingUploads.length > 0) return
|
|
349
|
+
setIsSaving(true)
|
|
350
|
+
setSaveError(undefined)
|
|
351
|
+
setFieldErrors({})
|
|
352
|
+
const result = await submitQuickReply({ api, editing, labels })
|
|
353
|
+
setIsSaving(false)
|
|
354
|
+
if (result.outcome === 'saved') {
|
|
355
|
+
setQuickReplies((current) =>
|
|
356
|
+
editing.id === null
|
|
357
|
+
? [result.quickReply, ...current]
|
|
358
|
+
: current.map((item) => (item.id === result.quickReply.id ? result.quickReply : item)),
|
|
359
|
+
)
|
|
360
|
+
setEditing(undefined)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
setFieldErrors(result.fieldErrors)
|
|
364
|
+
if (result.outcome === 'rejected') setSaveError(result.formError)
|
|
365
|
+
}, [editing, api, labels, pendingUploads.length])
|
|
366
|
+
|
|
367
|
+
const remove = useCallback(
|
|
368
|
+
async (id: string) => {
|
|
369
|
+
if (!api.deleteQuickReply) return
|
|
370
|
+
setDeletingId(id)
|
|
371
|
+
try {
|
|
372
|
+
await api.deleteQuickReply(id)
|
|
373
|
+
setQuickReplies((current) => current.filter((item) => item.id !== id))
|
|
374
|
+
} finally {
|
|
375
|
+
setDeletingId(undefined)
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
[api],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const removeAttachment = useCallback((uploadId: string) => {
|
|
382
|
+
setEditing((current) =>
|
|
383
|
+
current
|
|
384
|
+
? { ...current, attachments: current.attachments.filter((attachment) => attachment.uploadId !== uploadId) }
|
|
385
|
+
: current,
|
|
386
|
+
)
|
|
387
|
+
}, [])
|
|
388
|
+
|
|
389
|
+
const moveAttachmentAt = useCallback((index: number, direction: -1 | 1) => {
|
|
390
|
+
setEditing((current) =>
|
|
391
|
+
current ? { ...current, attachments: moveAttachment(current.attachments, index, direction) } : current,
|
|
392
|
+
)
|
|
393
|
+
}, [])
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
quickReplies,
|
|
397
|
+
filtered,
|
|
398
|
+
isLoading,
|
|
399
|
+
loadError,
|
|
400
|
+
search,
|
|
401
|
+
setSearch,
|
|
402
|
+
readOnly: !api.createQuickReply,
|
|
403
|
+
canEdit: Boolean(api.updateQuickReply),
|
|
404
|
+
canDelete: Boolean(api.deleteQuickReply),
|
|
405
|
+
editing,
|
|
406
|
+
startCreate,
|
|
407
|
+
startEdit,
|
|
408
|
+
cancelEdit,
|
|
409
|
+
updateField,
|
|
410
|
+
fieldErrors,
|
|
411
|
+
isSaving,
|
|
412
|
+
saveError,
|
|
413
|
+
submit,
|
|
414
|
+
remove,
|
|
415
|
+
deletingId,
|
|
416
|
+
hasAttachmentsCapability: Boolean(api.uploadQuickReplyAttachment),
|
|
417
|
+
pendingUploads,
|
|
418
|
+
addAttachmentFiles,
|
|
419
|
+
retryAttachmentUpload,
|
|
420
|
+
cancelAttachmentUpload,
|
|
421
|
+
removeAttachment,
|
|
422
|
+
moveAttachmentAt,
|
|
423
|
+
attachmentRejections,
|
|
424
|
+
dismissAttachmentRejections,
|
|
425
|
+
}
|
|
426
|
+
}
|