@adatechnology/conversations-ui 0.1.1 → 0.2.1
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 +2181 -507
- 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 +467 -0
- package/src/quickReplies/quickReplyAttachments.ts +328 -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 +169 -0
- package/src/workspace/useComposerQueue.ts +229 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fila de upload de anexo do formulário de cadastro (QR-31/M1), extraída de
|
|
3
|
+
* `useQuickRepliesWorkspace`: até 3 uploads em voo por vez, progresso real por arquivo, e
|
|
4
|
+
* cancelamento de tudo ao trocar de registro (M2) sem `setState` órfão depois do unmount.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { createUploadQueue } from './createUploadQueue'
|
|
9
|
+
import { validateAttachmentFiles, type AttachmentFileRejection } from './quickReplyAttachmentUpload'
|
|
10
|
+
import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
11
|
+
import type { QuickReplyAttachment } from './quickReply.types'
|
|
12
|
+
import type { QuickRepliesWorkspaceLabels } from './labels'
|
|
13
|
+
|
|
14
|
+
/** Item em upload no formulário: estado local, nunca persistido — some ao terminar ou ser removido. */
|
|
15
|
+
export type PendingAttachmentUpload = {
|
|
16
|
+
readonly localId: string
|
|
17
|
+
readonly file: File
|
|
18
|
+
readonly status: 'uploading' | 'error'
|
|
19
|
+
readonly progress: number
|
|
20
|
+
readonly error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type UseQuickReplyAttachmentUploadsParams = {
|
|
24
|
+
readonly upload?: (
|
|
25
|
+
file: File,
|
|
26
|
+
options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
|
|
27
|
+
) => Promise<QuickReplyAttachment>
|
|
28
|
+
readonly labels: QuickRepliesWorkspaceLabels
|
|
29
|
+
/** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
|
|
30
|
+
readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
|
|
31
|
+
/** Quantos anexos já confirmados (`editing.attachments.length`) — entra no teto de 10 (QR-31). */
|
|
32
|
+
readonly attachmentsCount: number
|
|
33
|
+
/** Onde o item entra assim que o upload resolve — o hook não sabe de `editing`, só devolve o resultado. */
|
|
34
|
+
readonly onUploaded: (attachment: QuickReplyAttachment) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UseQuickReplyAttachmentUploadsResult = {
|
|
38
|
+
readonly pendingUploads: readonly PendingAttachmentUpload[]
|
|
39
|
+
readonly attachmentRejections: readonly AttachmentFileRejection[]
|
|
40
|
+
readonly addAttachmentFiles: (files: FileList | readonly File[]) => void
|
|
41
|
+
readonly retryAttachmentUpload: (localId: string) => void
|
|
42
|
+
readonly cancelAttachmentUpload: (localId: string) => void
|
|
43
|
+
readonly dismissAttachmentRejections: () => void
|
|
44
|
+
/** Cancela todo upload em voo — trocar de registro sem isso deixaria um `fetch` órfão terminando
|
|
45
|
+
* sozinho e tentando atualizar um estado que já não existe mais. */
|
|
46
|
+
readonly abortAllUploads: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Estado e orquestração do upload de anexo — sem saber de `editing`, só de arquivos e resultado. */
|
|
50
|
+
export function useQuickReplyAttachmentUploads({
|
|
51
|
+
upload,
|
|
52
|
+
labels,
|
|
53
|
+
attachmentSizeLimits,
|
|
54
|
+
attachmentsCount,
|
|
55
|
+
onUploaded,
|
|
56
|
+
}: UseQuickReplyAttachmentUploadsParams): UseQuickReplyAttachmentUploadsResult {
|
|
57
|
+
const [pendingUploads, setPendingUploads] = useState<readonly PendingAttachmentUpload[]>([])
|
|
58
|
+
const [attachmentRejections, setAttachmentRejections] = useState<readonly AttachmentFileRejection[]>([])
|
|
59
|
+
/** Fila compartilhada (M1): no máximo 3 uploads em voo ao mesmo tempo, somando o que
|
|
60
|
+
* `addAttachmentFiles` e `retryAttachmentUpload` enfileiram — nenhum dos dois abre janela própria. */
|
|
61
|
+
const uploadQueueRef = useRef(createUploadQueue(3))
|
|
62
|
+
/** M2: depois do unmount, nenhuma promessa de upload em voo pode chamar setState — só aborta. */
|
|
63
|
+
const isMountedRef = useRef(true)
|
|
64
|
+
|
|
65
|
+
useEffect(
|
|
66
|
+
() => () => {
|
|
67
|
+
isMountedRef.current = false
|
|
68
|
+
uploadQueueRef.current.abortAll()
|
|
69
|
+
},
|
|
70
|
+
[],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const abortAllUploads = useCallback(() => {
|
|
74
|
+
uploadQueueRef.current.abortAll()
|
|
75
|
+
setPendingUploads([])
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
const dismissAttachmentRejections = useCallback(() => setAttachmentRejections([]), [])
|
|
79
|
+
|
|
80
|
+
const cancelAttachmentUpload = useCallback((localId: string) => {
|
|
81
|
+
uploadQueueRef.current.abort(localId)
|
|
82
|
+
setPendingUploads((current) => current.filter((item) => item.localId !== localId))
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
const updatePendingUpload = useCallback((localId: string, patch: Partial<PendingAttachmentUpload>) => {
|
|
86
|
+
setPendingUploads((current) => current.map((item) => (item.localId === localId ? { ...item, ...patch } : item)))
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
/** Sobe um arquivo já validado, através da fila compartilhada (M1): progresso real por
|
|
90
|
+
* `onProgress`, e o resultado vira `onUploaded` na ordem de chegada assim que a promessa
|
|
91
|
+
* resolve. Enfileira e retorna — quem chama não espera. */
|
|
92
|
+
const uploadOneFile = useCallback(
|
|
93
|
+
(localId: string, file: File) => {
|
|
94
|
+
if (!upload) return
|
|
95
|
+
uploadQueueRef.current.enqueue(localId, async (signal) => {
|
|
96
|
+
try {
|
|
97
|
+
const attachment = await upload(file, {
|
|
98
|
+
onProgress: (fraction) => {
|
|
99
|
+
if (isMountedRef.current) updatePendingUpload(localId, { progress: fraction })
|
|
100
|
+
},
|
|
101
|
+
signal,
|
|
102
|
+
})
|
|
103
|
+
if (!isMountedRef.current) return
|
|
104
|
+
onUploaded(attachment)
|
|
105
|
+
setPendingUploads((current) => current.filter((item) => item.localId !== localId))
|
|
106
|
+
} catch (caught: unknown) {
|
|
107
|
+
if (signal.aborted || !isMountedRef.current) return
|
|
108
|
+
updatePendingUpload(localId, {
|
|
109
|
+
status: 'error',
|
|
110
|
+
error: caught instanceof Error ? caught.message : labels.saveError,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
[upload, updatePendingUpload, onUploaded, labels.saveError],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const retryAttachmentUpload = useCallback(
|
|
119
|
+
(localId: string) => {
|
|
120
|
+
const item = pendingUploads.find((pending) => pending.localId === localId)
|
|
121
|
+
if (!item) return
|
|
122
|
+
updatePendingUpload(localId, { status: 'uploading', progress: 0, error: undefined })
|
|
123
|
+
uploadOneFile(localId, item.file)
|
|
124
|
+
},
|
|
125
|
+
[pendingUploads, updatePendingUpload, uploadOneFile],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
/** Valida (teto de 10, tamanho por tipo) ANTES de subir (QR-31) — só o aceito vira upload; o
|
|
129
|
+
* recusado fica em `attachmentRejections` para a tela explicar por quê, sem gastar rede nele. */
|
|
130
|
+
const addAttachmentFiles = useCallback(
|
|
131
|
+
(files: FileList | readonly File[]) => {
|
|
132
|
+
if (!upload) return
|
|
133
|
+
const currentCount = attachmentsCount + pendingUploads.length
|
|
134
|
+
const { accepted, rejected } = validateAttachmentFiles(Array.from(files), currentCount, attachmentSizeLimits)
|
|
135
|
+
setAttachmentRejections(rejected)
|
|
136
|
+
if (accepted.length === 0) return
|
|
137
|
+
const newItems: PendingAttachmentUpload[] = accepted.map((file) => ({
|
|
138
|
+
localId: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
|
|
139
|
+
file,
|
|
140
|
+
status: 'uploading',
|
|
141
|
+
progress: 0,
|
|
142
|
+
}))
|
|
143
|
+
setPendingUploads((current) => [...current, ...newItems])
|
|
144
|
+
// Até 3 em paralelo (QR-31), somando com retries em voo — a fila compartilhada decide (M1).
|
|
145
|
+
for (const item of newItems) uploadOneFile(item.localId, item.file)
|
|
146
|
+
},
|
|
147
|
+
[upload, attachmentsCount, pendingUploads.length, uploadOneFile, attachmentSizeLimits],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
pendingUploads,
|
|
152
|
+
attachmentRejections,
|
|
153
|
+
addAttachmentFiles,
|
|
154
|
+
retryAttachmentUpload,
|
|
155
|
+
cancelAttachmentUpload,
|
|
156
|
+
dismissAttachmentRejections,
|
|
157
|
+
abortAllUploads,
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -818,6 +818,93 @@
|
|
|
818
818
|
border-color: rgb(51 65 85);
|
|
819
819
|
}
|
|
820
820
|
|
|
821
|
+
/* Item da fila com miniatura, estado de envio (QR-47) e saída com transição curta. */
|
|
822
|
+
.cv-attachment-item {
|
|
823
|
+
max-width: 16rem;
|
|
824
|
+
}
|
|
825
|
+
.cv-attachment-item__thumbnail {
|
|
826
|
+
display: block;
|
|
827
|
+
flex: none;
|
|
828
|
+
width: 2rem;
|
|
829
|
+
height: 2rem;
|
|
830
|
+
overflow: hidden;
|
|
831
|
+
border-radius: 0.375rem;
|
|
832
|
+
background: rgb(226 232 240);
|
|
833
|
+
}
|
|
834
|
+
.dark .cv-attachment-item__thumbnail { background: rgb(51 65 85); }
|
|
835
|
+
.cv-attachment-item__thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
|
836
|
+
.cv-attachment-item__info {
|
|
837
|
+
display: flex;
|
|
838
|
+
flex-direction: column;
|
|
839
|
+
overflow: hidden;
|
|
840
|
+
min-width: 0;
|
|
841
|
+
}
|
|
842
|
+
.cv-attachment-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
843
|
+
.cv-attachment-item__meta { font-size: 0.6875rem; color: rgb(100 116 139); }
|
|
844
|
+
.dark .cv-attachment-item__meta { color: rgb(148 163 184); }
|
|
845
|
+
.cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(220 38 38); }
|
|
846
|
+
.dark .cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(248 113 113); }
|
|
847
|
+
.cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(22 163 74); }
|
|
848
|
+
.dark .cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(74 222 128); }
|
|
849
|
+
.cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(180 83 9); }
|
|
850
|
+
.dark .cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(251 191 36); }
|
|
851
|
+
.cv-attachment-item__retry {
|
|
852
|
+
flex: none;
|
|
853
|
+
border: 0;
|
|
854
|
+
background: none;
|
|
855
|
+
padding: 0;
|
|
856
|
+
font-size: 0.6875rem;
|
|
857
|
+
text-decoration: underline;
|
|
858
|
+
color: rgb(37 99 235);
|
|
859
|
+
cursor: pointer;
|
|
860
|
+
}
|
|
861
|
+
.cv-attachment-item {
|
|
862
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
863
|
+
opacity: 1;
|
|
864
|
+
transform: scale(1);
|
|
865
|
+
}
|
|
866
|
+
.cv-attachment-item--departing {
|
|
867
|
+
opacity: 0;
|
|
868
|
+
transform: scale(0.92);
|
|
869
|
+
}
|
|
870
|
+
@media (prefers-reduced-motion: reduce) {
|
|
871
|
+
.cv-attachment-item { transition: none; }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* Esqueleto de linha — usado no painel de mensagens prontas e na tabela de cadastro (QR-47): nunca
|
|
875
|
+
"Carregando…" solto, e o conteúdo nunca aparece de uma vez. */
|
|
876
|
+
.cv-skeleton-line {
|
|
877
|
+
border-radius: 0.25rem;
|
|
878
|
+
background: linear-gradient(90deg, rgb(226 232 240) 25%, rgb(241 245 249) 50%, rgb(226 232 240) 75%);
|
|
879
|
+
background-size: 200% 100%;
|
|
880
|
+
animation: cv-skeleton-shimmer 1.4s ease-in-out infinite;
|
|
881
|
+
}
|
|
882
|
+
.dark .cv-skeleton-line {
|
|
883
|
+
background: linear-gradient(90deg, rgb(51 65 85) 25%, rgb(71 85 105) 50%, rgb(51 65 85) 75%);
|
|
884
|
+
background-size: 200% 100%;
|
|
885
|
+
}
|
|
886
|
+
@keyframes cv-skeleton-shimmer {
|
|
887
|
+
0% { background-position: 200% 0; }
|
|
888
|
+
100% { background-position: -200% 0; }
|
|
889
|
+
}
|
|
890
|
+
@media (prefers-reduced-motion: reduce) {
|
|
891
|
+
.cv-skeleton-line { animation: none; }
|
|
892
|
+
}
|
|
893
|
+
/* Larguras da linha de esqueleto da tabela de mensagens prontas: uma por coluna, porque colunas
|
|
894
|
+
diferentes leem melhor com proporções diferentes em vez de todas do mesmo tamanho. */
|
|
895
|
+
.cv-skeleton-line--title { width: 70%; height: 0.75rem; }
|
|
896
|
+
.cv-skeleton-line--shortcut { width: 50%; height: 0.75rem; }
|
|
897
|
+
.cv-skeleton-line--body { width: 90%; height: 0.75rem; }
|
|
898
|
+
|
|
899
|
+
/* Linha excluída sai com transição curta em vez de sumir de repente (QR-47). */
|
|
900
|
+
.cv-row-departing {
|
|
901
|
+
transition: opacity 200ms ease;
|
|
902
|
+
opacity: 0;
|
|
903
|
+
}
|
|
904
|
+
@media (prefers-reduced-motion: reduce) {
|
|
905
|
+
.cv-row-departing { transition: none; }
|
|
906
|
+
}
|
|
907
|
+
|
|
821
908
|
/* Peças de listagem (documentos, mensagens): tabela com filtros, seleção em lote e paginação. */
|
|
822
909
|
.cv-filter-count {
|
|
823
910
|
display: inline-flex;
|