@softize/opus 25.2.0 → 25.3.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 CHANGED
@@ -7,6 +7,23 @@ 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.3.0 — 2026-10-05
11
+
12
+ `ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
13
+ Cada dimensão referencia um filtro `select` com até 12 opções estáticas ou de dicionário, e a
14
+ action precisa usar `paginate: 'offset'` e devolver `total`. A pessoa escolhe o agrupamento em
15
+ **Exibição**; cada grupo consulta o servidor separadamente, pode ser recolhido e carrega páginas
16
+ adicionais sem descartar as anteriores. O agrupamento não altera views alternativas nem o modo de
17
+ composição por `children`.
18
+
19
+ O manifest e os stubs passam a projetar `grouping`. `ActionListState` e os helpers de URL preservam
20
+ a dimensão em `group`; dimensões desconhecidas são ignoradas. A implementação das consultas por
21
+ grupo permanece interna à `ActionList`, sem introduzir um hook público adicional.
22
+
23
+ `useListAction(action, input, { enabled })` pode suspender a consulta declarativa sem desmontar o
24
+ hook. O padrão é `true`; a `ActionList` usa essa opção internamente ao alternar entre a consulta
25
+ plana e as consultas agrupadas.
26
+
10
27
  ## 25.2.0 — 2026-10-03
11
28
 
12
29
  Uma action `view` pode declarar o assistente do recurso que ela lê (ADR 0053). Com isso, a conversa
@@ -80,6 +80,7 @@ function shapeAction(a) {
80
80
  out.paginate = a.paginate;
81
81
  out.text = a.text;
82
82
  out.periods = a.periods;
83
+ if (a.grouping !== undefined) out.grouping = a.grouping;
83
84
  }
84
85
  if (a.kind === "view") {
85
86
  out.projection = a.projection ?? [];
@@ -43,7 +43,7 @@
43
43
  * input: JSONSchema, // zod-to-json-schema
44
44
  * output: JSONSchema, // zod-to-json-schema
45
45
  * fields?: Record<string, FieldSpecSerialized>,
46
- * filters?, sort?, paginate?, text?, periods?, // search
46
+ * filters?, sort?, paginate?, text?, periods?, grouping?, // list
47
47
  * projection?, expand?, // view
48
48
  * background?: boolean,
49
49
  * emits?: string[],
@@ -433,6 +433,7 @@ function serializeAction(action, ctx) {
433
433
  base.paginate = action.paginate ?? null;
434
434
  base.text = action.text ?? null;
435
435
  base.periods = Array.isArray(action.periods) ? action.periods : [];
436
+ if (action.grouping !== undefined) base.grouping = action.grouping;
436
437
  }
437
438
 
438
439
  if (action.kind === "view") {
@@ -149,6 +149,9 @@ function renderActionStub(action) {
149
149
  const filters = stripNullsFromFields(action.filters)
150
150
  lines.push(` filters: ${formatObjectLiteral(filters, ' ')},`)
151
151
  }
152
+ if (action.grouping && Array.isArray(action.grouping.fields)) {
153
+ lines.push(` grouping: ${JSON.stringify(action.grouping)},`)
154
+ }
152
155
  }
153
156
  if (Array.isArray(action.invalidates) && action.invalidates.length > 0) {
154
157
  lines.push(` invalidates: ${JSON.stringify(action.invalidates)},`)
@@ -0,0 +1,82 @@
1
+ # ADR 0054 — Agrupamento da ActionList é um recorte declarado e consultado no servidor
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-03.
5
+ - **Relacionadas:** ADR 0013 (Presentation orientada a action) e ADR 0024 (preenchimento das
6
+ regiões de lista).
7
+
8
+ ## Contexto
9
+
10
+ Listagens operacionais precisam, em alguns casos, comparar conjuntos dentro do mesmo recorte. A
11
+ fila de eventos da Grand Brasil é o primeiro caso: a pessoa quer reunir os eventos por status sem
12
+ perder busca, filtros, ordenação, contagens ou paginação.
13
+
14
+ Agrupar apenas os itens carregados criaria uma representação falsa. Uma página de cinquenta itens
15
+ não permite concluir quantos registros existem em cada grupo, e trocar de página faria grupos
16
+ sumirem ou mudarem de tamanho. Acrescentar um envelope agregado ao protocolo resolveria isso com
17
+ uma consulta, mas obrigaria todo handler e adapter a aprender uma segunda forma de resposta antes de
18
+ existir evidência de que o custo compensa.
19
+
20
+ O campo também não pode ser inferido de toda coluna ou filtro. Texto livre e lookup podem ter
21
+ cardinalidade alta, data e número exigem faixas, e nem toda dimensão fechada faz sentido como
22
+ organização da lista. A decisão precisa continuar no contrato de produto.
23
+
24
+ ## Decisão
25
+
26
+ ### 1. A action declara as dimensões permitidas
27
+
28
+ Uma `ListAction` pode declarar:
29
+
30
+ ```ts
31
+ grouping: {
32
+ fields: ['status', 'stage'],
33
+ }
34
+ ```
35
+
36
+ Cada nome referencia um filtro `select` com opções `static` ou `dictionary`. O controle do filtro
37
+ pode aceitar uma ou várias opções; cada consulta de grupo envia um único valor na forma esperada
38
+ pelo filtro. A declaração não define agrupamento inicial: a lista continua plana até a pessoa
39
+ escolher “Agrupar por” em **Exibição**. O estado usa `groupBy` e a URL usa `group=<campo>`.
40
+
41
+ O Opus rejeita dimensões ausentes, filtros de outro tipo, lookups, conjuntos estáticos sem opções
42
+ fechadas e actions sem `paginate: 'offset'`. A interface oferece apenas dicionários resolvidos com
43
+ até doze opções por dimensão; acima disso, a declaração não é adequada para agrupamento operacional.
44
+
45
+ ### 2. Cada grupo é uma consulta da mesma ListAction
46
+
47
+ O agrupamento preserva o input base, a busca, o período, os demais filtros e a ordenação. Para cada
48
+ opção da dimensão, o Opus executa a mesma action com o valor do grupo aplicado ao filtro. Um filtro
49
+ já aplicado à dimensão restringe quais grupos aparecem; não é removido nem reinterpretado.
50
+
51
+ Cada resposta precisa devolver `total`. A ausência desse dado vira o estado de erro da lista, sem
52
+ lançar uma exceção durante o render. Esse total nomeia o grupo e governa sua paginação. O primeiro
53
+ recorte usa o limite da lista; **Carregar mais** busca a página seguinte daquele grupo e acrescenta
54
+ os itens, sem reiniciar os demais. A invalidação ou a atualização manual refaz todas as consultas.
55
+
56
+ Essa primeira implementação aceita o custo de até doze consultas em paralelo. Um envelope de
57
+ facetas ou uma action agregada só será introduzido quando medição real justificar outra fronteira.
58
+
59
+ ### 3. O agrupamento pertence à tabela
60
+
61
+ A tabela mantém um único cabeçalho e abre um `tbody` por grupo. O cabeçalho do grupo é recolhível,
62
+ expõe `aria-expanded`, apresenta o valor pelo dicionário quando houver e mostra a contagem correta,
63
+ inclusive zero. O agrupamento não se aplica a `children` nem a views alternativas; ao sair da tabela,
64
+ os dados voltam à consulta plana.
65
+
66
+ Busca, filtros e período são aplicados antes do agrupamento; ordenação vale dentro de cada grupo. A
67
+ seleção em lote cobre os itens carregados dos grupos abertos. Recolher um grupo remove da seleção os
68
+ itens dele, para nenhuma ação operar sobre linhas invisíveis.
69
+
70
+ Se uma consulta falhar, a lista apresenta o erro padrão e permite refazer o conjunto. Se todos os
71
+ totais forem zero, há um único estado vazio. Não existe paginador global enquanto o agrupamento está
72
+ ativo; o rodapé mostra apenas a soma dos totais.
73
+
74
+ ## Consequências
75
+
76
+ - O contrato distingue capacidade de agrupamento de uma seleção corrente.
77
+ - Contagens e páginas continuam autoritativas no servidor; não há agrupamento parcial silencioso.
78
+ - O número de requisições cresce com as opções da dimensão, de forma explícita e limitada.
79
+ - O primeiro consumidor deve testar que a soma dos totais agrupados coincide com o total da lista
80
+ plana para o mesmo recorte.
81
+ - Uma futura otimização de protocolo pode preservar `grouping` e `groupBy`, trocando apenas como os
82
+ dados chegam ao cliente.
package/docs/protocol.md CHANGED
@@ -129,6 +129,7 @@ type ListAction<In, Out> = ActionBase<In, Paginated<Out>> & {
129
129
  paginate?: 'cursor' | 'offset' | false
130
130
  text?: { fields: string[] }
131
131
  periods?: { value: string, label: I18nRef }[]
132
+ grouping?: { fields: string[] }
132
133
  }
133
134
 
134
135
  type ViewAction<In, Out> = ActionBase<In, Out> & {
@@ -205,7 +206,10 @@ type OptionsSpec =
205
206
 
206
207
  **`search`**
207
208
  - `filters?: Record<string, FilterSpec>` — filtros disponíveis (chave é nome).
208
- - `sort?`, `paginate?`, `text?`, `periods?` — ver tipos abaixo.
209
+ - `sort?`, `paginate?`, `text?`, `periods?`, `grouping?` — ver tipos abaixo.
210
+ - `grouping.fields` referencia filtros `select` com opções fechadas (`static` ou
211
+ `dictionary`). A interface consulta cada valor como um recorte da mesma action; não agrupa só a
212
+ página carregada.
209
213
 
210
214
  ```ts
