@asteby/metacore-runtime-react 23.5.1 → 23.7.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 +16 -0
- package/dist/action-modal-dispatcher.js +8 -7
- package/dist/custom-stages.d.ts +171 -0
- package/dist/custom-stages.d.ts.map +1 -0
- package/dist/custom-stages.js +535 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +7 -4
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +81 -32
- package/dist/field-grid.d.ts +17 -0
- package/dist/field-grid.d.ts.map +1 -0
- package/dist/field-grid.js +12 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/action-modal-grid-layout.test.tsx +86 -0
- package/src/__tests__/custom-stages.test.tsx +480 -0
- package/src/__tests__/stage-automations.test.tsx +243 -0
- package/src/action-modal-dispatcher.tsx +29 -30
- package/src/custom-stages.tsx +1113 -0
- package/src/dialogs/dynamic-record.tsx +14 -8
- package/src/dynamic-kanban.tsx +203 -5
- package/src/field-grid.tsx +57 -0
- package/src/index.ts +39 -0
- package/src/stage-automations.tsx +580 -0
- package/src/types.ts +26 -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
|
+
})
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
Button,
|
|
27
27
|
Input,
|
|
28
28
|
Textarea,
|
|
29
|
-
Label,
|
|
30
29
|
Select,
|
|
31
30
|
SelectContent,
|
|
32
31
|
SelectItem,
|
|
@@ -44,6 +43,7 @@ import { DynamicSelectField } from './dynamic-select-field'
|
|
|
44
43
|
import { DynamicDateField } from './dynamic-date-field'
|
|
45
44
|
import { UploadField } from './upload-field'
|
|
46
45
|
import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn } from './dynamic-form-schema'
|
|
46
|
+
import { FieldGrid, FieldCell, FieldLabel } from './field-grid'
|
|
47
47
|
import type { ActionFieldDef } from './types'
|
|
48
48
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
49
49
|
import {
|
|
@@ -383,7 +383,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
383
383
|
is inline (guaranteed) since an arbitrary max-h-[90vh] class may be
|
|
384
384
|
dropped by a consuming app's Tailwind scan. */}
|
|
385
385
|
<DialogContent
|
|
386
|
-
className={'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-
|
|
386
|
+
className={'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl')}
|
|
387
387
|
style={{ maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
|
|
388
388
|
>
|
|
389
389
|
<DialogHeader className="shrink-0">
|
|
@@ -393,34 +393,33 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
393
393
|
</DialogTitle>
|
|
394
394
|
{action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
|
|
395
395
|
</DialogHeader>
|
|
396
|
-
{/* Scrollable body.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
{field.
|
|
414
|
-
</
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
)}
|
|
396
|
+
{/* Scrollable body. The shared FieldGrid lays scalar fields out
|
|
397
|
+
in two responsive columns (single column on phones); line-items
|
|
398
|
+
grids and textareas span the full width. `min-w-0` on each cell
|
|
399
|
+
(in FieldCell) keeps a long select/input value from blowing the
|
|
400
|
+
grid past the dialog and spawning a horizontal scrollbar. */}
|
|
401
|
+
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
|
|
402
|
+
<FieldGrid>
|
|
403
|
+
{action.fields?.map((field) => {
|
|
404
|
+
const fullWidth =
|
|
405
|
+
isLineItemsField(field) ||
|
|
406
|
+
resolveWidget(field) === 'textarea' ||
|
|
407
|
+
resolveWidget(field) === 'richtext'
|
|
408
|
+
return (
|
|
409
|
+
<FieldCell key={field.key} fullWidth={fullWidth}>
|
|
410
|
+
<FieldLabel htmlFor={field.key} required={field.required}>
|
|
411
|
+
{field.label}
|
|
412
|
+
</FieldLabel>
|
|
413
|
+
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
|
|
414
|
+
</FieldCell>
|
|
415
|
+
)
|
|
416
|
+
})}
|
|
417
|
+
{relations.length > 0 && (
|
|
418
|
+
<FieldCell fullWidth>
|
|
419
|
+
<DynamicRelations record={record} relations={relations} />
|
|
420
|
+
</FieldCell>
|
|
421
|
+
)}
|
|
422
|
+
</FieldGrid>
|
|
424
423
|
</div>
|
|
425
424
|
<DialogFooter className="shrink-0">
|
|
426
425
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
|