@asteby/metacore-runtime-react 23.5.1 → 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,243 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Stage automations coverage:
4
+ // 1. Pure helpers: isTagColumn, automationFieldOptions (per action type),
5
+ // groupAutomationsByStage, activeAutomationCount.
6
+ // 2. Dialog render + create rule (mock fetch), toggle enabled, delete.
7
+ // 3. DynamicKanban stays intact when the /stage-automations endpoint 404s
8
+ // (the ⚡ affordance simply never renders).
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+ import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
11
+
12
+ const I18N_T = (k: string, opts?: Record<string, any>) => {
13
+ let s = (opts?.defaultValue as string) ?? k
14
+ if (opts) {
15
+ for (const [ok, ov] of Object.entries(opts)) {
16
+ if (ok === 'defaultValue') continue
17
+ s = s.replace(new RegExp(`{{${ok}}}`, 'g'), String(ov))
18
+ }
19
+ }
20
+ return s
21
+ }
22
+ const I18N = { language: 'es' }
23
+ const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
24
+ vi.mock('react-i18next', () => ({
25
+ useTranslation: () => USE_TRANSLATION,
26
+ }))
27
+ vi.mock('sonner', () => ({
28
+ toast: { success: vi.fn(), error: vi.fn() },
29
+ }))
30
+ vi.mock('@tanstack/react-router', () => ({
31
+ useNavigate: () => () => {},
32
+ }))
33
+
34
+ import {
35
+ isTagColumn,
36
+ automationFieldOptions,
37
+ groupAutomationsByStage,
38
+ activeAutomationCount,
39
+ StageAutomationsButton,
40
+ useStageAutomations,
41
+ type StageAutomation,
42
+ } from '../stage-automations'
43
+ import { ApiProvider, type ApiClient } from '../api-context'
44
+ import type { ColumnDefinition } from '../types'
45
+ import React from 'react'
46
+
47
+ afterEach(cleanup)
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Fixtures
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const COLUMNS: ColumnDefinition[] = [
54
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false },
55
+ { key: 'labels', label: 'Labels', type: 'tags', sortable: false, filterable: false },
56
+ { key: 'priority', label: 'Priority', type: 'text', sortable: false, filterable: false },
57
+ { key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false, readonly: true } as ColumnDefinition,
58
+ { key: 'secret', label: 'Secret', type: 'text', sortable: false, filterable: false, hidden: true },
59
+ ]
60
+
61
+ const RULES: StageAutomation[] = [
62
+ {
63
+ id: 'r1',
64
+ model: 'issue',
65
+ from_stage: '*',
66
+ to_stage: 'done',
67
+ actions: [{ type: 'add_tag', field: 'labels', value: 'shipped' }],
68
+ enabled: true,
69
+ },
70
+ {
71
+ id: 'r2',
72
+ model: 'issue',
73
+ from_stage: '*',
74
+ to_stage: 'done',
75
+ actions: [{ type: 'set_field', field: 'priority', value: 'low' }],
76
+ enabled: false,
77
+ },
78
+ ]
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // 1. Pure helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('isTagColumn', () => {
85
+ it('accepts tags/json columns and rejects plain fields', () => {
86
+ expect(isTagColumn(COLUMNS[1])).toBe(true)
87
+ expect(isTagColumn(COLUMNS[0])).toBe(false)
88
+ expect(isTagColumn({ key: 'j', label: 'J', type: 'json' as any, sortable: false, filterable: false })).toBe(true)
89
+ })
90
+ })
91
+
92
+ describe('automationFieldOptions', () => {
93
+ it('offers only tag columns for add_tag / remove_tag', () => {
94
+ expect(automationFieldOptions(COLUMNS, 'add_tag').map((c) => c.key)).toEqual(['labels'])
95
+ expect(automationFieldOptions(COLUMNS, 'remove_tag').map((c) => c.key)).toEqual(['labels'])
96
+ })
97
+ it('offers all editable (non-readonly, non-hidden) columns for set_field', () => {
98
+ expect(automationFieldOptions(COLUMNS, 'set_field').map((c) => c.key)).toEqual([
99
+ 'title',
100
+ 'labels',
101
+ 'priority',
102
+ ])
103
+ })
104
+ })
105
+
106
+ describe('groupAutomationsByStage / activeAutomationCount', () => {
107
+ it('buckets by destination stage', () => {
108
+ const m = groupAutomationsByStage(RULES)
109
+ expect(m.get('done')!.length).toBe(2)
110
+ })
111
+ it('counts only enabled rules', () => {
112
+ expect(activeAutomationCount(RULES)).toBe(1)
113
+ expect(activeAutomationCount([])).toBe(0)
114
+ expect(activeAutomationCount(undefined)).toBe(0)
115
+ })
116
+ })
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // 2. Dialog: render, create, toggle, delete
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function renderButton(over: Partial<React.ComponentProps<typeof StageAutomationsButton>> = {}) {
123
+ const onCreate = vi.fn(async () => {})
124
+ const onUpdate = vi.fn(async () => {})
125
+ const onRemove = vi.fn(async () => {})
126
+ render(
127
+ <StageAutomationsButton
128
+ model="issue"
129
+ stageKey="done"
130
+ stageLabel="Done"
131
+ columns={COLUMNS}
132
+ rules={RULES}
133
+ onCreate={onCreate}
134
+ onUpdate={onUpdate}
135
+ onRemove={onRemove}
136
+ {...over}
137
+ />,
138
+ )
139
+ return { onCreate, onUpdate, onRemove }
140
+ }
141
+
142
+ describe('StageAutomationsButton', () => {
143
+ it('shows the active-rule count badge (1 of 2 enabled)', () => {
144
+ renderButton()
145
+ const trigger = screen.getByTestId('automations-trigger-done')
146
+ expect(trigger.textContent).toContain('1')
147
+ })
148
+
149
+ it('opens the dialog listing this stage rules with their summaries', () => {
150
+ renderButton()
151
+ fireEvent.click(screen.getByTestId('automations-trigger-done'))
152
+ expect(screen.getByTestId('automation-rule-r1')).toBeTruthy()
153
+ expect(screen.getByText(/Agregar tag "shipped" a Labels/)).toBeTruthy()
154
+ expect(screen.getByText(/Setear Priority = "low"/)).toBeTruthy()
155
+ })
156
+
157
+ it('creates a rule with the picked action/field/value', async () => {
158
+ const { onCreate } = renderButton()
159
+ fireEvent.click(screen.getByTestId('automations-trigger-done'))
160
+ // default action is add_tag → field defaults to the only tag column
161
+ fireEvent.change(screen.getByTestId('automation-value-input'), {
162
+ target: { value: 'urgent' },
163
+ })
164
+ fireEvent.click(screen.getByTestId('automation-add'))
165
+ await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
166
+ expect(onCreate).toHaveBeenCalledWith({
167
+ model: 'issue',
168
+ from_stage: '*',
169
+ to_stage: 'done',
170
+ actions: [{ type: 'add_tag', field: 'labels', value: 'urgent' }],
171
+ enabled: true,
172
+ })
173
+ })
174
+
175
+ it('toggles a rule enabled flag', () => {
176
+ const { onUpdate } = renderButton()
177
+ fireEvent.click(screen.getByTestId('automations-trigger-done'))
178
+ fireEvent.click(screen.getByTestId('automation-toggle-r2'))
179
+ expect(onUpdate).toHaveBeenCalledWith('r2', { enabled: true })
180
+ })
181
+
182
+ it('deletes a rule', async () => {
183
+ const { onRemove } = renderButton()
184
+ fireEvent.click(screen.getByTestId('automations-trigger-done'))
185
+ fireEvent.click(screen.getByTestId('automation-delete-r1'))
186
+ await waitFor(() => expect(onRemove).toHaveBeenCalledWith('r1'))
187
+ })
188
+ })
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // 3. Hook degrades gracefully when the endpoint is missing
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function HookProbe({ model }: { model: string }) {
195
+ const a = useStageAutomations(model)
196
+ return (
197
+ <div>
198
+ <span data-testid="available">{String(a.available)}</span>
199
+ <span data-testid="loading">{String(a.loading)}</span>
200
+ <span data-testid="count">{a.byStage.get('done')?.length ?? 0}</span>
201
+ </div>
202
+ )
203
+ }
204
+
205
+ function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
206
+ return {
207
+ get: vi.fn(async () => ({ data: { success: true, data: RULES } })),
208
+ post: vi.fn(async () => ({ data: { success: true, data: null } })),
209
+ put: vi.fn(async () => ({ data: { success: true, data: null } })),
210
+ delete: vi.fn(async () => ({ data: { success: true, data: null } })),
211
+ ...over,
212
+ }
213
+ }
214
+
215
+ describe('useStageAutomations', () => {
216
+ it('loads rules and groups them by destination stage', async () => {
217
+ render(
218
+ <ApiProvider client={fakeApi()}>
219
+ <HookProbe model="issue" />
220
+ </ApiProvider>,
221
+ )
222
+ await waitFor(() =>
223
+ expect(screen.getByTestId('available').textContent).toBe('true'),
224
+ )
225
+ expect(screen.getByTestId('count').textContent).toBe('2')
226
+ })
227
+
228
+ it('reports available=false when the endpoint 404s (no kanban break)', async () => {
229
+ const get = vi.fn(async () => {
230
+ throw { response: { status: 404 } }
231
+ })
232
+ render(
233
+ <ApiProvider client={fakeApi({ get })}>
234
+ <HookProbe model="issue" />
235
+ </ApiProvider>,
236
+ )
237
+ await waitFor(() =>
238
+ expect(screen.getByTestId('loading').textContent).toBe('false'),
239
+ )
240
+ expect(screen.getByTestId('available').textContent).toBe('false')
241
+ expect(screen.getByTestId('count').textContent).toBe('0')
242
+ })
243
+ })
@@ -79,6 +79,12 @@ import {
79
79
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
80
80
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
81
81
  import { useApi } from './api-context'
82
+ import {
83
+ useStageAutomations,
84
+ StageAutomationsButton,
85
+ type StageAutomation,
86
+ type NewStageAutomation,
87
+ } from './stage-automations'
82
88
  import { useDynamicFilters } from './use-dynamic-filters'
83
89
  import {
84
90
  FilterChipsRow,
@@ -453,6 +459,10 @@ export function DynamicKanban({
453
459
  const api = useApi()
454
460
  const isDark = useIsDarkTheme()
455
461
 
462
+ // Stage automations (Bitrix-style per-lane rules). Degrades to no-op when
463
+ // the host has no `/stage-automations` endpoint — the ⚡ affordance hides.
464
+ const automations = useStageAutomations(model)
465
+
456
466
  const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
457
467
  const cachedMeta = getMetadata(model)
458
468
 
@@ -1075,6 +1085,15 @@ export function DynamicKanban({
1075
1085
  isDark={isDark}
1076
1086
  dimmed={!!activeId && !droppableAllowed}
1077
1087
  disabled={!!activeId && !droppableAllowed}
1088
+ model={model}
1089
+ columns={metadata?.columns ?? []}
1090
+ automationsAvailable={
1091
+ automations.available && stage.key !== UNASSIGNED_LANE
1092
+ }
1093
+ automationRules={automations.byStage.get(stage.key) ?? []}
1094
+ onAutomationCreate={automations.create}
1095
+ onAutomationUpdate={automations.update}
1096
+ onAutomationRemove={automations.remove}
1078
1097
  >
1079
1098
  {loadingData && cards.length === 0 ? (
1080
1099
  <>
@@ -1243,6 +1262,18 @@ interface KanbanLaneProps {
1243
1262
  isDark: boolean
1244
1263
  dimmed: boolean
1245
1264
  disabled: boolean
1265
+ /** Model key + columns for the stage-automations editor. */
1266
+ model: string
1267
+ columns: ColumnDefinition[]
1268
+ /** Whether the ⚡ automations affordance should render for this lane. */
1269
+ automationsAvailable: boolean
1270
+ automationRules: StageAutomation[]
1271
+ onAutomationCreate: (draft: NewStageAutomation) => Promise<void>
1272
+ onAutomationUpdate: (
1273
+ id: StageAutomation['id'],
1274
+ patch: Partial<StageAutomation>,
1275
+ ) => Promise<void>
1276
+ onAutomationRemove: (id: StageAutomation['id']) => Promise<void>
1246
1277
  children: React.ReactNode
1247
1278
  }
1248
1279
 
@@ -1261,6 +1292,13 @@ function KanbanLane({
1261
1292
  isDark,
1262
1293
  dimmed,
1263
1294
  disabled,
1295
+ model,
1296
+ columns,
1297
+ automationsAvailable,
1298
+ automationRules,
1299
+ onAutomationCreate,
1300
+ onAutomationUpdate,
1301
+ onAutomationRemove,
1264
1302
  children,
1265
1303
  }: KanbanLaneProps) {
1266
1304
  const { t } = useTranslation()
@@ -1359,6 +1397,18 @@ function KanbanLane({
1359
1397
  value={funnelValue}
1360
1398
  onChange={onFunnelChange}
1361
1399
  />
1400
+ {automationsAvailable && (
1401
+ <StageAutomationsButton
1402
+ model={model}
1403
+ stageKey={stage.key}
1404
+ stageLabel={stage.label}
1405
+ columns={columns}
1406
+ rules={automationRules}
1407
+ onCreate={onAutomationCreate}
1408
+ onUpdate={onAutomationUpdate}
1409
+ onRemove={onAutomationRemove}
1410
+ />
1411
+ )}
1362
1412
  </div>
1363
1413
  </div>
1364
1414
  {searchOpen && (
package/src/index.ts CHANGED
@@ -16,6 +16,20 @@ export {
16
16
  selectCardColumns,
17
17
  UNASSIGNED_LANE,
18
18
  } from './dynamic-kanban'
19
+ export {
20
+ useStageAutomations,
21
+ StageAutomationsButton,
22
+ isTagColumn,
23
+ automationFieldOptions,
24
+ groupAutomationsByStage,
25
+ activeAutomationCount,
26
+ type StageAutomation,
27
+ type StageAutomationAction,
28
+ type StageAutomationActionType,
29
+ type NewStageAutomation,
30
+ type UseStageAutomationsResult,
31
+ type StageAutomationsButtonProps,
32
+ } from './stage-automations'
19
33
  export {
20
34
  DynamicView,
21
35
  resolveViewRenderer,