211
215
  type FilterSpec = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.2.0",
3
+ "version": "25.3.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,8 +9,9 @@
9
9
  * resto server-only) — ver `contracts.ts`. `defineAction` continua válido e
10
10
  * suportado para action server-only, em que não há contrato a compartilhar.
11
11
  *
12
- * Validação estrutural acontece no `Runtime.register()`, não aqui. Esta função
13
- * é puramente sobre **declaração**.
12
+ * A factory valida contradições que existem inteiras na declaração, como um
13
+ * `grouping` que referencia um filtro incompatível. O runtime repete essas
14
+ * guardas para actions montadas sem a factory.
14
15
  *
15
16
  * Ver §2 do protocolo.
16
17
  */
@@ -22,6 +23,7 @@ import type {
22
23
  SimpleAction,
23
24
  ViewAction,
24
25
  } from './types.ts'
26
+ import { assertValidListGrouping } from './list-grouping.ts'
25
27
 
26
28
  /* eslint-disable @typescript-eslint/no-explicit-any */
27
29
 
@@ -61,6 +63,7 @@ export function defineAction<In, Out, ParsedIn = In>(
61
63
  ): ViewAction<In, Out, ParsedIn>
62
64
 
63
65
  export function defineAction(spec: any): any {
66
+ if (spec.kind === 'list') assertValidListGrouping(spec)
64
67
  return spec
65
68
  }
66
69
 
@@ -28,6 +28,7 @@ import type {
28
28
  SimpleAction,
29
29
  ViewAction,
30
30
  } from './types.ts'
31
+ import { assertValidListGrouping } from './list-grouping.ts'
31
32
 
32
33
  /* eslint-disable @typescript-eslint/no-explicit-any */
33
34
 
@@ -117,6 +118,7 @@ export function defineContract<In, Out, ParsedIn = In>(
117
118
  contract: ViewContract<In, Out, ParsedIn>,
118
119
  ): ViewContract<In, Out, ParsedIn>
119
120
  export function defineContract(contract: any): any {
121
+ if (contract.kind === 'list') assertValidListGrouping(contract)
120
122
  return contract
121
123
  }
122
124
 
@@ -0,0 +1,45 @@
1
+ import type { ListAction } from './types.ts'
2
+
3
+ const MAX_GROUP_OPTIONS = 12
4
+
5
+ /** Valida a parte estrutural de `ListAction.grouping` no momento da declaração.
6
+ * Dicionários são resolvidos pelo provider e têm a cardinalidade conferida pela UI. */
7
+ export function assertValidListGrouping(
8
+ action: Pick<ListAction<unknown, unknown>, 'name' | 'filters' | 'grouping' | 'paginate'>,
9
+ ): void {
10
+ const fields = action.grouping?.fields
11
+ if (fields === undefined) return
12
+ if (fields.length === 0) {
13
+ throw new Error(`A action "${action.name}" declara grouping.fields vazio.`)
14
+ }
15
+ if (new Set(fields).size !== fields.length) {
16
+ throw new Error(`A action "${action.name}" repete uma dimensão em grouping.fields.`)
17
+ }
18
+ if (action.filters?.group !== undefined) {
19
+ throw new Error(`A action "${action.name}" usa o filtro reservado "group" enquanto declara grouping.`)
20
+ }
21
+ if (action.paginate !== 'offset') {
22
+ throw new Error(`A action "${action.name}" declara grouping, mas o agrupamento exige paginate: "offset".`)
23
+ }
24
+ for (const field of fields) {
25
+ const filter = action.filters?.[field]
26
+ if (filter === undefined) {
27
+ throw new Error(`A action "${action.name}" agrupa por "${field}", mas esse filtro não existe.`)
28
+ }
29
+ if (filter.type !== 'select' || (filter.options?.kind !== 'static' && filter.options?.kind !== 'dictionary')) {
30
+ throw new Error(
31
+ `A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ser ` +
32
+ 'um filtro select com opções static ou dictionary.',
33
+ )
34
+ }
35
+ if (
36
+ filter.options.kind === 'static' &&
37
+ (filter.options.items.length === 0 || filter.options.items.length > MAX_GROUP_OPTIONS)
38
+ ) {
39
+ throw new Error(
40
+ `A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ter ` +
41
+ `entre 1 e ${MAX_GROUP_OPTIONS} opções.`,
42
+ )
43
+ }
44
+ }
45
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { isReaction } from './reactions.ts'
15
15
  import { isBackgroundAction } from './actions.ts'
16
+ import { assertValidListGrouping } from './list-grouping.ts'
16
17
  import { isSchedule } from './schedules.ts'
17
18
  import {
18
19
  collectDomainWarnings,
@@ -547,6 +548,7 @@ export class Runtime {
547
548
  })
548
549
  }
549
550
  this.claimAssistant(action)
551
+ if (action.kind === 'list') assertValidListGrouping(action)
550
552
  this.actions.set(action.name, action)
551
553
  if (this.config.dev) this.warnIgnoredExecutionFields(action)
552
554
  }
