@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 +17 -0
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +2 -1
- package/bin/lib/gen-stubs.mjs +3 -0
- package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +82 -0
- package/docs/protocol.md +5 -1
- package/package.json +1 -1
- package/src/core/actions.ts +5 -2
- package/src/core/contracts.ts +2 -0
- package/src/core/list-grouping.ts +45 -0
- package/src/core/runtime.ts +2 -0
- package/src/core/types.ts +5 -0
- package/src/ui/components/patterns/grouped-list.ts +88 -0
- package/src/ui/components/patterns/list.tsx +395 -61
- package/src/ui/docs/content/action-list.md +42 -9
- package/src/ui/drivers/context.tsx +50 -0
- package/src/ui/drivers/react.tsx +9 -69
- package/src/ui/meta.ts +1 -1
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
|
package/bin/lib/gen-manifest.mjs
CHANGED
package/bin/lib/gen-runner.mjs
CHANGED
|
@@ -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?, //
|
|
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") {
|
package/bin/lib/gen-stubs.mjs
CHANGED
|
@@ -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
package/src/core/actions.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
package/src/core/contracts.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/runtime.ts
CHANGED
|
@@ -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
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
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
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
-
{
|
|
2481
|
+
{null}
|
|
2285
2482
|
</DataState>
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
)
|
|
2289
|
-
|
|
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 &&
|
|
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
|
|
485
|
-
|
|
|
486
|
-
| `columns`
|
|
487
|
-
| `filters`
|
|
488
|
-
| `text`
|
|
489
|
-
| `sort`
|
|
490
|
-
| `periods`
|
|
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
|
+
}
|
package/src/ui/drivers/react.tsx
CHANGED
|
@@ -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
|
|
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",
|