@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.
@@ -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
- customStage: customStages.available
1183
- ? customByKey.get(stage.key)
1184
- : undefined,
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
- /** When set, this lane is a user-defined custom stage show Editar/Eliminar. */
1720
- customStage?: CustomStage
1721
- onEditStage?: (stage: CustomStage) => void
1722
- onDeleteStage?: (stage: CustomStage) => void
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
- customStage,
1749
- onEditStage,
1750
- onDeleteStage,
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
- {customStage && onEditStage && onDeleteStage && (
1877
- <CustomStageLaneMenu
1878
- stage={customStage}
1879
- onEdit={onEditStage}
1880
- onDelete={onDeleteStage}
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
  /**