package/src/core/types.ts CHANGED
@@ -924,6 +924,9 @@ export interface SearchFilters {
924
924
  paginate?: 'cursor' | 'offset' | false
925
925
  text?: { fields: string[]; placeholder?: I18nRef }
926
926
  periods?: PeriodSpec[]
927
+ /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. Cada nome
928
+ * referencia um filtro select com opções static ou dictionary. */
929
+ grouping?: { fields: string[] }
927
930
  /**
928
931
  * Cláusula DSL aplicada **sempre** pelo repository do consumer (ex:
929
932
  * `'NOT archived'`). Runtime NÃO executa SQL — quem consome a search
@@ -956,6 +959,8 @@ export interface ListAction<In, Out, ParsedIn = In>
956
959
  paginate?: 'cursor' | 'offset' | false
957
960
  text?: { fields: string[]; placeholder?: I18nRef }
958
961
  periods?: PeriodSpec[]
962
+ /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. */
963
+ grouping?: { fields: string[] }
959
964
  /**
960
965
  * Ver `SearchFilters.where`. Baseline DSL aplicado pelo repository
961
966
  * via `compileWhere` (do `@softize/opus/dsl`). Runtime não executa SQL.
@@ -0,0 +1,88 @@
1
+ import { useQueries } from '@tanstack/react-query'
2
+ import { useRef } from 'react'
3
+ import type { ActionDef, ActionError, Paginated } from '../../../core/index.ts'
4
+ import { useClient } from '../../drivers/context.tsx'
5
+
6
+ export interface ListActionGroup<TItem> {
7
+ value: string
8
+ items: TItem[]
9
+ total: number | undefined
10
+ error: ActionError | undefined
11
+ isLoading: boolean
12
+ isFetching: boolean
13
+ isError: boolean
14
+ refetch: () => Promise<void>
15
+ }
16
+
17
+ /** Implementação interna do ActionList para executar um recorte paginado por grupo. */
18
+ export function useGroupedListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
19
+ action: { name: string; kind: 'list' },
20
+ input: TInput,
21
+ field: string | null,
22
+ values: string[],
23
+ multiple: boolean,
24
+ pages: Record<string, number>,
25
+ pageSize: number,
26
+ ): ListActionGroup<TItem>[] {
27
+ const client = useClient('ActionList grouping')
28
+ const previous = useRef<{
29
+ field: string | null
30
+ groups: Map<string, { items: TItem[]; total: number }>
31
+ }>({ field, groups: new Map() })
32
+ if (previous.current.field !== field) {
33
+ previous.current = { field, groups: new Map() }
34
+ }
35
+ const groupField = field ?? '__disabled_group__'
36
+ const requests =
37
+ field === null
38
+ ? []
39
+ : values.flatMap((value) =>
40
+ Array.from({ length: pages[value] ?? 1 }, (_, index) => ({
41
+ value,
42
+ page: index + 1,
43
+ })),
44
+ )
45
+ const queries = useQueries({
46
+ queries: requests.map(({ value, page }) => {
47
+ const groupInput = {
48
+ ...input,
49
+ [groupField]: multiple ? [value] : value,
50
+ page,
51
+ limit: pageSize,
52
+ }
53
+ return {
54
+ queryKey: [action.name, groupInput],
55
+ queryFn: async (): Promise<Paginated<TItem>> => {
56
+ const result = await client.run<Paginated<TItem>>(action as unknown as ActionDef, groupInput)
57
+ if (!result.ok) throw result.error
58
+ return result.data
59
+ },
60
+ }
61
+ }),
62
+ })
63
+
64
+ return values.map((value) => {
65
+ const groupQueries = queries.filter((_, index) => requests[index]?.value === value)
66
+ const items = groupQueries.flatMap((query) => query.data?.items ?? [])
67
+ const total = groupQueries.find((query) => query.data?.total !== undefined)?.data?.total
68
+ const isError = groupQueries.some((query) => query.isError)
69
+ const hasCompleteData = groupQueries.length > 0 && groupQueries.every((query) => query.data !== undefined)
70
+ if (hasCompleteData && !isError && total !== undefined) {
71
+ previous.current.groups.set(value, { items, total })
72
+ }
73
+ const fallback = previous.current.groups.get(value)
74
+ const hasCurrentData = groupQueries.some((query) => query.data !== undefined)
75
+ return {
76
+ value,
77
+ items: hasCurrentData ? items : (fallback?.items ?? []),
78
+ total: hasCurrentData ? total : fallback?.total,
79
+ error: (groupQueries.find((query) => query.error !== null)?.error as unknown as ActionError | null) ?? undefined,
80
+ isLoading: (groupQueries[0]?.isPending ?? true) && fallback === undefined,
81
+ isFetching: groupQueries.some((query) => query.isFetching),
82
+ isError,
83
+ refetch: async () => {
84
+ await Promise.all(groupQueries.map((query) => query.refetch()))
85
+ },
86
+ }
87
+ })
88
+ }
@@ -38,6 +38,7 @@ import {
38
38
  Calendar as CalendarIcon,
39
39
  Check,
40
40
  ChevronDown,
41
+ ChevronRight,
41
42
  Eraser,
42
43
  Filter,
43
44
  RotateCw,
@@ -65,6 +66,7 @@ import {
65
66
  useLookupAction,
66
67
  type DictLike,
67
68
  } from "../../drivers/react.tsx";
69
+ import { useGroupedListAction } from "./grouped-list.ts";
68
70
  import { cn } from "../../lib/cn.ts";
69
71
  import { FillBoundary, useFillLeaf } from "./fill-region.ts";
70
72
  import { objectSchemaShape } from "../../lib/object-schema.ts";
@@ -82,6 +84,7 @@ import {
82
84
  PaginationPrevious,
83
85
  } from "../primitives/pagination.tsx";
84
86
  import { Button } from "../primitives/button.tsx";
87
+ import { focusRing } from "../primitives/control.ts";
85
88
  import { ButtonGroup } from "../primitives/button-group.tsx";
86
89
  import { Calendar } from "../primitives/calendar.tsx";
87
90
  import { Checkbox } from "../primitives/checkbox.tsx";
@@ -205,6 +208,8 @@ export interface ActionListState {
205
208
  limit: number | null;
206
209
  /** Página atual (1-based). Mudar filtro/busca/sort/período volta pra 1. */
207
210
  page: number;
211
+ /** Dimensão ativa de agrupamento. null mantém a listagem plana. */
212
+ groupBy?: string | null;
208
213
  }
209
214
 
210
215
  /** Um estado vazio/default — útil pra montar o inicial em quem controla. */
@@ -220,6 +225,7 @@ export function emptyListState(): ActionListState {
220
225
  columns: null,
221
226
  limit: null,
222
227
  page: 1,
228
+ groupBy: null,
223
229
  };
224
230
  }
225
231
 
@@ -270,6 +276,7 @@ export interface ListActionLike {
270
276
  sort?: { fields: string[]; default?: SortSpec[] } | undefined;
271
277
  text?: { fields: string[]; placeholder?: unknown } | undefined;
272
278
  periods?: PeriodSpec[] | undefined;
279
+ grouping?: { fields: string[] } | undefined;
273
280
  }
274
281
 
275
282
  /** Estado compartilhado pelos filtros declarativos, independente da listagem. */
