@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.
- package/CHANGELOG.md +21 -0
- package/dist/action-modal-dispatcher.js +14 -6
- package/dist/custom-stages.d.ts +13 -1
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +3 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +259 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +3 -1
- package/dist/stage-layout.d.ts +23 -0
- package/dist/stage-layout.d.ts.map +1 -0
- package/dist/stage-layout.js +70 -0
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +228 -0
- package/src/__tests__/model-action-toolbar-i18n.test.tsx +101 -0
- package/src/__tests__/stage-layout.test.tsx +210 -0
- package/src/action-modal-dispatcher.tsx +16 -8
- package/src/custom-stages.tsx +30 -3
- package/src/dynamic-kanban.tsx +474 -144
- package/src/index.ts +5 -0
- package/src/model-action-toolbar.tsx +9 -1
- package/src/stage-layout.ts +93 -0
|
@@ -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
|
-
{
|
|
251
|
+
{label}
|
|
248
252
|
</AlertDialogTitle>
|
|
249
253
|
<AlertDialogDescription>
|
|
250
|
-
{action.confirmMessage || `${
|
|
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
|
-
{
|
|
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>
|
package/src/custom-stages.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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"
|