@softize/opus 25.2.0 → 25.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/bin/lib/assistant-schema.d.mts +3 -2
- package/bin/lib/assistant-schema.mjs +41 -21
- 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/0052-floating-layer-coexists-with-modal-surfaces.md +19 -0
- package/docs/adr/0053-read-action-declares-the-resource-assistant.md +8 -1
- package/docs/adr/0054-action-list-grouping-is-a-declared-server-cut.md +82 -0
- package/docs/protocol.md +9 -2
- 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/components/primitives/floating-layer.tsx +4 -4
- package/src/ui/docs/content/action-list.md +42 -9
- package/src/ui/docs/content/actions.md +6 -1
- package/src/ui/docs/content/floating-layer.md +17 -10
- package/src/ui/drivers/context.tsx +50 -0
- package/src/ui/drivers/react.tsx +9 -69
- package/src/ui/lib/modal-layers.ts +18 -10
- package/src/ui/meta.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ 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.1 — 2026-10-05
|
|
11
|
+
|
|
12
|
+
Com um `Dialog` ou `Drawer` aberto e a pessoa numa `FloatingLayer` sem `onEscapeKeyDown`, o Esc
|
|
13
|
+
fecha o modal. Antes, o Opus impedia o modal de fechar e chamava um handler que não existia: quem
|
|
14
|
+
chegava ao botão do assistente pelo Tab e apertava Esc não via efeito nenhum. Com o handler, nada
|
|
15
|
+
muda: a camada recebe a tecla e o modal continua aberto. Para que o Esc no botão feche o modal,
|
|
16
|
+
passe `onEscapeKeyDown` só enquanto a camada tiver o que fechar, como com o painel aberto, e
|
|
17
|
+
`undefined` no restante do tempo. A decisão está numa nota da seção 5 da ADR 0052.
|
|
18
|
+
|
|
19
|
+
`assistantContextLeaves`, de `@softize/opus/assistant-schema`, passa a listar em `open` dois pontos
|
|
20
|
+
por onde qualquer campo chega ao modelo e que antes ficavam de fora: o `$ref` que não resolve dentro
|
|
21
|
+
do schema, que sumia do resultado, e o objeto que aceita chaves não declaradas
|
|
22
|
+
(`additionalProperties: true`, do `.passthrough()` do zod), que aparece como `<caminho>.*`. A forma
|
|
23
|
+
do resultado e a conferência do `opus gen` não mudam. Um teste de campos pessoais que reprova pontos
|
|
24
|
+
abertos pode passar a acusar esses casos; reprove-os ou justifique cada um.
|
|
25
|
+
|
|
26
|
+
A ADR 0052 e a página da `FloatingLayer` registram que o roteiro manual em Safari com VoiceOver e no
|
|
27
|
+
Firefox não foi executado: na adoção pela Grand Brasil, o dono do produto o dispensou. A evidência
|
|
28
|
+
é a dos testes de navegador no Chromium real, e o roteiro volta a ser exigido se surgir problema de
|
|
29
|
+
leitor de tela, de Safari ou de Firefox.
|
|
30
|
+
|
|
31
|
+
## 25.3.0 — 2026-10-05
|
|
32
|
+
|
|
33
|
+
`ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
|
|
34
|
+
Cada dimensão referencia um filtro `select` com até 12 opções estáticas ou de dicionário, e a
|
|
35
|
+
action precisa usar `paginate: 'offset'` e devolver `total`. A pessoa escolhe o agrupamento em
|
|
36
|
+
**Exibição**; cada grupo consulta o servidor separadamente, pode ser recolhido e carrega páginas
|
|
37
|
+
adicionais sem descartar as anteriores. O agrupamento não altera views alternativas nem o modo de
|
|
38
|
+
composição por `children`.
|
|
39
|
+
|
|
40
|
+
O manifest e os stubs passam a projetar `grouping`. `ActionListState` e os helpers de URL preservam
|
|
41
|
+
a dimensão em `group`; dimensões desconhecidas são ignoradas. A implementação das consultas por
|
|
42
|
+
grupo permanece interna à `ActionList`, sem introduzir um hook público adicional.
|
|
43
|
+
|
|
44
|
+
`useListAction(action, input, { enabled })` pode suspender a consulta declarativa sem desmontar o
|
|
45
|
+
hook. O padrão é `true`; a `ActionList` usa essa opção internamente ao alternar entre a consulta
|
|
46
|
+
plana e as consultas agrupadas.
|
|
47
|
+
|
|
10
48
|
## 25.2.0 — 2026-10-03
|
|
11
49
|
|
|
12
50
|
Uma action `view` pode declarar o assistente do recurso que ela lê (ADR 0053). Com isso, a conversa
|
|
@@ -13,8 +13,9 @@ export interface AssistantContextLeaves {
|
|
|
13
13
|
/** Os campos folha alcançados, com ponto e sem índice de lista; `*` marca a chave de um mapa. */
|
|
14
14
|
leaves: string[]
|
|
15
15
|
/**
|
|
16
|
-
* Pontos
|
|
17
|
-
*
|
|
16
|
+
* Pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema que não
|
|
17
|
+
* declara forma, o `$ref` que volta a si mesmo ou que não resolve, e, como `<ponto>.*`, o objeto
|
|
18
|
+
* que aceita chaves não declaradas (`additionalProperties: true`, do `.passthrough()` do zod).
|
|
18
19
|
*/
|
|
19
20
|
open: string[]
|
|
20
21
|
}
|
|
@@ -45,18 +45,26 @@ function isNullOnly(schema) {
|
|
|
45
45
|
const NO_REFS = new Set();
|
|
46
46
|
/** Marca o ponto em que um `$ref` volta a um schema que já está no caminho atual. */
|
|
47
47
|
const CYCLE = Object.freeze({ cycle: true });
|
|
48
|
+
/** Marca o ponto em que um `$ref` não resolve dentro do schema: não há forma a conferir ali. */
|
|
49
|
+
const UNRESOLVED = Object.freeze({ unresolved: true });
|
|
50
|
+
|
|
51
|
+
/** Um dos marcadores de `expand`, e não um schema de verdade. */
|
|
52
|
+
function isMarker(schema) {
|
|
53
|
+
return schema === CYCLE || schema === UNRESOLVED;
|
|
54
|
+
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
57
|
* Os ramos concretos e não nulos de um schema, com `$ref` e combinadores expandidos. Cada ramo traz
|
|
51
58
|
* os `$ref` percorridos no caminho atual; um `$ref` que se repete ali vira `CYCLE`, em vez de
|
|
52
|
-
* expandir de novo.
|
|
59
|
+
* expandir de novo, e um `$ref` que não resolve vira `UNRESOLVED`, em vez de sumir.
|
|
53
60
|
*/
|
|
54
61
|
function expand(schema, root, active = NO_REFS, depth = 0) {
|
|
55
62
|
if (schema === null || typeof schema !== "object" || depth > MAX_DEPTH) return [];
|
|
56
63
|
if (typeof schema.$ref === "string") {
|
|
57
64
|
if (active.has(schema.$ref)) return [{ schema: CYCLE, active }];
|
|
58
65
|
const target = resolveRef(root, schema.$ref);
|
|
59
|
-
|
|
66
|
+
if (target === undefined) return [{ schema: UNRESOLVED, active }];
|
|
67
|
+
return expand(target, root, new Set([...active, schema.$ref]), depth + 1);
|
|
60
68
|
}
|
|
61
69
|
const nested = COMBINATORS.flatMap((key) =>
|
|
62
70
|
Array.isArray(schema[key]) ? schema[key].flatMap((branch) => expand(branch, root, active, depth + 1)) : [],
|
|
@@ -68,9 +76,9 @@ function expand(schema, root, active = NO_REFS, depth = 0) {
|
|
|
68
76
|
return [{ schema, active }, ...nested];
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
/** Os ramos sem os
|
|
79
|
+
/** Os ramos sem os marcadores, para quem só olha o tipo do próprio nó. */
|
|
72
80
|
function branches(schema, root) {
|
|
73
|
-
return expand(schema, root).flatMap(({ schema: branch }) => (branch
|
|
81
|
+
return expand(schema, root).flatMap(({ schema: branch }) => (isMarker(branch) ? [] : [branch]));
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
function isOpen(schema) {
|
|
@@ -89,7 +97,9 @@ function isArraySchema(schema) {
|
|
|
89
97
|
* Os schemas alcançados por `segments` em todos os ramos não nulos. Um schema sem forma alcança
|
|
90
98
|
* qualquer caminho abaixo dele e entra como está. Com `throughLists`, uma lista é atravessada. Um
|
|
91
99
|
* ciclo de `$ref` sem avançar no caminho (por lista ou união) não leva a lugar nenhum; ao descer
|
|
92
|
-
* por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore.
|
|
100
|
+
* por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore. Um `$ref` que não
|
|
101
|
+
* resolve antes do fim do caminho entra como `UNRESOLVED`: quem confere o caminho o descarta, e
|
|
102
|
+
* quem lista as folhas o marca como aberto.
|
|
93
103
|
*/
|
|
94
104
|
function collectTargets(schema, segments, root, throughLists, out, active = NO_REFS, depth = 0) {
|
|
95
105
|
if (depth > MAX_DEPTH) return;
|
|
@@ -99,7 +109,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
|
|
|
99
109
|
}
|
|
100
110
|
for (const { schema: branch, active: refs } of expand(schema, root, active)) {
|
|
101
111
|
if (branch === CYCLE) continue;
|
|
102
|
-
if (isOpen(branch)) {
|
|
112
|
+
if (branch === UNRESOLVED || isOpen(branch)) {
|
|
103
113
|
out.push(branch);
|
|
104
114
|
continue;
|
|
105
115
|
}
|
|
@@ -127,7 +137,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
|
|
|
127
137
|
export function schemaHasPath(schema, segments, root = schema) {
|
|
128
138
|
const targets = [];
|
|
129
139
|
collectTargets(schema, segments, root, true, targets);
|
|
130
|
-
return targets.
|
|
140
|
+
return targets.some((target) => target !== UNRESOLVED);
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
const TITLE_TYPES = new Set(["string", "number", "integer"]);
|
|
@@ -139,19 +149,24 @@ const TITLE_TYPES = new Set(["string", "number", "integer"]);
|
|
|
139
149
|
export function schemaTitleIsText(schema, segments, root = schema) {
|
|
140
150
|
const targets = [];
|
|
141
151
|
collectTargets(schema, segments, root, false, targets);
|
|
142
|
-
return targets.some(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
return targets.some(
|
|
153
|
+
(target) =>
|
|
154
|
+
target !== UNRESOLVED &&
|
|
155
|
+
(isOpen(target) ||
|
|
156
|
+
branches(target, root).some((branch) => {
|
|
157
|
+
if (isOpen(branch)) return true;
|
|
158
|
+
const types = Array.isArray(branch.type) ? branch.type : [branch.type];
|
|
159
|
+
return types.some((type) => TITLE_TYPES.has(type));
|
|
160
|
+
})),
|
|
149
161
|
);
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
/**
|
|
153
|
-
* As folhas abaixo de `schema`.
|
|
154
|
-
*
|
|
165
|
+
* As folhas abaixo de `schema`. Entram em `open`, no ponto em que aparecem, o schema sem forma, o
|
|
166
|
+
* `$ref` que volta a um schema já no caminho atual (uma estrutura recursiva) e o `$ref` que não
|
|
167
|
+
* resolve: abaixo deles, qualquer campo pode chegar ao modelo. Um objeto que aceita chaves não
|
|
168
|
+
* declaradas (`additionalProperties: true`, como o `.passthrough()` do zod) entra como `<ponto>.*`,
|
|
169
|
+
* a mesma marca da chave de um mapa, porque a projeção copia o objeto inteiro.
|
|
155
170
|
*/
|
|
156
171
|
function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, depth = 0) {
|
|
157
172
|
if (depth > MAX_DEPTH) {
|
|
@@ -159,7 +174,7 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
|
|
|
159
174
|
return;
|
|
160
175
|
}
|
|
161
176
|
for (const { schema: branch, active: refs } of expand(schema, root, active)) {
|
|
162
|
-
if (branch
|
|
177
|
+
if (isMarker(branch) || isOpen(branch)) {
|
|
163
178
|
open.add(prefix);
|
|
164
179
|
continue;
|
|
165
180
|
}
|
|
@@ -176,10 +191,13 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
|
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
const additional = branch.additionalProperties;
|
|
179
|
-
|
|
194
|
+
const hasAdditional = additional === true || (additional !== null && typeof additional === "object");
|
|
195
|
+
if (additional === true) {
|
|
196
|
+
open.add(`${prefix}.*`);
|
|
197
|
+
} else if (hasAdditional) {
|
|
180
198
|
collectLeaves(additional, `${prefix}.*`, root, leaves, open, refs, depth + 1);
|
|
181
199
|
}
|
|
182
|
-
if (!hasProperties && !
|
|
200
|
+
if (!hasProperties && !hasAdditional) leaves.add(prefix);
|
|
183
201
|
}
|
|
184
202
|
}
|
|
185
203
|
|
|
@@ -190,8 +208,10 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
|
|
|
190
208
|
* ao modelo por um caminho que termina num contêiner.
|
|
191
209
|
*
|
|
192
210
|
* - `leaves`: caminhos das folhas, com ponto e sem índice de lista; `*` marca a chave de um mapa;
|
|
193
|
-
* - `open`: pontos
|
|
194
|
-
*
|
|
211
|
+
* - `open`: pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema
|
|
212
|
+
* que não declara forma (`z.unknown()`, `z.any()`), o `$ref` que volta a si mesmo ou que não
|
|
213
|
+
* resolve, e, como `<ponto>.*`, o objeto que aceita chaves não declaradas
|
|
214
|
+
* (`additionalProperties: true`, do `.passthrough()` do zod).
|
|
195
215
|
*/
|
|
196
216
|
export function assistantContextLeaves(output, context, root = output) {
|
|
197
217
|
return context.map((path) => {
|
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)},`)
|
|
@@ -225,6 +225,17 @@ camada uma vez. No modal, o Esc fecha o modal, como hoje. A alternativa de fecha
|
|
|
225
225
|
está visualmente por cima foi descartada: quem preenche o modal e aperta Esc quer sair do modal, não
|
|
226
226
|
do painel que nem está usando.
|
|
227
227
|
|
|
228
|
+
*Nota (2026-10-05):* a regra vale para a camada que tem o que fechar. Uma camada sem
|
|
229
|
+
`onEscapeKeyDown` não engole o Esc: com a pessoa nela, a tecla segue para o modal de trabalho, que
|
|
230
|
+
fecha como fecharia com a pessoa nele. Antes, o Opus marcava o evento e chamava um handler ausente,
|
|
231
|
+
e nem o modal nem a camada reagiam; quem chegava ao FAB pelo Tab e apertava Esc encontrava uma
|
|
232
|
+
tecla sem efeito. Com o handler, nada muda: a camada recebe a tecla e o modal continua aberto. O
|
|
233
|
+
consumidor que só tem o que fechar com o painel aberto passa o handler nesse estado e `undefined`
|
|
234
|
+
com o FAB, como a Grand Brasil já faz. A revisão também apontou o handler presente que não tem o que
|
|
235
|
+
fechar naquele momento; o Opus não consegue distinguir esse caso, e a forma de dizer “não há nada a
|
|
236
|
+
fechar aqui” é não passar o handler. Os testes do pacote cobrem o FAB focado e o clique numa área
|
|
237
|
+
sem foco da camada, com Dialog e Drawer, e o navegador real cobre o FAB alcançado pelo Tab.
|
|
238
|
+
|
|
228
239
|
Antes dessa regra vale a do Radix: um `DismissableLayer` aberto depois do modal consome o Esc, onde
|
|
229
240
|
quer que a pessoa esteja. Isso dá três exceções conhecidas e aceitas:
|
|
230
241
|
|
|
@@ -658,6 +669,12 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
|
|
|
658
669
|
navegadores. O roteiro manual acima fica para a adoção na Grand Brasil, executado no Mac do João
|
|
659
670
|
(Safari com VoiceOver) e no Firefox antes de a adoção chegar à `main` da Grand Brasil, com o
|
|
660
671
|
resultado registrado na entrega da adoção.
|
|
672
|
+
|
|
673
|
+
*Nota (2026-10-05):* o roteiro manual não foi executado. Na adoção pela Grand Brasil, o dono do
|
|
674
|
+
produto decidiu seguir sem ele. A evidência da camada é a dos testes de navegador do pacote, no
|
|
675
|
+
Chromium real, somada aos testes da adoção. Safari, Firefox e VoiceOver continuam sem
|
|
676
|
+
verificação. Se aparecer em uso um problema de leitor de tela, de Safari ou de Firefox com a
|
|
677
|
+
camada sobre um modal, o roteiro acima volta a ser exigido.
|
|
661
678
|
- **Arquitetura:**
|
|
662
679
|
- todo uso de portal em `src/ui` lê o contêiner da camada;
|
|
663
680
|
- só `modal-layers.ts`, `dialog.tsx`, `drawer.tsx` e `floating-layer.tsx` aplicam as exceções;
|
|
@@ -666,3 +683,5 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
|
|
|
666
683
|
- o teste de numeração de ADRs continua passando.
|
|
667
684
|
- **Na Grand Brasil:** um teste do shell confirma que o FAB e o painel ficam dentro de
|
|
668
685
|
`[data-slot="floating-layer"]`, e o roteiro de navegador acima roda na pilha de desenvolvimento.
|
|
686
|
+
*Nota (2026-10-05):* o roteiro foi dispensado na adoção e não rodou; ver a nota da mesma data no
|
|
687
|
+
item **Navegador real**.
|
|
@@ -355,7 +355,14 @@ aberto assim:
|
|
|
355
355
|
`assistantContextLeaves(output, context)`: a partir do JSON Schema da saída, como o manifest o
|
|
356
356
|
traz, devolve por caminho os campos folha alcançados e os pontos sem forma declarada ou em que um
|
|
357
357
|
`$ref` volta a si mesmo, por onde qualquer campo pode passar. O subpath publica tipos
|
|
358
|
-
(`assistant-schema.d.mts`) para um teste em TypeScript estrito.
|
|
358
|
+
(`assistant-schema.d.mts`) para um teste em TypeScript estrito. *Nota (2026-10-05):* dois pontos
|
|
359
|
+
por onde qualquer campo passa ficavam fora de `open`. Um `$ref` que não resolve dentro do schema
|
|
360
|
+
sumia sem aparecer em `leaves` nem em `open`; agora entra em `open` no ponto em que aparece. Um
|
|
361
|
+
objeto que aceita chaves não declaradas (`additionalProperties: true`, que o `.passthrough()` do
|
|
362
|
+
zod produz) era tratado como fechado, embora a projeção copie o objeto inteiro; agora entra em
|
|
363
|
+
`open` como `<caminho>.*`, a mesma marca da chave de um mapa. A conferência do `opus gen` não
|
|
364
|
+
muda: um caminho abaixo de um `$ref` que não resolve continua recusado, e ele não vale como
|
|
365
|
+
`title`.
|
|
359
366
|
|
|
360
367
|
## Consequências
|
|
361
368
|
|
|
@@ -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 = {
|
|
@@ -259,7 +263,10 @@ o dado necessário. A action `view` que lê o recurso declara as três coisas em
|
|
|
259
263
|
um intermediário `null` aparece como `null`. Num campo anulável ou numa união, o `opus gen` aceita
|
|
260
264
|
o caminho que existe em algum ramo não nulo. Um caminho que termina num objeto ou numa lista leva
|
|
261
265
|
tudo abaixo dele (`assistantContextLeaves`, de `@softize/opus/assistant-schema`, lista o que cada
|
|
262
|
-
caminho alcança
|
|
266
|
+
caminho alcança: em `leaves`, as folhas; em `open`, os pontos por onde qualquer campo passa sem
|
|
267
|
+
conferência, como um schema sem forma, um `$ref` recursivo ou que não resolve e, como
|
|
268
|
+
`<caminho>.*`, um objeto com `additionalProperties: true`). `title` aponta para texto ou número,
|
|
269
|
+
fora de lista.
|
|
263
270
|
- **Leitura.** `runtime.readResource(resource, id, base, { context? })` executa a action dona com
|
|
264
271
|
`{ id }` pelo `execute`: autorização, loaders, auditoria, marca de dado pessoal e span são os da
|
|
265
272
|
action. Devolve `{ ok: true, resource: { kind, id, title, label, skill }, context?, meta }` ou o
|
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
|
+
}
|