@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.
@@ -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-lg')}
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. Responsive 2-column grid: scalar fields
397
- (journal, date, reference) flow side-by-side instead of one
398
- tall vertical stack; line-items grids and textareas span the
399
- full width. Mirrors DynamicForm driven only by field shape. */}
400
- <div className="-mx-1 grid min-h-0 flex-1 gap-4 overflow-y-auto px-1 py-4 sm:grid-cols-2">
401
- {action.fields?.map((field) => {
402
- const fullWidth =
403
- isLineItemsField(field) ||
404
- resolveWidget(field) === 'textarea' ||
405
- resolveWidget(field) === 'richtext'
406
- return (
407
- <div
408
- key={field.key}
409
- className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}
410
- >
411
- <Label htmlFor={field.key}>
412
- {field.label}
413
- {field.required && <span className="text-red-500 ml-1">*</span>}
414
- </Label>
415
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
416
- </div>
417
- )
418
- })}
419
- {relations.length > 0 && (
420
- <div className="sm:col-span-2">
421
- <DynamicRelations record={record} relations={relations} />
422
- </div>
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}>