@softize/opus 25.3.1 → 25.4.1
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 +28 -0
- package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +3 -2
- package/docs/adr/0055-runtime-options-can-form-bounded-list-groups.md +57 -0
- package/docs/protocol.md +5 -3
- package/package.json +1 -1
- package/src/core/list-grouping.ts +4 -4
- package/src/core/types.ts +3 -3
- package/src/ui/components/patterns/grouped-list.ts +3 -4
- package/src/ui/components/patterns/list.tsx +223 -24
- package/src/ui/components/primitives/select.tsx +18 -5
- package/src/ui/docs/content/action-list.md +28 -11
- package/src/ui/docs/content/select.md +5 -3
- package/src/ui/drivers/react.tsx +35 -1
- package/src/ui/react.tsx +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 25.4.1 — 2026-10-05
|
|
11
|
+
|
|
12
|
+
Corrige a busca remota de filtros `lookup` quando a `ActionList` também recebe opções iniciais em
|
|
13
|
+
`filterOptions`, como nas dimensões disponíveis para agrupamento. Enquanto há um termo de busca, as
|
|
14
|
+
opções iniciais agora são filtradas localmente e combinadas com o resultado do servidor; antes,
|
|
15
|
+
elas permaneciam todas visíveis e podiam esconder o efeito da busca. Não há migração para o
|
|
16
|
+
consumidor. Valores já selecionados preservam o rótulo durante e depois da busca, inclusive quando
|
|
17
|
+
a opção veio apenas do servidor. Fechar o seletor limpa o termo também no lookup remoto. No
|
|
18
|
+
`Select` com `onSearch`, esse reset é comunicado imediatamente com `""` apenas quando uma busca não
|
|
19
|
+
vazia chegou a ser enviada; abrir e fechar sem digitar não repete a consulta inicial. Respostas
|
|
20
|
+
remotas fora de ordem não substituem mais o resultado da consulta mais recente.
|
|
21
|
+
|
|
22
|
+
## 25.4.0 — 2026-10-05
|
|
23
|
+
|
|
24
|
+
`ListAction.grouping.fields` passa a aceitar filtros `select` ou `lookup` cujas opções completas
|
|
25
|
+
sejam fornecidas em runtime por `ActionList.filterOptions`. A dimensão só aparece em **Agrupar por**
|
|
26
|
+
com 1 a 12 opções; catálogo ausente, vazio ou maior mantém a lista plana, sem agrupar uma amostra.
|
|
27
|
+
A ADR 0055 registra essa extensão da decisão original.
|
|
28
|
+
|
|
29
|
+
As preferências de ordenação passam para **Exibição**, com a direção como ação dentro do próprio
|
|
30
|
+
campo. O botão sinaliza quando ordenação, agrupamento, colunas ou quantidade por página diferem do
|
|
31
|
+
padrão, e o rodapé explicita **Agrupado por** e **Ordenado por** quando aplicáveis.
|
|
32
|
+
|
|
33
|
+
Também corrige a `ActionList` agrupada em aplicações Vite que consomem o código-fonte TypeScript do
|
|
34
|
+
Opus. Ao ativar um agrupamento, a lista podia deixar de reconhecer o `OpusProvider` já presente na
|
|
35
|
+
aplicação e interromper a renderização. O agrupamento agora usa o mesmo driver React dos demais
|
|
36
|
+
hooks de action. Não há migração para o consumidor.
|
|
37
|
+
|
|
10
38
|
## 25.3.1 — 2026-10-05
|
|
11
39
|
|
|
12
40
|
Com um `Dialog` ou `Drawer` aberto e a pessoa numa `FloatingLayer` sem `onEscapeKeyDown`, o Esc
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# ADR 0054 — Agrupamento da ActionList é um recorte declarado e consultado no servidor
|
|
2
2
|
|
|
3
|
-
- **Status:** aceita.
|
|
3
|
+
- **Status:** aceita, com a fonte de opções ampliada pela ADR 0055.
|
|
4
4
|
- **Data:** 2026-10-03.
|
|
5
5
|
- **Relacionadas:** ADR 0013 (Presentation orientada a action) e ADR 0024 (preenchimento das
|
|
6
|
-
regiões de lista).
|
|
6
|
+
regiões de lista). A ADR 0055 substitui somente a restrição desta decisão a opções estáticas ou
|
|
7
|
+
de dicionário; recorte no servidor, limite de cardinalidade e demais garantias permanecem.
|
|
7
8
|
|
|
8
9
|
## Contexto
|
|
9
10
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ADR 0055 — Opções resolvidas em runtime podem formar grupos limitados da ActionList
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-10-05.
|
|
5
|
+
- **Relacionada:** ADR 0054 (agrupamento da ActionList é um recorte declarado e consultado no
|
|
6
|
+
servidor).
|
|
7
|
+
|
|
8
|
+
## Contexto
|
|
9
|
+
|
|
10
|
+
A ADR 0054 limitou `grouping.fields` a filtros `select` com opções estáticas ou de dicionário. A
|
|
11
|
+
restrição garantia que a interface conhecesse todos os grupos antes de consultar a listagem e que o
|
|
12
|
+
custo não passasse de doze requisições paralelas.
|
|
13
|
+
|
|
14
|
+
Alguns catálogos fechados, porém administráveis, chegam ao consumidor como opções resolvidas em
|
|
15
|
+
runtime. Equipe, unidade e serviço são exemplos: continuam sendo dimensões enumeráveis no recorte
|
|
16
|
+
da tela, mas não pertencem ao contrato como literais nem a um dicionário versionado. Proibir esses
|
|
17
|
+
campos força o produto a duplicar dados administráveis no contrato; aceitar qualquer lookup, por
|
|
18
|
+
outro lado, permitiria agrupar por resultados parciais de uma busca e produziria contagens
|
|
19
|
+
enganosas.
|
|
20
|
+
|
|
21
|
+
## Decisão
|
|
22
|
+
|
|
23
|
+
### 1. O contrato aceita filtros enumeráveis de runtime
|
|
24
|
+
|
|
25
|
+
Um campo declarado em `grouping.fields` pode referenciar um filtro `select` ou `lookup`. As opções
|
|
26
|
+
estáticas continuam sendo validadas na declaração e precisam conter entre uma e doze entradas.
|
|
27
|
+
Filtros sem opções estáticas são estruturalmente válidos porque sua cardinalidade só é conhecida na
|
|
28
|
+
interface.
|
|
29
|
+
|
|
30
|
+
### 2. `filterOptions` precisa representar o catálogo completo do recorte
|
|
31
|
+
|
|
32
|
+
A `ActionList` só oferece a dimensão quando consegue resolver entre uma e doze opções. Para
|
|
33
|
+
`lookup`, isso exige que o consumidor forneça explicitamente em `filterOptions[campo]` o catálogo
|
|
34
|
+
completo aplicável à tela; os resultados transitórios da busca do próprio lookup não são usados
|
|
35
|
+
como grupos.
|
|
36
|
+
|
|
37
|
+
Se as opções estiverem ausentes, vazias ou acima do limite, a dimensão não aparece em **Agrupar
|
|
38
|
+
por** e um `groupBy` recebido por estado ou URL é ignorado. Não há agrupamento parcial silencioso.
|
|
39
|
+
|
|
40
|
+
### 3. O protocolo de consulta não muda
|
|
41
|
+
|
|
42
|
+
Cada opção continua produzindo um recorte da mesma `ListAction`, como definido na ADR 0054. O
|
|
43
|
+
limite de doze consultas paralelas, a exigência de `paginate: 'offset'`, o `total` autoritativo e a
|
|
44
|
+
paginação independente por grupo permanecem iguais.
|
|
45
|
+
|
|
46
|
+
Catálogos maiores precisam de uma futura fronteira agregada no servidor; esta decisão não antecipa
|
|
47
|
+
esse protocolo.
|
|
48
|
+
|
|
49
|
+
## Consequências
|
|
50
|
+
|
|
51
|
+
- Produtos podem agrupar por catálogos administráveis sem copiá-los para o contrato.
|
|
52
|
+
- O consumidor assume explicitamente que `filterOptions[campo]` é exaustivo para o recorte atual.
|
|
53
|
+
- Um lookup pesquisável ou de alta cardinalidade não se torna uma dimensão de agrupamento apenas
|
|
54
|
+
por estar declarado.
|
|
55
|
+
- Serviço deixa de ser oferecido como agrupamento quando o catálogo resolvido tiver mais de doze
|
|
56
|
+
opções; isso é uma limitação visível e segura, não uma amostra dos primeiros resultados.
|
|
57
|
+
|
package/docs/protocol.md
CHANGED
|
@@ -207,9 +207,11 @@ type OptionsSpec =
|
|
|
207
207
|
**`search`**
|
|
208
208
|
- `filters?: Record<string, FilterSpec>` — filtros disponíveis (chave é nome).
|
|
209
209
|
- `sort?`, `paginate?`, `text?`, `periods?`, `grouping?` — ver tipos abaixo.
|
|
210
|
-
- `grouping.fields` referencia filtros `select`
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
- `grouping.fields` referencia filtros enumeráveis `select` ou `lookup`. Opções estáticas e de
|
|
211
|
+
dicionário podem vir da declaração/provider; catálogos administráveis vêm de `filterOptions` no
|
|
212
|
+
consumidor. A interface só oferece dimensões com 1 a 12 opções completas e consulta cada valor
|
|
213
|
+
como um recorte da mesma action; não agrupa só a página carregada nem resultados parciais de uma
|
|
214
|
+
busca.
|
|
213
215
|
|
|
214
216
|
```ts
|
|
215
217
|
type FilterSpec = {
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import type { ListAction } from './types.ts'
|
|
|
3
3
|
const MAX_GROUP_OPTIONS = 12
|
|
4
4
|
|
|
5
5
|
/** Valida a parte estrutural de `ListAction.grouping` no momento da declaração.
|
|
6
|
-
* Dicionários
|
|
6
|
+
* Dicionários e opções resolvidas em runtime têm a cardinalidade conferida pela UI. */
|
|
7
7
|
export function assertValidListGrouping(
|
|
8
8
|
action: Pick<ListAction<unknown, unknown>, 'name' | 'filters' | 'grouping' | 'paginate'>,
|
|
9
9
|
): void {
|
|
@@ -26,14 +26,14 @@ export function assertValidListGrouping(
|
|
|
26
26
|
if (filter === undefined) {
|
|
27
27
|
throw new Error(`A action "${action.name}" agrupa por "${field}", mas esse filtro não existe.`)
|
|
28
28
|
}
|
|
29
|
-
if (filter.type !== 'select'
|
|
29
|
+
if (filter.type !== 'select' && filter.type !== 'lookup') {
|
|
30
30
|
throw new Error(
|
|
31
31
|
`A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ser ` +
|
|
32
|
-
'um filtro select com opções
|
|
32
|
+
'um filtro select ou lookup com opções enumeráveis.',
|
|
33
33
|
)
|
|
34
34
|
}
|
|
35
35
|
if (
|
|
36
|
-
filter.options
|
|
36
|
+
filter.options?.kind === 'static' &&
|
|
37
37
|
(filter.options.items.length === 0 || filter.options.items.length > MAX_GROUP_OPTIONS)
|
|
38
38
|
) {
|
|
39
39
|
throw new Error(
|
package/src/core/types.ts
CHANGED
|
@@ -924,8 +924,8 @@ export interface SearchFilters {
|
|
|
924
924
|
paginate?: 'cursor' | 'offset' | false
|
|
925
925
|
text?: { fields: string[]; placeholder?: I18nRef }
|
|
926
926
|
periods?: PeriodSpec[]
|
|
927
|
-
/** Dimensões
|
|
928
|
-
* referencia um filtro select com opções
|
|
927
|
+
/** Dimensões enumeráveis que a ActionList pode usar para agrupar a tabela. Cada nome
|
|
928
|
+
* referencia um filtro select/lookup com até 12 opções declaradas ou resolvidas em runtime. */
|
|
929
929
|
grouping?: { fields: string[] }
|
|
930
930
|
/**
|
|
931
931
|
* Cláusula DSL aplicada **sempre** pelo repository do consumer (ex:
|
|
@@ -959,7 +959,7 @@ export interface ListAction<In, Out, ParsedIn = In>
|
|
|
959
959
|
paginate?: 'cursor' | 'offset' | false
|
|
960
960
|
text?: { fields: string[]; placeholder?: I18nRef }
|
|
961
961
|
periods?: PeriodSpec[]
|
|
962
|
-
/** Dimensões
|
|
962
|
+
/** Dimensões enumeráveis que a ActionList pode usar para agrupar a tabela. */
|
|
963
963
|
grouping?: { fields: string[] }
|
|
964
964
|
/**
|
|
965
965
|
* Ver `SearchFilters.where`. Baseline DSL aplicado pelo repository
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useQueries } from '@tanstack/react-query'
|
|
2
2
|
import { useRef } from 'react'
|
|
3
|
-
import type { ActionDef, ActionError, Paginated } from '../../../core/index.ts'
|
|
4
|
-
import { useClient } from '../../drivers/context.tsx'
|
|
3
|
+
import type { ActionDef, ActionError, ClientAdapter, Paginated } from '../../../core/index.ts'
|
|
5
4
|
|
|
6
5
|
export interface ListActionGroup<TItem> {
|
|
7
6
|
value: string
|
|
@@ -15,7 +14,8 @@ export interface ListActionGroup<TItem> {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/** Implementação interna do ActionList para executar um recorte paginado por grupo. */
|
|
18
|
-
export function
|
|
17
|
+
export function useGroupedListQueries<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
|
|
18
|
+
client: ClientAdapter,
|
|
19
19
|
action: { name: string; kind: 'list' },
|
|
20
20
|
input: TInput,
|
|
21
21
|
field: string | null,
|
|
@@ -24,7 +24,6 @@ export function useGroupedListAction<TItem = unknown, TInput extends Record<stri
|
|
|
24
24
|
pages: Record<string, number>,
|
|
25
25
|
pageSize: number,
|
|
26
26
|
): ListActionGroup<TItem>[] {
|
|
27
|
-
const client = useClient('ActionList grouping')
|
|
28
27
|
const previous = useRef<{
|
|
29
28
|
field: string | null
|
|
30
29
|
groups: Map<string, { items: TItem[]; total: number }>
|
|
@@ -62,11 +62,11 @@ import {
|
|
|
62
62
|
} from "../../../core/index.ts";
|
|
63
63
|
import {
|
|
64
64
|
useDicts,
|
|
65
|
+
useGroupedListAction,
|
|
65
66
|
useListAction,
|
|
66
67
|
useLookupAction,
|
|
67
68
|
type DictLike,
|
|
68
69
|
} from "../../drivers/react.tsx";
|
|
69
|
-
import { useGroupedListAction } from "./grouped-list.ts";
|
|
70
70
|
import { cn } from "../../lib/cn.ts";
|
|
71
71
|
import { FillBoundary, useFillLeaf } from "./fill-region.ts";
|
|
72
72
|
import { objectSchemaShape } from "../../lib/object-schema.ts";
|
|
@@ -516,14 +516,16 @@ function useLookupOptions(
|
|
|
516
516
|
): {
|
|
517
517
|
options: SelectOption[];
|
|
518
518
|
loading: boolean;
|
|
519
|
+
query: string;
|
|
519
520
|
onSearch?: (q: string) => void;
|
|
520
521
|
} {
|
|
522
|
+
const [query, setQuery] = useState("");
|
|
521
523
|
const lookup = spec.options?.kind === "lookup" ? spec.options : undefined;
|
|
522
524
|
const search = useLookupAction<Record<string, unknown>, SelectOption>({
|
|
523
525
|
name: lookup?.source ?? "noop",
|
|
524
526
|
kind: "list",
|
|
525
527
|
} as unknown as ActionDef);
|
|
526
|
-
if (lookup === undefined) return { options: [], loading: false };
|
|
528
|
+
if (lookup === undefined) return { options: [], loading: false, query: "" };
|
|
527
529
|
const depInput: Record<string, unknown> = {};
|
|
528
530
|
for (const d of lookup.depends ?? []) {
|
|
529
531
|
if (!isEmptyValue(allValues[d])) depInput[d] = allValues[d];
|
|
@@ -534,7 +536,9 @@ function useLookupOptions(
|
|
|
534
536
|
label: i.label,
|
|
535
537
|
})),
|
|
536
538
|
loading: search.isLoading,
|
|
539
|
+
query,
|
|
537
540
|
onSearch: (q: string) => {
|
|
541
|
+
setQuery(q);
|
|
538
542
|
void search.run({ ...(q !== "" ? { q } : {}), ...depInput });
|
|
539
543
|
},
|
|
540
544
|
};
|
|
@@ -577,21 +581,66 @@ function FilterField({
|
|
|
577
581
|
);
|
|
578
582
|
const lookup = useLookupOptions(spec, allValues);
|
|
579
583
|
const dicts = useDicts();
|
|
584
|
+
const resolvedOptionByValue = useRef(new Map<string, SelectOption>());
|
|
580
585
|
// Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
|
|
581
586
|
// sem nenhuma, as do lookup (server-side).
|
|
582
587
|
const base = options.length > 0 ? options : dictOptionsFor(spec, dicts);
|
|
588
|
+
const baseByValue = new Map(base.map((option) => [option.value, option]));
|
|
589
|
+
const canonicalLookupOptions = lookup.options.map(
|
|
590
|
+
(option) => baseByValue.get(option.value) ?? option,
|
|
591
|
+
);
|
|
592
|
+
for (const option of canonicalLookupOptions) {
|
|
593
|
+
if (!resolvedOptionByValue.current.has(option.value)) {
|
|
594
|
+
resolvedOptionByValue.current.set(option.value, option);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const option of base) {
|
|
598
|
+
resolvedOptionByValue.current.set(option.value, option);
|
|
599
|
+
}
|
|
600
|
+
const selectedValues = new Set(
|
|
601
|
+
Array.isArray(value)
|
|
602
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
603
|
+
: typeof value === "string"
|
|
604
|
+
? value === ""
|
|
605
|
+
? []
|
|
606
|
+
: [value]
|
|
607
|
+
: defaultValue === ""
|
|
608
|
+
? []
|
|
609
|
+
: [defaultValue],
|
|
610
|
+
);
|
|
611
|
+
const runtimeOptions =
|
|
612
|
+
lookup.onSearch !== undefined && lookup.query !== ""
|
|
613
|
+
? base.filter((option) =>
|
|
614
|
+
selectedValues.has(option.value) ||
|
|
615
|
+
option.label
|
|
616
|
+
.toLocaleLowerCase()
|
|
617
|
+
.includes(lookup.query.toLocaleLowerCase()),
|
|
618
|
+
)
|
|
619
|
+
: base;
|
|
620
|
+
const selectedResolvedOptions = [...selectedValues]
|
|
621
|
+
.map((selectedValue) => resolvedOptionByValue.current.get(selectedValue))
|
|
622
|
+
.filter((option): option is SelectOption => option !== undefined);
|
|
623
|
+
const mergeUniqueOptions = (
|
|
624
|
+
...groups: SelectOption[][]
|
|
625
|
+
): SelectOption[] => {
|
|
626
|
+
const merged = new Map<string, SelectOption>();
|
|
627
|
+
for (const group of groups) {
|
|
628
|
+
for (const option of group) {
|
|
629
|
+
if (!merged.has(option.value)) merged.set(option.value, option);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return [...merged.values()];
|
|
633
|
+
};
|
|
583
634
|
const effOptions =
|
|
584
635
|
lookup.onSearch !== undefined && base.length > 0
|
|
585
|
-
?
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
),
|
|
591
|
-
]
|
|
636
|
+
? mergeUniqueOptions(
|
|
637
|
+
runtimeOptions,
|
|
638
|
+
canonicalLookupOptions,
|
|
639
|
+
selectedResolvedOptions,
|
|
640
|
+
)
|
|
592
641
|
: base.length > 0
|
|
593
642
|
? base
|
|
594
|
-
:
|
|
643
|
+
: mergeUniqueOptions(canonicalLookupOptions, selectedResolvedOptions);
|
|
595
644
|
const singleValue = (value as string | undefined) ?? defaultValue;
|
|
596
645
|
const changedFromDefault = singleValue !== defaultValue;
|
|
597
646
|
const acceptsEmptyValue = spec.allowEmpty !== false;
|
|
@@ -1779,6 +1828,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1779
1828
|
const raw = controlledState ?? internal;
|
|
1780
1829
|
const state: ActionListState = {
|
|
1781
1830
|
...raw,
|
|
1831
|
+
sort: raw.sort ?? defaultSort,
|
|
1782
1832
|
period: raw.period ?? null,
|
|
1783
1833
|
from: raw.from ?? null,
|
|
1784
1834
|
to: raw.to ?? null,
|
|
@@ -1818,11 +1868,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1818
1868
|
const availableGroupingFields = groupingFields.filter((field) => {
|
|
1819
1869
|
if (action.filters?.group !== undefined) return false;
|
|
1820
1870
|
const spec = action.filters?.[field];
|
|
1821
|
-
if (
|
|
1822
|
-
spec?.type !== "select" ||
|
|
1823
|
-
(spec.options?.kind !== "static" && spec.options?.kind !== "dictionary")
|
|
1824
|
-
)
|
|
1825
|
-
return false;
|
|
1871
|
+
if (spec?.type !== "select" && spec?.type !== "lookup") return false;
|
|
1826
1872
|
const options = [
|
|
1827
1873
|
...optionsFor(field, spec, filterOptions),
|
|
1828
1874
|
...dictOptionsFor(spec, dicts),
|
|
@@ -1841,6 +1887,10 @@ export function ActionList<TInput, TItem>({
|
|
|
1841
1887
|
availableGroupingFields.includes(requestedGroupBy)
|
|
1842
1888
|
? requestedGroupBy
|
|
1843
1889
|
: null;
|
|
1890
|
+
const activeGroupingLabel =
|
|
1891
|
+
activeGroupBy === null
|
|
1892
|
+
? null
|
|
1893
|
+
: text(action.filters?.[activeGroupBy]?.label, activeGroupBy);
|
|
1844
1894
|
const hasSegment = children === undefined && viewEntries.length > 1;
|
|
1845
1895
|
const periods = action.periods ?? [];
|
|
1846
1896
|
const hasToolbar =
|
|
@@ -2090,6 +2140,34 @@ export function ActionList<TInput, TItem>({
|
|
|
2090
2140
|
state.sort?.field === key && state.sort.dir === "asc" ? "desc" : "asc";
|
|
2091
2141
|
setStateResetPage({ ...state, sort: { field: key, dir } });
|
|
2092
2142
|
};
|
|
2143
|
+
const activeSort = state.sort;
|
|
2144
|
+
const sortLabelFor = (field: string): string | null => {
|
|
2145
|
+
const declared = action.columns?.find((column) => column.key === field);
|
|
2146
|
+
if (declared !== undefined) return text(declared.label, field);
|
|
2147
|
+
const explicit = columns?.find((column) => column.key === field)?.header;
|
|
2148
|
+
return typeof explicit === "string" ? explicit : null;
|
|
2149
|
+
};
|
|
2150
|
+
const sortableFields = (action.sort?.fields ?? []).flatMap((field) => {
|
|
2151
|
+
const label = sortLabelFor(field);
|
|
2152
|
+
return label === null ? [] : [{ value: field, label }];
|
|
2153
|
+
});
|
|
2154
|
+
const activeSortLabel =
|
|
2155
|
+
activeSort === null
|
|
2156
|
+
? null
|
|
2157
|
+
: sortLabelFor(activeSort.field);
|
|
2158
|
+
const activeSortDirection =
|
|
2159
|
+
activeSort?.dir === "asc" ? "crescente" : "decrescente";
|
|
2160
|
+
const sortCustomized =
|
|
2161
|
+
activeSort?.field !== defaultSort?.field ||
|
|
2162
|
+
activeSort?.dir !== defaultSort?.dir;
|
|
2163
|
+
const displayCustomized =
|
|
2164
|
+
activeGroupBy !== null ||
|
|
2165
|
+
state.columns !== null ||
|
|
2166
|
+
state.limit !== null ||
|
|
2167
|
+
sortCustomized;
|
|
2168
|
+
const displayLabel = displayCustomized
|
|
2169
|
+
? "Exibição personalizada"
|
|
2170
|
+
: "Exibição";
|
|
2093
2171
|
|
|
2094
2172
|
// — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
|
|
2095
2173
|
// elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
|
|
@@ -2180,14 +2258,20 @@ export function ActionList<TInput, TItem>({
|
|
|
2180
2258
|
<Button
|
|
2181
2259
|
variant="ghost"
|
|
2182
2260
|
size="icon"
|
|
2183
|
-
className="shrink-0"
|
|
2184
|
-
aria-label=
|
|
2261
|
+
className="relative shrink-0"
|
|
2262
|
+
aria-label={displayLabel}
|
|
2185
2263
|
>
|
|
2186
2264
|
<Settings2 className="h-3.5 w-3.5" />
|
|
2265
|
+
{displayCustomized && (
|
|
2266
|
+
<Dot
|
|
2267
|
+
context="primary"
|
|
2268
|
+
className="absolute top-1 right-1 ring-2 ring-background"
|
|
2269
|
+
/>
|
|
2270
|
+
)}
|
|
2187
2271
|
</Button>
|
|
2188
2272
|
</PopoverTrigger>
|
|
2189
2273
|
</TooltipTrigger>
|
|
2190
|
-
<TooltipContent>
|
|
2274
|
+
<TooltipContent>{displayLabel}</TooltipContent>
|
|
2191
2275
|
</Tooltip>
|
|
2192
2276
|
<PopoverContent
|
|
2193
2277
|
align="end"
|
|
@@ -2195,6 +2279,93 @@ export function ActionList<TInput, TItem>({
|
|
|
2195
2279
|
data-slot="action-list-display"
|
|
2196
2280
|
>
|
|
2197
2281
|
<div className="space-y-3">
|
|
2282
|
+
{children === undefined &&
|
|
2283
|
+
activeView === "table" &&
|
|
2284
|
+
sortableFields.length > 0 && (
|
|
2285
|
+
<>
|
|
2286
|
+
<div className="space-y-2">
|
|
2287
|
+
<div className="flex items-center justify-between">
|
|
2288
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2289
|
+
Ordenação
|
|
2290
|
+
</span>
|
|
2291
|
+
{sortCustomized && (
|
|
2292
|
+
<button
|
|
2293
|
+
type="button"
|
|
2294
|
+
onClick={() =>
|
|
2295
|
+
setState({ ...state, sort: defaultSort, page: 1 })
|
|
2296
|
+
}
|
|
2297
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2298
|
+
>
|
|
2299
|
+
<Eraser className="size-3" />
|
|
2300
|
+
Padrão
|
|
2301
|
+
</button>
|
|
2302
|
+
)}
|
|
2303
|
+
</div>
|
|
2304
|
+
<Select
|
|
2305
|
+
aria-label="Ordenar por"
|
|
2306
|
+
size="sm"
|
|
2307
|
+
className="w-full"
|
|
2308
|
+
value={activeSort?.field ?? "__none__"}
|
|
2309
|
+
onChange={(field) =>
|
|
2310
|
+
setState({
|
|
2311
|
+
...state,
|
|
2312
|
+
sort:
|
|
2313
|
+
field === "__none__"
|
|
2314
|
+
? null
|
|
2315
|
+
: {
|
|
2316
|
+
field,
|
|
2317
|
+
dir:
|
|
2318
|
+
activeSort?.field === field
|
|
2319
|
+
? activeSort.dir
|
|
2320
|
+
: "asc",
|
|
2321
|
+
},
|
|
2322
|
+
page: 1,
|
|
2323
|
+
})
|
|
2324
|
+
}
|
|
2325
|
+
options={[
|
|
2326
|
+
...(defaultSort === null
|
|
2327
|
+
? [{ value: "__none__", label: "Sem ordenação" }]
|
|
2328
|
+
: []),
|
|
2329
|
+
...sortableFields,
|
|
2330
|
+
]}
|
|
2331
|
+
trailing={
|
|
2332
|
+
activeSort !== null ? (
|
|
2333
|
+
<Button
|
|
2334
|
+
type="button"
|
|
2335
|
+
variant="ghost"
|
|
2336
|
+
size="icon-xs"
|
|
2337
|
+
aria-label={
|
|
2338
|
+
activeSort.dir === "asc"
|
|
2339
|
+
? "Ordem crescente. Alterar para decrescente"
|
|
2340
|
+
: "Ordem decrescente. Alterar para crescente"
|
|
2341
|
+
}
|
|
2342
|
+
onClick={() =>
|
|
2343
|
+
setState({
|
|
2344
|
+
...state,
|
|
2345
|
+
sort: {
|
|
2346
|
+
...activeSort,
|
|
2347
|
+
dir:
|
|
2348
|
+
activeSort.dir === "asc"
|
|
2349
|
+
? "desc"
|
|
2350
|
+
: "asc",
|
|
2351
|
+
},
|
|
2352
|
+
page: 1,
|
|
2353
|
+
})
|
|
2354
|
+
}
|
|
2355
|
+
>
|
|
2356
|
+
{activeSort.dir === "asc" ? (
|
|
2357
|
+
<ArrowUp aria-hidden="true" />
|
|
2358
|
+
) : (
|
|
2359
|
+
<ArrowDown aria-hidden="true" />
|
|
2360
|
+
)}
|
|
2361
|
+
</Button>
|
|
2362
|
+
) : undefined
|
|
2363
|
+
}
|
|
2364
|
+
/>
|
|
2365
|
+
</div>
|
|
2366
|
+
<Separator />
|
|
2367
|
+
</>
|
|
2368
|
+
)}
|
|
2198
2369
|
{children === undefined &&
|
|
2199
2370
|
activeView === "table" &&
|
|
2200
2371
|
availableGroupingFields.length > 0 && (
|
|
@@ -2676,21 +2847,49 @@ export function ActionList<TInput, TItem>({
|
|
|
2676
2847
|
? Math.max(1, Math.ceil(total / (state.limit ?? pageSize)))
|
|
2677
2848
|
: 1;
|
|
2678
2849
|
const goTo = (page: number): void => setState({ ...state, page });
|
|
2850
|
+
const displaySummary =
|
|
2851
|
+
activeGroupingLabel !== null || activeSortLabel !== null ? (
|
|
2852
|
+
<span
|
|
2853
|
+
data-slot="action-list-display-summary"
|
|
2854
|
+
className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
|
|
2855
|
+
>
|
|
2856
|
+
{activeGroupingLabel !== null && (
|
|
2857
|
+
<span className="min-w-0 break-words">
|
|
2858
|
+
Agrupado por {activeGroupingLabel}
|
|
2859
|
+
</span>
|
|
2860
|
+
)}
|
|
2861
|
+
{activeGroupingLabel !== null && activeSortLabel !== null && (
|
|
2862
|
+
<span aria-hidden="true">·</span>
|
|
2863
|
+
)}
|
|
2864
|
+
{activeSortLabel !== null && (
|
|
2865
|
+
<span className="min-w-0 break-words">
|
|
2866
|
+
Ordenado por {activeSortLabel} ({activeSortDirection})
|
|
2867
|
+
</span>
|
|
2868
|
+
)}
|
|
2869
|
+
</span>
|
|
2870
|
+
) : null;
|
|
2679
2871
|
const footer =
|
|
2680
2872
|
total !== undefined && !isError && activeGroupBy !== null ? (
|
|
2681
2873
|
<div
|
|
2682
2874
|
data-slot="action-list-footer"
|
|
2683
|
-
className="flex min-h-10 items-center justify-
|
|
2875
|
+
className="flex min-h-10 flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
|
|
2684
2876
|
>
|
|
2685
|
-
{
|
|
2877
|
+
{displaySummary}
|
|
2878
|
+
<span className={cn(displaySummary === null && "ml-auto")}>
|
|
2879
|
+
{total} {total === 1 ? "item" : "itens"} no total
|
|
2880
|
+
</span>
|
|
2686
2881
|
</div>
|
|
2687
2882
|
) : total !== undefined && !isError ? (
|
|
2688
2883
|
<div
|
|
2689
2884
|
data-slot="action-list-footer"
|
|
2690
|
-
className="grid min-h-10 grid-cols-[
|
|
2885
|
+
className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
|
|
2691
2886
|
>
|
|
2692
|
-
<span>
|
|
2693
|
-
|
|
2887
|
+
<span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
2888
|
+
<span className="whitespace-nowrap">
|
|
2889
|
+
Página {state.page} de {pages}
|
|
2890
|
+
</span>
|
|
2891
|
+
{displaySummary !== null && <span aria-hidden="true">·</span>}
|
|
2892
|
+
{displaySummary}
|
|
2694
2893
|
</span>
|
|
2695
2894
|
{pages > 1 && (
|
|
2696
2895
|
// A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
|
|
@@ -2735,7 +2934,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2735
2934
|
)}
|
|
2736
2935
|
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
2737
2936
|
{pages <= 1 && <span />}
|
|
2738
|
-
<span className="justify-self-end">
|
|
2937
|
+
<span className="justify-self-end whitespace-nowrap">
|
|
2739
2938
|
{total} {total === 1 ? "item" : "itens"} no total
|
|
2740
2939
|
</span>
|
|
2741
2940
|
</div>
|
|
@@ -95,7 +95,7 @@ interface SelectCustomBase extends SelectBaseProps {
|
|
|
95
95
|
/** Liga a busca no próprio campo (lista longa/que cresce). */
|
|
96
96
|
searchable?: boolean;
|
|
97
97
|
/** Busca SERVER-SIDE (debounced): quando definida, o filtro local desliga e o pai
|
|
98
|
-
* atualiza `options`. Implica `searchable
|
|
98
|
+
* atualiza `options`. Implica `searchable`; ao fechar, limpa uma busca enviada. */
|
|
99
99
|
onSearch?: (query: string) => void;
|
|
100
100
|
/** "Buscando…" enquanto o fetch corre (use com `onSearch`). */
|
|
101
101
|
loading?: boolean;
|
|
@@ -279,6 +279,10 @@ function CustomSelect(
|
|
|
279
279
|
/** Fechando por Esc: o foco volta pro gatilho, e focar abre. Marca pra ignorar UM foco. */
|
|
280
280
|
const closingRef = React.useRef(false);
|
|
281
281
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
282
|
+
const wasOpenRef = React.useRef(false);
|
|
283
|
+
const lastSearchedQueryRef = React.useRef("");
|
|
284
|
+
const onSearchRef = React.useRef(onSearch);
|
|
285
|
+
onSearchRef.current = onSearch;
|
|
282
286
|
|
|
283
287
|
const selectedOption = options.find((o) => o.value === singleValue);
|
|
284
288
|
|
|
@@ -303,7 +307,14 @@ function CustomSelect(
|
|
|
303
307
|
* reabrir filtrada por algo que a pessoa procurou minutos antes e já esqueceu.
|
|
304
308
|
*/
|
|
305
309
|
React.useEffect(() => {
|
|
306
|
-
if (!open)
|
|
310
|
+
if (!open && wasOpenRef.current) {
|
|
311
|
+
setQuery("");
|
|
312
|
+
if (lastSearchedQueryRef.current !== "") {
|
|
313
|
+
lastSearchedQueryRef.current = "";
|
|
314
|
+
onSearchRef.current?.("");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
wasOpenRef.current = open;
|
|
307
318
|
}, [open]);
|
|
308
319
|
|
|
309
320
|
// O cmdk carimba o id DELE no input (a11y interna) e descarta o nosso — reaplicamos por
|
|
@@ -312,11 +323,13 @@ function CustomSelect(
|
|
|
312
323
|
if (id !== undefined && inputRef.current !== null) inputRef.current.id = id;
|
|
313
324
|
}, [id]);
|
|
314
325
|
|
|
315
|
-
const onSearchRef = React.useRef(onSearch);
|
|
316
|
-
onSearchRef.current = onSearch;
|
|
317
326
|
React.useEffect(() => {
|
|
318
327
|
if (onSearchRef.current === undefined || !open) return;
|
|
319
|
-
const t = setTimeout(() =>
|
|
328
|
+
const t = setTimeout(() => {
|
|
329
|
+
const normalizedQuery = query.trim();
|
|
330
|
+
lastSearchedQueryRef.current = normalizedQuery;
|
|
331
|
+
onSearchRef.current?.(normalizedQuery);
|
|
332
|
+
}, 200);
|
|
320
333
|
return () => clearTimeout(t);
|
|
321
334
|
}, [query, open]);
|
|
322
335
|
|
|
@@ -217,11 +217,11 @@ periods: [
|
|
|
217
217
|
|
|
218
218
|
## Agrupamento
|
|
219
219
|
|
|
220
|
-
Declare `grouping.fields` quando uma dimensão
|
|
221
|
-
de negócio. Cada campo precisa ser um filtro `select`
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
220
|
+
Declare `grouping.fields` quando uma dimensão enumerável puder organizar a tabela sem mudar o
|
|
221
|
+
recorte de negócio. Cada campo precisa ser um filtro `select` ou `lookup`; texto, número e data não
|
|
222
|
+
são dimensões de agrupamento. O filtro pode aceitar uma ou várias opções: cada consulta de grupo
|
|
223
|
+
envia um único valor na forma declarada. A lista continua plana por padrão, e a pessoa escolhe
|
|
224
|
+
**Agrupar por** em **Exibição**.
|
|
225
225
|
|
|
226
226
|
```ts
|
|
227
227
|
filters: {
|
|
@@ -242,6 +242,12 @@ autoritativa, e **Carregar mais** expande apenas aquele grupo. A soma aparece no
|
|
|
242
242
|
global. O limite é de doze opções por dimensão. Se um dicionário não estiver disponível ou exceder
|
|
243
243
|
esse limite, a dimensão não é oferecida em **Agrupar por**.
|
|
244
244
|
|
|
245
|
+
Opções administráveis também podem chegar por `filterOptions`. Nesse caso, o consumidor precisa
|
|
246
|
+
fornecer o catálogo completo aplicável ao recorte — não apenas os resultados correntes de uma busca.
|
|
247
|
+
Um `lookup` só é oferecido para agrupamento quando `filterOptions[campo]` contém de uma a doze
|
|
248
|
+
opções. Sem opções, ou acima desse limite, a dimensão e qualquer `groupBy` recebido pela URL são
|
|
249
|
+
ignorados; a lista nunca agrupa uma amostra parcial.
|
|
250
|
+
|
|
245
251
|
O agrupamento pertence à tabela derivada. Views alternativas e o modo `children` continuam planos.
|
|
246
252
|
`groupBy` integra `ActionListState`; `listStateToParams` o serializa como `group=<campo>`, e filtros
|
|
247
253
|
aplicados à mesma dimensão restringem os grupos exibidos.
|
|
@@ -403,7 +409,10 @@ render(
|
|
|
403
409
|
- **`options: { kind: 'dictionary', ref: 'sessionStatus' }`:** resolve opções e rótulos por um
|
|
404
410
|
dicionário registrado no `OpusProvider`. `filterOptions` substitui essa fonte quando informado.
|
|
405
411
|
- **`options: { kind: 'lookup', source: 'x.lookup' }`:** consulta uma action com `{ q }` e os valores
|
|
406
|
-
das dependências. A resposta segue o formato `{ value, label }`.
|
|
412
|
+
das dependências. A resposta segue o formato `{ value, label }`. Quando o consumidor também
|
|
413
|
+
fornece `filterOptions`, essas opções são filtradas localmente pelo termo e combinadas com a
|
|
414
|
+
resposta remota. Para o mesmo `value`, o rótulo e a apresentação de `filterOptions` prevalecem;
|
|
415
|
+
valores selecionados preservam o rótulo mesmo depois que a busca é limpa.
|
|
407
416
|
|
|
408
417
|
```tsx
|
|
409
418
|
filters: {
|
|
@@ -427,9 +436,17 @@ filters: {
|
|
|
427
436
|
## Exibição e estado na URL
|
|
428
437
|
|
|
429
438
|
O botão de exibição abre as preferências da listagem em qualquer visualização. Na tabela, permite
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
439
|
+
escolher a ordenação, inverter sua direção, agrupar e mostrar ou ocultar colunas, inclusive as
|
|
440
|
+
declaradas com `hidden`; em todas as visualizações, permite alterar a quantidade de itens por
|
|
441
|
+
página. Um ponto sinaliza quando alguma preferência diverge do padrão. O rodapé explicita a
|
|
442
|
+
ordenação e o agrupamento ativos junto da paginação e do total. Busca, ordenação, filtros,
|
|
443
|
+
visualização, colunas e limite podem ser serializados na query string pelos helpers do componente:
|
|
444
|
+
|
|
445
|
+
Cada campo de `sort.fields` precisa de uma coluna com rótulo textual para aparecer no seletor e no
|
|
446
|
+
resumo do rodapé; a coluna pode estar `hidden`. Campos sem coluna continuam válidos para o handler e
|
|
447
|
+
para uma ordenação padrão, mas o Opus não expõe seu identificador técnico na interface. **Sem
|
|
448
|
+
ordenação** só aparece quando o contrato não declara `sort.default`; com padrão, use **Padrão** para
|
|
449
|
+
restaurá-lo.
|
|
433
450
|
|
|
434
451
|
```tsx
|
|
435
452
|
<ActionList
|
|
@@ -519,7 +536,7 @@ render(
|
|
|
519
536
|
| `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Ela aparece depois dos filtros inline e imediatamente antes do acesso aos filtros avançados. A busca participa da contagem de controles inline: com mais de um controle, a barra exibe as labels; quando ela é o único recorte, sua label é omitida. |
|
|
520
537
|
| `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
|
|
521
538
|
| `periods` | `{ value, label, default? }[]` — o controle de período (presets + Personalizado com calendário); `default: true` marca o preset inicial, senão vale o primeiro; materializa em `from`/`to` no input. |
|
|
522
|
-
| `grouping` | `{ fields }` — dimensões
|
|
539
|
+
| `grouping` | `{ fields }` — dimensões enumeráveis disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` ou `lookup` com até 12 opções declaradas ou resolvidas por `filterOptions`. Não define agrupamento inicial. |
|
|
523
540
|
|
|
524
541
|
## useListAction
|
|
525
542
|
|
|
@@ -538,7 +555,7 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
|
|
|
538
555
|
| `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
|
|
539
556
|
| `layout` | `'flow' \| 'fill'` | `'fill'` como filha direta de `PageBody`, `SurfaceBody` ou do corpo da `Presentation` em uma página `fill`; `'flow'` nos demais casos | Em `fill`, ocupa a altura de um ancestral limitado e rola somente a região de resultados; na tabela, o cabeçalho fica fixo. Sem ancestral limitado, equivale a `flow`. |
|
|
540
557
|
| `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
|
|
541
|
-
| `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para
|
|
558
|
+
| `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para filtros select/lookup. Em uma dimensão de agrupamento, a coleção precisa ser completa e ter de 1 a 12 itens. |
|
|
542
559
|
| `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
|
|
543
560
|
| `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
|
|
544
561
|
| `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
|
|
@@ -140,8 +140,10 @@ render(
|
|
|
140
140
|
|
|
141
141
|
## Busca no servidor
|
|
142
142
|
|
|
143
|
-
Com `onSearch`, o consumidor consulta as opções ao abrir e ao digitar, com debounce.
|
|
144
|
-
|
|
143
|
+
Com `onSearch`, o consumidor consulta as opções ao abrir e ao digitar, com debounce. Ao fechar o
|
|
144
|
+
seletor depois de uma busca, recebe `""` imediatamente para restaurar as opções iniciais; abrir e
|
|
145
|
+
fechar sem digitar não repete a consulta vazia. Atualize `options` com a resposta e use `loading`
|
|
146
|
+
durante a consulta. Esse modo já habilita o campo de busca.
|
|
145
147
|
|
|
146
148
|
```tsx preview col md
|
|
147
149
|
const ASSIGNEES = [
|
|
@@ -395,7 +397,7 @@ render(
|
|
|
395
397
|
| `placeholder` | `string` | `'Selecione…'`; sem padrão com `native` | Texto do campo vazio. Com `native`, só existe quando declarado e vira uma opção desabilitada no início da lista. |
|
|
396
398
|
| `searchPlaceholder` | `string` | `'Buscar…'` | Placeholder da linha de busca dentro da lista. |
|
|
397
399
|
| `emptyText` | `string` | `'Nada encontrado.'` | Mensagem quando a busca não acha nada. |
|
|
398
|
-
| `onSearch` | `(query: string) => void` | | Busca no servidor, chamada ao abrir a lista e depois de uma breve pausa na digitação. Desliga o filtro local; o consumidor atualiza `options` com a resposta. |
|
|
400
|
+
| `onSearch` | `(query: string) => void` | | Busca no servidor, chamada ao abrir a lista e depois de uma breve pausa na digitação. Ao fechar depois de uma busca enviada, recebe `""` imediatamente para restaurar as opções iniciais. Desliga o filtro local; o consumidor atualiza `options` com a resposta. |
|
|
399
401
|
| `loading` | `boolean` | | Mostra “Buscando…” enquanto a consulta de `onSearch` está em andamento. |
|
|
400
402
|
| `clearable` | `boolean` | `false` | Mostra, quando há seleção, uma ação que limpa o valor sem abrir a lista. |
|
|
401
403
|
| `icon` | `React.ReactNode` | | Ícone decorativo no início do controle, em `1rem` e no tom atenuado. |
|
package/src/ui/drivers/react.tsx
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import {
|
|
15
15
|
useCallback,
|
|
16
16
|
useMemo,
|
|
17
|
+
useRef,
|
|
17
18
|
useState,
|
|
18
19
|
} from 'react'
|
|
19
20
|
import {
|
|
@@ -47,6 +48,10 @@ export {
|
|
|
47
48
|
} from './context.tsx'
|
|
48
49
|
import { zodErrorMapPtBr } from '../lib/zod-pt-br.ts'
|
|
49
50
|
import { objectSchemaShape } from '../lib/object-schema.ts'
|
|
51
|
+
import {
|
|
52
|
+
useGroupedListQueries,
|
|
53
|
+
type ListActionGroup,
|
|
54
|
+
} from '../components/patterns/grouped-list.ts'
|
|
50
55
|
|
|
51
56
|
// =============================================================================
|
|
52
57
|
// useAction
|
|
@@ -122,9 +127,11 @@ export function useLookupAction<TInput = unknown, TItem = unknown>(
|
|
|
122
127
|
): UseLookupActionResult<TInput, TItem> {
|
|
123
128
|
const client = useClient('useLookupAction')
|
|
124
129
|
const [state, setState] = useState<ListState<TItem>>(() => idleLookup<TItem>())
|
|
130
|
+
const latestRunRef = useRef(0)
|
|
125
131
|
|
|
126
132
|
const run = useCallback(
|
|
127
133
|
async (input: TInput): Promise<ListState<TItem>> => {
|
|
134
|
+
const runId = ++latestRunRef.current
|
|
128
135
|
setState((prev) => ({
|
|
129
136
|
...prev,
|
|
130
137
|
error: undefined,
|
|
@@ -158,13 +165,14 @@ export function useLookupAction<TInput = unknown, TItem = unknown>(
|
|
|
158
165
|
isError: true,
|
|
159
166
|
}
|
|
160
167
|
|
|
161
|
-
setState(next)
|
|
168
|
+
if (runId === latestRunRef.current) setState(next)
|
|
162
169
|
return next
|
|
163
170
|
},
|
|
164
171
|
[client, action],
|
|
165
172
|
)
|
|
166
173
|
|
|
167
174
|
const reset = useCallback(() => {
|
|
175
|
+
latestRunRef.current += 1
|
|
168
176
|
setState(idleLookup<TItem>())
|
|
169
177
|
}, [])
|
|
170
178
|
|
|
@@ -238,6 +246,32 @@ export function useListAction<TItem = unknown, TInput extends Record<string, unk
|
|
|
238
246
|
}
|
|
239
247
|
}
|
|
240
248
|
|
|
249
|
+
/** Implementação interna do ActionList para executar recortes paginados por grupo. */
|
|
250
|
+
export function useGroupedListAction<
|
|
251
|
+
TItem = unknown,
|
|
252
|
+
TInput extends Record<string, unknown> = Record<string, unknown>,
|
|
253
|
+
>(
|
|
254
|
+
action: { name: string; kind: 'list' },
|
|
255
|
+
input: TInput,
|
|
256
|
+
field: string | null,
|
|
257
|
+
values: string[],
|
|
258
|
+
multiple: boolean,
|
|
259
|
+
pages: Record<string, number>,
|
|
260
|
+
pageSize: number,
|
|
261
|
+
): ListActionGroup<TItem>[] {
|
|
262
|
+
const client = useClient('ActionList grouping')
|
|
263
|
+
return useGroupedListQueries<TItem, TInput>(
|
|
264
|
+
client,
|
|
265
|
+
action,
|
|
266
|
+
input,
|
|
267
|
+
field,
|
|
268
|
+
values,
|
|
269
|
+
multiple,
|
|
270
|
+
pages,
|
|
271
|
+
pageSize,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
241
275
|
// =============================================================================
|
|
242
276
|
// useFormAction
|
|
243
277
|
// =============================================================================
|
package/src/ui/react.tsx
CHANGED
|
@@ -5,7 +5,31 @@
|
|
|
5
5
|
* `meta` de cada componente NÃO é re-exportado aqui de propósito — é metadado de
|
|
6
6
|
* build-time (lido pelo manifesto/MCP da fonte), fora do bundle de runtime.
|
|
7
7
|
*/
|
|
8
|
-
export
|
|
8
|
+
export {
|
|
9
|
+
OpusProvider,
|
|
10
|
+
useAction,
|
|
11
|
+
useDicts,
|
|
12
|
+
useFormAction,
|
|
13
|
+
useListAction,
|
|
14
|
+
useLookupAction,
|
|
15
|
+
useTriggerAction,
|
|
16
|
+
useViewAction,
|
|
17
|
+
} from "./drivers/react.tsx";
|
|
18
|
+
export type {
|
|
19
|
+
ActionError,
|
|
20
|
+
ActionState,
|
|
21
|
+
DictLike,
|
|
22
|
+
ListState,
|
|
23
|
+
OpusProviderProps,
|
|
24
|
+
UseActionResult,
|
|
25
|
+
UseFormActionOptions,
|
|
26
|
+
UseFormActionResult,
|
|
27
|
+
UseListActionResult,
|
|
28
|
+
UseLookupActionResult,
|
|
29
|
+
UseTriggerActionOptions,
|
|
30
|
+
UseTriggerActionResult,
|
|
31
|
+
UseViewActionResult,
|
|
32
|
+
} from "./drivers/react.tsx";
|
|
9
33
|
|
|
10
34
|
// Utilitário
|
|
11
35
|
export { cn } from "./lib/cn.ts";
|