@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,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
|
+
})
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
|
|
@@ -877,9 +887,9 @@ export function DynamicKanban({
|
|
|
877
887
|
|
|
878
888
|
if (loading) {
|
|
879
889
|
return (
|
|
880
|
-
<div className="flex gap-4 overflow-x-auto p-1">
|
|
890
|
+
<div className="flex w-full gap-4 overflow-x-auto p-1">
|
|
881
891
|
{[0, 1, 2, 3].map((i) => (
|
|
882
|
-
<div key={i} className="w-[
|
|
892
|
+
<div key={i} className="min-w-[280px] max-w-[420px] flex-1 shrink-0 space-y-3">
|
|
883
893
|
<Skeleton className="h-8 w-full" />
|
|
884
894
|
<Skeleton className="h-24 w-full" />
|
|
885
895
|
<Skeleton className="h-24 w-full" />
|
|
@@ -1023,7 +1033,7 @@ export function DynamicKanban({
|
|
|
1023
1033
|
/>
|
|
1024
1034
|
|
|
1025
1035
|
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
1026
|
-
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
1036
|
+
<div className="flex w-full min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
1027
1037
|
{lanes.map((stage) => {
|
|
1028
1038
|
const allCards = grouped.get(stage.key) ?? []
|
|
1029
1039
|
// Per-lane client-side narrowing (instant, scoped to this
|
|
@@ -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()
|
|
@@ -1309,7 +1347,11 @@ function KanbanLane({
|
|
|
1309
1347
|
return (
|
|
1310
1348
|
<div
|
|
1311
1349
|
ref={setNodeRef}
|
|
1312
|
-
|
|
1350
|
+
// Fluid width: lanes grow (flex-1) to fill the board when they all
|
|
1351
|
+
// fit, capped at max-w so a couple of lanes don't stretch absurdly
|
|
1352
|
+
// wide. Below min-w the board's overflow-x-auto takes over and the
|
|
1353
|
+
// lanes scroll horizontally at their minimum width.
|
|
1354
|
+
className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
|
|
1313
1355
|
style={{
|
|
1314
1356
|
opacity: dimmed ? 0.45 : 1,
|
|
1315
1357
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
@@ -1355,6 +1397,18 @@ function KanbanLane({
|
|
|
1355
1397
|
value={funnelValue}
|
|
1356
1398
|
onChange={onFunnelChange}
|
|
1357
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
|
+
)}
|
|
1358
1412
|
</div>
|
|
1359
1413
|
</div>
|
|
1360
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,
|