@asteby/metacore-runtime-react 21.1.0 → 22.0.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 +46 -0
- package/dist/dynamic-columns-shim.d.ts +9 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +1 -0
- package/dist/dynamic-kanban.d.ts +16 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +211 -23
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +51 -4
- package/dist/use-dynamic-filters.d.ts +19 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +75 -5
- package/dist/use-facet-loaders.d.ts +22 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +67 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +96 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +182 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +496 -78
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +100 -5
- package/src/use-facet-loaders.ts +79 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 22.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bd30e57: Filtros pro para tabla y kanban dinámicos.
|
|
8
|
+
- **Etapa como select (kanban/tabla):** una columna `group_by` sin opciones
|
|
9
|
+
propias hereda las etapas del pipeline (`metadata.stages`, con su color) como
|
|
10
|
+
un select real en lugar de caer a un cuadro de texto "Contiene...".
|
|
11
|
+
- **Filtros por facetas:** las columnas de texto filtrables se convierten en un
|
|
12
|
+
selector de valores (`filterType: 'facet'`) que carga de forma perezosa los
|
|
13
|
+
valores distintos + su conteo desde `GET /data/:model/facets` al abrir el
|
|
14
|
+
popover, con búsqueda server-side. Degrada con gracia al input "Contiene..."
|
|
15
|
+
(ILIKE) si el endpoint falla, devuelve vacío o no está disponible, y mantiene
|
|
16
|
+
ese affordance de texto libre incluso cuando hay opciones. Las columnas de
|
|
17
|
+
texto largo (`body`/`description`, `cellStyle: truncate-text`, tipos
|
|
18
|
+
json/long_text) se quedan como texto plano. Nuevo `ColumnFilterControl`
|
|
19
|
+
`loadOptions` y `FilterOption.count` en `@asteby/metacore-ui`; hook compartido
|
|
20
|
+
`useFacetLoaders` y `filterType: 'facet'` en `@asteby/metacore-runtime-react`.
|
|
21
|
+
- **Búsqueda por columna del kanban:** cada lane tiene un icono de búsqueda que
|
|
22
|
+
expande un input inline (autofocus, colapsa con Escape) filtrando client-side
|
|
23
|
+
las tarjetas de esa columna por título y valores de campo visibles. Convive y
|
|
24
|
+
se combina (AND) con el embudo por campo, que ahora ofrece un select de
|
|
25
|
+
valores cuando el campo tiene opciones conocidas.
|
|
26
|
+
- **Panel de filtros rediseñado (nivel Linear/Notion):** el Sheet deja de ser
|
|
27
|
+
una lista de botones grises idénticos: cada campo es una fila tipo settings
|
|
28
|
+
con icono por tipo de dato (Hash número, Calendar fecha, CircleDot etapa, Tag
|
|
29
|
+
select/facet, ToggleLeft boolean, Type texto), label y resumen del valor
|
|
30
|
+
activo a la derecha ("Cualquiera" en muted si no hay). Nueva variante
|
|
31
|
+
`ColumnFilterControl` `variant='row'` con props `icon` y `valueSummary`.
|
|
32
|
+
Popovers con `rounded-xl`, sombra, header del campo (el input "Contiene..."
|
|
33
|
+
nunca es contenido crudo sin jerarquía), opciones con checkbox + dot de color
|
|
34
|
+
- count a la derecha. Filtros con selección agrupados arriba, resto
|
|
35
|
+
alfabético, footer sticky con conteo. Fila de chips activos removibles bajo la
|
|
36
|
+
toolbar (con dot de color del valor, p.ej. color de la etapa) y "Limpiar
|
|
37
|
+
todo". Header de lane del kanban con acciones (buscar/embudo) en hover-reveal
|
|
38
|
+
para un board limpio. Pensado dark-mode-first (muted/accent del theme).
|
|
39
|
+
|
|
40
|
+
La URL del endpoint de facetas se deriva como el endpoint de agregados de
|
|
41
|
+
DynamicTable: `<endpoint>/facets` (o `/data/<model>/facets` como fallback), así
|
|
42
|
+
funciona tanto con `endpoint="/data/:model/me"` como sin él.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- Updated dependencies [bd30e57]
|
|
47
|
+
- @asteby/metacore-ui@2.8.0
|
|
48
|
+
|
|
3
49
|
## 21.1.0
|
|
4
50
|
|
|
5
51
|
### Minor Changes
|
|
@@ -5,15 +5,23 @@ export interface FilterOption {
|
|
|
5
5
|
value: string;
|
|
6
6
|
icon?: string;
|
|
7
7
|
color?: string;
|
|
8
|
+
/** Occurrence count for `facet` options (rendered muted, right-aligned). */
|
|
9
|
+
count?: number;
|
|
8
10
|
}
|
|
9
11
|
export interface ColumnFilterConfig {
|
|
10
|
-
filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | string;
|
|
12
|
+
filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | 'facet' | string;
|
|
11
13
|
filterKey: string;
|
|
12
14
|
options: FilterOption[];
|
|
13
15
|
selectedValues: string[];
|
|
14
16
|
onFilterChange: (filterKey: string, values: string[]) => void;
|
|
15
17
|
loading?: boolean;
|
|
16
18
|
searchEndpoint?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Lazy option loader for `facet` filters — resolves the column's distinct
|
|
21
|
+
* values + counts from the `/facets` endpoint. Passed straight through to
|
|
22
|
+
* `ColumnFilterControl.loadOptions`.
|
|
23
|
+
*/
|
|
24
|
+
loadOptions?: (q?: string) => Promise<FilterOption[]>;
|
|
17
25
|
}
|
|
18
26
|
/** Signature for the host-provided `getDynamicColumns` factory. */
|
|
19
27
|
export type GetDynamicColumns = (metadata: TableMetadata, handleAction: (action: string, row: any) => void, t: (key: string, options?: any) => string, language: string, columnFilterConfigs: Map<string, ColumnFilterConfig>, timeZone?: string, currency?: string) => ColumnDef<any>[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns-shim.d.ts","sourceRoot":"","sources":["../src/dynamic-columns-shim.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-columns-shim.d.ts","sourceRoot":"","sources":["../src/dynamic-columns-shim.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAA;IAC5F,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;CACxD;AAED,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAC5B,QAAQ,EAAE,aAAa,EACvB,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,EAChD,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,EACzC,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACpD,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,KAChB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;AAErB,8DAA8D;AAC9D,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAiC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqKhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAiC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqKhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA8mBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -490,6 +490,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
490
490
|
filterSearchEndpoint: filterConfig.searchEndpoint,
|
|
491
491
|
selectedValues: filterConfig.selectedValues,
|
|
492
492
|
onFilterChange: filterConfig.onFilterChange,
|
|
493
|
+
loadOptions: filterConfig.loadOptions,
|
|
493
494
|
};
|
|
494
495
|
Object.assign(columnMeta, fm);
|
|
495
496
|
}
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -40,6 +40,22 @@ export declare function selectCardColumns(metadata: TableMetadata, maxFields?: n
|
|
|
40
40
|
title: ColumnDefinition | null;
|
|
41
41
|
fields: ColumnDefinition[];
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Human-readable summary of a field's selected filter values for the removable
|
|
45
|
+
* chip row. Resolves option labels, unwraps the wire operators
|
|
46
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
47
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
48
|
+
*/
|
|
49
|
+
export declare function summarizeFilterValues(values: string[] | undefined, options: {
|
|
50
|
+
label: string;
|
|
51
|
+
value: string;
|
|
52
|
+
}[] | undefined, maxShown?: number): string;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
55
|
+
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
56
|
+
* matches everything. Pure — exported for unit tests.
|
|
57
|
+
*/
|
|
58
|
+
export declare function cardMatchesLaneQuery(card: any, cols: ColumnDefinition[], query: string): boolean;
|
|
43
59
|
export interface DynamicKanbanProps {
|
|
44
60
|
/** Model key as registered on the backend (e.g. "issue"). */
|
|
45
61
|
model: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA+D9B,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAMhB;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAejE;AAQD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAA;AAE/C,wBAAgB,YAAY,CACxB,OAAO,EAAE,GAAG,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,EAAE,GACpB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAepB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,WAAW,EAAE,eAAe,EAAE,GAAG,SAAS,EAC1C,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,GACX,OAAO,CAOT;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,EAC3B,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACnB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAYpB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,SAAS,SAAI,GACd;IAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,EAC5B,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,SAAS,EACvD,QAAQ,SAAI,GACb,MAAM,CAuBR;AAgCD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,GAAG,EACT,IAAI,EAAE,gBAAgB,EAAE,EACxB,KAAK,EAAE,MAAM,GACd,OAAO,CAQT;AAqCD,MAAM,WAAW,kBAAkB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACvC;AAED,wBAAgB,aAAa,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,WAAW,EACX,QAAQ,EACR,QAAc,EACd,QAAQ,EACR,QAAQ,EACR,cAAc,GACjB,EAAE,kBAAkB,qBA2jBpB"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
|
|
5
|
-
import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react';
|
|
5
|
+
import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, ToggleLeft, Type, X, } from 'lucide-react';
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
7
|
import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
8
8
|
import { ColumnFilterControl } from '@asteby/metacore-ui/data-table';
|
|
9
|
-
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
|
|
9
|
+
import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
10
10
|
import { useApi } from './api-context';
|
|
11
11
|
import { useDynamicFilters } from './use-dynamic-filters';
|
|
12
12
|
import { useMetadataCache } from './metadata-cache';
|
|
@@ -129,6 +129,77 @@ export function selectCardColumns(metadata, maxFields = 3) {
|
|
|
129
129
|
.slice(0, maxFields);
|
|
130
130
|
return { title, fields };
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Human-readable summary of a field's selected filter values for the removable
|
|
134
|
+
* chip row. Resolves option labels, unwraps the wire operators
|
|
135
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
136
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
137
|
+
*/
|
|
138
|
+
export function summarizeFilterValues(values, options, maxShown = 2) {
|
|
139
|
+
if (!values || values.length === 0)
|
|
140
|
+
return '';
|
|
141
|
+
const opts = options ?? [];
|
|
142
|
+
const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
|
|
143
|
+
const first = values[0];
|
|
144
|
+
if (values.length === 1) {
|
|
145
|
+
if (first.startsWith('ILIKE:'))
|
|
146
|
+
return `"${first.slice(6)}"`;
|
|
147
|
+
if (first.startsWith('IN:')) {
|
|
148
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown);
|
|
149
|
+
}
|
|
150
|
+
if (first.startsWith('RANGE:')) {
|
|
151
|
+
const [min, max] = first.slice(6).split(',');
|
|
152
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
153
|
+
}
|
|
154
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
155
|
+
return first.replace('_', ' – ');
|
|
156
|
+
}
|
|
157
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
158
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
|
|
159
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
|
|
160
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
161
|
+
}
|
|
162
|
+
return summarizeList(values, labelFor, maxShown);
|
|
163
|
+
}
|
|
164
|
+
function summarizeList(items, labelFor, maxShown) {
|
|
165
|
+
const labels = items.map(labelFor);
|
|
166
|
+
if (labels.length <= maxShown)
|
|
167
|
+
return labels.join(', ');
|
|
168
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
172
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
173
|
+
* operator/range/free-text values that carry no option color.
|
|
174
|
+
*/
|
|
175
|
+
function chipValueColor(config) {
|
|
176
|
+
const sel = config.selectedValues;
|
|
177
|
+
if (!sel || sel.length === 0)
|
|
178
|
+
return undefined;
|
|
179
|
+
const first = sel[0];
|
|
180
|
+
let value = first;
|
|
181
|
+
if (first.startsWith('IN:'))
|
|
182
|
+
value = first.slice(3).split(',')[0];
|
|
183
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
|
|
184
|
+
return undefined;
|
|
185
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
186
|
+
return undefined;
|
|
187
|
+
const opt = config.options.find((o) => o.value === value);
|
|
188
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
192
|
+
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
193
|
+
* matches everything. Pure — exported for unit tests.
|
|
194
|
+
*/
|
|
195
|
+
export function cardMatchesLaneQuery(card, cols, query) {
|
|
196
|
+
const q = query.trim().toLowerCase();
|
|
197
|
+
if (!q)
|
|
198
|
+
return true;
|
|
199
|
+
return cols.some((c) => String(card?.[c.key] ?? '')
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.includes(q));
|
|
202
|
+
}
|
|
132
203
|
// ---------------------------------------------------------------------------
|
|
133
204
|
// Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
|
|
134
205
|
// ---------------------------------------------------------------------------
|
|
@@ -199,7 +270,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
199
270
|
// Shared metadata-driven filter engine — the SAME configs, option prefetch
|
|
200
271
|
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
201
272
|
// identically to its table sibling.
|
|
202
|
-
const { globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, clearAll, } = useDynamicFilters(metadata, { defaultFilters });
|
|
273
|
+
const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
|
|
203
274
|
// ---- records fetch (same path as DynamicTable, single large page) ----
|
|
204
275
|
const fetchData = useCallback(async () => {
|
|
205
276
|
if (!metadata)
|
|
@@ -245,18 +316,36 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
245
316
|
}
|
|
246
317
|
return out;
|
|
247
318
|
}, [metadata, columnFilterConfigs, t]);
|
|
319
|
+
// Split filters into active (with a selection) and the rest — the Sheet
|
|
320
|
+
// groups the active ones on top, the rest alphabetically. Also drives the
|
|
321
|
+
// removable chip row below the toolbar.
|
|
322
|
+
const { activeFields, inactiveFields } = useMemo(() => {
|
|
323
|
+
const active = filterFields.filter((f) => (f.config.selectedValues?.length ?? 0) > 0);
|
|
324
|
+
const inactive = filterFields
|
|
325
|
+
.filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
|
|
326
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
327
|
+
return { activeFields: active, inactiveFields: inactive };
|
|
328
|
+
}, [filterFields]);
|
|
248
329
|
// Sheet (grouped global filters) open state + per-lane client-side filters.
|
|
249
330
|
// A lane filter narrows ONLY that stage's already-fetched cards by a field
|
|
250
331
|
// value — instant, no refetch — so a user can drill into one column without
|
|
251
332
|
// touching the rest of the board (the global filters, by contrast, refetch
|
|
252
333
|
// the whole board server-side).
|
|
253
334
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
335
|
+
// Per-lane client-side narrowing. Two independent, AND-combined dimensions:
|
|
336
|
+
// - `field`/`value`: the funnel — a field-scoped substring match.
|
|
337
|
+
// - `query`: the lane search — a substring over the card title + every
|
|
338
|
+
// visible field value.
|
|
339
|
+
// A lane with neither is dropped from the map (so it reads as "unfiltered").
|
|
254
340
|
const [laneFilters, setLaneFilters] = useState({});
|
|
255
|
-
const
|
|
341
|
+
const updateLaneFilter = useCallback((stageKey, patch) => {
|
|
256
342
|
setLaneFilters((prev) => {
|
|
343
|
+
const merged = { ...prev[stageKey], ...patch };
|
|
257
344
|
const next = { ...prev };
|
|
258
|
-
|
|
259
|
-
|
|
345
|
+
const hasFunnel = !!(merged.field && merged.value?.trim());
|
|
346
|
+
const hasQuery = !!merged.query?.trim();
|
|
347
|
+
if (hasFunnel || hasQuery)
|
|
348
|
+
next[stageKey] = merged;
|
|
260
349
|
else
|
|
261
350
|
delete next[stageKey];
|
|
262
351
|
return next;
|
|
@@ -267,6 +356,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
267
356
|
const transitions = metadata?.transitions;
|
|
268
357
|
const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
|
|
269
358
|
const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
|
|
359
|
+
// Columns the lane search scans: the card title + its visible field cells.
|
|
360
|
+
const searchCols = useMemo(() => [titleCol, ...fieldCols].filter(Boolean), [titleCol, fieldCols]);
|
|
270
361
|
// Row-placement actions resolved EXACTLY like DynamicTable's action column:
|
|
271
362
|
// capability-gated (when a <PermissionsProvider> is mounted) and with the
|
|
272
363
|
// implicit View/Edit/Delete trio materialized for CRUD models. An action the
|
|
@@ -362,37 +453,114 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
362
453
|
order: Number.MAX_SAFE_INTEGER,
|
|
363
454
|
});
|
|
364
455
|
}
|
|
365
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-
|
|
456
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-0 p-0 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0 border-b px-4 py-3", children: _jsx(SheetTitle, { className: "text-sm", children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3", children: [activeFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: t('kanban.activeFilters', {
|
|
457
|
+
defaultValue: 'Con filtros activos',
|
|
458
|
+
}) }), activeFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key))), inactiveFields.length > 0 && (_jsx("div", { className: "my-2 border-t" }))] })), inactiveFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key)))] }), _jsxs("div", { className: "sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3", children: [_jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: activeFilterCount > 0
|
|
459
|
+
? t('kanban.activeCount', {
|
|
460
|
+
defaultValue: '{{count}} activos',
|
|
461
|
+
count: activeFilterCount,
|
|
462
|
+
})
|
|
463
|
+
: t('kanban.noActiveFilters', {
|
|
464
|
+
defaultValue: 'Sin filtros',
|
|
465
|
+
}) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), activeFields.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", "data-testid": "kanban-filter-chips", children: [activeFields.map((field) => {
|
|
466
|
+
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
467
|
+
const dot = chipValueColor(field.config);
|
|
468
|
+
return (_jsxs(Badge, { variant: "secondary", className: "h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal", children: [dot && (_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { backgroundColor: dot } })), _jsxs("span", { className: "font-medium", children: [field.label, ":"] }), _jsx("span", { className: "max-w-[180px] truncate text-muted-foreground", children: summary }), _jsx("button", { type: "button", onClick: () => field.config.onFilterChange(field.config.filterKey, []), className: "ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20", "aria-label": t('kanban.removeFilter', {
|
|
469
|
+
defaultValue: 'Quitar filtro',
|
|
470
|
+
}), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
|
|
471
|
+
}), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: clearAll, children: t('kanban.clearAll', { defaultValue: 'Limpiar todo' }) })] })), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
|
|
366
472
|
const allCards = grouped.get(stage.key) ?? [];
|
|
367
|
-
// Per-lane client-side narrowing
|
|
368
|
-
//
|
|
369
|
-
//
|
|
473
|
+
// Per-lane client-side narrowing (instant, scoped to this
|
|
474
|
+
// stage). The funnel (field/value) and the lane search
|
|
475
|
+
// (query) are AND-combined.
|
|
370
476
|
const laneFilter = laneFilters[stage.key];
|
|
371
|
-
|
|
372
|
-
|
|
477
|
+
let cards = allCards;
|
|
478
|
+
if (laneFilter?.field && laneFilter.value?.trim()) {
|
|
479
|
+
const v = laneFilter.value.trim().toLowerCase();
|
|
480
|
+
const field = laneFilter.field;
|
|
481
|
+
cards = cards.filter((c) => String(c[field] ?? '')
|
|
373
482
|
.toLowerCase()
|
|
374
|
-
.includes(
|
|
375
|
-
|
|
483
|
+
.includes(v));
|
|
484
|
+
}
|
|
485
|
+
if (laneFilter?.query?.trim()) {
|
|
486
|
+
cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
|
|
487
|
+
}
|
|
376
488
|
const droppableAllowed = !activeId ||
|
|
377
489
|
stage.key === activeStage ||
|
|
378
490
|
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
379
|
-
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter,
|
|
491
|
+
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
492
|
+
field: f?.field,
|
|
493
|
+
value: f?.value,
|
|
494
|
+
}), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))) }, stage.key));
|
|
380
495
|
}) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
|
|
381
496
|
}
|
|
382
|
-
|
|
497
|
+
/**
|
|
498
|
+
* A per-data-type glyph for the Filtros panel rows (and their popover header):
|
|
499
|
+
* Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
|
|
500
|
+
* for value pickers, ToggleLeft for booleans, Type for free text.
|
|
501
|
+
*/
|
|
502
|
+
function filterTypeIcon(filterType, isStage) {
|
|
503
|
+
if (isStage)
|
|
504
|
+
return _jsx(CircleDot, { className: "h-3.5 w-3.5" });
|
|
505
|
+
switch (filterType) {
|
|
506
|
+
case 'number_range':
|
|
507
|
+
return _jsx(Hash, { className: "h-3.5 w-3.5" });
|
|
508
|
+
case 'date_range':
|
|
509
|
+
return _jsx(Calendar, { className: "h-3.5 w-3.5" });
|
|
510
|
+
case 'boolean':
|
|
511
|
+
return _jsx(ToggleLeft, { className: "h-3.5 w-3.5" });
|
|
512
|
+
case 'select':
|
|
513
|
+
case 'dynamic_select':
|
|
514
|
+
case 'facet':
|
|
515
|
+
return _jsx(Tag, { className: "h-3.5 w-3.5" });
|
|
516
|
+
default:
|
|
517
|
+
return _jsx(Type, { className: "h-3.5 w-3.5" });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function SheetFilterRow({ field, isStage, }) {
|
|
521
|
+
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
522
|
+
return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
|
|
523
|
+
}
|
|
524
|
+
function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
|
|
383
525
|
const { t } = useTranslation();
|
|
384
526
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
|
|
385
527
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
386
528
|
isDark,
|
|
387
529
|
});
|
|
388
|
-
const
|
|
530
|
+
const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim());
|
|
531
|
+
const queryActive = !!laneFilter?.query?.trim();
|
|
532
|
+
const laneActive = funnelActive || queryActive;
|
|
389
533
|
const activeFieldLabel = filterFields.find((f) => f.key === laneFilter?.field)?.label ??
|
|
390
534
|
laneFilter?.field;
|
|
391
|
-
|
|
535
|
+
// Inline lane search: a Search icon expands an Input; Escape or blur-while-
|
|
536
|
+
// empty collapses it. The query itself lives in the parent's laneFilters so
|
|
537
|
+
// it survives collapse and combines with the funnel.
|
|
538
|
+
const [searchOpen, setSearchOpen] = useState(queryActive);
|
|
539
|
+
const searchRef = useRef(null);
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
if (searchOpen)
|
|
542
|
+
searchRef.current?.focus();
|
|
543
|
+
}, [searchOpen]);
|
|
544
|
+
const funnelValue = laneFilter?.field && laneFilter.value
|
|
545
|
+
? { field: laneFilter.field, value: laneFilter.value }
|
|
546
|
+
: undefined;
|
|
547
|
+
return (_jsxs("div", { ref: setNodeRef, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
|
|
392
548
|
opacity: dimmed ? 0.45 : 1,
|
|
393
549
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
394
550
|
outlineOffset: 2,
|
|
395
|
-
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }),
|
|
551
|
+
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: `flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${laneActive || searchOpen ? 'opacity-100' : 'opacity-0'}`, children: [_jsx("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
|
|
552
|
+
defaultValue: 'Buscar en la columna',
|
|
553
|
+
}), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
|
|
554
|
+
if (e.key === 'Escape') {
|
|
555
|
+
onQueryChange('');
|
|
556
|
+
setSearchOpen(false);
|
|
557
|
+
}
|
|
558
|
+
}, onBlur: () => {
|
|
559
|
+
if (!laneFilter?.query?.trim())
|
|
560
|
+
setSearchOpen(false);
|
|
561
|
+
}, placeholder: t('kanban.searchLanePlaceholder', {
|
|
562
|
+
defaultValue: 'Buscar tarjetas...',
|
|
563
|
+
}), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", laneFilter.value] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
|
|
396
564
|
defaultValue: 'Limpiar',
|
|
397
565
|
}), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx("div", { className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: children })] }));
|
|
398
566
|
}
|
|
@@ -414,6 +582,12 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
414
582
|
if (fields.length === 0)
|
|
415
583
|
return null;
|
|
416
584
|
const active = !!(value && value.value.trim());
|
|
585
|
+
// When the chosen field carries a known option set (select/facet with
|
|
586
|
+
// pre-loaded values), offer those values as a Select instead of a raw text
|
|
587
|
+
// box — so the funnel picks real values, not free strings.
|
|
588
|
+
const selectedFieldCfg = fields.find((f) => f.key === field)?.config;
|
|
589
|
+
const valueOptions = selectedFieldCfg?.options ?? [];
|
|
590
|
+
const hasValueOptions = valueOptions.length > 0;
|
|
417
591
|
const apply = () => {
|
|
418
592
|
if (field && text.trim())
|
|
419
593
|
onChange({ field, value: text });
|
|
@@ -421,12 +595,26 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
421
595
|
onChange(null);
|
|
422
596
|
setOpen(false);
|
|
423
597
|
};
|
|
424
|
-
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `
|
|
598
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
|
|
425
599
|
defaultValue: 'Filtrar columna',
|
|
426
|
-
}), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-56 space-y-2 p-2", children: [_jsxs(Select, { value: field, onValueChange:
|
|
600
|
+
}), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-56 space-y-2 p-2", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
|
|
601
|
+
setField(f);
|
|
602
|
+
// Reset the value when switching fields — a value picked
|
|
603
|
+
// for one field is meaningless for another.
|
|
604
|
+
setText('');
|
|
605
|
+
}, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }), hasValueOptions ? (_jsxs(Select, { value: text || undefined, onValueChange: (v) => {
|
|
606
|
+
setText(v);
|
|
607
|
+
if (field && v)
|
|
608
|
+
onChange({ field, value: v });
|
|
609
|
+
setOpen(false);
|
|
610
|
+
}, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: t('kanban.filterValue', {
|
|
611
|
+
defaultValue: 'Valor...',
|
|
612
|
+
}) }) }), _jsx(SelectContent, { children: valueOptions.map((o) => (_jsx(SelectItem, { value: o.value, className: "text-xs", children: o.label }, o.value))) })] })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
|
|
427
613
|
if (e.key === 'Enter')
|
|
428
614
|
apply();
|
|
429
|
-
}, placeholder: t('kanban.filterValue', {
|
|
615
|
+
}, placeholder: t('kanban.filterValue', {
|
|
616
|
+
defaultValue: 'Valor...',
|
|
617
|
+
}), className: "h-8 text-xs" })), _jsxs("div", { className: "flex justify-between gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "h-7 text-xs", onClick: () => {
|
|
430
618
|
onChange(null);
|
|
431
619
|
setOpen(false);
|
|
432
620
|
}, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAWnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BA02BnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -24,6 +24,7 @@ import { Progress } from './dialogs/_primitives';
|
|
|
24
24
|
import { useMetadataCache } from './metadata-cache';
|
|
25
25
|
import { useApi, useCurrentBranch } from './api-context';
|
|
26
26
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
|
|
27
|
+
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
|
|
27
28
|
import { OptionsContext } from './options-context';
|
|
28
29
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
29
30
|
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
|
|
@@ -456,16 +457,28 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
456
457
|
setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }));
|
|
457
458
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
|
458
459
|
}, []);
|
|
460
|
+
// Same facet loader machinery the board uses, so a text column filters
|
|
461
|
+
// identically in the table header and the kanban Sheet (the host's
|
|
462
|
+
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
463
|
+
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
464
|
+
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
|
|
465
|
+
const getFacetLoader = useFacetLoaders(facetsBase);
|
|
459
466
|
const columnFilterConfigs = useMemo(() => {
|
|
460
467
|
const map = new Map();
|
|
461
468
|
if (!metadata)
|
|
462
469
|
return map;
|
|
470
|
+
const stageOptions = (metadata.stages ?? []).map((s) => ({
|
|
471
|
+
label: s.label,
|
|
472
|
+
value: s.key,
|
|
473
|
+
color: s.color,
|
|
474
|
+
}));
|
|
475
|
+
const groupBy = metadata.group_by;
|
|
463
476
|
// Explicit `metadata.filters` wins. When the backend does not emit
|
|
464
477
|
// them, derive a filter chip from every column flagged
|
|
465
478
|
// `filterable: true` — keeps the kernel API minimal (one flag on the
|
|
466
479
|
// column) while still rendering the FilterableColumnHeader.
|
|
467
480
|
for (const f of metadata.filters ?? []) {
|
|
468
|
-
|
|
481
|
+
let fType = f.type;
|
|
469
482
|
let options = [];
|
|
470
483
|
if (f.options && f.options.length > 0) {
|
|
471
484
|
options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color }));
|
|
@@ -473,6 +486,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
473
486
|
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
474
487
|
options = filterOptionsMap.get(f.searchEndpoint) || [];
|
|
475
488
|
}
|
|
489
|
+
// (A) A stage column with no options of its own inherits the
|
|
490
|
+
// pipeline stages (with their colors) as a real select.
|
|
491
|
+
if (options.length === 0 && !f.searchEndpoint && (f.column || f.key) === groupBy && stageOptions.length > 0) {
|
|
492
|
+
fType = 'select';
|
|
493
|
+
options = stageOptions;
|
|
494
|
+
}
|
|
495
|
+
// (B) A plain text filter becomes a facet value-picker when a facets
|
|
496
|
+
// endpoint is available (degrades to "Contiene..." if it yields nothing).
|
|
497
|
+
let loadOptions;
|
|
498
|
+
if (fType === 'text' && facetsBase) {
|
|
499
|
+
const loader = getFacetLoader(f.column || f.key);
|
|
500
|
+
if (loader) {
|
|
501
|
+
fType = 'facet';
|
|
502
|
+
loadOptions = loader;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
476
505
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
477
506
|
continue;
|
|
478
507
|
map.set(f.key, {
|
|
@@ -483,6 +512,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
483
512
|
onFilterChange: handleDynamicFilterChange,
|
|
484
513
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
485
514
|
searchEndpoint: f.searchEndpoint,
|
|
515
|
+
loadOptions,
|
|
486
516
|
});
|
|
487
517
|
}
|
|
488
518
|
for (const c of metadata.columns ?? []) {
|
|
@@ -500,8 +530,12 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
500
530
|
// - number / number_range / numeric → number range
|
|
501
531
|
// - date → date range picker (start/end calendar)
|
|
502
532
|
// - everything else (text, email, phone, tags…) → text contains
|
|
533
|
+
// (A) Stage column with no options of its own → colored select.
|
|
534
|
+
const isStageColumn = c.key === groupBy && !hasStaticOptions && !hasEndpoint && !c.filterType && stageOptions.length > 0;
|
|
503
535
|
let filterType;
|
|
504
|
-
if (
|
|
536
|
+
if (isStageColumn)
|
|
537
|
+
filterType = 'select';
|
|
538
|
+
else if (c.filterType)
|
|
505
539
|
filterType = c.filterType;
|
|
506
540
|
else if (isRelation && hasEndpoint)
|
|
507
541
|
filterType = 'dynamic_select';
|
|
@@ -515,7 +549,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
515
549
|
filterType = 'date_range';
|
|
516
550
|
else
|
|
517
551
|
filterType = 'text';
|
|
518
|
-
|
|
552
|
+
let options = hasStaticOptions
|
|
519
553
|
? c.options.map(o => ({
|
|
520
554
|
label: o.label,
|
|
521
555
|
value: String(o.value),
|
|
@@ -525,6 +559,18 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
525
559
|
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
|
|
526
560
|
? filterOptionsMap.get(c.searchEndpoint) || []
|
|
527
561
|
: [];
|
|
562
|
+
if (isStageColumn)
|
|
563
|
+
options = stageOptions;
|
|
564
|
+
// (B) Upgrade a plain text filter to a facet value-picker unless it's
|
|
565
|
+
// a long-text/body column (too many unique values to enumerate).
|
|
566
|
+
let loadOptions;
|
|
567
|
+
if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
|
|
568
|
+
const loader = getFacetLoader(c.key);
|
|
569
|
+
if (loader) {
|
|
570
|
+
filterType = 'facet';
|
|
571
|
+
loadOptions = loader;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
528
574
|
map.set(c.key, {
|
|
529
575
|
filterType,
|
|
530
576
|
filterKey: c.key,
|
|
@@ -533,10 +579,11 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
533
579
|
onFilterChange: handleDynamicFilterChange,
|
|
534
580
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
|
|
535
581
|
searchEndpoint: c.searchEndpoint,
|
|
582
|
+
loadOptions,
|
|
536
583
|
});
|
|
537
584
|
}
|
|
538
585
|
return map;
|
|
539
|
-
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
|
|
586
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
|
|
540
587
|
const columns = useMemo(() => {
|
|
541
588
|
if (!viewMetadata)
|
|
542
589
|
return [];
|