@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.
@@ -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 }