@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/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, GripVertical, X } from 'lucide-react'
|
|
22
|
+
import { Plus, Trash2, Pencil, MoreVertical, Filter, Flag, GripVertical, Lock, RotateCcw, X } from 'lucide-react'
|
|
23
23
|
import { toast } from 'sonner'
|
|
24
24
|
import {
|
|
25
25
|
Badge,
|
|
@@ -151,6 +151,8 @@ export function mergeLaneStages(
|
|
|
151
151
|
label: cs.label,
|
|
152
152
|
color: cs.color,
|
|
153
153
|
order: cs.position,
|
|
154
|
+
custom: true,
|
|
155
|
+
filters: cs.filters,
|
|
154
156
|
})
|
|
155
157
|
}
|
|
156
158
|
lanes.sort((a, b) => {
|
|
@@ -173,7 +175,7 @@ export function mergeLaneStages(
|
|
|
173
175
|
* Empty fields/values are skipped.
|
|
174
176
|
*/
|
|
175
177
|
export function smartLaneParams(
|
|
176
|
-
filters:
|
|
178
|
+
filters: { field: string; op: string; value: string }[] | undefined,
|
|
177
179
|
): Record<string, string> {
|
|
178
180
|
const params: Record<string, string> = {}
|
|
179
181
|
for (const f of filters ?? []) {
|
|
@@ -254,6 +256,43 @@ export function isCustomStageDraftValid(draft: {
|
|
|
254
256
|
return true
|
|
255
257
|
}
|
|
256
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Whether a card passes a set of extra lane `filters` (the same conditions a
|
|
261
|
+
* smart lane uses, layered on top of a real stage). Ops:
|
|
262
|
+
* - eq → equal (string compare)
|
|
263
|
+
* - neq → not equal
|
|
264
|
+
* - contains → the card's value (array or string) includes the value
|
|
265
|
+
* - in → the card's value is one of the comma-separated candidates
|
|
266
|
+
* Empty/absent filters pass. Pure — a client-side belt-and-suspenders over the
|
|
267
|
+
* initial (unscoped) board page; the server scopes the per-lane top-up queries.
|
|
268
|
+
*/
|
|
269
|
+
export function cardMatchesStageFilters(
|
|
270
|
+
card: any,
|
|
271
|
+
filters: CustomStageFilter[] | undefined,
|
|
272
|
+
): boolean {
|
|
273
|
+
if (!filters || filters.length === 0) return true
|
|
274
|
+
return filters.every((f) => {
|
|
275
|
+
if (!f.field || String(f.value ?? '').trim() === '') return true
|
|
276
|
+
const target = String(f.value).trim()
|
|
277
|
+
const raw = card?.[f.field]
|
|
278
|
+
switch (f.op) {
|
|
279
|
+
case 'neq':
|
|
280
|
+
return String(raw ?? '') !== target
|
|
281
|
+
case 'contains':
|
|
282
|
+
if (Array.isArray(raw)) return raw.map(String).includes(target)
|
|
283
|
+
return String(raw ?? '').includes(target)
|
|
284
|
+
case 'in':
|
|
285
|
+
return target
|
|
286
|
+
.split(',')
|
|
287
|
+
.map((s) => s.trim())
|
|
288
|
+
.includes(String(raw ?? ''))
|
|
289
|
+
case 'eq':
|
|
290
|
+
default:
|
|
291
|
+
return String(raw ?? '') === target
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
257
296
|
/** Slugify a label into a stable-ish lane key (used only when creating). */
|
|
258
297
|
export function slugifyStageKey(label: string): string {
|
|
259
298
|
return (
|
|
@@ -463,6 +502,141 @@ export function CustomStageLaneMenu({
|
|
|
463
502
|
)
|
|
464
503
|
}
|
|
465
504
|
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Condition builder (field / operator / value rows) — shared by the smart-lane
|
|
507
|
+
// editor and the declared-stage config dialog.
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
export interface StageConditionBuilderProps {
|
|
511
|
+
/** The current filter rows. */
|
|
512
|
+
filters: CustomStageFilter[]
|
|
513
|
+
/** Called with the next filter rows on any add/remove/patch. */
|
|
514
|
+
onChange: (filters: CustomStageFilter[]) => void
|
|
515
|
+
/** Columns offered in the field dropdown (visible, non-id). */
|
|
516
|
+
fieldChoices: ColumnDefinition[]
|
|
517
|
+
/** Optional heading shown above the rows. */
|
|
518
|
+
label?: string
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The reusable field/operator/value condition builder. Emits `CustomStageFilter[]`
|
|
523
|
+
* through `onChange`. Same ops (eq/neq/contains/in) and testids the smart-lane
|
|
524
|
+
* editor has always used, so it drops in for both the custom-stage smart lane
|
|
525
|
+
* and the declared-stage config dialog.
|
|
526
|
+
*/
|
|
527
|
+
export function StageConditionBuilder({
|
|
528
|
+
filters,
|
|
529
|
+
onChange,
|
|
530
|
+
fieldChoices,
|
|
531
|
+
label,
|
|
532
|
+
}: StageConditionBuilderProps) {
|
|
533
|
+
const { t } = useTranslation()
|
|
534
|
+
const patchFilter = (i: number, patch: Partial<CustomStageFilter>) =>
|
|
535
|
+
onChange(filters.map((f, idx) => (idx === i ? { ...f, ...patch } : f)))
|
|
536
|
+
const addFilter = () =>
|
|
537
|
+
onChange([...filters, emptyCustomStageFilter(fieldChoices[0]?.key ?? '')])
|
|
538
|
+
const removeFilter = (i: number) =>
|
|
539
|
+
onChange(filters.filter((_, idx) => idx !== i))
|
|
540
|
+
return (
|
|
541
|
+
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
|
|
542
|
+
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
543
|
+
<Filter className="h-3.5 w-3.5" />
|
|
544
|
+
{label ??
|
|
545
|
+
t('dynamic.custom_stages.conditions_label', {
|
|
546
|
+
defaultValue: 'Condiciones',
|
|
547
|
+
})}
|
|
548
|
+
</div>
|
|
549
|
+
{filters.map((f, i) => (
|
|
550
|
+
<div
|
|
551
|
+
key={i}
|
|
552
|
+
className="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-1.5"
|
|
553
|
+
data-testid={`custom-stage-condition-${i}`}
|
|
554
|
+
>
|
|
555
|
+
<Select
|
|
556
|
+
value={f.field}
|
|
557
|
+
onValueChange={(v) => patchFilter(i, { field: v })}
|
|
558
|
+
>
|
|
559
|
+
<SelectTrigger className="h-8 text-xs" data-testid={`custom-stage-condition-field-${i}`}>
|
|
560
|
+
<SelectValue
|
|
561
|
+
placeholder={t(
|
|
562
|
+
'dynamic.custom_stages.field_placeholder',
|
|
563
|
+
{ defaultValue: 'Campo' },
|
|
564
|
+
)}
|
|
565
|
+
/>
|
|
566
|
+
</SelectTrigger>
|
|
567
|
+
<SelectContent>
|
|
568
|
+
{fieldChoices.map((c) => (
|
|
569
|
+
<SelectItem key={c.key} value={c.key} className="text-xs">
|
|
570
|
+
{t(c.label, { defaultValue: c.label })}
|
|
571
|
+
</SelectItem>
|
|
572
|
+
))}
|
|
573
|
+
</SelectContent>
|
|
574
|
+
</Select>
|
|
575
|
+
<Select
|
|
576
|
+
value={f.op}
|
|
577
|
+
onValueChange={(v) =>
|
|
578
|
+
patchFilter(i, { op: v as CustomStageFilterOp })
|
|
579
|
+
}
|
|
580
|
+
>
|
|
581
|
+
<SelectTrigger className="h-8 w-[104px] text-xs" data-testid={`custom-stage-condition-op-${i}`}>
|
|
582
|
+
<SelectValue />
|
|
583
|
+
</SelectTrigger>
|
|
584
|
+
<SelectContent>
|
|
585
|
+
{CUSTOM_STAGE_FILTER_OPS.map((op) => (
|
|
586
|
+
<SelectItem key={op} value={op} className="text-xs">
|
|
587
|
+
{t(`dynamic.custom_stages.op.${op}`, {
|
|
588
|
+
defaultValue:
|
|
589
|
+
op === 'eq'
|
|
590
|
+
? 'es igual'
|
|
591
|
+
: op === 'neq'
|
|
592
|
+
? 'distinto'
|
|
593
|
+
: op === 'contains'
|
|
594
|
+
? 'contiene'
|
|
595
|
+
: 'en lista',
|
|
596
|
+
})}
|
|
597
|
+
</SelectItem>
|
|
598
|
+
))}
|
|
599
|
+
</SelectContent>
|
|
600
|
+
</Select>
|
|
601
|
+
<Input
|
|
602
|
+
value={f.value}
|
|
603
|
+
onChange={(e) => patchFilter(i, { value: e.target.value })}
|
|
604
|
+
placeholder={t('dynamic.custom_stages.value_placeholder', {
|
|
605
|
+
defaultValue: 'Valor',
|
|
606
|
+
})}
|
|
607
|
+
className="h-8 text-xs"
|
|
608
|
+
data-testid={`custom-stage-condition-value-${i}`}
|
|
609
|
+
/>
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
onClick={() => removeFilter(i)}
|
|
613
|
+
disabled={filters.length === 1}
|
|
614
|
+
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive disabled:opacity-40"
|
|
615
|
+
aria-label={t('dynamic.custom_stages.remove_condition', {
|
|
616
|
+
defaultValue: 'Quitar condición',
|
|
617
|
+
})}
|
|
618
|
+
data-testid={`custom-stage-condition-remove-${i}`}
|
|
619
|
+
>
|
|
620
|
+
<X className="h-3.5 w-3.5" />
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
))}
|
|
624
|
+
<Button
|
|
625
|
+
variant="ghost"
|
|
626
|
+
size="sm"
|
|
627
|
+
className="h-7 justify-start gap-1 text-xs"
|
|
628
|
+
onClick={addFilter}
|
|
629
|
+
data-testid="custom-stage-add-condition"
|
|
630
|
+
>
|
|
631
|
+
<Plus className="h-3.5 w-3.5" />
|
|
632
|
+
{t('dynamic.custom_stages.add_condition', {
|
|
633
|
+
defaultValue: 'Agregar condición',
|
|
634
|
+
})}
|
|
635
|
+
</Button>
|
|
636
|
+
</div>
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
|
|
466
640
|
// ---------------------------------------------------------------------------
|
|
467
641
|
// Create / edit dialog
|
|
468
642
|
// ---------------------------------------------------------------------------
|
|
@@ -526,18 +700,6 @@ export function CustomStageDialog({
|
|
|
526
700
|
}
|
|
527
701
|
}, [open, initial, fieldChoices])
|
|
528
702
|
|
|
529
|
-
const patchFilter = (i: number, patch: Partial<CustomStageFilter>) =>
|
|
530
|
-
setFilters((prev) =>
|
|
531
|
-
prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f)),
|
|
532
|
-
)
|
|
533
|
-
const addFilter = () =>
|
|
534
|
-
setFilters((prev) => [
|
|
535
|
-
...prev,
|
|
536
|
-
emptyCustomStageFilter(fieldChoices[0]?.key ?? ''),
|
|
537
|
-
])
|
|
538
|
-
const removeFilter = (i: number) =>
|
|
539
|
-
setFilters((prev) => prev.filter((_, idx) => idx !== i))
|
|
540
|
-
|
|
541
703
|
const valid = isCustomStageDraftValid({ label, type, filters })
|
|
542
704
|
|
|
543
705
|
const submit = async () => {
|
|
@@ -697,101 +859,11 @@ export function CustomStageDialog({
|
|
|
697
859
|
|
|
698
860
|
{/* Condition builder — smart lanes only */}
|
|
699
861
|
{type === 'smart' && (
|
|
700
|
-
<
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
})}
|
|
706
|
-
</div>
|
|
707
|
-
{filters.map((f, i) => (
|
|
708
|
-
<div
|
|
709
|
-
key={i}
|
|
710
|
-
className="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-1.5"
|
|
711
|
-
data-testid={`custom-stage-condition-${i}`}
|
|
712
|
-
>
|
|
713
|
-
<Select
|
|
714
|
-
value={f.field}
|
|
715
|
-
onValueChange={(v) => patchFilter(i, { field: v })}
|
|
716
|
-
>
|
|
717
|
-
<SelectTrigger className="h-8 text-xs" data-testid={`custom-stage-condition-field-${i}`}>
|
|
718
|
-
<SelectValue
|
|
719
|
-
placeholder={t(
|
|
720
|
-
'dynamic.custom_stages.field_placeholder',
|
|
721
|
-
{ defaultValue: 'Campo' },
|
|
722
|
-
)}
|
|
723
|
-
/>
|
|
724
|
-
</SelectTrigger>
|
|
725
|
-
<SelectContent>
|
|
726
|
-
{fieldChoices.map((c) => (
|
|
727
|
-
<SelectItem key={c.key} value={c.key} className="text-xs">
|
|
728
|
-
{t(c.label, { defaultValue: c.label })}
|
|
729
|
-
</SelectItem>
|
|
730
|
-
))}
|
|
731
|
-
</SelectContent>
|
|
732
|
-
</Select>
|
|
733
|
-
<Select
|
|
734
|
-
value={f.op}
|
|
735
|
-
onValueChange={(v) =>
|
|
736
|
-
patchFilter(i, { op: v as CustomStageFilterOp })
|
|
737
|
-
}
|
|
738
|
-
>
|
|
739
|
-
<SelectTrigger className="h-8 w-[104px] text-xs" data-testid={`custom-stage-condition-op-${i}`}>
|
|
740
|
-
<SelectValue />
|
|
741
|
-
</SelectTrigger>
|
|
742
|
-
<SelectContent>
|
|
743
|
-
{CUSTOM_STAGE_FILTER_OPS.map((op) => (
|
|
744
|
-
<SelectItem key={op} value={op} className="text-xs">
|
|
745
|
-
{t(`dynamic.custom_stages.op.${op}`, {
|
|
746
|
-
defaultValue:
|
|
747
|
-
op === 'eq'
|
|
748
|
-
? 'es igual'
|
|
749
|
-
: op === 'neq'
|
|
750
|
-
? 'distinto'
|
|
751
|
-
: op === 'contains'
|
|
752
|
-
? 'contiene'
|
|
753
|
-
: 'en lista',
|
|
754
|
-
})}
|
|
755
|
-
</SelectItem>
|
|
756
|
-
))}
|
|
757
|
-
</SelectContent>
|
|
758
|
-
</Select>
|
|
759
|
-
<Input
|
|
760
|
-
value={f.value}
|
|
761
|
-
onChange={(e) => patchFilter(i, { value: e.target.value })}
|
|
762
|
-
placeholder={t('dynamic.custom_stages.value_placeholder', {
|
|
763
|
-
defaultValue: 'Valor',
|
|
764
|
-
})}
|
|
765
|
-
className="h-8 text-xs"
|
|
766
|
-
data-testid={`custom-stage-condition-value-${i}`}
|
|
767
|
-
/>
|
|
768
|
-
<button
|
|
769
|
-
type="button"
|
|
770
|
-
onClick={() => removeFilter(i)}
|
|
771
|
-
disabled={filters.length === 1}
|
|
772
|
-
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive disabled:opacity-40"
|
|
773
|
-
aria-label={t('dynamic.custom_stages.remove_condition', {
|
|
774
|
-
defaultValue: 'Quitar condición',
|
|
775
|
-
})}
|
|
776
|
-
data-testid={`custom-stage-condition-remove-${i}`}
|
|
777
|
-
>
|
|
778
|
-
<X className="h-3.5 w-3.5" />
|
|
779
|
-
</button>
|
|
780
|
-
</div>
|
|
781
|
-
))}
|
|
782
|
-
<Button
|
|
783
|
-
variant="ghost"
|
|
784
|
-
size="sm"
|
|
785
|
-
className="h-7 justify-start gap-1 text-xs"
|
|
786
|
-
onClick={addFilter}
|
|
787
|
-
data-testid="custom-stage-add-condition"
|
|
788
|
-
>
|
|
789
|
-
<Plus className="h-3.5 w-3.5" />
|
|
790
|
-
{t('dynamic.custom_stages.add_condition', {
|
|
791
|
-
defaultValue: 'Agregar condición',
|
|
792
|
-
})}
|
|
793
|
-
</Button>
|
|
794
|
-
</div>
|
|
862
|
+
<StageConditionBuilder
|
|
863
|
+
filters={filters}
|
|
864
|
+
onChange={setFilters}
|
|
865
|
+
fieldChoices={fieldChoices}
|
|
866
|
+
/>
|
|
795
867
|
)}
|
|
796
868
|
</div>
|
|
797
869
|
|
|
@@ -968,6 +1040,541 @@ export function CustomStageDeleteDialog({
|
|
|
968
1040
|
)
|
|
969
1041
|
}
|
|
970
1042
|
|
|
1043
|
+
// ---------------------------------------------------------------------------
|
|
1044
|
+
// Stage config dialog (the per-lane gear ⚙) — unifies DECLARED-lane overrides
|
|
1045
|
+
// and CUSTOM real-stage editing behind one "Configurar etapa" UI.
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
|
|
1048
|
+
export type StageConfigKind = 'declared' | 'custom'
|
|
1049
|
+
|
|
1050
|
+
/** What the gear opens the config dialog against — a declared or custom lane. */
|
|
1051
|
+
export interface StageConfigTarget {
|
|
1052
|
+
kind: StageConfigKind
|
|
1053
|
+
/** Stable lane key. */
|
|
1054
|
+
stageKey: string
|
|
1055
|
+
/** Custom-stage id — required (and only used) when `kind === 'custom'`. */
|
|
1056
|
+
id?: CustomStage['id']
|
|
1057
|
+
label: string
|
|
1058
|
+
color: string
|
|
1059
|
+
filters: CustomStageFilter[]
|
|
1060
|
+
/**
|
|
1061
|
+
* Declared kind: an override is currently applied → shows a "Personalizada"
|
|
1062
|
+
* badge + "Restablecer al original". Ignored for custom stages (they reset
|
|
1063
|
+
* via their own delete flow).
|
|
1064
|
+
*/
|
|
1065
|
+
overridden?: boolean
|
|
1066
|
+
/** Terminal stage (e.g. "Done") — surfaces an "Etapa final" chip + tooltip. */
|
|
1067
|
+
isFinal?: boolean
|
|
1068
|
+
/**
|
|
1069
|
+
* The manifest ORIGINAL (pre-override) values, when the host serves them
|
|
1070
|
+
* (`metadata.stages[].original`). Drives the "Restablecer al original" confirm
|
|
1071
|
+
* so the user sees exactly what reverts. Absent → a generic confirm.
|
|
1072
|
+
*/
|
|
1073
|
+
original?: {
|
|
1074
|
+
label?: string
|
|
1075
|
+
color?: string
|
|
1076
|
+
filters?: CustomStageFilter[]
|
|
1077
|
+
}
|
|
1078
|
+
/** The backing CustomStage (custom kind) so "Eliminar" can trigger delete. */
|
|
1079
|
+
customStage?: CustomStage
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** A legible operator glyph for a condition chip: = / ≠ / contiene / en. */
|
|
1083
|
+
export function stageFilterOpSymbol(op: string): string {
|
|
1084
|
+
switch (op) {
|
|
1085
|
+
case 'neq':
|
|
1086
|
+
return '≠'
|
|
1087
|
+
case 'contains':
|
|
1088
|
+
return 'contiene'
|
|
1089
|
+
case 'in':
|
|
1090
|
+
return 'en'
|
|
1091
|
+
case 'eq':
|
|
1092
|
+
default:
|
|
1093
|
+
return '='
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export interface StageConfigDialogProps {
|
|
1098
|
+
open: boolean
|
|
1099
|
+
onOpenChange: (open: boolean) => void
|
|
1100
|
+
/** Columns for the condition builder. */
|
|
1101
|
+
columns: ColumnDefinition[]
|
|
1102
|
+
/** The lane being configured, or null. */
|
|
1103
|
+
target: StageConfigTarget | null
|
|
1104
|
+
/** Declared: upsert the lane override (PUT /stage-overrides). */
|
|
1105
|
+
onSaveOverride: (
|
|
1106
|
+
stageKey: string,
|
|
1107
|
+
patch: { label: string; color: string; filters: CustomStageFilter[] },
|
|
1108
|
+
) => Promise<void>
|
|
1109
|
+
/** Declared: reset the lane to its manifest default (DELETE /stage-overrides). */
|
|
1110
|
+
onResetOverride: (stageKey: string) => Promise<void>
|
|
1111
|
+
/** Custom: update the stage via its own CRUD (PUT /custom-stages/:id). */
|
|
1112
|
+
onUpdateCustom: (
|
|
1113
|
+
id: CustomStage['id'],
|
|
1114
|
+
patch: Partial<CustomStage>,
|
|
1115
|
+
) => Promise<void>
|
|
1116
|
+
/** Custom: hand off to the existing delete (reassign) flow. */
|
|
1117
|
+
onDeleteCustom: (stage: CustomStage) => void
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The gear (⚙) dialog. Renames, recolors and attaches extra CONDITIONS to a
|
|
1122
|
+
* lane. One UI, two backends: a DECLARED lane persists through `/stage-overrides`
|
|
1123
|
+
* (with a "Restablecer etapa" that drops the override); a CUSTOM real stage
|
|
1124
|
+
* persists through its own `/custom-stages` CRUD (and deletes via that flow). The
|
|
1125
|
+
* conditions narrow which cards the lane shows/counts but never stop it being a
|
|
1126
|
+
* drop target — dropping a card only sets the stage value.
|
|
1127
|
+
*/
|
|
1128
|
+
export function StageConfigDialog({
|
|
1129
|
+
open,
|
|
1130
|
+
onOpenChange,
|
|
1131
|
+
columns,
|
|
1132
|
+
target,
|
|
1133
|
+
onSaveOverride,
|
|
1134
|
+
onResetOverride,
|
|
1135
|
+
onUpdateCustom,
|
|
1136
|
+
onDeleteCustom,
|
|
1137
|
+
}: StageConfigDialogProps) {
|
|
1138
|
+
const { t } = useTranslation()
|
|
1139
|
+
const isDark =
|
|
1140
|
+
typeof document !== 'undefined' &&
|
|
1141
|
+
document.documentElement.classList.contains('dark')
|
|
1142
|
+
const fieldChoices = useMemo(
|
|
1143
|
+
() => customStageFilterFields(columns),
|
|
1144
|
+
[columns],
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
const [label, setLabel] = useState('')
|
|
1148
|
+
const [color, setColor] = useState<string>(CUSTOM_STAGE_COLORS[0])
|
|
1149
|
+
const [filters, setFilters] = useState<CustomStageFilter[]>([])
|
|
1150
|
+
const [saving, setSaving] = useState(false)
|
|
1151
|
+
const [resetting, setResetting] = useState(false)
|
|
1152
|
+
// Two-step reset: the first click reveals a confirm panel listing exactly
|
|
1153
|
+
// what reverts (the manifest original), the second actually drops the override.
|
|
1154
|
+
const [confirmingReset, setConfirmingReset] = useState(false)
|
|
1155
|
+
|
|
1156
|
+
// Re-seed the WHOLE form from the lane's current (already-override-applied)
|
|
1157
|
+
// metadata each time the dialog opens — the user always sees the live state,
|
|
1158
|
+
// never a blank form. Label, color and every extra condition pre-fill editable.
|
|
1159
|
+
useEffect(() => {
|
|
1160
|
+
if (!open || !target) return
|
|
1161
|
+
setLabel(target.label)
|
|
1162
|
+
setColor(target.color || CUSTOM_STAGE_COLORS[0])
|
|
1163
|
+
setFilters(target.filters?.map((f) => ({ ...f })) ?? [])
|
|
1164
|
+
setConfirmingReset(false)
|
|
1165
|
+
}, [open, target])
|
|
1166
|
+
|
|
1167
|
+
if (!target) return null
|
|
1168
|
+
|
|
1169
|
+
const valid = label.trim().length > 0
|
|
1170
|
+
// Only complete conditions are persisted; empty rows are dropped.
|
|
1171
|
+
const cleanFilters = () =>
|
|
1172
|
+
filters.filter((f) => f.field && String(f.value).trim() !== '')
|
|
1173
|
+
// The conditions that make up the lane's EFFECTIVE query, for the chip row:
|
|
1174
|
+
// the immovable stage scope first, then each extra (removable) condition.
|
|
1175
|
+
const chipFilters = filters.filter(
|
|
1176
|
+
(f) => f.field && String(f.value).trim() !== '',
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
const removeFilterAt = (i: number) =>
|
|
1180
|
+
setFilters((prev) => prev.filter((_, idx) => idx !== i))
|
|
1181
|
+
|
|
1182
|
+
const submit = async () => {
|
|
1183
|
+
if (!valid || saving) return
|
|
1184
|
+
setSaving(true)
|
|
1185
|
+
try {
|
|
1186
|
+
if (target.kind === 'declared') {
|
|
1187
|
+
await onSaveOverride(target.stageKey, {
|
|
1188
|
+
label: label.trim(),
|
|
1189
|
+
color,
|
|
1190
|
+
filters: cleanFilters(),
|
|
1191
|
+
})
|
|
1192
|
+
} else if (target.id != null) {
|
|
1193
|
+
await onUpdateCustom(target.id, {
|
|
1194
|
+
label: label.trim(),
|
|
1195
|
+
color,
|
|
1196
|
+
filters: cleanFilters(),
|
|
1197
|
+
})
|
|
1198
|
+
}
|
|
1199
|
+
toast.success(
|
|
1200
|
+
t('dynamic.stage_config.saved', {
|
|
1201
|
+
defaultValue: 'Etapa configurada',
|
|
1202
|
+
}),
|
|
1203
|
+
)
|
|
1204
|
+
onOpenChange(false)
|
|
1205
|
+
} catch {
|
|
1206
|
+
// toast already surfaced by the caller's hook; keep the draft open.
|
|
1207
|
+
} finally {
|
|
1208
|
+
setSaving(false)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const reset = async () => {
|
|
1213
|
+
if (resetting) return
|
|
1214
|
+
setResetting(true)
|
|
1215
|
+
try {
|
|
1216
|
+
await onResetOverride(target.stageKey)
|
|
1217
|
+
toast.success(
|
|
1218
|
+
t('dynamic.stage_config.reset_done', {
|
|
1219
|
+
defaultValue: 'Etapa restablecida',
|
|
1220
|
+
}),
|
|
1221
|
+
)
|
|
1222
|
+
onOpenChange(false)
|
|
1223
|
+
} catch {
|
|
1224
|
+
toast.error(
|
|
1225
|
+
t('dynamic.stage_config.reset_error', {
|
|
1226
|
+
defaultValue: 'No se pudo restablecer la etapa',
|
|
1227
|
+
}),
|
|
1228
|
+
)
|
|
1229
|
+
} finally {
|
|
1230
|
+
setResetting(false)
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const headerDot = generateBadgeStyles(
|
|
1235
|
+
color || optionColor(target.stageKey),
|
|
1236
|
+
{ isDark },
|
|
1237
|
+
)
|
|
1238
|
+
// A one-line, human summary of the manifest original (for the reset confirm).
|
|
1239
|
+
const original = target.original
|
|
1240
|
+
const originalConditions =
|
|
1241
|
+
original?.filters && original.filters.length > 0
|
|
1242
|
+
? original.filters
|
|
1243
|
+
.map(
|
|
1244
|
+
(f) =>
|
|
1245
|
+
`${f.field} ${stageFilterOpSymbol(f.op)} ${f.value}`,
|
|
1246
|
+
)
|
|
1247
|
+
.join(', ')
|
|
1248
|
+
: t('dynamic.stage_config.original_no_conditions', {
|
|
1249
|
+
defaultValue: 'sin condiciones',
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
return (
|
|
1253
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1254
|
+
<DialogContent className="sm:max-w-lg">
|
|
1255
|
+
<DialogHeader>
|
|
1256
|
+
<DialogTitle className="flex items-center gap-2">
|
|
1257
|
+
<span
|
|
1258
|
+
className="inline-block size-3 shrink-0 rounded-full"
|
|
1259
|
+
style={{
|
|
1260
|
+
backgroundColor:
|
|
1261
|
+
headerDot.backgroundColor ||
|
|
1262
|
+
(headerDot as any).background,
|
|
1263
|
+
}}
|
|
1264
|
+
aria-hidden
|
|
1265
|
+
/>
|
|
1266
|
+
{t('dynamic.stage_config.title', {
|
|
1267
|
+
defaultValue: 'Configurar etapa',
|
|
1268
|
+
})}
|
|
1269
|
+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-normal text-muted-foreground">
|
|
1270
|
+
{target.stageKey}
|
|
1271
|
+
</code>
|
|
1272
|
+
{target.kind === 'declared' && target.overridden && (
|
|
1273
|
+
<Badge
|
|
1274
|
+
variant="secondary"
|
|
1275
|
+
className="ml-auto text-[10px] font-medium"
|
|
1276
|
+
data-testid="stage-config-personalizada"
|
|
1277
|
+
>
|
|
1278
|
+
{t('dynamic.stage_config.customized', {
|
|
1279
|
+
defaultValue: 'Personalizada',
|
|
1280
|
+
})}
|
|
1281
|
+
</Badge>
|
|
1282
|
+
)}
|
|
1283
|
+
</DialogTitle>
|
|
1284
|
+
<DialogDescription>
|
|
1285
|
+
{t('dynamic.stage_config.description', {
|
|
1286
|
+
defaultValue:
|
|
1287
|
+
'Renombra la columna, cambia su color y agrega condiciones para acotar qué tarjetas muestra y cuenta.',
|
|
1288
|
+
})}
|
|
1289
|
+
</DialogDescription>
|
|
1290
|
+
</DialogHeader>
|
|
1291
|
+
|
|
1292
|
+
<div className="flex flex-col gap-4">
|
|
1293
|
+
{/* "Condiciones actuales" — the lane's EFFECTIVE query as chips.
|
|
1294
|
+
The base "Etapa = <label>" chip is always present, locked and
|
|
1295
|
+
dimmed (it's the stage scope, never removable); the terminal
|
|
1296
|
+
chip appears for a final stage; each extra condition is an
|
|
1297
|
+
editable/removable chip mirroring the builder below. */}
|
|
1298
|
+
<div className="flex flex-col gap-1.5">
|
|
1299
|
+
<Label className="text-xs text-muted-foreground">
|
|
1300
|
+
{t('dynamic.stage_config.current_conditions_label', {
|
|
1301
|
+
defaultValue: 'Condiciones actuales',
|
|
1302
|
+
})}
|
|
1303
|
+
</Label>
|
|
1304
|
+
<div
|
|
1305
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
1306
|
+
data-testid="stage-config-current-conditions"
|
|
1307
|
+
>
|
|
1308
|
+
<Badge
|
|
1309
|
+
variant="outline"
|
|
1310
|
+
className="cursor-default gap-1 border-dashed text-[11px] font-normal text-muted-foreground opacity-80"
|
|
1311
|
+
title={t('dynamic.stage_config.base_chip_hint', {
|
|
1312
|
+
defaultValue:
|
|
1313
|
+
'Alcance base de la etapa: no se puede quitar.',
|
|
1314
|
+
})}
|
|
1315
|
+
data-testid="stage-config-base-chip"
|
|
1316
|
+
>
|
|
1317
|
+
<Lock className="h-3 w-3" />
|
|
1318
|
+
{t('dynamic.stage_config.base_chip', {
|
|
1319
|
+
defaultValue: 'Etapa = {{label}}',
|
|
1320
|
+
label: label || target.label,
|
|
1321
|
+
})}
|
|
1322
|
+
</Badge>
|
|
1323
|
+
{target.isFinal && (
|
|
1324
|
+
<Badge
|
|
1325
|
+
variant="outline"
|
|
1326
|
+
className="cursor-default gap-1 text-[11px] font-normal text-muted-foreground"
|
|
1327
|
+
title={t('dynamic.stage_config.final_chip_hint', {
|
|
1328
|
+
defaultValue:
|
|
1329
|
+
'Etapa terminal: al mover una tarjeta aquí se marca como cerrada (p. ej. en GitHub cierra el issue).',
|
|
1330
|
+
})}
|
|
1331
|
+
data-testid="stage-config-final-chip"
|
|
1332
|
+
>
|
|
1333
|
+
<Flag className="h-3 w-3" />
|
|
1334
|
+
{t('dynamic.stage_config.final_chip', {
|
|
1335
|
+
defaultValue: 'Etapa final',
|
|
1336
|
+
})}
|
|
1337
|
+
</Badge>
|
|
1338
|
+
)}
|
|
1339
|
+
{chipFilters.map((f) => {
|
|
1340
|
+
// Chip index maps back to its position in `filters`.
|
|
1341
|
+
const i = filters.indexOf(f)
|
|
1342
|
+
return (
|
|
1343
|
+
<Badge
|
|
1344
|
+
key={i}
|
|
1345
|
+
variant="secondary"
|
|
1346
|
+
className="gap-1 pr-1 text-[11px] font-normal"
|
|
1347
|
+
data-testid={`stage-config-filter-chip-${i}`}
|
|
1348
|
+
>
|
|
1349
|
+
<span className="font-mono">{f.field}</span>
|
|
1350
|
+
<span className="opacity-70">
|
|
1351
|
+
{stageFilterOpSymbol(f.op)}
|
|
1352
|
+
</span>
|
|
1353
|
+
<span>{f.value}</span>
|
|
1354
|
+
<button
|
|
1355
|
+
type="button"
|
|
1356
|
+
onClick={() => removeFilterAt(i)}
|
|
1357
|
+
className="ml-0.5 rounded p-0.5 hover:bg-background/60"
|
|
1358
|
+
aria-label={t(
|
|
1359
|
+
'dynamic.stage_config.remove_chip',
|
|
1360
|
+
{ defaultValue: 'Quitar condición' },
|
|
1361
|
+
)}
|
|
1362
|
+
data-testid={`stage-config-filter-chip-remove-${i}`}
|
|
1363
|
+
>
|
|
1364
|
+
<X className="h-2.5 w-2.5" />
|
|
1365
|
+
</button>
|
|
1366
|
+
</Badge>
|
|
1367
|
+
)
|
|
1368
|
+
})}
|
|
1369
|
+
</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
|
|
1372
|
+
<div className="border-t" />
|
|
1373
|
+
|
|
1374
|
+
{/* Name */}
|
|
1375
|
+
<div className="flex flex-col gap-1.5">
|
|
1376
|
+
<Label className="text-xs">
|
|
1377
|
+
{t('dynamic.stage_config.name_label', {
|
|
1378
|
+
defaultValue: 'Nombre',
|
|
1379
|
+
})}
|
|
1380
|
+
</Label>
|
|
1381
|
+
<Input
|
|
1382
|
+
value={label}
|
|
1383
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
1384
|
+
placeholder={t('dynamic.stage_config.name_placeholder', {
|
|
1385
|
+
defaultValue: 'Nombre de la etapa',
|
|
1386
|
+
})}
|
|
1387
|
+
data-testid="stage-config-name"
|
|
1388
|
+
autoFocus
|
|
1389
|
+
/>
|
|
1390
|
+
</div>
|
|
1391
|
+
|
|
1392
|
+
{/* Color palette */}
|
|
1393
|
+
<div className="flex flex-col gap-1.5">
|
|
1394
|
+
<Label className="text-xs">
|
|
1395
|
+
{t('dynamic.stage_config.color_label', {
|
|
1396
|
+
defaultValue: 'Color',
|
|
1397
|
+
})}
|
|
1398
|
+
</Label>
|
|
1399
|
+
<div className="flex flex-wrap gap-1.5" data-testid="stage-config-colors">
|
|
1400
|
+
{CUSTOM_STAGE_COLORS.map((c) => {
|
|
1401
|
+
const style = generateBadgeStyles(c, { isDark })
|
|
1402
|
+
const selected = c === color
|
|
1403
|
+
return (
|
|
1404
|
+
<button
|
|
1405
|
+
key={c}
|
|
1406
|
+
type="button"
|
|
1407
|
+
onClick={() => setColor(c)}
|
|
1408
|
+
className={`size-6 rounded-full border-2 transition-transform hover:scale-110 ${
|
|
1409
|
+
selected
|
|
1410
|
+
? 'border-foreground'
|
|
1411
|
+
: 'border-transparent'
|
|
1412
|
+
}`}
|
|
1413
|
+
style={{
|
|
1414
|
+
backgroundColor:
|
|
1415
|
+
style.backgroundColor ||
|
|
1416
|
+
(style as any).background,
|
|
1417
|
+
}}
|
|
1418
|
+
aria-label={c}
|
|
1419
|
+
aria-pressed={selected}
|
|
1420
|
+
data-testid={`stage-config-color-${c}`}
|
|
1421
|
+
/>
|
|
1422
|
+
)
|
|
1423
|
+
})}
|
|
1424
|
+
</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
|
|
1427
|
+
{/* Conditions — the editable builder (pre-filled from the lane) */}
|
|
1428
|
+
<StageConditionBuilder
|
|
1429
|
+
filters={filters}
|
|
1430
|
+
onChange={setFilters}
|
|
1431
|
+
fieldChoices={fieldChoices}
|
|
1432
|
+
label={t('dynamic.stage_config.conditions_label', {
|
|
1433
|
+
defaultValue: 'Condiciones (opcional)',
|
|
1434
|
+
})}
|
|
1435
|
+
/>
|
|
1436
|
+
{filters.length === 0 && (
|
|
1437
|
+
<Button
|
|
1438
|
+
variant="ghost"
|
|
1439
|
+
size="sm"
|
|
1440
|
+
className="-mt-2 h-7 justify-start gap-1 text-xs"
|
|
1441
|
+
onClick={() =>
|
|
1442
|
+
setFilters([
|
|
1443
|
+
emptyCustomStageFilter(fieldChoices[0]?.key ?? ''),
|
|
1444
|
+
])
|
|
1445
|
+
}
|
|
1446
|
+
data-testid="stage-config-add-first-condition"
|
|
1447
|
+
>
|
|
1448
|
+
<Plus className="h-3.5 w-3.5" />
|
|
1449
|
+
{t('dynamic.stage_config.add_condition', {
|
|
1450
|
+
defaultValue: 'Agregar condición',
|
|
1451
|
+
})}
|
|
1452
|
+
</Button>
|
|
1453
|
+
)}
|
|
1454
|
+
|
|
1455
|
+
{/* Reset confirm — spells out exactly what reverts to the
|
|
1456
|
+
manifest original before dropping the override. */}
|
|
1457
|
+
{confirmingReset && (
|
|
1458
|
+
<div
|
|
1459
|
+
className="flex flex-col gap-2 rounded-md border border-dashed bg-muted/30 p-3 text-xs"
|
|
1460
|
+
data-testid="stage-config-reset-confirm-panel"
|
|
1461
|
+
>
|
|
1462
|
+
<p className="font-medium">
|
|
1463
|
+
{t('dynamic.stage_config.reset_confirm_title', {
|
|
1464
|
+
defaultValue:
|
|
1465
|
+
'Se restablecerá la etapa a su versión original:',
|
|
1466
|
+
})}
|
|
1467
|
+
</p>
|
|
1468
|
+
<ul className="list-inside list-disc text-muted-foreground">
|
|
1469
|
+
<li>
|
|
1470
|
+
{t('dynamic.stage_config.reset_confirm_label', {
|
|
1471
|
+
defaultValue: 'Nombre: {{label}}',
|
|
1472
|
+
label: original?.label ?? target.stageKey,
|
|
1473
|
+
})}
|
|
1474
|
+
</li>
|
|
1475
|
+
<li>
|
|
1476
|
+
{t('dynamic.stage_config.reset_confirm_color', {
|
|
1477
|
+
defaultValue: 'Color: {{color}}',
|
|
1478
|
+
color: original?.color ?? '—',
|
|
1479
|
+
})}
|
|
1480
|
+
</li>
|
|
1481
|
+
<li>
|
|
1482
|
+
{t('dynamic.stage_config.reset_confirm_conditions', {
|
|
1483
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
1484
|
+
list: originalConditions,
|
|
1485
|
+
})}
|
|
1486
|
+
</li>
|
|
1487
|
+
</ul>
|
|
1488
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
1489
|
+
<Button
|
|
1490
|
+
variant="outline"
|
|
1491
|
+
size="sm"
|
|
1492
|
+
className="h-7 text-xs"
|
|
1493
|
+
onClick={() => setConfirmingReset(false)}
|
|
1494
|
+
disabled={resetting}
|
|
1495
|
+
>
|
|
1496
|
+
{t('dynamic.stage_config.cancel', {
|
|
1497
|
+
defaultValue: 'Cancelar',
|
|
1498
|
+
})}
|
|
1499
|
+
</Button>
|
|
1500
|
+
<Button
|
|
1501
|
+
variant="destructive"
|
|
1502
|
+
size="sm"
|
|
1503
|
+
className="h-7 text-xs"
|
|
1504
|
+
onClick={() => void reset()}
|
|
1505
|
+
disabled={resetting}
|
|
1506
|
+
data-testid="stage-config-reset-confirm"
|
|
1507
|
+
>
|
|
1508
|
+
{t('dynamic.stage_config.reset_confirm', {
|
|
1509
|
+
defaultValue: 'Restablecer al original',
|
|
1510
|
+
})}
|
|
1511
|
+
</Button>
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
)}
|
|
1515
|
+
</div>
|
|
1516
|
+
|
|
1517
|
+
<DialogFooter className="sm:justify-between">
|
|
1518
|
+
{/* Reset (declared+overridden) / delete (custom) on the left. */}
|
|
1519
|
+
<div>
|
|
1520
|
+
{target.kind === 'declared' && target.overridden && (
|
|
1521
|
+
<Button
|
|
1522
|
+
variant="ghost"
|
|
1523
|
+
className="gap-1.5 text-muted-foreground"
|
|
1524
|
+
onClick={() => setConfirmingReset(true)}
|
|
1525
|
+
disabled={saving || resetting || confirmingReset}
|
|
1526
|
+
data-testid="stage-config-reset"
|
|
1527
|
+
>
|
|
1528
|
+
<RotateCcw className="h-4 w-4" />
|
|
1529
|
+
{t('dynamic.stage_config.reset', {
|
|
1530
|
+
defaultValue: 'Restablecer al original',
|
|
1531
|
+
})}
|
|
1532
|
+
</Button>
|
|
1533
|
+
)}
|
|
1534
|
+
{target.kind === 'custom' && target.customStage && (
|
|
1535
|
+
<Button
|
|
1536
|
+
variant="ghost"
|
|
1537
|
+
className="gap-1.5 text-destructive hover:text-destructive"
|
|
1538
|
+
onClick={() => {
|
|
1539
|
+
onOpenChange(false)
|
|
1540
|
+
onDeleteCustom(target.customStage!)
|
|
1541
|
+
}}
|
|
1542
|
+
disabled={saving}
|
|
1543
|
+
data-testid="stage-config-delete"
|
|
1544
|
+
>
|
|
1545
|
+
<Trash2 className="h-4 w-4" />
|
|
1546
|
+
{t('dynamic.stage_config.delete', {
|
|
1547
|
+
defaultValue: 'Eliminar etapa',
|
|
1548
|
+
})}
|
|
1549
|
+
</Button>
|
|
1550
|
+
)}
|
|
1551
|
+
</div>
|
|
1552
|
+
<div className="flex gap-2">
|
|
1553
|
+
<Button
|
|
1554
|
+
variant="outline"
|
|
1555
|
+
onClick={() => onOpenChange(false)}
|
|
1556
|
+
disabled={saving || resetting}
|
|
1557
|
+
>
|
|
1558
|
+
{t('dynamic.stage_config.cancel', {
|
|
1559
|
+
defaultValue: 'Cancelar',
|
|
1560
|
+
})}
|
|
1561
|
+
</Button>
|
|
1562
|
+
<Button
|
|
1563
|
+
onClick={() => void submit()}
|
|
1564
|
+
disabled={!valid || saving || resetting}
|
|
1565
|
+
data-testid="stage-config-save"
|
|
1566
|
+
>
|
|
1567
|
+
{t('dynamic.stage_config.save', {
|
|
1568
|
+
defaultValue: 'Guardar',
|
|
1569
|
+
})}
|
|
1570
|
+
</Button>
|
|
1571
|
+
</div>
|
|
1572
|
+
</DialogFooter>
|
|
1573
|
+
</DialogContent>
|
|
1574
|
+
</Dialog>
|
|
1575
|
+
)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
971
1578
|
// ---------------------------------------------------------------------------
|
|
972
1579
|
// Smart (virtual) lane — fetches its own records from the model's list
|
|
973
1580
|
// ---------------------------------------------------------------------------
|