@asteby/metacore-runtime-react 23.7.0 → 23.8.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,210 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // useStageLayout (per-org kanban lane-order persistence) + its non-intrusive
4
+ // gating in DynamicKanban: the reset affordance only shows when a custom order
5
+ // exists, and a missing `/stage-layout` endpoint leaves the board fully intact.
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import {
8
+ cleanup,
9
+ fireEvent,
10
+ render,
11
+ renderHook,
12
+ screen,
13
+ waitFor,
14
+ } from '@testing-library/react'
15
+ import React from 'react'
16
+
17
+ const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
18
+ const I18N = { language: 'es' }
19
+ const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
20
+ vi.mock('react-i18next', () => ({
21
+ useTranslation: () => USE_TRANSLATION,
22
+ }))
23
+ vi.mock('@tanstack/react-router', () => ({
24
+ useNavigate: () => () => {},
25
+ }))
26
+
27
+ import { useStageLayout } from '../stage-layout'
28
+ import { DynamicKanban } from '../dynamic-kanban'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import { useMetadataCache } from '../metadata-cache'
31
+ import type { TableMetadata } from '../types'
32
+
33
+ const STAGES = [
34
+ { key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
35
+ { key: 'in_progress', label: 'In Progress', color: 'blue', order: 1 },
36
+ { key: 'done', label: 'Done', color: 'green', order: 2 },
37
+ ]
38
+
39
+ function meta(): TableMetadata {
40
+ return {
41
+ title: 'Issues',
42
+ endpoint: '/data/issue',
43
+ view_type: 'kanban',
44
+ group_by: 'stage',
45
+ stages: STAGES,
46
+ columns: [
47
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
48
+ {
49
+ key: 'stage',
50
+ label: 'Stage',
51
+ type: 'status',
52
+ sortable: false,
53
+ filterable: true,
54
+ options: STAGES.map((s) => ({ value: s.key, label: s.label, color: s.color })),
55
+ },
56
+ ],
57
+ actions: [],
58
+ perPageOptions: [50],
59
+ defaultPerPage: 50,
60
+ searchPlaceholder: 'Buscar...',
61
+ enableCRUDActions: true,
62
+ hasActions: false,
63
+ }
64
+ }
65
+
66
+ const CARDS = [{ id: 1, title: 'Fix login bug', stage: 'backlog' }]
67
+
68
+ function fakeApi(over: Partial<ApiClient> = {}, stageLayoutData: any = { model: 'issue', stage_order: null }): ApiClient {
69
+ const ok = (data: unknown) => ({ data: { success: true, data } })
70
+ return {
71
+ get: vi.fn(async (url: string) => {
72
+ if (url.startsWith('/metadata/table/')) return ok(meta())
73
+ if (url.startsWith('/stage-layout')) return ok(stageLayoutData)
74
+ if (url.startsWith('/custom-stages')) return { data: { success: false } }
75
+ return ok(CARDS)
76
+ }),
77
+ post: vi.fn(async () => ok(null)),
78
+ put: vi.fn(async () => ok(null)),
79
+ delete: vi.fn(async () => ok(null)),
80
+ ...over,
81
+ }
82
+ }
83
+
84
+ const wrapper =
85
+ (client: ApiClient) =>
86
+ ({ children }: { children: React.ReactNode }) =>
87
+ <ApiProvider client={client}>{children}</ApiProvider>
88
+
89
+ afterEach(cleanup)
90
+
91
+ describe('useStageLayout', () => {
92
+ it('reports available + hasCustomLayout from the GET', async () => {
93
+ const api = fakeApi({}, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
94
+ const { result } = renderHook(() => useStageLayout('issue'), {
95
+ wrapper: wrapper(api),
96
+ })
97
+ await waitFor(() => expect(result.current.available).toBe(true))
98
+ expect(result.current.hasCustomLayout).toBe(true)
99
+ })
100
+
101
+ it('is unavailable when the endpoint 404s (drag stays off)', async () => {
102
+ const api = fakeApi({
103
+ get: vi.fn(async (url: string) => {
104
+ if (url.startsWith('/stage-layout')) throw new Error('404')
105
+ return { data: { success: true, data: meta() } }
106
+ }),
107
+ })
108
+ const { result } = renderHook(() => useStageLayout('issue'), {
109
+ wrapper: wrapper(api),
110
+ })
111
+ // give the effect a tick
112
+ await waitFor(() => expect(api.get).toHaveBeenCalled())
113
+ await new Promise((r) => setTimeout(r, 0))
114
+ expect(result.current.available).toBe(false)
115
+ expect(result.current.hasCustomLayout).toBe(false)
116
+ })
117
+
118
+ it('save PUTs the full order and reset DELETEs', async () => {
119
+ const put = vi.fn(async () => ({ data: { success: true, data: null } }))
120
+ const del = vi.fn(async () => ({ data: { success: true, data: null } }))
121
+ const api = fakeApi({ put, delete: del })
122
+ const { result } = renderHook(() => useStageLayout('issue'), {
123
+ wrapper: wrapper(api),
124
+ })
125
+ await waitFor(() => expect(result.current.available).toBe(true))
126
+
127
+ await result.current.save(['done', 'backlog', 'in_progress'])
128
+ expect(put).toHaveBeenCalledWith('/stage-layout', {
129
+ model: 'issue',
130
+ stage_order: ['done', 'backlog', 'in_progress'],
131
+ })
132
+ await waitFor(() => expect(result.current.hasCustomLayout).toBe(true))
133
+
134
+ await result.current.reset()
135
+ expect(del).toHaveBeenCalledWith('/stage-layout?model=issue')
136
+ })
137
+
138
+ it('save re-throws on a {success:false} envelope', async () => {
139
+ const put = vi.fn(async () => ({ data: { success: false, message: 'nope' } }))
140
+ const api = fakeApi({ put })
141
+ const { result } = renderHook(() => useStageLayout('issue'), {
142
+ wrapper: wrapper(api),
143
+ })
144
+ await waitFor(() => expect(result.current.available).toBe(true))
145
+ await expect(result.current.save(['a', 'b'])).rejects.toThrow()
146
+ })
147
+ })
148
+
149
+ describe('DynamicKanban stage-layout gating', () => {
150
+ it('shows the reset-order affordance only when a custom order exists', async () => {
151
+ useMetadataCache.getState().setMetadata('issue', meta())
152
+ const api = fakeApi({}, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
153
+ render(
154
+ <ApiProvider client={api}>
155
+ <DynamicKanban model="issue" />
156
+ </ApiProvider>,
157
+ )
158
+ await screen.findByText('Backlog')
159
+ expect(
160
+ await screen.findByTestId('kanban-reset-order'),
161
+ ).toBeTruthy()
162
+ })
163
+
164
+ it('resets the order: DELETE + metadata refetch', async () => {
165
+ useMetadataCache.getState().setMetadata('issue', meta())
166
+ const del = vi.fn(async () => ({ data: { success: true, data: null } }))
167
+ const api = fakeApi({ delete: del }, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
168
+ render(
169
+ <ApiProvider client={api}>
170
+ <DynamicKanban model="issue" />
171
+ </ApiProvider>,
172
+ )
173
+ const btn = await screen.findByTestId('kanban-reset-order')
174
+ const metaCallsBefore = (api.get as any).mock.calls.filter((c: any[]) =>
175
+ String(c[0]).startsWith('/metadata/table/'),
176
+ ).length
177
+ fireEvent.click(btn)
178
+ await waitFor(() => expect(del).toHaveBeenCalledWith('/stage-layout?model=issue'))
179
+ // a fresh metadata GET followed the reset (falls back to declared order)
180
+ await waitFor(() => {
181
+ const after = (api.get as any).mock.calls.filter((c: any[]) =>
182
+ String(c[0]).startsWith('/metadata/table/'),
183
+ ).length
184
+ expect(after).toBeGreaterThan(metaCallsBefore)
185
+ })
186
+ })
187
+
188
+ it('a missing /stage-layout endpoint leaves the board intact (no reset, lanes + cards render)', async () => {
189
+ useMetadataCache.getState().setMetadata('issue', meta())
190
+ const api = fakeApi({
191
+ get: vi.fn(async (url: string) => {
192
+ if (url.startsWith('/metadata/table/')) return { data: { success: true, data: meta() } }
193
+ if (url.startsWith('/stage-layout')) throw new Error('404')
194
+ if (url.startsWith('/custom-stages')) return { data: { success: false } }
195
+ return { data: { success: true, data: CARDS } }
196
+ }),
197
+ })
198
+ render(
199
+ <ApiProvider client={api}>
200
+ <DynamicKanban model="issue" />
201
+ </ApiProvider>,
202
+ )
203
+ // board still paints every lane + a card
204
+ expect(await screen.findByText('Backlog')).toBeTruthy()
205
+ expect(screen.getByText('Done')).toBeTruthy()
206
+ expect(await screen.findByText('Fix login bug')).toBeTruthy()
207
+ // no reset affordance without a wired endpoint
208
+ expect(screen.queryByTestId('kanban-reset-order')).toBeNull()
209
+ })
210
+ })
@@ -218,6 +218,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
218
218
  const { t } = useTranslation()
219
219
  const api = useApi()
220
220
  const [executing, setExecuting] = useState(false)
221
+ // `action.label` is an addon-contributed i18n key; its locale bundle loads
222
+ // asynchronously, so translate at render (defaultValue keeps an already
223
+ // localized label unchanged).
224
+ const label = t(action.label, { defaultValue: action.label })
221
225
 
222
226
  const execute = async () => {
223
227
  setExecuting(true)
@@ -244,10 +248,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
244
248
  <AlertDialogHeader>
245
249
  <AlertDialogTitle className="flex items-center gap-2">
246
250
  <DynamicIcon name={action.icon} className="h-5 w-5" />
247
- {action.label}
251
+ {label}
248
252
  </AlertDialogTitle>
249
253
  <AlertDialogDescription>
250
- {action.confirmMessage || `${action.label}?`}
254
+ {action.confirmMessage || `${label}?`}
251
255
  </AlertDialogDescription>
252
256
  </AlertDialogHeader>
253
257
  <AlertDialogFooter>
@@ -258,7 +262,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
258
262
  style={action.color ? { backgroundColor: action.color } : undefined}
259
263
  >
260
264
  {executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
261
- {action.label}
265
+ {label}
262
266
  </AlertDialogAction>
263
267
  </AlertDialogFooter>
264
268
  </AlertDialogContent>
@@ -268,6 +272,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
268
272
 
269
273
  function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
270
274
  const { t } = useTranslation()
275
+ // Addon-contributed labels (action + fields) are i18n keys whose locale
276
+ // bundle loads asynchronously; translate at render so they don't render raw.
277
+ // defaultValue keeps an already-localized string unchanged.
278
+ const tl = (s: string) => t(s, { defaultValue: s })
271
279
  const api = useApi()
272
280
  const [formData, setFormData] = useState<Record<string, any>>({})
273
281
  const [executing, setExecuting] = useState(false)
@@ -326,13 +334,13 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
326
334
  if (isLineItemsField(field)) {
327
335
  const rows = formData[field.key]
328
336
  if (!Array.isArray(rows) || rows.length === 0) {
329
- toast.error(`${field.label} requiere al menos un renglón`)
337
+ toast.error(`${tl(field.label)} requiere al menos un renglón`)
330
338
  return
331
339
  }
332
340
  continue
333
341
  }
334
342
  if (!formData[field.key] && formData[field.key] !== false) {
335
- toast.error(`${field.label} es requerido`)
343
+ toast.error(`${tl(field.label)} es requerido`)
336
344
  return
337
345
  }
338
346
  }
@@ -389,7 +397,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
389
397
  <DialogHeader className="shrink-0">
390
398
  <DialogTitle className="flex items-center gap-2">
391
399
  <DynamicIcon name={action.icon} className="h-5 w-5" />
392
- {action.label}
400
+ {tl(action.label)}
393
401
  </DialogTitle>
394
402
  {action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
395
403
  </DialogHeader>
@@ -408,7 +416,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
408
416
  return (
409
417
  <FieldCell key={field.key} fullWidth={fullWidth}>
410
418
  <FieldLabel htmlFor={field.key} required={field.required}>
411
- {field.label}
419
+ {tl(field.label)}
412
420
  </FieldLabel>
413
421
  {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
414
422
  </FieldCell>
@@ -431,7 +439,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
431
439
  style={action.color ? { backgroundColor: action.color, color: 'white' } : undefined}
432
440
  >
433
441
  {executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
434
- {action.label}
442
+ {tl(action.label)}
435
443
  </Button>
436
444
  </DialogFooter>
437
445
  </DialogContent>
@@ -19,7 +19,7 @@
19
19
  // All UI text goes through t() with a Spanish defaultValue.
20
20
  import React, { useCallback, useEffect, useMemo, useState } from 'react'
21
21
  import { useTranslation } from 'react-i18next'
22
- import { Plus, Trash2, Pencil, MoreVertical, Filter, X } from 'lucide-react'
22
+ import { Plus, Trash2, Pencil, MoreVertical, Filter, GripVertical, X } from 'lucide-react'
23
23
  import { toast } from 'sonner'
24
24
  import {
25
25
  Badge,
@@ -987,6 +987,18 @@ export interface SmartLaneProps {
987
987
  refreshTrigger?: any
988
988
  onEdit: (stage: CustomStage) => void
989
989
  onDelete: (stage: CustomStage) => void
990
+ /**
991
+ * Optional drag-and-drop wiring (from the kanban's sortable wrapper) so a
992
+ * smart lane can be reordered by its header like a real stage. Absent → the
993
+ * lane is static.
994
+ */
995
+ dnd?: {
996
+ setNodeRef: (el: HTMLElement | null) => void
997
+ style?: React.CSSProperties
998
+ isDragging?: boolean
999
+ handleRef?: (el: HTMLElement | null) => void
1000
+ handleProps?: Record<string, any>
1001
+ }
990
1002
  }
991
1003
 
992
1004
  /**
@@ -1006,6 +1018,7 @@ export function SmartLane({
1006
1018
  refreshTrigger,
1007
1019
  onEdit,
1008
1020
  onDelete,
1021
+ dnd,
1009
1022
  }: SmartLaneProps) {
1010
1023
  const { t } = useTranslation()
1011
1024
  const api = useApi()
@@ -1054,12 +1067,26 @@ export function SmartLane({
1054
1067
 
1055
1068
  return (
1056
1069
  <div
1057
- className="flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
1070
+ ref={dnd?.setNodeRef}
1071
+ className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
1072
+ style={{ opacity: dnd?.isDragging ? 0.6 : 1, ...dnd?.style }}
1058
1073
  data-smart-stage={stage.key}
1059
1074
  data-testid={`smart-lane-${stage.key}`}
1060
1075
  >
1061
1076
  <div className="flex items-center justify-between gap-2 px-3 py-2.5">
1062
- <div className="flex min-w-0 items-center gap-2">
1077
+ <div
1078
+ ref={dnd ? dnd.handleRef : undefined}
1079
+ {...(dnd ? dnd.handleProps : {})}
1080
+ className={`flex min-w-0 items-center gap-1.5 ${
1081
+ dnd ? 'cursor-grab active:cursor-grabbing' : ''
1082
+ }`}
1083
+ >
1084
+ {dnd && (
1085
+ <GripVertical
1086
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70"
1087
+ aria-hidden
1088
+ />
1089
+ )}
1063
1090
  <Badge
1064
1091
  variant="outline"
1065
1092
  className="border-0 text-xs font-semibold"