@asteby/metacore-runtime-react 23.0.0 → 23.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ // Shared filter presentation: the removable active-filter chip row + the pure
2
+ // helpers that summarize a filter's value and resolve its color. Used by BOTH
3
+ // DynamicKanban and DynamicTable so the two surfaces render identical chips
4
+ // (one component, no drift).
5
+ import { useTranslation } from 'react-i18next'
6
+ import { X } from 'lucide-react'
7
+ import { Badge, Button } from '@asteby/metacore-ui/primitives'
8
+ import { resolveColorCss } from '@asteby/metacore-ui/lib'
9
+
10
+ /**
11
+ * Human-readable summary of a field's selected filter values for a chip / row.
12
+ * Resolves option labels, unwraps the wire operators
13
+ * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
14
+ * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
15
+ */
16
+ export function summarizeFilterValues(
17
+ values: string[] | undefined,
18
+ options: { label: string; value: string }[] | undefined,
19
+ maxShown = 2,
20
+ ): string {
21
+ if (!values || values.length === 0) return ''
22
+ const opts = options ?? []
23
+ const labelFor = (v: string) => opts.find((o) => o.value === v)?.label ?? v
24
+ const first = values[0]
25
+ if (values.length === 1) {
26
+ if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
27
+ if (first.startsWith('IN:')) {
28
+ return summarizeList(first.slice(3).split(','), labelFor, maxShown)
29
+ }
30
+ if (first.startsWith('RANGE:')) {
31
+ const [min, max] = first.slice(6).split(',')
32
+ return `${min || '…'} – ${max || '…'}`
33
+ }
34
+ if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
35
+ }
36
+ if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
37
+ const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
38
+ const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
39
+ return `${min || '…'} – ${max || '…'}`
40
+ }
41
+ return summarizeList(values, labelFor, maxShown)
42
+ }
43
+
44
+ function summarizeList(
45
+ items: string[],
46
+ labelFor: (v: string) => string,
47
+ maxShown: number,
48
+ ): string {
49
+ const labels = items.map(labelFor)
50
+ if (labels.length <= maxShown) return labels.join(', ')
51
+ return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
52
+ }
53
+
54
+ /**
55
+ * The color of a filter's first selected value (for the chip's dot) — e.g. a
56
+ * stage's palette color. Returns a resolved CSS color, or undefined for
57
+ * operator/range/free-text values that carry no option color.
58
+ */
59
+ export function chipValueColor(config: {
60
+ selectedValues: string[]
61
+ options: { value: string; color?: string }[]
62
+ }): string | undefined {
63
+ const sel = config.selectedValues
64
+ if (!sel || sel.length === 0) return undefined
65
+ const first = sel[0]
66
+ let value = first
67
+ if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
68
+ else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
69
+ else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
70
+ const opt = config.options.find((o) => o.value === value)
71
+ return opt?.color ? resolveColorCss(opt.color) : undefined
72
+ }
73
+
74
+ /**
75
+ * Translates option labels through the app translator (manifest i18n keys →
76
+ * localized text). A raw value with no matching key falls through to itself via
77
+ * `defaultValue`. Pure — exported for unit tests.
78
+ */
79
+ export function translateOptionLabels<T extends { label: string }>(
80
+ options: T[],
81
+ translate: (key: string) => string,
82
+ ): T[] {
83
+ return options.map((o) => ({ ...o, label: translate(o.label) }))
84
+ }
85
+
86
+ export interface FilterChipField {
87
+ key: string
88
+ label: string
89
+ config: {
90
+ selectedValues: string[]
91
+ options: { label: string; value: string; color?: string }[]
92
+ filterKey: string
93
+ onFilterChange: (filterKey: string, values: string[]) => void
94
+ }
95
+ }
96
+
97
+ export interface FilterChipsRowProps {
98
+ /** Fields with an active selection (one chip each). */
99
+ fields: FilterChipField[]
100
+ /** Clears every filter (the trailing "Limpiar todo"). */
101
+ onClearAll: () => void
102
+ className?: string
103
+ 'data-testid'?: string
104
+ }
105
+
106
+ /**
107
+ * The removable active-filter chip row shown under a board/table toolbar. Each
108
+ * chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
109
+ * X that clears that field; a trailing "Limpiar todo". Renders nothing when no
110
+ * field is active.
111
+ */
112
+ export function FilterChipsRow({
113
+ fields,
114
+ onClearAll,
115
+ className,
116
+ 'data-testid': testId,
117
+ }: FilterChipsRowProps) {
118
+ const { t } = useTranslation()
119
+ if (fields.length === 0) return null
120
+ return (
121
+ <div
122
+ className={`flex flex-wrap items-center gap-1.5${className ? ` ${className}` : ''}`}
123
+ data-testid={testId}
124
+ >
125
+ {fields.map((field) => {
126
+ const summary = summarizeFilterValues(
127
+ field.config.selectedValues,
128
+ field.config.options,
129
+ )
130
+ const dot = chipValueColor(field.config)
131
+ return (
132
+ <Badge
133
+ key={field.key}
134
+ variant="secondary"
135
+ className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
136
+ >
137
+ {dot && (
138
+ <span
139
+ className="size-2 shrink-0 rounded-full"
140
+ style={{ backgroundColor: dot }}
141
+ />
142
+ )}
143
+ <span className="font-medium">{field.label}:</span>
144
+ <span className="max-w-[180px] truncate text-muted-foreground">
145
+ {summary}
146
+ </span>
147
+ <button
148
+ type="button"
149
+ onClick={() =>
150
+ field.config.onFilterChange(
151
+ field.config.filterKey,
152
+ [],
153
+ )
154
+ }
155
+ className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
156
+ aria-label={t('filters.removeFilter', {
157
+ defaultValue: 'Quitar filtro',
158
+ })}
159
+ >
160
+ <X className="h-3 w-3" />
161
+ </button>
162
+ </Badge>
163
+ )
164
+ })}
165
+ <Button
166
+ variant="ghost"
167
+ size="sm"
168
+ className="h-6 gap-1 px-2 text-xs text-muted-foreground"
169
+ onClick={onClearAll}
170
+ >
171
+ {t('filters.clearAll', { defaultValue: 'Limpiar todo' })}
172
+ </Button>
173
+ </div>
174
+ )
175
+ }