@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,188 @@
|
|
|
1
|
+
import { useEffect, useRef, type KeyboardEvent } from 'react'
|
|
2
|
+
import { Paperclip } from 'lucide-react'
|
|
3
|
+
import { applyQuickReplyVariables } from '../MessageComposer'
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
5
|
+
import { highlightMatch } from './quickReplySearch'
|
|
6
|
+
import type { QuickReply } from './quickReply.types'
|
|
7
|
+
import { DEFAULT_QUICK_REPLIES_PICKER_LABELS, type QuickRepliesPickerLabels } from './labels'
|
|
8
|
+
|
|
9
|
+
const LOADING_SKELETON_ROWS = 4
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Presente só no modo botão: o picker desenha e é dono da própria busca — focada ao abrir, dona de
|
|
13
|
+
* seta/Enter/Esc. O campo de mensagem não é tocado (QR-04): abrir pelo raio nunca insere `/` nele.
|
|
14
|
+
* Ausente, a busca vem do atalho `/termo` digitado no próprio campo, que continua no comando.
|
|
15
|
+
*/
|
|
16
|
+
export type QuickRepliesPickerOwnSearch = {
|
|
17
|
+
readonly value: string
|
|
18
|
+
readonly onChange: (value: string) => void
|
|
19
|
+
readonly onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
|
20
|
+
readonly label: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type QuickRepliesPickerProps = {
|
|
24
|
+
/** Id do `listbox`, para o campo (ou a busca própria) apontar `aria-activedescendant`. */
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly items: readonly QuickReply[]
|
|
27
|
+
readonly search: string
|
|
28
|
+
readonly highlightedIndex: number
|
|
29
|
+
readonly isLoading: boolean
|
|
30
|
+
/** Sinalizador puro — o texto exibido é sempre `labels.error`, nunca mensagem crua de exceção. */
|
|
31
|
+
readonly hasError?: boolean
|
|
32
|
+
readonly onHover: (index: number) => void
|
|
33
|
+
readonly onSelect: (quickReply: QuickReply) => void
|
|
34
|
+
readonly labels?: Partial<QuickRepliesPickerLabels>
|
|
35
|
+
readonly className?: string
|
|
36
|
+
readonly ownSearch?: QuickRepliesPickerOwnSearch
|
|
37
|
+
/** Resolve `{{marcador}}` antes de destacar e mostrar a prévia (QR-04) — quem busca "joão" espera
|
|
38
|
+
* ver "Olá João" na lista, não o marcador cru. */
|
|
39
|
+
readonly variables?: Readonly<Record<string, string>>
|
|
40
|
+
/** Sem `sendStoredAttachments` no host, a linha com anexo avisa em vez de prometer envio (QR-33). */
|
|
41
|
+
readonly hasAttachmentsCapability?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lista flutuante do atalho `/` e do botão de raio. Puramente controlada — todo estado (busca,
|
|
46
|
+
* destaque, carregamento) vem de `useQuickRepliesPicker`, este componente só desenha.
|
|
47
|
+
*/
|
|
48
|
+
export function QuickRepliesPicker({
|
|
49
|
+
id,
|
|
50
|
+
items,
|
|
51
|
+
search,
|
|
52
|
+
highlightedIndex,
|
|
53
|
+
isLoading,
|
|
54
|
+
hasError,
|
|
55
|
+
onHover,
|
|
56
|
+
onSelect,
|
|
57
|
+
labels,
|
|
58
|
+
className,
|
|
59
|
+
ownSearch,
|
|
60
|
+
variables,
|
|
61
|
+
hasAttachmentsCapability,
|
|
62
|
+
}: QuickRepliesPickerProps) {
|
|
63
|
+
const text = { ...DEFAULT_QUICK_REPLIES_PICKER_LABELS, ...labels }
|
|
64
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (ownSearch) searchInputRef.current?.focus()
|
|
68
|
+
// Só ao montar: o picker do modo botão nasce com a busca já em foco.
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const activeOptionId = items.length > 0 ? `${id}-option-${highlightedIndex}` : undefined
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className={className}>
|
|
76
|
+
{ownSearch ? (
|
|
77
|
+
<input
|
|
78
|
+
ref={searchInputRef}
|
|
79
|
+
type="text"
|
|
80
|
+
role="combobox"
|
|
81
|
+
aria-expanded="true"
|
|
82
|
+
aria-controls={id}
|
|
83
|
+
aria-activedescendant={activeOptionId}
|
|
84
|
+
value={ownSearch.value}
|
|
85
|
+
onChange={(event) => ownSearch.onChange(event.target.value)}
|
|
86
|
+
onKeyDown={ownSearch.onKeyDown}
|
|
87
|
+
placeholder={ownSearch.label}
|
|
88
|
+
aria-label={ownSearch.label}
|
|
89
|
+
className="mb-1 w-72 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none focus:border-gray-400 dark:border-gray-700 dark:bg-gray-900"
|
|
90
|
+
/>
|
|
91
|
+
) : null}
|
|
92
|
+
<ul
|
|
93
|
+
role="listbox"
|
|
94
|
+
id={id}
|
|
95
|
+
className="max-h-64 w-72 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
|
96
|
+
>
|
|
97
|
+
{isLoading ? (
|
|
98
|
+
<li aria-busy="true" aria-live="polite">
|
|
99
|
+
<span className="sr-only">{text.loading}</span>
|
|
100
|
+
{Array.from({ length: LOADING_SKELETON_ROWS }).map((_, index) => (
|
|
101
|
+
<span key={index} className="flex flex-col gap-1 px-3 py-2">
|
|
102
|
+
<span className="cv-skeleton-line" style={{ width: '60%', height: '0.75rem' }} />
|
|
103
|
+
<span className="cv-skeleton-line" style={{ width: '85%', height: '0.625rem' }} />
|
|
104
|
+
</span>
|
|
105
|
+
))}
|
|
106
|
+
</li>
|
|
107
|
+
) : null}
|
|
108
|
+
{!isLoading && hasError ? (
|
|
109
|
+
<li role="alert" className="px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
|
110
|
+
{text.error}
|
|
111
|
+
</li>
|
|
112
|
+
) : null}
|
|
113
|
+
{!isLoading && !hasError && items.length === 0 ? (
|
|
114
|
+
<li className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
|
115
|
+
{search.trim() ? text.noResults : text.empty}
|
|
116
|
+
</li>
|
|
117
|
+
) : null}
|
|
118
|
+
{!isLoading && !hasError
|
|
119
|
+
? items.map((quickReply, index) => {
|
|
120
|
+
const resolvedBody = applyQuickReplyVariables(quickReply.body, variables)
|
|
121
|
+
return (
|
|
122
|
+
<li
|
|
123
|
+
key={quickReply.id}
|
|
124
|
+
id={`${id}-option-${index}`}
|
|
125
|
+
role="option"
|
|
126
|
+
aria-selected={index === highlightedIndex}
|
|
127
|
+
className={cn(
|
|
128
|
+
'flex cursor-pointer flex-col gap-0.5 px-3 py-2 text-sm',
|
|
129
|
+
index === highlightedIndex ? 'bg-gray-100 dark:bg-gray-800' : undefined,
|
|
130
|
+
)}
|
|
131
|
+
onMouseEnter={() => onHover(index)}
|
|
132
|
+
// `mousedown` (não `click`): o campo mantém o foco, então o `blur` não fecha o
|
|
133
|
+
// picker antes da seleção acontecer.
|
|
134
|
+
onMouseDown={(event) => {
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
onSelect(quickReply)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<span className="flex items-center gap-2">
|
|
140
|
+
<span className="font-mono text-xs text-gray-400">/{quickReply.shortcut}</span>
|
|
141
|
+
<span className="truncate font-medium">
|
|
142
|
+
{highlightMatch(quickReply.title, search).map((segment, segmentIndex) =>
|
|
143
|
+
segment.isMatch ? (
|
|
144
|
+
<mark key={segmentIndex} className="rounded-sm bg-yellow-200 dark:bg-yellow-700/60">
|
|
145
|
+
{segment.text}
|
|
146
|
+
</mark>
|
|
147
|
+
) : (
|
|
148
|
+
<span key={segmentIndex}>{segment.text}</span>
|
|
149
|
+
),
|
|
150
|
+
)}
|
|
151
|
+
</span>
|
|
152
|
+
{quickReply.attachments && quickReply.attachments.length > 0 ? (
|
|
153
|
+
<span
|
|
154
|
+
className={cn(
|
|
155
|
+
'flex flex-none items-center gap-0.5 text-xs',
|
|
156
|
+
hasAttachmentsCapability
|
|
157
|
+
? 'text-gray-500 dark:text-gray-400'
|
|
158
|
+
: 'text-amber-600 dark:text-amber-400',
|
|
159
|
+
)}
|
|
160
|
+
title={hasAttachmentsCapability ? undefined : text.attachmentsUnavailable}
|
|
161
|
+
>
|
|
162
|
+
<Paperclip aria-hidden="true" className="h-3 w-3" />
|
|
163
|
+
{text.attachmentsCount(quickReply.attachments.length)}
|
|
164
|
+
</span>
|
|
165
|
+
) : null}
|
|
166
|
+
</span>
|
|
167
|
+
{quickReply.attachments && quickReply.attachments.length > 0 && !hasAttachmentsCapability ? (
|
|
168
|
+
<span className="text-xs text-amber-600 dark:text-amber-400">{text.attachmentsUnavailable}</span>
|
|
169
|
+
) : null}
|
|
170
|
+
<span className="truncate text-xs text-gray-500 dark:text-gray-400">
|
|
171
|
+
{highlightMatch(resolvedBody, search).map((segment, segmentIndex) =>
|
|
172
|
+
segment.isMatch ? (
|
|
173
|
+
<mark key={segmentIndex} className="rounded-sm bg-yellow-200 dark:bg-yellow-700/60">
|
|
174
|
+
{segment.text}
|
|
175
|
+
</mark>
|
|
176
|
+
) : (
|
|
177
|
+
<span key={segmentIndex}>{segment.text}</span>
|
|
178
|
+
),
|
|
179
|
+
)}
|
|
180
|
+
</span>
|
|
181
|
+
</li>
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
: null}
|
|
185
|
+
</ul>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { QuickRepliesWorkspace } from './QuickRepliesWorkspace'
|
|
5
|
+
|
|
6
|
+
describe('QuickRepliesWorkspace', () => {
|
|
7
|
+
it('sem createQuickReply, fica só leitura — sem "nova mensagem" e sem coluna de ações', () => {
|
|
8
|
+
const markup = renderToStaticMarkup(<QuickRepliesWorkspace api={{}} />)
|
|
9
|
+
|
|
10
|
+
expect(markup).not.toContain('Nova mensagem')
|
|
11
|
+
expect(markup).not.toContain('Ações')
|
|
12
|
+
expect(markup).toContain('Você só pode consultar as mensagens prontas.')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('com createQuickReply, mostra o botão de criar e a coluna de ações', () => {
|
|
16
|
+
const markup = renderToStaticMarkup(
|
|
17
|
+
<QuickRepliesWorkspace
|
|
18
|
+
api={{ createQuickReply: async () => ({ id: '1', title: 'T', shortcut: 't', body: 'B' }) }}
|
|
19
|
+
/>,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(markup).toContain('Nova mensagem')
|
|
23
|
+
expect(markup).not.toContain('Você só pode consultar as mensagens prontas.')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('deixa o host trocar o título sem mexer no resto dos textos', () => {
|
|
27
|
+
const markup = renderToStaticMarkup(<QuickRepliesWorkspace api={{}} labels={{ title: 'Respostas rápidas' }} />)
|
|
28
|
+
|
|
29
|
+
expect(markup).toContain('Respostas rápidas')
|
|
30
|
+
expect(markup).toContain('Buscar por título, atalho ou texto')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
import { useQuickRepliesWorkspace, insertAtCursor, type QuickRepliesWorkspaceApi } from './useQuickRepliesWorkspace'
|
|
4
|
+
import { AttachmentsFormSection } from './AttachmentsFormSection'
|
|
5
|
+
import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS, type QuickRepliesWorkspaceLabels } from './labels'
|
|
6
|
+
import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
7
|
+
import type { ConversationVariable } from './quickReply.types'
|
|
8
|
+
|
|
9
|
+
const TABLE_SKELETON_ROWS = 3
|
|
10
|
+
|
|
11
|
+
export interface QuickRepliesWorkspaceProps {
|
|
12
|
+
readonly api: QuickRepliesWorkspaceApi
|
|
13
|
+
/** Catálogo de variáveis oferecido pelos botões "Inserir variável" do formulário. */
|
|
14
|
+
readonly variables?: readonly ConversationVariable[]
|
|
15
|
+
readonly labels?: Partial<QuickRepliesWorkspaceLabels>
|
|
16
|
+
readonly className?: string
|
|
17
|
+
/** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
|
|
18
|
+
readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tela de gestão das mensagens prontas: tabela com busca e zebra, formulário de criação/edição, e
|
|
23
|
+
* exclusão com confirmação. Some o formulário quando o host não passa `createQuickReply` — vira
|
|
24
|
+
* consulta, a mesma regra de capacidade do resto do pacote.
|
|
25
|
+
*/
|
|
26
|
+
export function QuickRepliesWorkspace({
|
|
27
|
+
api,
|
|
28
|
+
variables,
|
|
29
|
+
labels,
|
|
30
|
+
className,
|
|
31
|
+
attachmentSizeLimits,
|
|
32
|
+
}: QuickRepliesWorkspaceProps) {
|
|
33
|
+
const text = { ...DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS, ...labels }
|
|
34
|
+
const bodyFieldRef = useRef<HTMLTextAreaElement>(null)
|
|
35
|
+
const formId = useId()
|
|
36
|
+
const titleFieldId = `${formId}-title`
|
|
37
|
+
const shortcutFieldId = `${formId}-shortcut`
|
|
38
|
+
const bodyFieldId = `${formId}-body`
|
|
39
|
+
/** Linha da tabela pedindo confirmação antes de excluir — em vez de `window.confirm`, que trava a
|
|
40
|
+
* aba inteira e não segue os tokens visuais do pacote. */
|
|
41
|
+
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
|
42
|
+
/** Um botão "Excluir" por linha — para onde o foco volta quando a confirmação é cancelada. */
|
|
43
|
+
const deleteButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
|
44
|
+
const confirmButtonRef = useRef<HTMLButtonElement>(null)
|
|
45
|
+
|
|
46
|
+
// A confirmação nasce sem foco nenhum: sem isto, Tab a partir de onde o operador estava levaria
|
|
47
|
+
// por cima dela, e quem usa teclado nunca saberia que ela apareceu.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (confirmingDeleteId) confirmButtonRef.current?.focus()
|
|
50
|
+
}, [confirmingDeleteId])
|
|
51
|
+
|
|
52
|
+
const cancelDeleteConfirm = (id: string) => {
|
|
53
|
+
setConfirmingDeleteId(null)
|
|
54
|
+
deleteButtonRefs.current[id]?.focus()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handleDeleteConfirmKeyDown = (id: string) => (event: KeyboardEvent<HTMLSpanElement>) => {
|
|
58
|
+
if (event.key === 'Escape') {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
cancelDeleteConfirm(id)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
quickReplies,
|
|
66
|
+
filtered,
|
|
67
|
+
isLoading,
|
|
68
|
+
loadError,
|
|
69
|
+
search,
|
|
70
|
+
setSearch,
|
|
71
|
+
readOnly,
|
|
72
|
+
canEdit,
|
|
73
|
+
canDelete,
|
|
74
|
+
editing,
|
|
75
|
+
startCreate,
|
|
76
|
+
startEdit,
|
|
77
|
+
cancelEdit,
|
|
78
|
+
updateField,
|
|
79
|
+
fieldErrors,
|
|
80
|
+
isSaving,
|
|
81
|
+
saveError,
|
|
82
|
+
submit,
|
|
83
|
+
remove,
|
|
84
|
+
deletingId,
|
|
85
|
+
hasAttachmentsCapability,
|
|
86
|
+
pendingUploads,
|
|
87
|
+
addAttachmentFiles,
|
|
88
|
+
retryAttachmentUpload,
|
|
89
|
+
cancelAttachmentUpload,
|
|
90
|
+
removeAttachment,
|
|
91
|
+
moveAttachmentAt,
|
|
92
|
+
attachmentRejections,
|
|
93
|
+
dismissAttachmentRejections,
|
|
94
|
+
} = useQuickRepliesWorkspace({ api, labels: text, ...(attachmentSizeLimits ? { attachmentSizeLimits } : {}) })
|
|
95
|
+
|
|
96
|
+
const hasPendingUploads = pendingUploads.length > 0
|
|
97
|
+
|
|
98
|
+
const insertVariableAtCursor = (marker: string) => {
|
|
99
|
+
const field = bodyFieldRef.current
|
|
100
|
+
if (!field || !editing) return
|
|
101
|
+
const { text: nextBody, caret } = insertAtCursor(editing.body, field.selectionStart, field.selectionEnd, marker)
|
|
102
|
+
updateField('body', nextBody)
|
|
103
|
+
requestAnimationFrame(() => {
|
|
104
|
+
field.focus()
|
|
105
|
+
field.setSelectionRange(caret, caret)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className={cn('space-y-4', className)}>
|
|
111
|
+
<header className="space-y-0.5">
|
|
112
|
+
<h2 className="text-lg font-semibold">{text.title}</h2>
|
|
113
|
+
{/* Total cadastrado, nunca o filtrado — a busca não deve fazer parecer que sumiram
|
|
114
|
+
mensagens; a contagem de resultado da busca fica em `noResults`/`empty`. */}
|
|
115
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">{text.subtitle(quickReplies.length)}</p>
|
|
116
|
+
</header>
|
|
117
|
+
|
|
118
|
+
{readOnly ? <p className="text-sm text-gray-500 dark:text-gray-400">{text.readOnlyNotice}</p> : null}
|
|
119
|
+
|
|
120
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
121
|
+
<input
|
|
122
|
+
type="search"
|
|
123
|
+
value={search}
|
|
124
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
125
|
+
placeholder={text.searchPlaceholder}
|
|
126
|
+
aria-label={text.searchPlaceholder}
|
|
127
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
{!readOnly ? (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={startCreate}
|
|
134
|
+
className="cv-header-action ml-auto inline-flex items-center gap-1"
|
|
135
|
+
>
|
|
136
|
+
{text.create}
|
|
137
|
+
</button>
|
|
138
|
+
) : null}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{isLoading ? (
|
|
142
|
+
<span className="sr-only" aria-live="polite">
|
|
143
|
+
{text.loading}
|
|
144
|
+
</span>
|
|
145
|
+
) : null}
|
|
146
|
+
{loadError ? (
|
|
147
|
+
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
|
148
|
+
{loadError || text.failure}
|
|
149
|
+
</p>
|
|
150
|
+
) : null}
|
|
151
|
+
|
|
152
|
+
{editing ? (
|
|
153
|
+
<form
|
|
154
|
+
className="space-y-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
|
155
|
+
onSubmit={(event) => {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
void submit()
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<div className="space-y-1">
|
|
161
|
+
<label className="block text-sm font-medium" htmlFor={titleFieldId}>
|
|
162
|
+
{text.fieldTitle}
|
|
163
|
+
</label>
|
|
164
|
+
<input
|
|
165
|
+
id={titleFieldId}
|
|
166
|
+
type="text"
|
|
167
|
+
maxLength={40}
|
|
168
|
+
value={editing.title}
|
|
169
|
+
onChange={(event) => updateField('title', event.target.value)}
|
|
170
|
+
aria-invalid={Boolean(fieldErrors.title)}
|
|
171
|
+
aria-describedby={fieldErrors.title ? `${titleFieldId}-error` : undefined}
|
|
172
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900"
|
|
173
|
+
/>
|
|
174
|
+
{fieldErrors.title ? (
|
|
175
|
+
<p id={`${titleFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
|
|
176
|
+
{fieldErrors.title}
|
|
177
|
+
</p>
|
|
178
|
+
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="space-y-1">
|
|
182
|
+
<label className="block text-sm font-medium" htmlFor={shortcutFieldId}>
|
|
183
|
+
{text.fieldShortcut}
|
|
184
|
+
</label>
|
|
185
|
+
<input
|
|
186
|
+
id={shortcutFieldId}
|
|
187
|
+
type="text"
|
|
188
|
+
maxLength={20}
|
|
189
|
+
value={editing.shortcut}
|
|
190
|
+
onChange={(event) => updateField('shortcut', event.target.value)}
|
|
191
|
+
aria-invalid={Boolean(fieldErrors.shortcut)}
|
|
192
|
+
aria-describedby={fieldErrors.shortcut ? `${shortcutFieldId}-error` : `${shortcutFieldId}-hint`}
|
|
193
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono dark:border-gray-700 dark:bg-gray-900"
|
|
194
|
+
/>
|
|
195
|
+
{fieldErrors.shortcut ? (
|
|
196
|
+
<p id={`${shortcutFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
|
|
197
|
+
{fieldErrors.shortcut}
|
|
198
|
+
</p>
|
|
199
|
+
) : (
|
|
200
|
+
<p id={`${shortcutFieldId}-hint`} className="text-xs text-gray-400">
|
|
201
|
+
{text.fieldShortcutHint}
|
|
202
|
+
</p>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div className="space-y-1">
|
|
207
|
+
<label className="block text-sm font-medium" htmlFor={bodyFieldId}>
|
|
208
|
+
{text.fieldBody}
|
|
209
|
+
</label>
|
|
210
|
+
<textarea
|
|
211
|
+
id={bodyFieldId}
|
|
212
|
+
ref={bodyFieldRef}
|
|
213
|
+
maxLength={1000}
|
|
214
|
+
rows={4}
|
|
215
|
+
value={editing.body}
|
|
216
|
+
onChange={(event) => updateField('body', event.target.value)}
|
|
217
|
+
aria-invalid={Boolean(fieldErrors.body)}
|
|
218
|
+
aria-describedby={fieldErrors.body ? `${bodyFieldId}-error` : undefined}
|
|
219
|
+
className="w-full resize-none rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900"
|
|
220
|
+
/>
|
|
221
|
+
{fieldErrors.body ? (
|
|
222
|
+
<p id={`${bodyFieldId}-error`} role="alert" className="text-xs text-red-600 dark:text-red-400">
|
|
223
|
+
{fieldErrors.body}
|
|
224
|
+
</p>
|
|
225
|
+
) : null}
|
|
226
|
+
{variables && variables.length > 0 ? (
|
|
227
|
+
<div className="flex flex-wrap gap-1 pt-1">
|
|
228
|
+
{variables.map((variable) => (
|
|
229
|
+
<button
|
|
230
|
+
key={variable.id}
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={() => insertVariableAtCursor(variable.marker)}
|
|
233
|
+
aria-label={`${text.insertVariable}: ${variable.label}`}
|
|
234
|
+
className="rounded-full border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
235
|
+
>
|
|
236
|
+
{variable.label}
|
|
237
|
+
</button>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{hasAttachmentsCapability ? (
|
|
244
|
+
<AttachmentsFormSection
|
|
245
|
+
labels={text}
|
|
246
|
+
attachments={editing.attachments}
|
|
247
|
+
pendingUploads={pendingUploads}
|
|
248
|
+
attachmentRejections={attachmentRejections}
|
|
249
|
+
onAddFiles={addAttachmentFiles}
|
|
250
|
+
onRetryUpload={retryAttachmentUpload}
|
|
251
|
+
onCancelUpload={cancelAttachmentUpload}
|
|
252
|
+
onRemoveAttachment={removeAttachment}
|
|
253
|
+
onMoveAttachment={moveAttachmentAt}
|
|
254
|
+
onDismissRejections={dismissAttachmentRejections}
|
|
255
|
+
/>
|
|
256
|
+
) : null}
|
|
257
|
+
|
|
258
|
+
{saveError ? (
|
|
259
|
+
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
|
260
|
+
{saveError}
|
|
261
|
+
</p>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
<div className="flex items-center gap-2">
|
|
265
|
+
<button
|
|
266
|
+
type="submit"
|
|
267
|
+
disabled={isSaving || hasPendingUploads}
|
|
268
|
+
aria-busy={isSaving}
|
|
269
|
+
title={hasPendingUploads ? text.saveBlockedUploading : undefined}
|
|
270
|
+
className="cv-header-action inline-flex items-center gap-1 disabled:opacity-40"
|
|
271
|
+
>
|
|
272
|
+
{isSaving ? text.saving : hasPendingUploads ? text.saveBlockedUploading : text.save}
|
|
273
|
+
</button>
|
|
274
|
+
<button type="button" onClick={cancelEdit} className="text-sm text-gray-500 hover:underline">
|
|
275
|
+
{text.cancel}
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
</form>
|
|
279
|
+
) : null}
|
|
280
|
+
|
|
281
|
+
<div className="cv-table-card">
|
|
282
|
+
<table className="cv-table">
|
|
283
|
+
<thead>
|
|
284
|
+
<tr>
|
|
285
|
+
<th scope="col" className="px-3 py-2 text-left text-xs font-medium">
|
|
286
|
+
{text.columnTitle}
|
|
287
|
+
</th>
|
|
288
|
+
<th scope="col" className="px-3 py-2 text-left text-xs font-medium">
|
|
289
|
+
{text.columnShortcut}
|
|
290
|
+
</th>
|
|
291
|
+
<th scope="col" className="hidden px-3 py-2 text-left text-xs font-medium sm:table-cell">
|
|
292
|
+
{text.columnBody}
|
|
293
|
+
</th>
|
|
294
|
+
{!readOnly ? (
|
|
295
|
+
<th scope="col" className="px-3 py-2 text-left text-xs font-medium">
|
|
296
|
+
{text.columnActions}
|
|
297
|
+
</th>
|
|
298
|
+
) : null}
|
|
299
|
+
</tr>
|
|
300
|
+
</thead>
|
|
301
|
+
<tbody>
|
|
302
|
+
{isLoading ? (
|
|
303
|
+
Array.from({ length: TABLE_SKELETON_ROWS }).map((_, index) => (
|
|
304
|
+
<tr key={index} aria-hidden="true">
|
|
305
|
+
<td className="px-3 py-3">
|
|
306
|
+
<span className="cv-skeleton-line cv-skeleton-line--title block" />
|
|
307
|
+
</td>
|
|
308
|
+
<td className="px-3 py-3">
|
|
309
|
+
<span className="cv-skeleton-line cv-skeleton-line--shortcut block" />
|
|
310
|
+
</td>
|
|
311
|
+
<td className="hidden px-3 py-3 sm:table-cell">
|
|
312
|
+
<span className="cv-skeleton-line cv-skeleton-line--body block" />
|
|
313
|
+
</td>
|
|
314
|
+
{!readOnly ? <td className="px-3 py-3" /> : null}
|
|
315
|
+
</tr>
|
|
316
|
+
))
|
|
317
|
+
) : filtered.length === 0 ? (
|
|
318
|
+
<tr>
|
|
319
|
+
<td colSpan={4} className="px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
320
|
+
{search.trim() ? text.noResults : text.empty}
|
|
321
|
+
</td>
|
|
322
|
+
</tr>
|
|
323
|
+
) : null}
|
|
324
|
+
{!isLoading &&
|
|
325
|
+
filtered.map((quickReply) => (
|
|
326
|
+
<tr key={quickReply.id} className={cn(deletingId === quickReply.id ? 'cv-row-departing' : undefined)}>
|
|
327
|
+
<td className="px-3 py-2 font-medium">{quickReply.title}</td>
|
|
328
|
+
<td className="px-3 py-2 font-mono text-xs text-gray-500">/{quickReply.shortcut}</td>
|
|
329
|
+
<td className="hidden max-w-sm truncate px-3 py-2 text-gray-500 sm:table-cell">{quickReply.body}</td>
|
|
330
|
+
{!readOnly ? (
|
|
331
|
+
<td className="flex gap-2 px-3 py-2">
|
|
332
|
+
{canEdit ? (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
onClick={() => startEdit(quickReply)}
|
|
336
|
+
className="text-xs text-blue-600 hover:underline dark:text-blue-400"
|
|
337
|
+
>
|
|
338
|
+
{text.edit}
|
|
339
|
+
</button>
|
|
340
|
+
) : null}
|
|
341
|
+
{canDelete ? (
|
|
342
|
+
confirmingDeleteId === quickReply.id ? (
|
|
343
|
+
// Linha de confirmação inline em vez de `window.confirm`: trava a aba
|
|
344
|
+
// inteira e não segue os tokens visuais do pacote (`web.md` §14).
|
|
345
|
+
<span
|
|
346
|
+
role="group"
|
|
347
|
+
aria-label={text.removeConfirm(quickReply.title)}
|
|
348
|
+
onKeyDown={handleDeleteConfirmKeyDown(quickReply.id)}
|
|
349
|
+
className="flex items-center gap-2 text-xs"
|
|
350
|
+
>
|
|
351
|
+
{text.removeConfirm(quickReply.title)}
|
|
352
|
+
<button
|
|
353
|
+
ref={confirmButtonRef}
|
|
354
|
+
type="button"
|
|
355
|
+
onClick={() => {
|
|
356
|
+
setConfirmingDeleteId(null)
|
|
357
|
+
void remove(quickReply.id)
|
|
358
|
+
}}
|
|
359
|
+
className="font-medium text-red-600 hover:underline dark:text-red-400"
|
|
360
|
+
>
|
|
361
|
+
{text.remove}
|
|
362
|
+
</button>
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
onClick={() => cancelDeleteConfirm(quickReply.id)}
|
|
366
|
+
className="text-gray-500 hover:underline dark:text-gray-400"
|
|
367
|
+
>
|
|
368
|
+
{text.cancel}
|
|
369
|
+
</button>
|
|
370
|
+
</span>
|
|
371
|
+
) : (
|
|
372
|
+
<button
|
|
373
|
+
ref={(node) => {
|
|
374
|
+
deleteButtonRefs.current[quickReply.id] = node
|
|
375
|
+
}}
|
|
376
|
+
type="button"
|
|
377
|
+
disabled={deletingId === quickReply.id}
|
|
378
|
+
aria-busy={deletingId === quickReply.id}
|
|
379
|
+
onClick={() => setConfirmingDeleteId(quickReply.id)}
|
|
380
|
+
className="text-xs text-red-600 hover:underline disabled:opacity-50 dark:text-red-400"
|
|
381
|
+
>
|
|
382
|
+
{deletingId === quickReply.id ? text.deleting : text.remove}
|
|
383
|
+
</button>
|
|
384
|
+
)
|
|
385
|
+
) : null}
|
|
386
|
+
</td>
|
|
387
|
+
) : null}
|
|
388
|
+
</tr>
|
|
389
|
+
))}
|
|
390
|
+
</tbody>
|
|
391
|
+
</table>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
}
|