@asteby/metacore-runtime-react 23.8.0 → 23.9.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 +33 -0
- package/dist/custom-stages.d.ts +96 -1
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +259 -24
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +163 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/stage-overrides.d.ts +25 -0
- package/dist/stage-overrides.d.ts.map +1 -0
- package/dist/stage-overrides.js +64 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban.test.tsx +75 -0
- package/src/__tests__/stage-overrides.test.tsx +293 -0
- package/src/custom-stages.tsx +716 -109
- package/src/dynamic-kanban.tsx +210 -20
- package/src/index.ts +13 -0
- package/src/stage-overrides.ts +95 -0
- package/src/types.ts +28 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
MoreHorizontal,
|
|
55
55
|
RotateCcw,
|
|
56
56
|
Search,
|
|
57
|
+
Settings2,
|
|
57
58
|
Tag,
|
|
58
59
|
ToggleLeft,
|
|
59
60
|
Type,
|
|
@@ -99,13 +100,18 @@ import {
|
|
|
99
100
|
splitCustomStages,
|
|
100
101
|
mergeLaneStages,
|
|
101
102
|
resolveSmartLanes,
|
|
103
|
+
cardMatchesStageFilters,
|
|
104
|
+
smartLaneParams,
|
|
102
105
|
AddStageColumn,
|
|
103
|
-
CustomStageLaneMenu,
|
|
104
106
|
CustomStageDialog,
|
|
105
107
|
CustomStageDeleteDialog,
|
|
108
|
+
StageConfigDialog,
|
|
106
109
|
SmartLane,
|
|
107
110
|
type CustomStage,
|
|
111
|
+
type CustomStageFilter,
|
|
112
|
+
type StageConfigTarget,
|
|
108
113
|
} from './custom-stages'
|
|
114
|
+
import { useStageOverrides } from './stage-overrides'
|
|
109
115
|
import { useDynamicFilters } from './use-dynamic-filters'
|
|
110
116
|
import {
|
|
111
117
|
FilterChipsRow,
|
|
@@ -489,10 +495,16 @@ export function DynamicKanban({
|
|
|
489
495
|
// the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
|
|
490
496
|
// and lane menus simply don't render.
|
|
491
497
|
const customStages = useCustomStages(model)
|
|
498
|
+
// Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
|
|
499
|
+
// to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
|
|
500
|
+
// hides on declared lanes (custom lanes keep it via /custom-stages).
|
|
501
|
+
const stageOverrides = useStageOverrides(model)
|
|
492
502
|
// Dialog state: create/edit a stage, and the delete confirmation.
|
|
493
503
|
const [stageDialogOpen, setStageDialogOpen] = useState(false)
|
|
494
504
|
const [editingStage, setEditingStage] = useState<CustomStage | null>(null)
|
|
495
505
|
const [deletingStage, setDeletingStage] = useState<CustomStage | null>(null)
|
|
506
|
+
// The gear (⚙) "Configurar etapa" dialog — one UI for declared + custom lanes.
|
|
507
|
+
const [configTarget, setConfigTarget] = useState<StageConfigTarget | null>(null)
|
|
496
508
|
const openCreateStage = useCallback(() => {
|
|
497
509
|
setEditingStage(null)
|
|
498
510
|
setStageDialogOpen(true)
|
|
@@ -628,6 +640,7 @@ export function DynamicKanban({
|
|
|
628
640
|
const r = (await api.get(endpoint || `/data/${model}`, {
|
|
629
641
|
params: {
|
|
630
642
|
...filterParams,
|
|
643
|
+
...smartLaneParams(stage.filters),
|
|
631
644
|
page: 1,
|
|
632
645
|
per_page: 1,
|
|
633
646
|
[`f_${gb}`]: stage.key,
|
|
@@ -661,6 +674,27 @@ export function DynamicKanban({
|
|
|
661
674
|
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
662
675
|
// filterParams (the stage scope wins over any global group_by filter).
|
|
663
676
|
const groupByKey = metadata?.group_by || ''
|
|
677
|
+
// Extra per-lane conditions (stage overrides) keyed by stage. A real lane
|
|
678
|
+
// that carries these queries its data — and counts its header — with the
|
|
679
|
+
// stage scope PLUS these filters (serialized like a smart lane's), so the
|
|
680
|
+
// top-up + eager-total requests below layer them on. Sourced from
|
|
681
|
+
// `metadata.stages` (the kernel applies declared + custom-real overrides).
|
|
682
|
+
const stageExtraFilters = useMemo(() => {
|
|
683
|
+
const m = new Map<string, CustomStageFilter[]>()
|
|
684
|
+
for (const s of metadata?.stages ?? []) {
|
|
685
|
+
if (s.filters && s.filters.length > 0) {
|
|
686
|
+
m.set(
|
|
687
|
+
s.key,
|
|
688
|
+
s.filters.map((f) => ({
|
|
689
|
+
field: f.field,
|
|
690
|
+
op: f.op as CustomStageFilter['op'],
|
|
691
|
+
value: f.value,
|
|
692
|
+
})),
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return m
|
|
697
|
+
}, [metadata?.stages])
|
|
664
698
|
const loadMoreLane = useCallback(
|
|
665
699
|
async (stageKey: string) => {
|
|
666
700
|
if (!metadata || !groupByKey) return
|
|
@@ -680,6 +714,7 @@ export function DynamicKanban({
|
|
|
680
714
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
681
715
|
params: {
|
|
682
716
|
...filterParams,
|
|
717
|
+
...smartLaneParams(stageExtraFilters.get(stageKey)),
|
|
683
718
|
page: nextPage,
|
|
684
719
|
per_page: lanePageSize,
|
|
685
720
|
[`f_${groupByKey}`]: stageKey,
|
|
@@ -712,7 +747,7 @@ export function DynamicKanban({
|
|
|
712
747
|
}))
|
|
713
748
|
}
|
|
714
749
|
},
|
|
715
|
-
[api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
|
|
750
|
+
[api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination, stageExtraFilters],
|
|
716
751
|
)
|
|
717
752
|
|
|
718
753
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
@@ -1133,14 +1168,82 @@ export function DynamicKanban({
|
|
|
1133
1168
|
stageKey === activeStage ||
|
|
1134
1169
|
isTransitionAllowed(transitions, activeStage, stageKey)
|
|
1135
1170
|
|
|
1171
|
+
// Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
|
|
1172
|
+
// right backend by kind: a custom real stage edits through /custom-stages, a
|
|
1173
|
+
// declared stage through /stage-overrides. Seeds the current label/color and
|
|
1174
|
+
// any extra conditions the lane already carries.
|
|
1175
|
+
const openConfigStage = (stage: StageMeta) => {
|
|
1176
|
+
const custom = customStages.available
|
|
1177
|
+
? customByKey.get(stage.key)
|
|
1178
|
+
: undefined
|
|
1179
|
+
const raw =
|
|
1180
|
+
stageExtraFilters.get(stage.key) ??
|
|
1181
|
+
(custom?.filters as CustomStageFilter[] | undefined) ??
|
|
1182
|
+
[]
|
|
1183
|
+
const filters: CustomStageFilter[] = raw.map((f) => ({
|
|
1184
|
+
field: f.field,
|
|
1185
|
+
op: f.op as CustomStageFilter['op'],
|
|
1186
|
+
value: f.value,
|
|
1187
|
+
}))
|
|
1188
|
+
if (custom) {
|
|
1189
|
+
setConfigTarget({
|
|
1190
|
+
kind: 'custom',
|
|
1191
|
+
stageKey: stage.key,
|
|
1192
|
+
id: custom.id,
|
|
1193
|
+
label: custom.label,
|
|
1194
|
+
color: custom.color,
|
|
1195
|
+
filters,
|
|
1196
|
+
customStage: custom,
|
|
1197
|
+
})
|
|
1198
|
+
} else {
|
|
1199
|
+
const original = stage.original
|
|
1200
|
+
? {
|
|
1201
|
+
label: stage.original.label,
|
|
1202
|
+
color: stage.original.color,
|
|
1203
|
+
filters: stage.original.filters?.map((f) => ({
|
|
1204
|
+
field: f.field,
|
|
1205
|
+
op: f.op as CustomStageFilter['op'],
|
|
1206
|
+
value: f.value,
|
|
1207
|
+
})),
|
|
1208
|
+
}
|
|
1209
|
+
: undefined
|
|
1210
|
+
setConfigTarget({
|
|
1211
|
+
kind: 'declared',
|
|
1212
|
+
stageKey: stage.key,
|
|
1213
|
+
label: t(stage.label, { defaultValue: stage.label }),
|
|
1214
|
+
color: stage.color ?? 'slate',
|
|
1215
|
+
filters,
|
|
1216
|
+
overridden: !!stage.overridden,
|
|
1217
|
+
isFinal: stage.is_final,
|
|
1218
|
+
original,
|
|
1219
|
+
})
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
// Whether a lane offers the gear: custom real stages always (their CRUD is
|
|
1223
|
+
// wired); declared lanes only when the host wired /stage-overrides. Never on
|
|
1224
|
+
// the synthetic "Sin etapa" lane.
|
|
1225
|
+
const isConfigurable = (stage: StageMeta): boolean => {
|
|
1226
|
+
if (stage.key === UNASSIGNED_LANE) return false
|
|
1227
|
+
return customByKey.has(stage.key)
|
|
1228
|
+
? customStages.available
|
|
1229
|
+
: stageOverrides.available
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1136
1232
|
// Builds every `KanbanLane` prop (minus the dnd wiring) for one stage —
|
|
1137
1233
|
// shared by the sortable real stages and the plain-droppable unassigned lane.
|
|
1138
1234
|
const buildLaneProps = (stage: StageMeta): Omit<KanbanLaneProps, 'dnd'> => {
|
|
1139
1235
|
const allCards = grouped.get(stage.key) ?? []
|
|
1236
|
+
// Extra lane conditions (stage override): the server already scopes this
|
|
1237
|
+
// lane's top-up + total queries by them, but the shared INITIAL board page
|
|
1238
|
+
// is unscoped, so narrow those cards client-side too (belt-and-suspenders).
|
|
1239
|
+
const extraFilters = stageExtraFilters.get(stage.key) ?? []
|
|
1140
1240
|
// Per-lane client-side narrowing (instant, scoped to this stage). The
|
|
1141
1241
|
// funnel (field/value) and the lane search (query) are AND-combined.
|
|
1142
1242
|
const laneFilter = laneFilters[stage.key]
|
|
1143
1243
|
let cards = allCards
|
|
1244
|
+
if (extraFilters.length > 0) {
|
|
1245
|
+
cards = cards.filter((c) => cardMatchesStageFilters(c, extraFilters))
|
|
1246
|
+
}
|
|
1144
1247
|
if (laneFilter?.field) {
|
|
1145
1248
|
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter))
|
|
1146
1249
|
}
|
|
@@ -1179,11 +1282,9 @@ export function DynamicKanban({
|
|
|
1179
1282
|
onAutomationCreate: automations.create,
|
|
1180
1283
|
onAutomationUpdate: automations.update,
|
|
1181
1284
|
onAutomationRemove: automations.remove,
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
onEditStage: openEditStage,
|
|
1186
|
-
onDeleteStage: openDeleteStage,
|
|
1285
|
+
extraFilters,
|
|
1286
|
+
configurable: isConfigurable(stage),
|
|
1287
|
+
onConfigure: () => openConfigStage(stage),
|
|
1187
1288
|
children:
|
|
1188
1289
|
loadingData && cards.length === 0 ? (
|
|
1189
1290
|
<>
|
|
@@ -1461,6 +1562,50 @@ export function DynamicKanban({
|
|
|
1461
1562
|
/>
|
|
1462
1563
|
</>
|
|
1463
1564
|
)}
|
|
1565
|
+
|
|
1566
|
+
{/* Unified "Configurar etapa" dialog opened by the per-lane ⚙ gear —
|
|
1567
|
+
routes saves to /stage-overrides (declared) or /custom-stages
|
|
1568
|
+
(custom) by the target's kind. */}
|
|
1569
|
+
<StageConfigDialog
|
|
1570
|
+
open={!!configTarget}
|
|
1571
|
+
onOpenChange={(o) => {
|
|
1572
|
+
if (!o) setConfigTarget(null)
|
|
1573
|
+
}}
|
|
1574
|
+
columns={metadata?.columns ?? []}
|
|
1575
|
+
target={configTarget}
|
|
1576
|
+
onSaveOverride={async (stageKey, patch) => {
|
|
1577
|
+
await stageOverrides.save(stageKey, patch)
|
|
1578
|
+
// The override changes the served label/color/filters — refetch
|
|
1579
|
+
// metadata so the board repaints, and reload the cards.
|
|
1580
|
+
try {
|
|
1581
|
+
const res = await api.get(`/metadata/table/${model}`)
|
|
1582
|
+
const body = res.data as ApiResponse<TableMetadata>
|
|
1583
|
+
if (body.success) {
|
|
1584
|
+
setMetadata(body.data)
|
|
1585
|
+
cacheMetadata(model, body.data)
|
|
1586
|
+
}
|
|
1587
|
+
} catch {
|
|
1588
|
+
/* keep the current metadata on a refetch miss */
|
|
1589
|
+
}
|
|
1590
|
+
void fetchData()
|
|
1591
|
+
}}
|
|
1592
|
+
onResetOverride={async (stageKey) => {
|
|
1593
|
+
await stageOverrides.reset(stageKey)
|
|
1594
|
+
try {
|
|
1595
|
+
const res = await api.get(`/metadata/table/${model}`)
|
|
1596
|
+
const body = res.data as ApiResponse<TableMetadata>
|
|
1597
|
+
if (body.success) {
|
|
1598
|
+
setMetadata(body.data)
|
|
1599
|
+
cacheMetadata(model, body.data)
|
|
1600
|
+
}
|
|
1601
|
+
} catch {
|
|
1602
|
+
/* keep the current metadata on a refetch miss */
|
|
1603
|
+
}
|
|
1604
|
+
void fetchData()
|
|
1605
|
+
}}
|
|
1606
|
+
onUpdateCustom={customStages.update}
|
|
1607
|
+
onDeleteCustom={openDeleteStage}
|
|
1608
|
+
/>
|
|
1464
1609
|
</div>
|
|
1465
1610
|
)
|
|
1466
1611
|
}
|
|
@@ -1716,10 +1861,12 @@ interface KanbanLaneProps {
|
|
|
1716
1861
|
patch: Partial<StageAutomation>,
|
|
1717
1862
|
) => Promise<void>
|
|
1718
1863
|
onAutomationRemove: (id: StageAutomation['id']) => Promise<void>
|
|
1719
|
-
/**
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1864
|
+
/** Extra lane conditions (stage override) — drives the header filter dot + tooltip. */
|
|
1865
|
+
extraFilters: CustomStageFilter[]
|
|
1866
|
+
/** Whether the ⚙ "Configurar etapa" gear should render for this lane. */
|
|
1867
|
+
configurable: boolean
|
|
1868
|
+
/** Opens the gear config dialog for this lane. */
|
|
1869
|
+
onConfigure: () => void
|
|
1723
1870
|
children: React.ReactNode
|
|
1724
1871
|
}
|
|
1725
1872
|
|
|
@@ -1745,9 +1892,9 @@ function KanbanLane({
|
|
|
1745
1892
|
onAutomationCreate,
|
|
1746
1893
|
onAutomationUpdate,
|
|
1747
1894
|
onAutomationRemove,
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1895
|
+
extraFilters,
|
|
1896
|
+
configurable,
|
|
1897
|
+
onConfigure,
|
|
1751
1898
|
children,
|
|
1752
1899
|
}: KanbanLaneProps) {
|
|
1753
1900
|
const { t } = useTranslation()
|
|
@@ -1792,6 +1939,25 @@ function KanbanLane({
|
|
|
1792
1939
|
}
|
|
1793
1940
|
: undefined
|
|
1794
1941
|
|
|
1942
|
+
// Stage-override conditions active on this lane → a small filter dot in the
|
|
1943
|
+
// header, its `title` listing the conditions (e.g. "priority es igual high").
|
|
1944
|
+
const hasConditions = extraFilters.length > 0
|
|
1945
|
+
const conditionsSummary = extraFilters
|
|
1946
|
+
.map((f) => {
|
|
1947
|
+
const opLabel = t(`dynamic.custom_stages.op.${f.op}`, {
|
|
1948
|
+
defaultValue:
|
|
1949
|
+
f.op === 'eq'
|
|
1950
|
+
? 'es igual'
|
|
1951
|
+
: f.op === 'neq'
|
|
1952
|
+
? 'distinto'
|
|
1953
|
+
: f.op === 'contains'
|
|
1954
|
+
? 'contiene'
|
|
1955
|
+
: 'en lista',
|
|
1956
|
+
})
|
|
1957
|
+
return `${f.field} ${opLabel} ${f.value}`
|
|
1958
|
+
})
|
|
1959
|
+
.join(' · ')
|
|
1960
|
+
|
|
1795
1961
|
return (
|
|
1796
1962
|
<div
|
|
1797
1963
|
ref={dnd.setNodeRef}
|
|
@@ -1836,6 +2002,22 @@ function KanbanLane({
|
|
|
1836
2002
|
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
1837
2003
|
{formatLaneCount(count, totalCount, serverTotal, laneActive)}
|
|
1838
2004
|
</span>
|
|
2005
|
+
{hasConditions && (
|
|
2006
|
+
<span
|
|
2007
|
+
className="flex items-center text-primary"
|
|
2008
|
+
title={t('dynamic.stage_config.conditions_active', {
|
|
2009
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
2010
|
+
list: conditionsSummary,
|
|
2011
|
+
})}
|
|
2012
|
+
data-testid={`lane-conditions-${stage.key}`}
|
|
2013
|
+
aria-label={t('dynamic.stage_config.conditions_active', {
|
|
2014
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
2015
|
+
list: conditionsSummary,
|
|
2016
|
+
})}
|
|
2017
|
+
>
|
|
2018
|
+
<ListFilter className="h-3 w-3" />
|
|
2019
|
+
</span>
|
|
2020
|
+
)}
|
|
1839
2021
|
</div>
|
|
1840
2022
|
{/* Lane actions — always visible in muted (a hidden hover-reveal
|
|
1841
2023
|
was undiscoverable); active state is a primary tint + a count
|
|
@@ -1873,12 +2055,20 @@ function KanbanLane({
|
|
|
1873
2055
|
onRemove={onAutomationRemove}
|
|
1874
2056
|
/>
|
|
1875
2057
|
)}
|
|
1876
|
-
{
|
|
1877
|
-
<
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2058
|
+
{configurable && (
|
|
2059
|
+
<button
|
|
2060
|
+
type="button"
|
|
2061
|
+
onClick={onConfigure}
|
|
2062
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
2063
|
+
hasConditions ? 'text-primary' : 'text-muted-foreground'
|
|
2064
|
+
}`}
|
|
2065
|
+
aria-label={t('dynamic.stage_config.open', {
|
|
2066
|
+
defaultValue: 'Configurar etapa',
|
|
2067
|
+
})}
|
|
2068
|
+
data-testid={`lane-config-${stage.key}`}
|
|
2069
|
+
>
|
|
2070
|
+
<Settings2 className="h-3.5 w-3.5" />
|
|
2071
|
+
</button>
|
|
1882
2072
|
)}
|
|
1883
2073
|
</div>
|
|
1884
2074
|
</div>
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export {
|
|
|
36
36
|
mergeLaneStages,
|
|
37
37
|
resolveSmartLanes,
|
|
38
38
|
smartLaneParams,
|
|
39
|
+
cardMatchesStageFilters,
|
|
39
40
|
customStageFilterFields,
|
|
40
41
|
isCustomStageDraftValid,
|
|
41
42
|
slugifyStageKey,
|
|
@@ -44,6 +45,9 @@ export {
|
|
|
44
45
|
CustomStageLaneMenu,
|
|
45
46
|
CustomStageDialog,
|
|
46
47
|
CustomStageDeleteDialog,
|
|
48
|
+
StageConditionBuilder,
|
|
49
|
+
StageConfigDialog,
|
|
50
|
+
stageFilterOpSymbol,
|
|
47
51
|
SmartLane,
|
|
48
52
|
CUSTOM_STAGE_COLORS,
|
|
49
53
|
CUSTOM_STAGE_FILTER_OPS,
|
|
@@ -53,12 +57,21 @@ export {
|
|
|
53
57
|
type CustomStageFilter,
|
|
54
58
|
type CustomStageFilterOp,
|
|
55
59
|
type UseCustomStagesResult,
|
|
60
|
+
type StageConfigTarget,
|
|
61
|
+
type StageConfigKind,
|
|
62
|
+
type StageConditionBuilderProps,
|
|
63
|
+
type StageConfigDialogProps,
|
|
56
64
|
} from './custom-stages'
|
|
57
65
|
export {
|
|
58
66
|
useStageLayout,
|
|
59
67
|
type StageLayout,
|
|
60
68
|
type UseStageLayoutResult,
|
|
61
69
|
} from './stage-layout'
|
|
70
|
+
export {
|
|
71
|
+
useStageOverrides,
|
|
72
|
+
type StageOverridePatch,
|
|
73
|
+
type UseStageOverridesResult,
|
|
74
|
+
} from './stage-overrides'
|
|
62
75
|
export {
|
|
63
76
|
DynamicView,
|
|
64
77
|
resolveViewRenderer,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Stage overrides — per-org customization of a model's DECLARED kanban lanes
|
|
2
|
+
// (Backlog, Done, …). A user can rename a lane, recolor it, and attach extra
|
|
3
|
+
// "conditions" (a field/operator/value builder) that narrow which cards the lane
|
|
4
|
+
// shows and counts — all without touching the addon manifest. The chosen values
|
|
5
|
+
// are stored server-side and the kernel serves each declared stage already
|
|
6
|
+
// carrying the overridden label/color (+ an `overridden` flag and the extra
|
|
7
|
+
// `filters`), so the board paints them straight from `metadata.stages`.
|
|
8
|
+
//
|
|
9
|
+
// Custom stages (`custom: true`) are NOT edited here — they keep their own CRUD
|
|
10
|
+
// (`/custom-stages`); this hook only owns the DECLARED-lane overrides.
|
|
11
|
+
//
|
|
12
|
+
// Non-intrusive by design: mirrors `useStageLayout`. If the host wires no
|
|
13
|
+
// `/stage-overrides` endpoint, the GET 404s, `available` stays false, and the
|
|
14
|
+
// per-lane gear (⚙) simply never renders on declared stages — the kanban keeps
|
|
15
|
+
// working untouched.
|
|
16
|
+
//
|
|
17
|
+
// Contract (matches the ops backend; envelope is {success, data} → read .data):
|
|
18
|
+
// GET /stage-overrides?model=<m> → [{ stage_key, label?, color?, filters? }] | null
|
|
19
|
+
// PUT /stage-overrides { model, stage_key, label?, color?, filters? } (upsert)
|
|
20
|
+
// DELETE /stage-overrides?model=<m>&stage_key=<k> → reset the lane to its declared default
|
|
21
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
22
|
+
import { useApi } from './api-context'
|
|
23
|
+
import type { CustomStageFilter } from './custom-stages'
|
|
24
|
+
|
|
25
|
+
/** Fields a stage override can carry. All optional — send only what changed. */
|
|
26
|
+
export interface StageOverridePatch {
|
|
27
|
+
label?: string
|
|
28
|
+
color?: string
|
|
29
|
+
/** Extra lane conditions (same shape/ops as smart-lane filters). */
|
|
30
|
+
filters?: CustomStageFilter[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseStageOverridesResult {
|
|
34
|
+
/** True only after a successful GET — gates the per-lane gear on declared stages. */
|
|
35
|
+
available: boolean
|
|
36
|
+
/** Upsert a declared lane's override (label/color/conditions). Throws on failure. */
|
|
37
|
+
save: (stageKey: string, patch: StageOverridePatch) => Promise<void>
|
|
38
|
+
/** Reset a declared lane to its manifest default (drops the stored override). */
|
|
39
|
+
reset: (stageKey: string) => Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Learns whether the host wired `/stage-overrides` (so the gear can appear on
|
|
44
|
+
* declared lanes) and exposes save/reset. A missing endpoint degrades to
|
|
45
|
+
* `available: false`; a real save/reset failure re-throws so the caller can
|
|
46
|
+
* surface an error + revert. The overridden VALUES themselves are read off
|
|
47
|
+
* `metadata.stages` (the kernel applies them), so this hook doesn't cache a list.
|
|
48
|
+
*/
|
|
49
|
+
export function useStageOverrides(model: string): UseStageOverridesResult {
|
|
50
|
+
const api = useApi()
|
|
51
|
+
const [available, setAvailable] = useState(false)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let cancelled = false
|
|
55
|
+
api
|
|
56
|
+
.get(`/stage-overrides?model=${encodeURIComponent(model)}`)
|
|
57
|
+
.then(() => {
|
|
58
|
+
if (!cancelled) setAvailable(true)
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {
|
|
61
|
+
// Endpoint absent or errored — leave the gear off on declared lanes.
|
|
62
|
+
if (!cancelled) setAvailable(false)
|
|
63
|
+
})
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true
|
|
66
|
+
}
|
|
67
|
+
}, [api, model])
|
|
68
|
+
|
|
69
|
+
const save = useCallback(
|
|
70
|
+
async (stageKey: string, patch: StageOverridePatch) => {
|
|
71
|
+
const res = (await api.put('/stage-overrides', {
|
|
72
|
+
model,
|
|
73
|
+
stage_key: stageKey,
|
|
74
|
+
...patch,
|
|
75
|
+
})) as { data?: { success?: boolean; message?: string } }
|
|
76
|
+
if (res?.data && res.data.success === false) {
|
|
77
|
+
throw new Error(res.data.message || 'stage_override_save_failed')
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[api, model],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const reset = useCallback(
|
|
84
|
+
async (stageKey: string) => {
|
|
85
|
+
await api.delete(
|
|
86
|
+
`/stage-overrides?model=${encodeURIComponent(
|
|
87
|
+
model,
|
|
88
|
+
)}&stage_key=${encodeURIComponent(stageKey)}`,
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
[api, model],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return { available, save, reset }
|
|
95
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -77,6 +77,34 @@ export interface StageMeta {
|
|
|
77
77
|
* Editar/Eliminar menu on it.
|
|
78
78
|
*/
|
|
79
79
|
custom?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* True when a per-org stage override (label/color/conditions) has been
|
|
82
|
+
* applied to this DECLARED lane (ops stage-overrides). The kernel serves the
|
|
83
|
+
* lane already carrying the overridden label/color; this flag only drives the
|
|
84
|
+
* "Restablecer etapa" affordance in the config dialog. Absent on hosts without
|
|
85
|
+
* stage overrides — purely additive.
|
|
86
|
+
*/
|
|
87
|
+
overridden?: boolean
|
|
88
|
+
/**
|
|
89
|
+
* Extra per-lane conditions layered on top of the stage's own `group_by`
|
|
90
|
+
* scope (ops stage-overrides). When present the lane queries its data — and
|
|
91
|
+
* counts its header — with the stage filter PLUS these conditions (serialized
|
|
92
|
+
* the same way as smart-lane filters). The lane stays a normal drop target;
|
|
93
|
+
* dropping a card only sets the stage value. Absent → the lane behaves as a
|
|
94
|
+
* plain declared stage. Snake_case ops as the kernel serves them.
|
|
95
|
+
*/
|
|
96
|
+
filters?: { field: string; op: string; value: string }[]
|
|
97
|
+
/**
|
|
98
|
+
* The manifest ORIGINAL (pre-override) label/color/conditions, served
|
|
99
|
+
* alongside an overridden declared lane so the "Restablecer al original"
|
|
100
|
+
* confirm can spell out exactly what reverts. Optional — hosts that don't
|
|
101
|
+
* snapshot the original simply omit it and the SDK shows a generic confirm.
|
|
102
|
+
*/
|
|
103
|
+
original?: {
|
|
104
|
+
label?: string
|
|
105
|
+
color?: string
|
|
106
|
+
filters?: { field: string; op: string; value: string }[]
|
|
107
|
+
}
|
|
80
108
|
}
|
|
81
109
|
|
|
82
110
|
/**
|