@adatechnology/conversations-ui 0.1.0 → 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 +2 -2
- 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,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { filterQuickReplies, highlightMatch, normalizeForSearch } from './quickReplySearch'
|
|
4
|
+
import type { QuickReply } from './quickReply.types'
|
|
5
|
+
|
|
6
|
+
const GREETING: QuickReply = { id: '1', title: 'Saudação', shortcut: 'ola', body: 'Olá {{nome}}, tudo bem?' }
|
|
7
|
+
const DOCS: QuickReply = { id: '2', title: 'Pedido de documento', shortcut: 'doc', body: 'Envie seu RG e CPF.' }
|
|
8
|
+
|
|
9
|
+
describe('normalizeForSearch', () => {
|
|
10
|
+
it('remove acento e ignora caixa', () => {
|
|
11
|
+
expect(normalizeForSearch('Saudação')).toBe('saudacao')
|
|
12
|
+
expect(normalizeForSearch('VOCÊ')).toBe('voce')
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('filterQuickReplies', () => {
|
|
17
|
+
it('busca sem acento no título', () => {
|
|
18
|
+
const result = filterQuickReplies({ quickReplies: [GREETING, DOCS], search: 'saudacao' })
|
|
19
|
+
expect(result).toEqual([GREETING])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('busca no atalho', () => {
|
|
23
|
+
const result = filterQuickReplies({ quickReplies: [GREETING, DOCS], search: 'doc' })
|
|
24
|
+
expect(result).toEqual([DOCS])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('busca no corpo já com a variável trocada', () => {
|
|
28
|
+
const result = filterQuickReplies({ quickReplies: [GREETING], search: 'marina', variables: { nome: 'Marina' } })
|
|
29
|
+
expect(result).toEqual([GREETING])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('termo vazio devolve tudo', () => {
|
|
33
|
+
expect(filterQuickReplies({ quickReplies: [GREETING, DOCS], search: ' ' })).toEqual([GREETING, DOCS])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('sem correspondência devolve lista vazia', () => {
|
|
37
|
+
expect(filterQuickReplies({ quickReplies: [GREETING, DOCS], search: 'boleto' })).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('termo só de marca combinante normaliza para vazio e não filtra nada', () => {
|
|
41
|
+
// "́" sozinho (acento agudo combinante, sem base) normaliza para string vazia.
|
|
42
|
+
expect(filterQuickReplies({ quickReplies: [GREETING, DOCS], search: '́' })).toEqual([GREETING, DOCS])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('highlightMatch', () => {
|
|
47
|
+
it('destaca o trecho encontrado preservando o texto original', () => {
|
|
48
|
+
const segments = highlightMatch('Pedido de documento', 'documento')
|
|
49
|
+
expect(segments).toEqual([
|
|
50
|
+
{ text: 'Pedido de ', isMatch: false },
|
|
51
|
+
{ text: 'documento', isMatch: true },
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('casa mesmo com acento diferente entre busca e texto', () => {
|
|
56
|
+
const segments = highlightMatch('Saudação', 'saudacao')
|
|
57
|
+
expect(segments.some((segment) => segment.isMatch)).toBe(true)
|
|
58
|
+
expect(segments.map((segment) => segment.text).join('')).toBe('Saudação')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('sem termo, devolve o texto inteiro sem destaque', () => {
|
|
62
|
+
expect(highlightMatch('Saudação', '')).toEqual([{ text: 'Saudação', isMatch: false }])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('sem correspondência, devolve um único segmento sem destaque', () => {
|
|
66
|
+
expect(highlightMatch('Saudação', 'boleto')).toEqual([{ text: 'Saudação', isMatch: false }])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('casa e recorta certo quando o texto vem em NFD (acento como combinante separado)', () => {
|
|
70
|
+
const nfdText = 'José'.normalize('NFD') // "José" com o acento como caractere combinante
|
|
71
|
+
const segments = highlightMatch(nfdText, 'jose')
|
|
72
|
+
expect(segments.map((segment) => segment.text).join('')).toBe(nfdText)
|
|
73
|
+
expect(segments.some((segment) => segment.isMatch)).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('casa e recorta certo quando minúscula tem mais caracteres que a maiúscula (İ)', () => {
|
|
77
|
+
const text = 'İstanbul'
|
|
78
|
+
const segments = highlightMatch(text, 'istanbul')
|
|
79
|
+
expect(segments.map((segment) => segment.text).join('')).toBe(text)
|
|
80
|
+
expect(segments.some((segment) => segment.isMatch)).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('termo só de marca combinante normaliza para vazio — não trava em loop e devolve o texto inteiro', () => {
|
|
84
|
+
// Sem o guard, `indexOf('')` sempre acha posição 0 e o cursor nunca avança: loop infinito.
|
|
85
|
+
const segments = highlightMatch('Saudação', '́')
|
|
86
|
+
expect(segments).toEqual([{ text: 'Saudação', isMatch: false }])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { applyQuickReplyVariables } from '../MessageComposer'
|
|
2
|
+
import type { QuickReply } from './quickReply.types'
|
|
3
|
+
|
|
4
|
+
const DIACRITIC_PATTERN = /[\u0300-\u036f]/g
|
|
5
|
+
|
|
6
|
+
export type NormalizedIndexMap = {
|
|
7
|
+
readonly normalized: string
|
|
8
|
+
/** `normalized[i]` veio do caractere original que começa em `originalIndexOf[i]` (UTF-16). */
|
|
9
|
+
readonly originalIndexOf: readonly number[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normaliza caractere a caractere (não a string inteira) e guarda, para cada posição do resultado,
|
|
14
|
+
* de qual índice do texto original ela veio. Um caractere de entrada não produz sempre um único
|
|
15
|
+
* caractere de saída: `'İ'.toLowerCase()` vira dois (`'i̇'`), e um acento em NFD normaliza para uma
|
|
16
|
+
* base sem o combinante. Sem o mapa, `highlightMatch` recorta o texto original no índice errado
|
|
17
|
+
* assim que a entrada tem um desses casos — foi o que quebrava com "José" em NFD e com maiúscula
|
|
18
|
+
* que cresce ao virar minúscula.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeForSearchWithMap(value: string): NormalizedIndexMap {
|
|
21
|
+
let normalized = ''
|
|
22
|
+
const originalIndexOf: number[] = []
|
|
23
|
+
let originalCursor = 0
|
|
24
|
+
for (const char of value) {
|
|
25
|
+
const piece = char.normalize('NFD').replace(DIACRITIC_PATTERN, '').toLowerCase()
|
|
26
|
+
for (let i = 0; i < piece.length; i++) originalIndexOf.push(originalCursor)
|
|
27
|
+
normalized += piece
|
|
28
|
+
originalCursor += char.length
|
|
29
|
+
}
|
|
30
|
+
return { normalized, originalIndexOf }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeForSearch(value: string): string {
|
|
34
|
+
return normalizeForSearchWithMap(value).normalized
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FilterQuickRepliesParams = {
|
|
38
|
+
readonly quickReplies: readonly QuickReply[]
|
|
39
|
+
readonly search: string
|
|
40
|
+
/** Resolve `{{marcador}}` antes de comparar — buscar "joão" deve achar quem cita o nome no corpo. */
|
|
41
|
+
readonly variables?: Readonly<Record<string, string>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Busca por título, atalho e corpo (com as variáveis já aplicadas). Termo vazio devolve tudo. */
|
|
45
|
+
export function filterQuickReplies({
|
|
46
|
+
quickReplies,
|
|
47
|
+
search,
|
|
48
|
+
variables,
|
|
49
|
+
}: FilterQuickRepliesParams): readonly QuickReply[] {
|
|
50
|
+
// Termo composto só de marcas combinantes (ex.: "́" sozinho) normaliza para string vazia —
|
|
51
|
+
// tratar como termo vazio, senão `indexOf('')` em `highlightMatch` casaria em todo índice.
|
|
52
|
+
const term = normalizeForSearch(search.trim())
|
|
53
|
+
if (!term) return quickReplies
|
|
54
|
+
return quickReplies.filter((quickReply) => {
|
|
55
|
+
const body = applyQuickReplyVariables(quickReply.body, variables)
|
|
56
|
+
return (
|
|
57
|
+
normalizeForSearch(quickReply.title).includes(term) ||
|
|
58
|
+
normalizeForSearch(quickReply.shortcut).includes(term) ||
|
|
59
|
+
normalizeForSearch(body).includes(term)
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type MatchSegment = {
|
|
65
|
+
readonly text: string
|
|
66
|
+
readonly isMatch: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Segmenta o texto ao redor do termo buscado para o chamador destacar sem HTML — quem cadastrou a
|
|
71
|
+
* mensagem não deve conseguir injetar marcação pelo próprio corpo ou título.
|
|
72
|
+
*/
|
|
73
|
+
export function highlightMatch(text: string, search: string): readonly MatchSegment[] {
|
|
74
|
+
const term = search.trim()
|
|
75
|
+
if (!term) return [{ text, isMatch: false }]
|
|
76
|
+
const { normalized: normalizedText, originalIndexOf } = normalizeForSearchWithMap(text)
|
|
77
|
+
const normalizedTerm = normalizeForSearch(term)
|
|
78
|
+
// Termo só de marcas combinantes (ex.: "́") normaliza para "" — `indexOf('')` sempre acha
|
|
79
|
+
// posição 0 e o laço abaixo nunca avança o cursor, travando a aba num loop infinito.
|
|
80
|
+
if (!normalizedTerm) return [{ text, isMatch: false }]
|
|
81
|
+
// Índice original correspondente a uma posição do texto normalizado — o comprimento do texto
|
|
82
|
+
// original fecha o mapa para quando a posição cai depois do último caractere normalizado.
|
|
83
|
+
const originalIndexAt = (normalizedIndex: number): number =>
|
|
84
|
+
normalizedIndex < originalIndexOf.length ? originalIndexOf[normalizedIndex]! : text.length
|
|
85
|
+
|
|
86
|
+
const segments: MatchSegment[] = []
|
|
87
|
+
let normalizedCursor = 0
|
|
88
|
+
let originalCursor = 0
|
|
89
|
+
while (normalizedCursor < normalizedText.length) {
|
|
90
|
+
const matchIndex = normalizedText.indexOf(normalizedTerm, normalizedCursor)
|
|
91
|
+
if (matchIndex === -1) break
|
|
92
|
+
const matchOriginalStart = originalIndexAt(matchIndex)
|
|
93
|
+
if (matchOriginalStart > originalCursor) {
|
|
94
|
+
segments.push({ text: text.slice(originalCursor, matchOriginalStart), isMatch: false })
|
|
95
|
+
}
|
|
96
|
+
const matchNormalizedEnd = matchIndex + normalizedTerm.length
|
|
97
|
+
const matchOriginalEnd = originalIndexAt(matchNormalizedEnd)
|
|
98
|
+
segments.push({ text: text.slice(matchOriginalStart, matchOriginalEnd), isMatch: true })
|
|
99
|
+
normalizedCursor = matchNormalizedEnd
|
|
100
|
+
originalCursor = matchOriginalEnd
|
|
101
|
+
}
|
|
102
|
+
if (originalCursor < text.length) segments.push({ text: text.slice(originalCursor), isMatch: false })
|
|
103
|
+
return segments
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { detectQuickReplyShortcut, replaceQuickReplyShortcut } from './quickReplyShortcut'
|
|
4
|
+
|
|
5
|
+
describe('detectQuickReplyShortcut', () => {
|
|
6
|
+
it('acha o atalho no início do texto', () => {
|
|
7
|
+
expect(detectQuickReplyShortcut('/doc')).toEqual({ start: 0, term: 'doc' })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('acha o atalho depois de espaço', () => {
|
|
11
|
+
expect(detectQuickReplyShortcut('Olá, /doc')).toEqual({ start: 5, term: 'doc' })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('acha o atalho vazio (só a barra, ainda sem termo)', () => {
|
|
15
|
+
expect(detectQuickReplyShortcut('/')).toEqual({ start: 0, term: '' })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('apagar a barra some com o atalho', () => {
|
|
19
|
+
expect(detectQuickReplyShortcut('Olá, doc')).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('barra no meio da palavra não é atalho', () => {
|
|
23
|
+
expect(detectQuickReplyShortcut('km/h')).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('replaceQuickReplyShortcut', () => {
|
|
28
|
+
it('troca o atalho pelo corpo resolvido e devolve onde o cursor cai', () => {
|
|
29
|
+
const result = replaceQuickReplyShortcut({
|
|
30
|
+
text: 'Olá, /doc',
|
|
31
|
+
start: 5,
|
|
32
|
+
caret: 9,
|
|
33
|
+
replacement: 'Envie seu RG, Marina.',
|
|
34
|
+
})
|
|
35
|
+
expect(result.text).toBe('Olá, Envie seu RG, Marina.')
|
|
36
|
+
expect(result.caret).toBe(5 + 'Envie seu RG, Marina.'.length)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecção e inserção do atalho `/termo` — compartilhado entre `MessageComposer` e
|
|
3
|
+
* `RichMessageComposer` para os dois campos abrirem e fecharem o picker do mesmo jeito.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Casa `/termo` no início do texto ou depois de espaço, sempre no fim da string. */
|
|
7
|
+
const QUICK_REPLY_SHORTCUT_PATTERN = /(?:^|\s)\/([a-z0-9-]*)$/i
|
|
8
|
+
|
|
9
|
+
export type QuickReplyShortcutMatch = {
|
|
10
|
+
/** Índice do primeiro caractere da "/", para a inserção saber onde trocar. */
|
|
11
|
+
readonly start: number
|
|
12
|
+
readonly term: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Sem correspondência (ou depois de apagar a "/"), devolve `undefined` — é o que fecha o picker. */
|
|
16
|
+
export function detectQuickReplyShortcut(text: string): QuickReplyShortcutMatch | undefined {
|
|
17
|
+
const match = QUICK_REPLY_SHORTCUT_PATTERN.exec(text)
|
|
18
|
+
if (!match) return undefined
|
|
19
|
+
return { start: match.index + (match[0].startsWith('/') ? 0 : 1), term: match[1] ?? '' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ReplaceQuickReplyShortcutParams = {
|
|
23
|
+
readonly text: string
|
|
24
|
+
readonly start: number
|
|
25
|
+
readonly caret: number
|
|
26
|
+
readonly replacement: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ReplaceQuickReplyShortcutResult = {
|
|
30
|
+
readonly text: string
|
|
31
|
+
/** Onde o cursor deve ficar depois — logo após o texto inserido. */
|
|
32
|
+
readonly caret: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Troca o `/termo` (de `start` até `caret`) pelo corpo já resolvido. */
|
|
36
|
+
export function replaceQuickReplyShortcut({
|
|
37
|
+
text,
|
|
38
|
+
start,
|
|
39
|
+
caret,
|
|
40
|
+
replacement,
|
|
41
|
+
}: ReplaceQuickReplyShortcutParams): ReplaceQuickReplyShortcutResult {
|
|
42
|
+
return {
|
|
43
|
+
text: text.slice(0, start) + replacement + text.slice(caret),
|
|
44
|
+
caret: start + replacement.length,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda o adaptador entre a lista nova de variáveis e as duas props antigas. O caso que decide o
|
|
3
|
+
* desenho: com a lista nova presente, as antigas são ignoradas — duas fontes dariam valores
|
|
4
|
+
* diferentes no chip e no botão de variáveis.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { applyQuickReplyVariables } from '../MessageComposer'
|
|
10
|
+
import { resolveConversationVariables } from './resolveConversationVariables'
|
|
11
|
+
|
|
12
|
+
const CUSTOMER_NAME = { id: 'customerName', label: 'Nome', marker: '{{nome}}', value: 'Marina' }
|
|
13
|
+
const EMPTY_EMAIL = { id: 'email', label: 'E-mail', marker: '{{email}}', value: '' }
|
|
14
|
+
|
|
15
|
+
describe('resolveConversationVariables', () => {
|
|
16
|
+
it('deriva o mapa e a lista do composer a partir da lista nova', () => {
|
|
17
|
+
const result = resolveConversationVariables({ conversationVariables: [CUSTOMER_NAME] })
|
|
18
|
+
expect(result.quickReplyVariables).toEqual({ nome: 'Marina' })
|
|
19
|
+
expect(result.composerVariables).toEqual([{ id: 'customerName', label: 'Nome', value: 'Marina' }])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('não oferece variável sem valor', () => {
|
|
23
|
+
const result = resolveConversationVariables({ conversationVariables: [CUSTOMER_NAME, EMPTY_EMAIL] })
|
|
24
|
+
expect(result.quickReplyVariables).toEqual({ nome: 'Marina' })
|
|
25
|
+
expect(result.composerVariables).toHaveLength(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('a lista nova manda sobre as props antigas', () => {
|
|
29
|
+
const result = resolveConversationVariables({
|
|
30
|
+
conversationVariables: [CUSTOMER_NAME],
|
|
31
|
+
quickReplyVariables: { nome: 'Outro' },
|
|
32
|
+
composerVariables: [{ id: 'x', label: 'X', value: 'y' }],
|
|
33
|
+
})
|
|
34
|
+
expect(result.quickReplyVariables).toEqual({ nome: 'Marina' })
|
|
35
|
+
expect(result.composerVariables).toEqual([{ id: 'customerName', label: 'Nome', value: 'Marina' }])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('sem a lista nova, devolve as props antigas intactas', () => {
|
|
39
|
+
const quickReplyVariables = { nome: 'Marina' }
|
|
40
|
+
const composerVariables = [{ id: 'x', label: 'X', value: 'y' }]
|
|
41
|
+
const result = resolveConversationVariables({ quickReplyVariables, composerVariables })
|
|
42
|
+
expect(result.quickReplyVariables).toBe(quickReplyVariables)
|
|
43
|
+
expect(result.composerVariables).toBe(composerVariables)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('sem nada, devolve ausência', () => {
|
|
47
|
+
expect(resolveConversationVariables({})).toEqual({ quickReplyVariables: undefined, composerVariables: undefined })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('marcador fora do formato cai no id', () => {
|
|
51
|
+
const result = resolveConversationVariables({
|
|
52
|
+
conversationVariables: [{ ...CUSTOMER_NAME, marker: 'nome' }],
|
|
53
|
+
})
|
|
54
|
+
expect(result.quickReplyVariables).toEqual({ customerName: 'Marina' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('o mapa derivado alimenta applyQuickReplyVariables sem mudança', () => {
|
|
58
|
+
const { quickReplyVariables } = resolveConversationVariables({
|
|
59
|
+
conversationVariables: [CUSTOMER_NAME, EMPTY_EMAIL],
|
|
60
|
+
})
|
|
61
|
+
expect(applyQuickReplyVariables('Olá {{nome}}, {{email}}', quickReplyVariables)).toBe('Olá Marina, ')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -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
|
+
}
|