@@ -1750,6 +1757,7 @@ export function ActionList<TInput, TItem>({
1750
1757
  onStateChange,
1751
1758
  }: ActionListProps<TInput, TItem>) {
1752
1759
  const confirmBodyId = useId();
1760
+ const groupRegionId = useId();
1753
1761
  const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
1754
1762
  // Query-backed (useListAction): busca declarativa por [name, input] E refetch
1755
1763
  // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
@@ -1778,6 +1786,7 @@ export function ActionList<TInput, TItem>({
1778
1786
  columns: raw.columns ?? null,
1779
1787
  limit: raw.limit ?? null,
1780
1788
  page: raw.page ?? 1,
1789
+ groupBy: raw.groupBy ?? null,
1781
1790
  };
1782
1791
  const setState = (next: ActionListState): void => {
1783
1792
  if (onStateChange !== undefined) onStateChange(next);
@@ -1804,6 +1813,34 @@ export function ActionList<TInput, TItem>({
1804
1813
  ),
1805
1814
  ];
1806
1815
  const activeView = state.view ?? viewEntries[0]?.[0] ?? "table";
1816
+ const groupingFields = action.grouping?.fields ?? [];
1817
+ const dicts = useDicts();
1818
+ const availableGroupingFields = groupingFields.filter((field) => {
1819
+ if (action.filters?.group !== undefined) return false;
1820
+ const spec = action.filters?.[field];
1821
+ if (
1822
+ spec?.type !== "select" ||
1823
+ (spec.options?.kind !== "static" && spec.options?.kind !== "dictionary")
1824
+ )
1825
+ return false;
1826
+ const options = [
1827
+ ...optionsFor(field, spec, filterOptions),
1828
+ ...dictOptionsFor(spec, dicts),
1829
+ ].filter(
1830
+ (option, index, all) =>
1831
+ all.findIndex((candidate) => candidate.value === option.value) ===
1832
+ index,
1833
+ );
1834
+ return options.length > 0 && options.length <= 12;
1835
+ });
1836
+ const requestedGroupBy = state.groupBy ?? null;
1837
+ const activeGroupBy =
1838
+ children === undefined &&
1839
+ activeView === "table" &&
1840
+ requestedGroupBy !== null &&
1841
+ availableGroupingFields.includes(requestedGroupBy)
1842
+ ? requestedGroupBy
1843
+ : null;
1807
1844
  const hasSegment = children === undefined && viewEntries.length > 1;
1808
1845
  const periods = action.periods ?? [];
1809
1846
  const hasToolbar =
@@ -1860,16 +1897,109 @@ export function ActionList<TInput, TItem>({
1860
1897
  } as TInput;
1861
1898
  }, [input, state, pageSize, filterDefaults]);
1862
1899
 
1863
- const inputKey = JSON.stringify(effectiveInput);
1864
- const { items, total, isLoading, isFetching, isError, error, refetch } =
1865
- useListAction<TItem, Record<string, unknown>>(
1866
- action as unknown as { name: string; kind: "list" },
1867
- effectiveInput as Record<string, unknown>,
1900
+ const inputKey = JSON.stringify([effectiveInput, activeGroupBy]);
1901
+ const groupSpec =
1902
+ activeGroupBy === null ? undefined : action.filters?.[activeGroupBy];
1903
+ const declaredGroupOptions =
1904
+ groupSpec === undefined
1905
+ ? []
1906
+ : [
1907
+ ...optionsFor(activeGroupBy ?? "", groupSpec, filterOptions),
1908
+ ...dictOptionsFor(groupSpec, dicts),
1909
+ ].filter(
1910
+ (option, index, all) =>
1911
+ all.findIndex((candidate) => candidate.value === option.value) ===
1912
+ index,
1913
+ );
1914
+ const selectedGroupFilter =
1915
+ activeGroupBy === null
1916
+ ? undefined
1917
+ : (state.filters[activeGroupBy] ?? filterDefaults?.[activeGroupBy]);
1918
+ const groupOptions = declaredGroupOptions.filter((option) =>
1919
+ Array.isArray(selectedGroupFilter) && selectedGroupFilter.length > 0
1920
+ ? selectedGroupFilter.includes(option.value)
1921
+ : typeof selectedGroupFilter === "string" && selectedGroupFilter !== ""
1922
+ ? selectedGroupFilter === option.value
1923
+ : true,
1924
+ );
1925
+ const [groupPaging, setGroupPaging] = useState<{
1926
+ key: string;
1927
+ pages: Record<string, number>;
1928
+ }>({ key: "", pages: {} });
1929
+ const groupPages = groupPaging.key === inputKey ? groupPaging.pages : {};
1930
+ const [groupCollapse, setGroupCollapse] = useState<{
1931
+ key: string;
1932
+ values: Set<string>;
1933
+ }>({ key: "", values: new Set() });
1934
+ const collapsedGroups =
1935
+ groupCollapse.key === inputKey ? groupCollapse.values : new Set<string>();
1936
+ const flat = useListAction<TItem, Record<string, unknown>>(
1937
+ action as unknown as { name: string; kind: "list" },
1938
+ effectiveInput as Record<string, unknown>,
1939
+ { enabled: activeGroupBy === null },
1940
+ );
1941
+ const grouped = useGroupedListAction<TItem, Record<string, unknown>>(
1942
+ action as unknown as { name: string; kind: "list" },
1943
+ effectiveInput as Record<string, unknown>,
1944
+ activeGroupBy,
1945
+ groupOptions.map((option) => option.value),
1946
+ groupSpec?.multiple === true,
1947
+ groupPages,
1948
+ state.limit ?? pageSize,
1949
+ );
1950
+ const items =
1951
+ activeGroupBy === null
1952
+ ? flat.items
1953
+ : grouped.flatMap((group) => group.items);
1954
+ const visibleItems =
1955
+ activeGroupBy === null
1956
+ ? items
1957
+ : grouped
1958
+ .filter((group) => !collapsedGroups.has(group.value))
1959
+ .flatMap((group) => group.items);
1960
+ const total =
1961
+ activeGroupBy === null
1962
+ ? flat.total
1963
+ : grouped.every((group) => group.total !== undefined)
1964
+ ? grouped.reduce((sum, group) => sum + (group.total ?? 0), 0)
1965
+ : undefined;
1966
+ const isLoading =
1967
+ activeGroupBy === null
1968
+ ? flat.isLoading
1969
+ : grouped.length > 0 && grouped.some((group) => group.isLoading);
1970
+ const isFetching =
1971
+ activeGroupBy === null
1972
+ ? flat.isFetching
1973
+ : grouped.some((group) => group.isFetching);
1974
+ const missingGroupTotal =
1975
+ activeGroupBy !== null &&
1976
+ grouped.some(
1977
+ (group) =>
1978
+ !group.isLoading && !group.isError && group.total === undefined,
1868
1979
  );
1980
+ const isError =
1981
+ activeGroupBy === null
1982
+ ? flat.isError
1983
+ : missingGroupTotal || grouped.some((group) => group.isError);
1984
+ const error =
1985
+ activeGroupBy === null
1986
+ ? flat.error
1987
+ : missingGroupTotal
1988
+ ? {
1989
+ code: "action_list.grouping_total_missing",
1990
+ category: "internal" as const,
1991
+ message: "Não foi possível carregar os grupos desta lista.",
1992
+ severity: "error" as const,
1993
+ retriable: false,
1994
+ }
1995
+ : grouped.find((group) => group.error !== undefined)?.error;
1996
+ const refetch = async (): Promise<void> => {
1997
+ if (activeGroupBy === null) return flat.refetch();
1998
+ await Promise.all(grouped.map((group) => group.refetch()));
1999
+ };
1869
2000
  // — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
1870
2001
  // picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
1871
2002
  const visibleKeys = state.columns;
1872
- const dicts = useDicts();
1873
2003
  const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> =
1874
2004
  useMemo(() => {
1875
2005
  if (columns !== undefined) return columns;
@@ -1972,7 +2102,7 @@ export function ActionList<TInput, TItem>({
1972
2102
  const selectable = (item: TItem): boolean =>
1973
2103
  batchActions.some((a) => a.can?.(item) ?? true);
1974
2104
  const selectableItems =
1975
- batchActions.length > 0 ? items.filter(selectable) : [];
2105
+ batchActions.length > 0 ? visibleItems.filter(selectable) : [];
1976
2106
  const hasSelection =
1977
2107
  batchActions.length > 0 &&
1978
2108
  selectableItems.length > 0 &&
@@ -1981,7 +2111,7 @@ export function ActionList<TInput, TItem>({
1981
2111
  // Página/recorte novo → seleção limpa (os ids podem nem estar mais na tela).
1982
2112
  setSelected(new Set());
1983
2113
  }, [inputKey]);
1984
- const selectedItems = items.filter((it) => selected.has(rowId(it)));
2114
+ const selectedItems = visibleItems.filter((it) => selected.has(rowId(it)));
1985
2115
  const eligibleFor = (a: ActionListBatchAction<TItem>): TItem[] =>
1986
2116
  selectedItems.filter((it) => a.can?.(it) ?? true);
1987
2117
  const runBatch = async (a: ActionListBatchAction<TItem>): Promise<void> => {
@@ -2065,6 +2195,41 @@ export function ActionList<TInput, TItem>({
2065
2195
  data-slot="action-list-display"
2066
2196
  >
2067
2197
  <div className="space-y-3">
2198
+ {children === undefined &&
2199
+ activeView === "table" &&
2200
+ availableGroupingFields.length > 0 && (
2201
+ <>
2202
+ <div className="space-y-2">
2203
+ <span className="text-xs font-medium text-muted-foreground">
2204
+ Agrupar por
2205
+ </span>
2206
+ <Select
2207
+ aria-label="Agrupar por"
2208
+ size="sm"
2209
+ className="w-full"
2210
+ value={activeGroupBy ?? "__none__"}
2211
+ onChange={(value) =>
2212
+ setState({
2213
+ ...state,
2214
+ groupBy: value === "__none__" ? null : value,
2215
+ page: 1,
2216
+ })
2217
+ }
2218
+ options={[
2219
+ { value: "__none__", label: "Sem agrupamento" },
2220
+ ...availableGroupingFields.map((field) => ({
2221
+ value: field,
2222
+ label: text(
2223
+ action.filters?.[field]?.label,
2224
+ field,
2225
+ ),
2226
+ })),
2227
+ ]}
2228
+ />
2229
+ </div>
2230
+ <Separator />
2231
+ </>
2232
+ )}
2068
2233
  {activeView === "table" && pickerColumns.length > 0 && (
2069
2234
  <>
2070
2235
  <div className="space-y-2">
@@ -2107,7 +2272,9 @@ export function ActionList<TInput, TItem>({
2107
2272
  )}
2108
2273
  <div className="space-y-2">
2109
2274
  <span className="text-xs font-medium text-muted-foreground">
2110
- Itens por página
2275
+ {activeGroupBy === null
2276
+ ? "Itens por página"
2277
+ : "Itens por grupo"}
2111
2278
  </span>
2112
2279
  <Select
2113
2280
  size="sm"
@@ -2152,43 +2319,54 @@ export function ActionList<TInput, TItem>({
2152
2319
  (hasSelection ? 1 : 0) +
2153
2320
  (rowActions !== undefined ? 1 : 0),
2154
2321
  );
2155
- const tableRows = items.map((item, idx) => (
2156
- <TableRow
2157
- // eslint-disable-next-line react/no-array-index-key
2158
- key={idx}
2159
- className={onRowClick !== undefined ? "cursor-pointer" : undefined}
2160
- onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
2161
- >
2162
- {hasSelection && (
2163
- <TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
2164
- <Checkbox
2165
- aria-label="Selecionar linha"
2166
- disabled={!selectable(item)}
2167
- checked={selected.has(rowId(item))}
2168
- onCheckedChange={(v) => {
2169
- const next = new Set(selected);
2170
- if (v === true) next.add(rowId(item));
2171
- else next.delete(rowId(item));
2172
- setSelected(next);
2173
- }}
2174
- />
2175
- </TableCell>
2176
- )}
2177
- {derived.map((col) => (
2178
- <TableCell key={col.key} className={col.className}>
2179
- {col.cell(item)}
2180
- </TableCell>
2181
- ))}
2182
- {rowActions !== undefined && (
2183
- <TableCell
2184
- className="w-px whitespace-nowrap text-right"
2185
- onClick={(e) => e.stopPropagation()}
2186
- >
2187
- <RowActions>{rowActions(item)}</RowActions>
2188
- </TableCell>
2189
- )}
2190
- </TableRow>
2191
- ));
2322
+ const renderTableRows = (
2323
+ rows: TItem[],
2324
+ options?: { hidden?: boolean; idPrefix?: string },
2325
+ ) =>
2326
+ rows.map((item, idx) => (
2327
+ <TableRow
2328
+ // eslint-disable-next-line react/no-array-index-key
2329
+ key={idx}
2330
+ id={
2331
+ options?.idPrefix === undefined
2332
+ ? undefined
2333
+ : `${options.idPrefix}-${idx}`
2334
+ }
2335
+ hidden={options?.hidden}
2336
+ className={onRowClick !== undefined ? "cursor-pointer" : undefined}
2337
+ onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
2338
+ >
2339
+ {hasSelection && (
2340
+ <TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
2341
+ <Checkbox
2342
+ aria-label="Selecionar linha"
2343
+ disabled={!selectable(item)}
2344
+ checked={selected.has(rowId(item))}
2345
+ onCheckedChange={(v) => {
2346
+ const next = new Set(selected);
2347
+ if (v === true) next.add(rowId(item));
2348
+ else next.delete(rowId(item));
2349
+ setSelected(next);
2350
+ }}
2351
+ />
2352
+ </TableCell>
2353
+ )}
2354
+ {derived.map((col) => (
2355
+ <TableCell key={col.key} className={col.className}>
2356
+ {col.cell(item)}
2357
+ </TableCell>
2358
+ ))}
2359
+ {rowActions !== undefined && (
2360
+ <TableCell
2361
+ className="w-px whitespace-nowrap text-right"
2362
+ onClick={(e) => e.stopPropagation()}
2363
+ >
2364
+ <RowActions>{rowActions(item)}</RowActions>
2365
+ </TableCell>
2366
+ )}
2367
+ </TableRow>
2368
+ ));
2369
+ const tableRows = renderTableRows(items);
2192
2370
  let content: ReactNode;
2193
2371
  if (children !== undefined) {
2194
2372
  // Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
@@ -2263,14 +2441,33 @@ export function ActionList<TInput, TItem>({
2263
2441
  {rowActions !== undefined && <TableHead className="w-px" />}
2264
2442
  </TableRow>
2265
2443
  </TableHeader>
2266
- <TableBody
2267
- className={
2268
- tableStateActive
2269
- ? "h-full [&_[data-slot=data-state]]:h-full"
2270
- : undefined
2271
- }
2272
- >
2273
- {tableFillsAvailableHeight ? (
2444
+ {activeGroupBy === null ? (
2445
+ <TableBody
2446
+ className={
2447
+ tableStateActive
2448
+ ? "h-full [&_[data-slot=data-state]]:h-full"
2449
+ : undefined
2450
+ }
2451
+ >
2452
+ {tableFillsAvailableHeight ? (
2453
+ <DataState
2454
+ loading={initialLoading}
2455
+ error={isError ? error : null}
2456
+ empty={isEmpty}
2457
+ emptyMessage={emptyMessage}
2458
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
2459
+ {...(retryLabel !== undefined ? { retryLabel } : {})}
2460
+ onRetry={() => refetch()}
2461
+ colSpan={tableColumnCount}
2462
+ >
2463
+ {tableRows}
2464
+ </DataState>
2465
+ ) : (
2466
+ tableRows
2467
+ )}
2468
+ </TableBody>
2469
+ ) : tableFillsAvailableHeight && tableStateActive ? (
2470
+ <TableBody className="h-full [&_[data-slot=data-state]]:h-full">
2274
2471
  <DataState
2275
2472
  loading={initialLoading}
2276
2473
  error={isError ? error : null}
@@ -2281,12 +2478,127 @@ export function ActionList<TInput, TItem>({
2281
2478
  onRetry={() => refetch()}
2282
2479
  colSpan={tableColumnCount}
2283
2480
  >
2284
- {tableRows}
2481
+ {null}
2285
2482
  </DataState>
2286
- ) : (
2287
- tableRows
2288
- )}
2289
- </TableBody>
2483
+ </TableBody>
2484
+ ) : (
2485
+ grouped.map((group, groupIndex) => {
2486
+ const option = groupOptions.find(
2487
+ (candidate) => candidate.value === group.value,
2488
+ );
2489
+ const collapsed = collapsedGroups.has(group.value);
2490
+ const loadedPages = groupPages[group.value] ?? 1;
2491
+ const canLoadMore =
2492
+ group.total !== undefined && group.items.length < group.total;
2493
+ const groupLabel = option?.label ?? group.value;
2494
+ const groupId = `${groupRegionId}-${groupIndex}-${group.value.replace(
2495
+ /[^a-zA-Z0-9_-]/g,
2496
+ "-",
2497
+ )}`;
2498
+ const bodyId = `${groupId}-body`;
2499
+ const controlledIds = [
2500
+ ...group.items.map((_, index) => `${bodyId}-${index}`),
2501
+ ...(canLoadMore ? [`${bodyId}-load-more`] : []),
2502
+ ];
2503
+ return (
2504
+ <TableBody
2505
+ key={group.value}
2506
+ data-group={group.value}
2507
+ aria-labelledby={`${groupId}-label`}
2508
+ >
2509
+ <TableRow className="!bg-muted/40 hover:!bg-muted/60">
2510
+ <TableHead
2511
+ id={`${groupId}-label`}
2512
+ scope="rowgroup"
2513
+ colSpan={tableColumnCount}
2514
+ className="h-auto py-1.5 text-foreground"
2515
+ >
2516
+ <button
2517
+ type="button"
2518
+ aria-expanded={!collapsed}
2519
+ aria-controls={
2520
+ controlledIds.length > 0
2521
+ ? controlledIds.join(" ")
2522
+ : undefined
2523
+ }
2524
+ aria-label={`${groupLabel}, ${
2525
+ group.total === undefined
2526
+ ? "carregando"
2527
+ : `${group.total} ${group.total === 1 ? "item" : "itens"}`
2528
+ }`}
2529
+ className={cn(
2530
+ "flex w-full items-center gap-2 rounded-sm text-left font-medium outline-none",
2531
+ focusRing,
2532
+ )}
2533
+ onClick={() => {
2534
+ const next = new Set(collapsedGroups);
2535
+ if (collapsed) next.delete(group.value);
2536
+ else {
2537
+ next.add(group.value);
2538
+ const hidden = new Set(group.items.map(rowId));
2539
+ setSelected(
2540
+ new Set(
2541
+ [...selected].filter((id) => !hidden.has(id)),
2542
+ ),
2543
+ );
2544
+ }
2545
+ setGroupCollapse({ key: inputKey, values: next });
2546
+ }}
2547
+ >
2548
+ {collapsed ? (
2549
+ <ChevronRight className="size-4" />
2550
+ ) : (
2551
+ <ChevronDown className="size-4" />
2552
+ )}
2553
+ <span className="flex items-center gap-1.5">
2554
+ {option?.content ?? option?.label ?? group.value}
2555
+ </span>
2556
+ <span className="text-muted-foreground">
2557
+ {group.total ?? "…"}
2558
+ </span>
2559
+ </button>
2560
+ </TableHead>
2561
+ </TableRow>
2562
+ {renderTableRows(group.items, {
2563
+ hidden: collapsed,
2564
+ idPrefix: bodyId,
2565
+ })}
2566
+ {canLoadMore && (
2567
+ <TableRow
2568
+ id={`${bodyId}-load-more`}
2569
+ hidden={collapsed}
2570
+ className="hover:bg-transparent"
2571
+ >
2572
+ <TableCell
2573
+ colSpan={tableColumnCount}
2574
+ className="text-center"
2575
+ >
2576
+ <Button
2577
+ size="sm"
2578
+ variant="ghost"
2579
+ aria-label={`Carregar mais de ${groupLabel}`}
2580
+ busy={group.isFetching}
2581
+ onClick={() =>
2582
+ setGroupPaging((current) => ({
2583
+ key: inputKey,
2584
+ pages: {
2585
+ ...(current.key === inputKey
2586
+ ? current.pages
2587
+ : {}),
2588
+ [group.value]: loadedPages + 1,
2589
+ },
2590
+ }))
2591
+ }
2592
+ >
2593
+ Carregar mais
2594
+ </Button>
2595
+ </TableCell>
2596
+ </TableRow>
2597
+ )}
2598
+ </TableBody>
2599
+ );
2600
+ })
2601
+ )}
2290
2602
  </Table>
2291
2603
  );
2292
2604
  }
@@ -2365,7 +2677,14 @@ export function ActionList<TInput, TItem>({
2365
2677
  : 1;
2366
2678
  const goTo = (page: number): void => setState({ ...state, page });
2367
2679
  const footer =
2368
- total !== undefined && !isError ? (
2680
+ total !== undefined && !isError && activeGroupBy !== null ? (
2681
+ <div
2682
+ data-slot="action-list-footer"
2683
+ className="flex min-h-10 items-center justify-end border-t border-border px-3 py-1 text-xs text-muted-foreground"
2684
+ >
2685
+ {total} {total === 1 ? "item" : "itens"} no total
2686
+ </div>
2687
+ ) : total !== undefined && !isError ? (
2369
2688
  <div
2370
2689
  data-slot="action-list-footer"
2371
2690
  className="grid min-h-10 grid-cols-[1fr_auto_1fr] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
@@ -2459,7 +2778,9 @@ export function ActionList<TInput, TItem>({
2459
2778
  className={cn(
2460
2779
  "min-h-0",
2461
2780
  layout === "fill" && "overflow-auto",
2462
- busy && items.length > 0 && "pointer-events-none opacity-60",
2781
+ busy &&
2782
+ items.length > 0 &&
2783
+ "pointer-events-none opacity-60",
2463
2784
  )}
2464
2785
  >
2465
2786
  {body}
@@ -2568,6 +2889,11 @@ export function listParamsToState(
2568
2889
  const periodParam = params.get("period");
2569
2890
  const range =
2570
2891
  periodParam?.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/) ?? null;
2892
+ const groupParam = params.get("group");
2893
+ const groupBy =
2894
+ groupParam !== null && (action.grouping?.fields ?? []).includes(groupParam)
2895
+ ? groupParam
2896
+ : null;
2571
2897
  return {
2572
2898
  q: params.get("q") ?? "",
2573
2899
  sort,
@@ -2579,6 +2905,7 @@ export function listParamsToState(
2579
2905
  columns: cols !== null && cols !== "" ? cols.split(",") : null,
2580
2906
  limit: Number.isInteger(limitParam) && limitParam > 0 ? limitParam : null,
2581
2907
  page: Number.isInteger(page) && page > 1 ? page : 1,
2908
+ groupBy,
2582
2909
  };
2583
2910
  }
2584
2911
 
@@ -2622,6 +2949,13 @@ export function listStateToParams(
2622
2949
  if (state.columns !== null) qs.set("cols", state.columns.join(","));
2623
2950
  if (state.limit !== null) qs.set("limit", String(state.limit));
2624
2951
  if (state.page > 1) qs.set("page", String(state.page));
2952
+ if (
2953
+ state.groupBy !== undefined &&
2954
+ state.groupBy !== null &&
2955
+ (action.grouping?.fields ?? []).includes(state.groupBy)
2956
+ ) {
2957
+ qs.set("group", state.groupBy);
2958
+ }
2625
2959
  const s = qs.toString();
2626
2960
  return s === "" ? "" : `?${s}`;
2627
2961
  }
@@ -215,6 +215,37 @@ periods: [
215
215
  // presetRange('last7') → { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } — o mesmo cálculo do pattern.
216
216
  ```
217
217
 
218
+ ## Agrupamento
219
+
220
+ Declare `grouping.fields` quando uma dimensão fechada puder organizar a tabela sem mudar o recorte
221
+ de negócio. Cada campo precisa ser um filtro `select` com opções `static` ou `dictionary`; texto,
222
+ número, data e lookup não são dimensões de agrupamento. O filtro pode aceitar uma ou várias opções:
223
+ cada consulta de grupo envia um único valor na forma declarada. A lista continua plana por padrão,
224
+ e a pessoa escolhe **Agrupar por** em **Exibição**.
225
+
226
+ ```ts
227
+ filters: {
228
+ status: {
229
+ label: 'Status',
230
+ type: 'select',
231
+ options: { kind: 'dictionary', ref: 'workspace.status' },
232
+ },
233
+ },
234
+ grouping: { fields: ['status'] },
235
+ paginate: 'offset',
236
+ ```
237
+
238
+ O Opus consulta a mesma `ListAction` uma vez para cada opção, aplicando o valor ao filtro e
239
+ preservando busca, período, demais filtros e ordenação. Por isso, o handler precisa devolver
240
+ `total` e a action precisa usar `paginate: 'offset'`: o cabeçalho recolhível apresenta a contagem
241
+ autoritativa, e **Carregar mais** expande apenas aquele grupo. A soma aparece no rodapé, sem paginador
242
+ global. O limite é de doze opções por dimensão. Se um dicionário não estiver disponível ou exceder
243
+ esse limite, a dimensão não é oferecida em **Agrupar por**.
244
+
245
+ O agrupamento pertence à tabela derivada. Views alternativas e o modo `children` continuam planos.
246
+ `groupBy` integra `ActionListState`; `listStateToParams` o serializa como `group=<campo>`, e filtros
247
+ aplicados à mesma dimensão restringem os grupos exibidos.
248
+
218
249
  ## Paginação e carregamento
219
250
 
220
251
  `ActionList` envia `limit`, definido por `pageSize`, e `page` no input. O handler aplica o recorte e
@@ -481,21 +512,23 @@ render(
481
512
 
482
513
  ## Declaração na ListAction
483
514
 
484
- | Chave | O que declara |
485
- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
486
- | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
487
- | `filters` | `{ [nome]: { label, type, options?, multiple?, presentation?, searchable?, allowEmpty?, placement?, section?, depends?, … } }` — a toolbar. `placement` classifica o controle como `inline`, `advanced` ou `external`; filtros avançados com `section` são agrupados no diálogo; `external` mantém estado e input, mas delega a apresentação ao consumidor. `presentation: 'toggle'` mostra todas as opções curtas de um `select` de escolha única em um `ToggleGroup`; sem a propriedade, o controle continua sendo `Select`. `searchable` habilita busca local em `select`; `allowEmpty: false` omite a opção vazia. `depends` desabilita/cascateia; options `kind: 'lookup'` busca em uma action. Valor aplicado entra no input com o mesmo nome. |
488
- | `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. |
489
- | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
490
- | `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. |
515
+ | Chave | O que declara |
516
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
517
+ | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
518
+ | `filters` | `{ [nome]: { label, type, options?, multiple?, presentation?, searchable?, allowEmpty?, placement?, section?, depends?, … } }` — a toolbar. `placement` classifica o controle como `inline`, `advanced` ou `external`; filtros avançados com `section` são agrupados no diálogo; `external` mantém estado e input, mas delega a apresentação ao consumidor. `presentation: 'toggle'` mostra todas as opções curtas de um `select` de escolha única em um `ToggleGroup`; sem a propriedade, o controle continua sendo `Select`. `searchable` habilita busca local em `select`; `allowEmpty: false` omite a opção vazia. `depends` desabilita/cascateia; options `kind: 'lookup'` busca em uma action. Valor aplicado entra no input com o mesmo nome. |
519
+ | `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
+ | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
521
+ | `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 fechadas disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` com opções estáticas ou de dicionário. Não define agrupamento inicial. |
491
523
 
492
524
  ## useListAction
493
525
 
494
- Quando o layout não cabe no `ActionList`, `useListAction(action, input)` entrega o mesmo fetch
526
+ Quando o layout não cabe no `ActionList`, `useListAction(action, input, options?)` entrega o mesmo fetch
495
527
  declarativo (`items`, `total`, `error`, `isLoading`, `isFetching`, `refetch`) sob o
496
528
  `QueryClientProvider`, e `presetRange(preset)` materializa os presets de período (`today`, `last7`,
497
529
  `thisMonth`…) em `{ from, to }` no formato `YYYY-MM-DD` — o mesmo cálculo que o pattern faz antes de
498
- chamar a action.
530
+ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem a opção, vale
531
+ `true`.
499
532
 
500
533
  ## Propriedades de ActionList
501
534
 
@@ -0,0 +1,50 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
+ import type { ClientAdapter } from '../../core/index.ts'
3
+
4
+ /**
5
+ * Dicionário estrutural pro resolvedor de opções: qualquer coisa com `options()`
6
+ * satisfaz o contrato sem este driver importar o driver de schema.
7
+ */
8
+ export interface DictLike {
9
+ options(): Array<{ value: string; label: string; icon?: string }>
10
+ }
11
+
12
+ interface OpusContextValue {
13
+ client: ClientAdapter
14
+ dicts: Record<string, DictLike>
15
+ }
16
+
17
+ const OpusContext = createContext<OpusContextValue | null>(null)
18
+
19
+ export interface OpusProviderProps {
20
+ client: ClientAdapter
21
+ /** Dicionários por ref usados por filtros e campos declarativos. */
22
+ dicts?: Record<string, DictLike>
23
+ children: ReactNode
24
+ }
25
+
26
+ export function OpusProvider({ client, dicts, children }: OpusProviderProps): ReactNode {
27
+ const value = useMemo<OpusContextValue>(() => ({ client, dicts: dicts ?? {} }), [client, dicts])
28
+ return <OpusContext.Provider value={value}>{children}</OpusContext.Provider>
29
+ }
30
+
31
+ /** Acesso interno ao adapter; não é reexportado por `@softize/opus/ui/react`. */
32
+ export function useClient(hook: string): ClientAdapter {
33
+ const ctx = useContext(OpusContext)
34
+ if (ctx === null) {
35
+ throw new Error(
36
+ `${hook} foi chamado fora de um <OpusProvider> e não tem como executar a action. ` +
37
+ 'Os hooks de action e os componentes que os usam, como ActionForm, ActionList e ActionView, ' +
38
+ 'precisam de um <OpusProvider client={...}> acima deles na árvore. Envolva a aplicação no provider; ' +
39
+ 'em testes e previews, envolva o componente renderizado.',
40
+ )
41
+ }
42
+ return ctx.client
43
+ }
44
+
45
+ const NO_DICTS: Record<string, DictLike> = {}
46
+
47
+ /** Dicionários do provider; fora dele, o resolvedor usa os fallbacks do contrato. */
48
+ export function useDicts(): Record<string, DictLike> {
49
+ return useContext(OpusContext)?.dicts ?? NO_DICTS
50
+ }
@@ -12,12 +12,9 @@
12
12
  */
13
13
 
14
14
  import {
15
- createContext,
16
15
  useCallback,
17
- useContext,
18
16
  useMemo,
19
17
  useState,
20
- type ReactNode,
21
18
  } from 'react'
22
19
  import {
23
20
  useForm,
@@ -32,7 +29,6 @@ import type {
32
29
  ActionContract,
33
30
  ActionDef,
34
31
  ActionError,
35
- ClientAdapter,
36
32
  FormContract,
37
33
  Paginated,
38
34
  } from '../../core/index.ts'
@@ -42,74 +38,16 @@ import {
42
38
  idleAction,
43
39
  idleLookup,
44
40
  } from '../index.ts'
41
+ import { useClient } from './context.tsx'
42
+ export {
43
+ OpusProvider,
44
+ useDicts,
45
+ type DictLike,
46
+ type OpusProviderProps,
47
+ } from './context.tsx'
45
48
  import { zodErrorMapPtBr } from '../lib/zod-pt-br.ts'
46
49
  import { objectSchemaShape } from '../lib/object-schema.ts'
47
50
 
48
- // =============================================================================
49
- // Provider
50
- // =============================================================================
51
-
52
- /**
53
- * Dicionário estrutural pro resolvedor de `options: { kind: 'dictionary', ref }`:
54
- * qualquer coisa com `options()` → `{ value, label, icon? }`. O `DictType` do `t.dict`
55
- * satisfaz direto — sem este driver importar o driver de schema. A metadata opcional
56
- * continua sendo declarada pelo dicionário; o provider não inventa apresentação.
57
- */
58
- export interface DictLike {
59
- options(): Array<{ value: string; label: string; icon?: string }>
60
- }
61
-
62
- interface OpusContextValue {
63
- client: ClientAdapter
64
- dicts: Record<string, DictLike>
65
- }
66
-
67
- const OpusContext = createContext<OpusContextValue | null>(null)
68
-
69
- export interface OpusProviderProps {
70
- client: ClientAdapter
71
- /** Dicionários por ref — resolvem `options: { kind: 'dictionary', ref }` de
72
- * filters/fields (ActionList/ActionForm). Campo de dict sem registro aqui ainda
73
- * resolve pelo fallback (a meta do `t.dict` viaja no schema do contrato). */
74
- dicts?: Record<string, DictLike>
75
- children: ReactNode
76
- }
77
-
78
- export function OpusProvider({
79
- client,
80
- dicts,
81
- children,
82
- }: OpusProviderProps): ReactNode {
83
- const value = useMemo<OpusContextValue>(() => ({ client, dicts: dicts ?? {} }), [client, dicts])
84
- return <OpusContext.Provider value={value}>{children}</OpusContext.Provider>
85
- }
86
-
87
- /**
88
- * O adapter do provider. `hook` nomeia quem chamou, para a mensagem apontar o hook que a
89
- * pessoa vê no stack — muitas vezes chamado por dentro de ActionForm, ActionList ou
90
- * ActionView, e não diretamente pela tela.
91
- */
92
- function useClient(hook: string): ClientAdapter {
93
- const ctx = useContext(OpusContext)
94
- if (ctx === null) {
95
- throw new Error(
96
- `${hook} foi chamado fora de um <OpusProvider> e não tem como executar a action. ` +
97
- 'Os hooks de action e os componentes que os usam, como ActionForm, ActionList e ActionView, ' +
98
- 'precisam de um <OpusProvider client={...}> acima deles na árvore. Envolva a aplicação no provider; ' +
99
- 'em testes e previews, envolva o componente renderizado.',
100
- )
101
- }
102
- return ctx.client
103
- }
104
-
105
- const NO_DICTS: Record<string, DictLike> = {}
106
-
107
- /** Dicts do provider. Default {} — inclusive fora do provider: o resolvedor cai
108
- * nos fallbacks (estáticas do spec, meta do `t.dict`) sem exigir registry. */
109
- export function useDicts(): Record<string, DictLike> {
110
- return useContext(OpusContext)?.dicts ?? NO_DICTS
111
- }
112
-
113
51
  // =============================================================================
114
52
  // useAction
115
53
  // =============================================================================
@@ -265,6 +203,7 @@ export interface UseListActionResult<TItem> {
265
203
  export function useListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
266
204
  action: { name: string; kind: 'list' },
267
205
  input?: TInput,
206
+ options?: { enabled?: boolean },
268
207
  ): UseListActionResult<TItem> {
269
208
  const client = useClient('useListAction')
270
209
 
@@ -278,6 +217,7 @@ export function useListAction<TItem = unknown, TInput extends Record<string, unk
278
217
  // Input mudou (filtro/página) → mantém os itens na tela enquanto busca: o
279
218
  // ActionList esmaece em vez de piscar skeleton. isPending = só o 1º load.
280
219
  placeholderData: keepPreviousData,
220
+ enabled: options?.enabled ?? true,
281
221
  })
282
222
 
283
223
  const refetch = useCallback(async () => {
package/src/ui/meta.ts CHANGED
@@ -388,7 +388,7 @@ export const componentMeta = {
388
388
  name: "action-list",
389
389
  ancestry: "opus",
390
390
  whenToUse:
391
- "Apresente uma coleção pesquisável a partir de uma ListAction. O contrato declara colunas, filtros, período e busca; ActionList coordena visualizações, paginação, seleção em lote e sincronização com a URL. A tabela derivada não cria moldura própria. Em uma página, envolva a lista com Surface para manter título e criação junto da coleção. Para um único recurso, use ActionView.",
391
+ "Apresente uma coleção pesquisável a partir de uma ListAction. O contrato declara colunas, filtros, período, busca e dimensões fechadas de agrupamento; ActionList coordena visualizações, paginação, seleção em lote e sincronização com a URL. A tabela derivada não cria moldura própria. Em uma página, envolva a lista com Surface para manter título e criação junto da coleção. Para um único recurso, use ActionView.",
392
392
  },
393
393
  "action-trigger": {
394
394
  name: "action-trigger",