@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.
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 +2181 -507
  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 +467 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +328 -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 +169 -0
  49. package/src/workspace/useComposerQueue.ts +229 -0
@@ -0,0 +1,41 @@
1
+ import type { RichComposerVariable } from '../RichMessageComposer'
2
+ import type { ConversationVariable } from './quickReply.types'
3
+
4
+ export type ResolveConversationVariablesParams = {
5
+ readonly conversationVariables?: readonly ConversationVariable[] | undefined
6
+ /** @deprecated Use `conversationVariables`. */
7
+ readonly quickReplyVariables?: Readonly<Record<string, string>> | undefined
8
+ /** @deprecated Use `conversationVariables`. */
9
+ readonly composerVariables?: readonly RichComposerVariable[] | undefined
10
+ }
11
+
12
+ export type ResolveConversationVariablesResult = {
13
+ readonly quickReplyVariables: Readonly<Record<string, string>> | undefined
14
+ readonly composerVariables: readonly RichComposerVariable[] | undefined
15
+ }
16
+
17
+ const MARKER_PATTERN = /^\{\{\s*([\w.]+)\s*\}\}$/
18
+
19
+ /** Nome que `applyQuickReplyVariables` casa; marcador fora do formato cai no `id`. */
20
+ function variableNameOf(variable: ConversationVariable): string {
21
+ return MARKER_PATTERN.exec(variable.marker)?.[1] ?? variable.id
22
+ }
23
+
24
+ /**
25
+ * Deriva os dois formatos antigos da lista nova. Quando a lista vem, ela manda — misturar as fontes
26
+ * faria o chip e o botão de variáveis mostrarem valores diferentes. Sem ela, as props antigas seguem
27
+ * valendo para os produtos que ainda não migraram.
28
+ */
29
+ export function resolveConversationVariables({
30
+ conversationVariables,
31
+ quickReplyVariables,
32
+ composerVariables,
33
+ }: ResolveConversationVariablesParams): ResolveConversationVariablesResult {
34
+ if (!conversationVariables) return { quickReplyVariables, composerVariables }
35
+ // Valor vazio não é oferecido: inserir o nada no texto só esconde que o dado falta.
36
+ const known = conversationVariables.filter((variable) => variable.value !== '')
37
+ return {
38
+ quickReplyVariables: Object.fromEntries(known.map((variable) => [variableNameOf(variable), variable.value])),
39
+ composerVariables: known.map(({ id, label, value }) => ({ id, label, value })),
40
+ }
41
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { nextHighlightedIndex } from './useQuickRepliesPicker'
4
+
5
+ describe('nextHighlightedIndex', () => {
6
+ it('avança e roda para o começo no fim da lista (ArrowDown)', () => {
7
+ expect(nextHighlightedIndex(0, 3, 1)).toBe(1)
8
+ expect(nextHighlightedIndex(2, 3, 1)).toBe(0)
9
+ })
10
+
11
+ it('volta e roda para o fim no começo da lista (ArrowUp)', () => {
12
+ expect(nextHighlightedIndex(1, 3, -1)).toBe(0)
13
+ expect(nextHighlightedIndex(0, 3, -1)).toBe(2)
14
+ })
15
+
16
+ it('lista vazia sempre fica em zero', () => {
17
+ expect(nextHighlightedIndex(0, 0, 1)).toBe(0)
18
+ expect(nextHighlightedIndex(0, 0, -1)).toBe(0)
19
+ })
20
+ })
@@ -0,0 +1,121 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { filterQuickReplies } from './quickReplySearch'
3
+ import type { QuickReply } from './quickReply.types'
4
+
5
+ export type UseQuickRepliesPickerParams = {
6
+ readonly isOpen: boolean
7
+ readonly search: string
8
+ readonly listQuickReplies?: (params?: { search?: string }) => Promise<QuickReply[]>
9
+ readonly quickReplyVariables?: Readonly<Record<string, string>>
10
+ readonly onSelect: (quickReply: QuickReply) => void
11
+ readonly onClose: () => void
12
+ }
13
+
14
+ /** Extraída para ser testável sem montar o hook: a aritmética do círculo é o que a tecla decide. */
15
+ export function nextHighlightedIndex(current: number, length: number, delta: 1 | -1): number {
16
+ if (length === 0) return 0
17
+ return (current + delta + length) % length
18
+ }
19
+
20
+ export type UseQuickRepliesPickerResult = {
21
+ readonly items: readonly QuickReply[]
22
+ readonly isLoading: boolean
23
+ /** Sinalizador puro — a mensagem exibida é sempre a do rótulo do host, nunca `caught.message` cru. */
24
+ readonly hasError: boolean
25
+ readonly highlightedIndex: number
26
+ readonly setHighlightedIndex: (index: number) => void
27
+ readonly handleKeyDown: (event: { key: string; preventDefault: () => void }) => void
28
+ /** Refaz a busca ignorando o cache — usado quando o host quer forçar atualização da lista. */
29
+ readonly refetch: () => void
30
+ }
31
+
32
+ /**
33
+ * Estado do picker do atalho `/` e do botão de raio. A lista é da instalação inteira, não por
34
+ * conversa — carregada uma vez por instância do hook (`cacheRef`, sobrevive a fechar/abrir o
35
+ * picker) e nunca reconsultada só porque a conversa mudou. `requestIdRef` descarta resposta de
36
+ * pedido antigo: trocar de conversa ou reabrir rápido não pode deixar `isLoading` preso em `true`
37
+ * nem aplicar um resultado que não é mais o mais recente.
38
+ */
39
+ export function useQuickRepliesPicker({
40
+ isOpen,
41
+ search,
42
+ listQuickReplies,
43
+ quickReplyVariables,
44
+ onSelect,
45
+ onClose,
46
+ }: UseQuickRepliesPickerParams): UseQuickRepliesPickerResult {
47
+ const cacheRef = useRef<QuickReply[] | undefined>(undefined)
48
+ const requestIdRef = useRef(0)
49
+ const [items, setItems] = useState<QuickReply[]>([])
50
+ const [isLoading, setIsLoading] = useState(false)
51
+ const [hasError, setHasError] = useState(false)
52
+ const [highlightedIndex, setHighlightedIndex] = useState(0)
53
+
54
+ const load = useCallback(() => {
55
+ if (!listQuickReplies) return
56
+ const requestId = ++requestIdRef.current
57
+ setIsLoading(true)
58
+ setHasError(false)
59
+ listQuickReplies()
60
+ .then((result) => {
61
+ if (requestIdRef.current !== requestId) return
62
+ cacheRef.current = result
63
+ setItems(result)
64
+ setIsLoading(false)
65
+ })
66
+ .catch(() => {
67
+ if (requestIdRef.current !== requestId) return
68
+ setHasError(true)
69
+ setIsLoading(false)
70
+ })
71
+ }, [listQuickReplies])
72
+
73
+ useEffect(() => {
74
+ if (!isOpen || !listQuickReplies) return
75
+ if (cacheRef.current) {
76
+ setItems(cacheRef.current)
77
+ return
78
+ }
79
+ load()
80
+ }, [isOpen, listQuickReplies, load])
81
+
82
+ const refetch = useCallback(() => {
83
+ cacheRef.current = undefined
84
+ load()
85
+ }, [load])
86
+
87
+ const filtered = filterQuickReplies({ quickReplies: items, search, variables: quickReplyVariables })
88
+
89
+ useEffect(() => {
90
+ setHighlightedIndex(0)
91
+ }, [search, filtered.length])
92
+
93
+ const handleKeyDown = useCallback(
94
+ (event: { key: string; preventDefault: () => void }) => {
95
+ if (event.key === 'ArrowDown') {
96
+ event.preventDefault()
97
+ setHighlightedIndex((index) => nextHighlightedIndex(index, filtered.length, 1))
98
+ return
99
+ }
100
+ if (event.key === 'ArrowUp') {
101
+ event.preventDefault()
102
+ setHighlightedIndex((index) => nextHighlightedIndex(index, filtered.length, -1))
103
+ return
104
+ }
105
+ if (event.key === 'Enter') {
106
+ const selected = filtered[highlightedIndex]
107
+ if (!selected) return
108
+ event.preventDefault()
109
+ onSelect(selected)
110
+ return
111
+ }
112
+ if (event.key === 'Escape') {
113
+ event.preventDefault()
114
+ onClose()
115
+ }
116
+ },
117
+ [filtered, highlightedIndex, onSelect, onClose],
118
+ )
119
+
120
+ return { items: filtered, isLoading, hasError, highlightedIndex, setHighlightedIndex, handleKeyDown, refetch }
121
+ }
@@ -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
+ })