@asteby/metacore-runtime-react 23.5.0 → 23.6.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/CHANGELOG.md +12 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +15 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/stage-automations.d.ts +75 -0
- package/dist/stage-automations.d.ts.map +1 -0
- package/dist/stage-automations.js +264 -0
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban.test.tsx +23 -0
- package/src/__tests__/stage-automations.test.tsx +243 -0
- package/src/dynamic-kanban.tsx +58 -4
- package/src/index.ts +14 -0
- package/src/stage-automations.tsx +580 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// Stage automations — a Bitrix-style "when a card enters this stage, run these
|
|
2
|
+
// actions" rule editor, surfaced per-lane in DynamicKanban. The feature is
|
|
3
|
+
// generic over any model: rules live server-side (the host wires the
|
|
4
|
+
// `/stage-automations` REST endpoints against the same api client the rest of
|
|
5
|
+
// the dynamic runtime uses) and reference the model's own columns.
|
|
6
|
+
//
|
|
7
|
+
// Non-intrusive by design: if the endpoint 404s or errors, the hook reports
|
|
8
|
+
// `available: false` and the lane simply omits the ⚡ affordance — the board
|
|
9
|
+
// keeps working. All UI text goes through t() with a Spanish defaultValue so a
|
|
10
|
+
// host that ships no `dynamic.automations.*` keys still renders in Spanish.
|
|
11
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
12
|
+
import { useTranslation } from 'react-i18next'
|
|
13
|
+
import { Zap, Trash2, Plus } from 'lucide-react'
|
|
14
|
+
import { toast } from 'sonner'
|
|
15
|
+
import {
|
|
16
|
+
Button,
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogContent,
|
|
19
|
+
DialogDescription,
|
|
20
|
+
DialogFooter,
|
|
21
|
+
DialogHeader,
|
|
22
|
+
DialogTitle,
|
|
23
|
+
Input,
|
|
24
|
+
Label,
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectItem,
|
|
28
|
+
SelectTrigger,
|
|
29
|
+
SelectValue,
|
|
30
|
+
Switch,
|
|
31
|
+
} from '@asteby/metacore-ui/primitives'
|
|
32
|
+
import { useApi } from './api-context'
|
|
33
|
+
import type { ApiClient } from './api-context'
|
|
34
|
+
import type { ColumnDefinition } from './types'
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Contract (matches the ops backend; envelope is {success, data} → read .data)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export type StageAutomationActionType = 'add_tag' | 'remove_tag' | 'set_field'
|
|
41
|
+
|
|
42
|
+
export interface StageAutomationAction {
|
|
43
|
+
type: StageAutomationActionType
|
|
44
|
+
field: string
|
|
45
|
+
value: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StageAutomation {
|
|
49
|
+
id: string | number
|
|
50
|
+
model: string
|
|
51
|
+
/** Source stage the card is leaving. `'*'` = any stage. */
|
|
52
|
+
from_stage: string
|
|
53
|
+
/** Destination stage that triggers the rule (the lane's stage key). */
|
|
54
|
+
to_stage: string
|
|
55
|
+
actions: StageAutomationAction[]
|
|
56
|
+
enabled: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Draft for a new rule — a single action, keyed to a destination stage. */
|
|
60
|
+
export interface NewStageAutomation {
|
|
61
|
+
model: string
|
|
62
|
+
from_stage: string
|
|
63
|
+
to_stage: string
|
|
64
|
+
actions: StageAutomationAction[]
|
|
65
|
+
enabled: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Pure helpers (exported for unit tests — no React, no transport)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const ACTION_TYPES: StageAutomationActionType[] = [
|
|
73
|
+
'add_tag',
|
|
74
|
+
'remove_tag',
|
|
75
|
+
'set_field',
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
/** Whether a column holds a tag/json array (valid target for add/remove_tag). */
|
|
79
|
+
export function isTagColumn(col: ColumnDefinition): boolean {
|
|
80
|
+
const t = col.type
|
|
81
|
+
const style = (col as { cellStyle?: string }).cellStyle
|
|
82
|
+
return t === 'tags' || t === ('json' as ColumnDefinition['type']) || style === 'tags'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Columns a given action type may target:
|
|
87
|
+
* - add_tag / remove_tag → only tag/json columns
|
|
88
|
+
* - set_field → any editable (non-readonly, non-hidden) column
|
|
89
|
+
* Hidden and readonly columns are never offered.
|
|
90
|
+
*/
|
|
91
|
+
export function automationFieldOptions(
|
|
92
|
+
columns: ColumnDefinition[],
|
|
93
|
+
actionType: StageAutomationActionType,
|
|
94
|
+
): ColumnDefinition[] {
|
|
95
|
+
return columns.filter((c) => {
|
|
96
|
+
if (c.hidden) return false
|
|
97
|
+
const ro = !!(c as { readonly?: boolean; read_only?: boolean }).readonly ||
|
|
98
|
+
!!(c as { readonly?: boolean; read_only?: boolean }).read_only
|
|
99
|
+
if (ro) return false
|
|
100
|
+
if (actionType === 'add_tag' || actionType === 'remove_tag') {
|
|
101
|
+
return isTagColumn(c)
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Rules grouped by their destination stage key (only what the lane needs). */
|
|
108
|
+
export function groupAutomationsByStage(
|
|
109
|
+
rules: StageAutomation[],
|
|
110
|
+
): Map<string, StageAutomation[]> {
|
|
111
|
+
const map = new Map<string, StageAutomation[]>()
|
|
112
|
+
for (const r of rules) {
|
|
113
|
+
const arr = map.get(r.to_stage) ?? []
|
|
114
|
+
arr.push(r)
|
|
115
|
+
map.set(r.to_stage, arr)
|
|
116
|
+
}
|
|
117
|
+
return map
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Count of ENABLED rules for a stage — drives the lane's ⚡ indicator. */
|
|
121
|
+
export function activeAutomationCount(rules: StageAutomation[] | undefined): number {
|
|
122
|
+
if (!rules) return 0
|
|
123
|
+
return rules.filter((r) => r.enabled).length
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Data hook
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function unwrap(res: { data: any }): any {
|
|
131
|
+
const body = res?.data
|
|
132
|
+
// Envelope {success, data}; tolerate a bare array/object too.
|
|
133
|
+
if (body && typeof body === 'object' && 'data' in body) return body.data
|
|
134
|
+
return body
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface UseStageAutomationsResult {
|
|
138
|
+
/** False when the endpoint is missing/errored — hide the ⚡ affordance. */
|
|
139
|
+
available: boolean
|
|
140
|
+
loading: boolean
|
|
141
|
+
/** Destination-stage-keyed rules. */
|
|
142
|
+
byStage: Map<string, StageAutomation[]>
|
|
143
|
+
create: (draft: NewStageAutomation) => Promise<void>
|
|
144
|
+
update: (id: StageAutomation['id'], patch: Partial<StageAutomation>) => Promise<void>
|
|
145
|
+
remove: (id: StageAutomation['id']) => Promise<void>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Loads a model's stage automations and exposes CRUD. Swallows a missing
|
|
150
|
+
* endpoint (404 / network error) into `available: false` so the kanban never
|
|
151
|
+
* breaks; real mutation failures surface a toast and re-throw so the dialog can
|
|
152
|
+
* keep its draft.
|
|
153
|
+
*/
|
|
154
|
+
export function useStageAutomations(model: string): UseStageAutomationsResult {
|
|
155
|
+
const api = useApi()
|
|
156
|
+
const { t } = useTranslation()
|
|
157
|
+
const [available, setAvailable] = useState(true)
|
|
158
|
+
const [loading, setLoading] = useState(true)
|
|
159
|
+
const [rules, setRules] = useState<StageAutomation[]>([])
|
|
160
|
+
|
|
161
|
+
const load = useCallback(async () => {
|
|
162
|
+
setLoading(true)
|
|
163
|
+
try {
|
|
164
|
+
const res = await api.get(`/stage-automations?model=${encodeURIComponent(model)}`)
|
|
165
|
+
const data = unwrap(res)
|
|
166
|
+
setRules(Array.isArray(data) ? data : [])
|
|
167
|
+
setAvailable(true)
|
|
168
|
+
} catch {
|
|
169
|
+
// Endpoint absent or errored — degrade silently.
|
|
170
|
+
setRules([])
|
|
171
|
+
setAvailable(false)
|
|
172
|
+
} finally {
|
|
173
|
+
setLoading(false)
|
|
174
|
+
}
|
|
175
|
+
}, [api, model])
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
void load()
|
|
179
|
+
}, [load])
|
|
180
|
+
|
|
181
|
+
const create = useCallback(
|
|
182
|
+
async (draft: NewStageAutomation) => {
|
|
183
|
+
try {
|
|
184
|
+
const res = await api.post('/stage-automations', draft)
|
|
185
|
+
const created = unwrap(res) as StageAutomation | null
|
|
186
|
+
await load()
|
|
187
|
+
if (!created) return
|
|
188
|
+
} catch (e) {
|
|
189
|
+
toast.error(
|
|
190
|
+
t('dynamic.automations.saveError', {
|
|
191
|
+
defaultValue: 'No se pudo guardar la automatización',
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
throw e
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
[api, load, t],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const update = useCallback(
|
|
201
|
+
async (id: StageAutomation['id'], patch: Partial<StageAutomation>) => {
|
|
202
|
+
try {
|
|
203
|
+
await api.put(`/stage-automations/${id}`, patch)
|
|
204
|
+
await load()
|
|
205
|
+
} catch (e) {
|
|
206
|
+
toast.error(
|
|
207
|
+
t('dynamic.automations.saveError', {
|
|
208
|
+
defaultValue: 'No se pudo guardar la automatización',
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
throw e
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
[api, load, t],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const remove = useCallback(
|
|
218
|
+
async (id: StageAutomation['id']) => {
|
|
219
|
+
try {
|
|
220
|
+
await api.delete(`/stage-automations/${id}`)
|
|
221
|
+
await load()
|
|
222
|
+
} catch (e) {
|
|
223
|
+
toast.error(
|
|
224
|
+
t('dynamic.automations.deleteError', {
|
|
225
|
+
defaultValue: 'No se pudo eliminar la automatización',
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
throw e
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
[api, load, t],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const byStage = useMemo(() => groupAutomationsByStage(rules), [rules])
|
|
235
|
+
|
|
236
|
+
return { available, loading, byStage, create, update, remove }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Lane affordance + dialog
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
export interface StageAutomationsButtonProps {
|
|
244
|
+
model: string
|
|
245
|
+
/** The lane's stage key + human label (destination that triggers rules). */
|
|
246
|
+
stageKey: string
|
|
247
|
+
stageLabel: string
|
|
248
|
+
columns: ColumnDefinition[]
|
|
249
|
+
rules: StageAutomation[]
|
|
250
|
+
onCreate: (draft: NewStageAutomation) => Promise<void>
|
|
251
|
+
onUpdate: (id: StageAutomation['id'], patch: Partial<StageAutomation>) => Promise<void>
|
|
252
|
+
onRemove: (id: StageAutomation['id']) => Promise<void>
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The per-lane ⚡ button: shows an active-rule count badge and opens the rule
|
|
257
|
+
* editor for THIS stage. Rendered only when the endpoint is available.
|
|
258
|
+
*/
|
|
259
|
+
export function StageAutomationsButton({
|
|
260
|
+
model,
|
|
261
|
+
stageKey,
|
|
262
|
+
stageLabel,
|
|
263
|
+
columns,
|
|
264
|
+
rules,
|
|
265
|
+
onCreate,
|
|
266
|
+
onUpdate,
|
|
267
|
+
onRemove,
|
|
268
|
+
}: StageAutomationsButtonProps) {
|
|
269
|
+
const { t } = useTranslation()
|
|
270
|
+
const [open, setOpen] = useState(false)
|
|
271
|
+
const activeCount = activeAutomationCount(rules)
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={() => setOpen(true)}
|
|
278
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
279
|
+
activeCount > 0 ? 'text-primary' : 'text-muted-foreground'
|
|
280
|
+
}`}
|
|
281
|
+
aria-label={t('dynamic.automations.open', {
|
|
282
|
+
defaultValue: 'Automatizaciones de la etapa',
|
|
283
|
+
})}
|
|
284
|
+
data-testid={`automations-trigger-${stageKey}`}
|
|
285
|
+
>
|
|
286
|
+
<Zap className="h-3.5 w-3.5" />
|
|
287
|
+
{activeCount > 0 && (
|
|
288
|
+
<span className="absolute -right-1 -top-1 flex min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-none text-primary-foreground">
|
|
289
|
+
{activeCount}
|
|
290
|
+
</span>
|
|
291
|
+
)}
|
|
292
|
+
</button>
|
|
293
|
+
{open && (
|
|
294
|
+
<StageAutomationsDialog
|
|
295
|
+
open={open}
|
|
296
|
+
onOpenChange={setOpen}
|
|
297
|
+
model={model}
|
|
298
|
+
stageKey={stageKey}
|
|
299
|
+
stageLabel={stageLabel}
|
|
300
|
+
columns={columns}
|
|
301
|
+
rules={rules}
|
|
302
|
+
onCreate={onCreate}
|
|
303
|
+
onUpdate={onUpdate}
|
|
304
|
+
onRemove={onRemove}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
</>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface StageAutomationsDialogProps extends StageAutomationsButtonProps {
|
|
312
|
+
open: boolean
|
|
313
|
+
onOpenChange: (open: boolean) => void
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function describeAction(
|
|
317
|
+
action: StageAutomationAction,
|
|
318
|
+
columns: ColumnDefinition[],
|
|
319
|
+
t: ReturnType<typeof useTranslation>['t'],
|
|
320
|
+
): string {
|
|
321
|
+
const col = columns.find((c) => c.key === action.field)
|
|
322
|
+
const fieldLabel = col ? t(col.label, { defaultValue: col.label }) : action.field
|
|
323
|
+
switch (action.type) {
|
|
324
|
+
case 'add_tag':
|
|
325
|
+
return t('dynamic.automations.summary.addTag', {
|
|
326
|
+
defaultValue: 'Agregar tag "{{value}}" a {{field}}',
|
|
327
|
+
value: action.value,
|
|
328
|
+
field: fieldLabel,
|
|
329
|
+
})
|
|
330
|
+
case 'remove_tag':
|
|
331
|
+
return t('dynamic.automations.summary.removeTag', {
|
|
332
|
+
defaultValue: 'Quitar tag "{{value}}" de {{field}}',
|
|
333
|
+
value: action.value,
|
|
334
|
+
field: fieldLabel,
|
|
335
|
+
})
|
|
336
|
+
default:
|
|
337
|
+
return t('dynamic.automations.summary.setField', {
|
|
338
|
+
defaultValue: 'Setear {{field}} = "{{value}}"',
|
|
339
|
+
value: action.value,
|
|
340
|
+
field: fieldLabel,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function StageAutomationsDialog({
|
|
346
|
+
open,
|
|
347
|
+
onOpenChange,
|
|
348
|
+
model,
|
|
349
|
+
stageKey,
|
|
350
|
+
stageLabel,
|
|
351
|
+
columns,
|
|
352
|
+
rules,
|
|
353
|
+
onCreate,
|
|
354
|
+
onUpdate,
|
|
355
|
+
onRemove,
|
|
356
|
+
}: StageAutomationsDialogProps) {
|
|
357
|
+
const { t } = useTranslation()
|
|
358
|
+
const [actionType, setActionType] = useState<StageAutomationActionType>('add_tag')
|
|
359
|
+
const [field, setField] = useState('')
|
|
360
|
+
const [value, setValue] = useState('')
|
|
361
|
+
const [saving, setSaving] = useState(false)
|
|
362
|
+
|
|
363
|
+
const fieldChoices = useMemo(
|
|
364
|
+
() => automationFieldOptions(columns, actionType),
|
|
365
|
+
[columns, actionType],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// Keep the selected field valid for the current action type.
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (!fieldChoices.some((c) => c.key === field)) {
|
|
371
|
+
setField(fieldChoices[0]?.key ?? '')
|
|
372
|
+
}
|
|
373
|
+
}, [fieldChoices, field])
|
|
374
|
+
|
|
375
|
+
const label = t(stageLabel, { defaultValue: stageLabel })
|
|
376
|
+
|
|
377
|
+
const canSubmit = !!field && !!value.trim() && !saving
|
|
378
|
+
|
|
379
|
+
const submit = async () => {
|
|
380
|
+
if (!canSubmit) return
|
|
381
|
+
setSaving(true)
|
|
382
|
+
try {
|
|
383
|
+
await onCreate({
|
|
384
|
+
model,
|
|
385
|
+
from_stage: '*',
|
|
386
|
+
to_stage: stageKey,
|
|
387
|
+
actions: [{ type: actionType, field, value: value.trim() }],
|
|
388
|
+
enabled: true,
|
|
389
|
+
})
|
|
390
|
+
setValue('')
|
|
391
|
+
toast.success(
|
|
392
|
+
t('dynamic.automations.saved', {
|
|
393
|
+
defaultValue: 'Automatización guardada',
|
|
394
|
+
}),
|
|
395
|
+
)
|
|
396
|
+
} catch {
|
|
397
|
+
// toast already surfaced by the hook; keep the draft.
|
|
398
|
+
} finally {
|
|
399
|
+
setSaving(false)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
405
|
+
<DialogContent className="sm:max-w-lg">
|
|
406
|
+
<DialogHeader>
|
|
407
|
+
<DialogTitle>
|
|
408
|
+
{t('dynamic.automations.title', {
|
|
409
|
+
defaultValue: 'Automatizaciones · {{stage}}',
|
|
410
|
+
stage: label,
|
|
411
|
+
})}
|
|
412
|
+
</DialogTitle>
|
|
413
|
+
<DialogDescription>
|
|
414
|
+
{t('dynamic.automations.description', {
|
|
415
|
+
defaultValue:
|
|
416
|
+
'Al entrar una tarjeta a esta etapa se ejecutan estas acciones.',
|
|
417
|
+
})}
|
|
418
|
+
</DialogDescription>
|
|
419
|
+
</DialogHeader>
|
|
420
|
+
|
|
421
|
+
{/* Existing rules for this stage */}
|
|
422
|
+
<div className="flex flex-col gap-2">
|
|
423
|
+
{rules.length === 0 ? (
|
|
424
|
+
<p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
|
|
425
|
+
{t('dynamic.automations.empty', {
|
|
426
|
+
defaultValue: 'Sin automatizaciones para esta etapa.',
|
|
427
|
+
})}
|
|
428
|
+
</p>
|
|
429
|
+
) : (
|
|
430
|
+
rules.map((rule) => (
|
|
431
|
+
<div
|
|
432
|
+
key={String(rule.id)}
|
|
433
|
+
className="flex items-center gap-2 rounded-md border px-3 py-2"
|
|
434
|
+
data-testid={`automation-rule-${rule.id}`}
|
|
435
|
+
>
|
|
436
|
+
<div className="min-w-0 flex-1">
|
|
437
|
+
{rule.actions.map((a, i) => (
|
|
438
|
+
<p
|
|
439
|
+
key={i}
|
|
440
|
+
className={`truncate text-sm ${
|
|
441
|
+
rule.enabled ? '' : 'text-muted-foreground line-through'
|
|
442
|
+
}`}
|
|
443
|
+
>
|
|
444
|
+
{describeAction(a, columns, t)}
|
|
445
|
+
</p>
|
|
446
|
+
))}
|
|
447
|
+
</div>
|
|
448
|
+
<Switch
|
|
449
|
+
checked={rule.enabled}
|
|
450
|
+
onCheckedChange={(checked) =>
|
|
451
|
+
void onUpdate(rule.id, { enabled: checked })
|
|
452
|
+
}
|
|
453
|
+
aria-label={t('dynamic.automations.toggle', {
|
|
454
|
+
defaultValue: 'Activar automatización',
|
|
455
|
+
})}
|
|
456
|
+
data-testid={`automation-toggle-${rule.id}`}
|
|
457
|
+
/>
|
|
458
|
+
<Button
|
|
459
|
+
variant="ghost"
|
|
460
|
+
size="icon"
|
|
461
|
+
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
|
|
462
|
+
onClick={() => {
|
|
463
|
+
void onRemove(rule.id).then(() =>
|
|
464
|
+
toast.success(
|
|
465
|
+
t('dynamic.automations.deleted', {
|
|
466
|
+
defaultValue: 'Automatización eliminada',
|
|
467
|
+
}),
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
}}
|
|
471
|
+
aria-label={t('dynamic.automations.delete', {
|
|
472
|
+
defaultValue: 'Eliminar automatización',
|
|
473
|
+
})}
|
|
474
|
+
data-testid={`automation-delete-${rule.id}`}
|
|
475
|
+
>
|
|
476
|
+
<Trash2 className="h-4 w-4" />
|
|
477
|
+
</Button>
|
|
478
|
+
</div>
|
|
479
|
+
))
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
{/* New rule form */}
|
|
484
|
+
<div className="grid grid-cols-1 gap-3 rounded-md border bg-muted/30 p-3 sm:grid-cols-3">
|
|
485
|
+
<div className="flex flex-col gap-1.5">
|
|
486
|
+
<Label className="text-xs">
|
|
487
|
+
{t('dynamic.automations.actionLabel', {
|
|
488
|
+
defaultValue: 'Acción',
|
|
489
|
+
})}
|
|
490
|
+
</Label>
|
|
491
|
+
<Select
|
|
492
|
+
value={actionType}
|
|
493
|
+
onValueChange={(v) => setActionType(v as StageAutomationActionType)}
|
|
494
|
+
>
|
|
495
|
+
<SelectTrigger data-testid="automation-action-select">
|
|
496
|
+
<SelectValue />
|
|
497
|
+
</SelectTrigger>
|
|
498
|
+
<SelectContent>
|
|
499
|
+
{ACTION_TYPES.map((at) => (
|
|
500
|
+
<SelectItem key={at} value={at}>
|
|
501
|
+
{t(`dynamic.automations.action.${at}`, {
|
|
502
|
+
defaultValue:
|
|
503
|
+
at === 'add_tag'
|
|
504
|
+
? 'Agregar tag'
|
|
505
|
+
: at === 'remove_tag'
|
|
506
|
+
? 'Quitar tag'
|
|
507
|
+
: 'Setear campo',
|
|
508
|
+
})}
|
|
509
|
+
</SelectItem>
|
|
510
|
+
))}
|
|
511
|
+
</SelectContent>
|
|
512
|
+
</Select>
|
|
513
|
+
</div>
|
|
514
|
+
<div className="flex flex-col gap-1.5">
|
|
515
|
+
<Label className="text-xs">
|
|
516
|
+
{t('dynamic.automations.fieldLabel', {
|
|
517
|
+
defaultValue: 'Campo',
|
|
518
|
+
})}
|
|
519
|
+
</Label>
|
|
520
|
+
<Select
|
|
521
|
+
value={field}
|
|
522
|
+
onValueChange={setField}
|
|
523
|
+
disabled={fieldChoices.length === 0}
|
|
524
|
+
>
|
|
525
|
+
<SelectTrigger data-testid="automation-field-select">
|
|
526
|
+
<SelectValue
|
|
527
|
+
placeholder={t('dynamic.automations.noFields', {
|
|
528
|
+
defaultValue: 'Sin campos',
|
|
529
|
+
})}
|
|
530
|
+
/>
|
|
531
|
+
</SelectTrigger>
|
|
532
|
+
<SelectContent>
|
|
533
|
+
{fieldChoices.map((c) => (
|
|
534
|
+
<SelectItem key={c.key} value={c.key}>
|
|
535
|
+
{t(c.label, { defaultValue: c.label })}
|
|
536
|
+
</SelectItem>
|
|
537
|
+
))}
|
|
538
|
+
</SelectContent>
|
|
539
|
+
</Select>
|
|
540
|
+
</div>
|
|
541
|
+
<div className="flex flex-col gap-1.5">
|
|
542
|
+
<Label className="text-xs">
|
|
543
|
+
{t('dynamic.automations.valueLabel', {
|
|
544
|
+
defaultValue: 'Valor',
|
|
545
|
+
})}
|
|
546
|
+
</Label>
|
|
547
|
+
<Input
|
|
548
|
+
value={value}
|
|
549
|
+
onChange={(e) => setValue(e.target.value)}
|
|
550
|
+
onKeyDown={(e) => {
|
|
551
|
+
if (e.key === 'Enter') void submit()
|
|
552
|
+
}}
|
|
553
|
+
placeholder={t('dynamic.automations.valuePlaceholder', {
|
|
554
|
+
defaultValue: 'Valor...',
|
|
555
|
+
})}
|
|
556
|
+
data-testid="automation-value-input"
|
|
557
|
+
/>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
<DialogFooter>
|
|
562
|
+
<Button
|
|
563
|
+
onClick={() => void submit()}
|
|
564
|
+
disabled={!canSubmit}
|
|
565
|
+
className="gap-1"
|
|
566
|
+
data-testid="automation-add"
|
|
567
|
+
>
|
|
568
|
+
<Plus className="h-4 w-4" />
|
|
569
|
+
{t('dynamic.automations.add', {
|
|
570
|
+
defaultValue: 'Agregar regla',
|
|
571
|
+
})}
|
|
572
|
+
</Button>
|
|
573
|
+
</DialogFooter>
|
|
574
|
+
</DialogContent>
|
|
575
|
+
</Dialog>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Re-export the api client type so hosts can see the transport contract.
|
|
580
|
+
export type { ApiClient }
|