@softize/opus 18.3.0 → 18.5.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 +20 -0
- package/README.md +1 -0
- package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +46 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +65 -0
- package/docs/adr/0022-execution-monitoring-is-a-projection.md +39 -0
- package/package.json +21 -1
- package/src/executions/drivers/memory.ts +39 -0
- package/src/executions/drivers/pg-changes.ts +104 -0
- package/src/executions/drivers/pg.ts +74 -0
- package/src/executions/index.ts +129 -0
- package/src/executions/stream.ts +123 -0
- package/src/ui/components/patterns/content-header.tsx +168 -41
- package/src/ui/components/patterns/list.tsx +43 -13
- package/src/ui/components/primitives/detail.tsx +1 -1
- package/src/ui/components/primitives/empty.tsx +2 -1
- package/src/ui/components/primitives/field.tsx +38 -16
- package/src/ui/components/primitives/icon-tile.tsx +91 -0
- package/src/ui/components/primitives/item.tsx +2 -1
- package/src/ui/components/primitives/table.tsx +1 -1
- package/src/ui/docs/content/action-list.md +61 -52
- package/src/ui/docs/content/content.md +84 -8
- package/src/ui/docs/content/executions.md +157 -0
- package/src/ui/docs/content/field.md +78 -30
- package/src/ui/docs/content/icon-tile.md +47 -0
- package/src/ui/docs/registry.tsx +13 -0
- package/src/ui/meta.ts +9 -3
- package/src/ui/react.tsx +17 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority'
|
|
2
|
+
|
|
3
|
+
import type { UiContext } from '../../../core/ui-context.ts'
|
|
4
|
+
import { cn } from '../../lib/cn.ts'
|
|
5
|
+
import {
|
|
6
|
+
semanticOutlineClasses,
|
|
7
|
+
semanticSubtleClasses,
|
|
8
|
+
} from '../../lib/semantic-context.ts'
|
|
9
|
+
import { controlGlyphClass, type ControlSize } from './control.ts'
|
|
10
|
+
|
|
11
|
+
export type IconTileContext = UiContext
|
|
12
|
+
export type IconTileSize = ControlSize
|
|
13
|
+
export type IconTileVariant = 'subtle' | 'outline'
|
|
14
|
+
|
|
15
|
+
const sizeClasses: Record<IconTileSize, string> = {
|
|
16
|
+
xs: 'size-6 rounded-md',
|
|
17
|
+
sm: 'size-8 rounded-md',
|
|
18
|
+
default: 'size-9 rounded-md',
|
|
19
|
+
lg: 'size-10 rounded-lg',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const iconTileSizeVariants = cva(
|
|
23
|
+
'inline-flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
size: sizeClasses,
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
size: 'default',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
export interface IconTileStyleOptions {
|
|
35
|
+
context?: IconTileContext | null
|
|
36
|
+
variant?: IconTileVariant | null
|
|
37
|
+
size?: IconTileSize | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IconTileProps
|
|
41
|
+
extends React.ComponentProps<'span'>,
|
|
42
|
+
IconTileStyleOptions {}
|
|
43
|
+
|
|
44
|
+
export function resolveIconTileStyle({
|
|
45
|
+
context,
|
|
46
|
+
variant,
|
|
47
|
+
size,
|
|
48
|
+
}: IconTileStyleOptions = {}): {
|
|
49
|
+
context: IconTileContext
|
|
50
|
+
variant: IconTileVariant
|
|
51
|
+
size: IconTileSize
|
|
52
|
+
} {
|
|
53
|
+
return {
|
|
54
|
+
context: context ?? 'neutral',
|
|
55
|
+
variant: variant ?? 'subtle',
|
|
56
|
+
size: size ?? 'default',
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function iconTileVariants(options: IconTileStyleOptions = {}): string {
|
|
61
|
+
const resolved = resolveIconTileStyle(options)
|
|
62
|
+
return cn(
|
|
63
|
+
iconTileSizeVariants({ size: resolved.size }),
|
|
64
|
+
resolved.variant === 'outline'
|
|
65
|
+
? cn('border bg-transparent', semanticOutlineClasses[resolved.context])
|
|
66
|
+
: semanticSubtleClasses[resolved.context],
|
|
67
|
+
controlGlyphClass(resolved.size),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function IconTile({
|
|
72
|
+
className,
|
|
73
|
+
context,
|
|
74
|
+
variant,
|
|
75
|
+
size,
|
|
76
|
+
...props
|
|
77
|
+
}: IconTileProps): React.ReactElement {
|
|
78
|
+
const resolved = resolveIconTileStyle({ context, variant, size })
|
|
79
|
+
return (
|
|
80
|
+
<span
|
|
81
|
+
data-slot="icon-tile"
|
|
82
|
+
data-context={resolved.context}
|
|
83
|
+
data-variant={resolved.variant}
|
|
84
|
+
data-size={resolved.size}
|
|
85
|
+
className={cn(iconTileVariants(resolved), className)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { IconTile }
|
|
@@ -4,6 +4,7 @@ import { Slot } from "radix-ui";
|
|
|
4
4
|
|
|
5
5
|
import { cn } from "../../lib/cn.ts";
|
|
6
6
|
import type { ControlSize } from "./control.ts";
|
|
7
|
+
import { iconTileVariants } from "./icon-tile.tsx";
|
|
7
8
|
import { Separator } from "./separator.tsx";
|
|
8
9
|
|
|
9
10
|
const itemGroupVariants = cva(
|
|
@@ -123,7 +124,7 @@ const itemMediaVariants = cva(
|
|
|
123
124
|
variants: {
|
|
124
125
|
variant: {
|
|
125
126
|
default: "bg-transparent",
|
|
126
|
-
icon:
|
|
127
|
+
icon: iconTileVariants({ size: "sm", variant: "outline" }),
|
|
127
128
|
image:
|
|
128
129
|
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
|
|
129
130
|
},
|
|
@@ -40,7 +40,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
|
40
40
|
<thead
|
|
41
41
|
data-slot="table-header"
|
|
42
42
|
className={cn(
|
|
43
|
-
"[&_tr]:border-b",
|
|
43
|
+
"[&_tr]:border-b [&_tr:hover]:bg-transparent",
|
|
44
44
|
variant === 'framed' && 'bg-muted/30 dark:bg-input/30',
|
|
45
45
|
className,
|
|
46
46
|
)}
|
|
@@ -5,6 +5,10 @@ define colunas, busca, filtros, período, ordenação e paginação; a interface
|
|
|
5
5
|
recursos e envia o estado correspondente no input. Filtros avançados aparecem no modal “Filtros” e
|
|
6
6
|
os filtros aplicados permanecem visíveis como chips removíveis.
|
|
7
7
|
|
|
8
|
+
A tabela derivada não cria uma moldura própria. A superfície que recebe o `ActionList` define os
|
|
9
|
+
limites visuais da coleção; use `Table` diretamente quando a composição precisar da variante
|
|
10
|
+
`framed`.
|
|
11
|
+
|
|
8
12
|
A barra se adapta ao espaço disponível. A busca cede largura primeiro e filtros que deixam de caber
|
|
9
13
|
migram para o modal; a linha só quebra quando nenhum controle restante puder ceder espaço.
|
|
10
14
|
`toolbarActions` acrescenta operações ligadas ao recorte atual no fim da barra. Recarregar e
|
|
@@ -86,7 +90,11 @@ ao modal “Filtros”; `external` mantém o filtro no estado navegável e no in
|
|
|
86
90
|
apresentação para o consumidor, como cards de status acima da lista. No modal, filtros com a mesma
|
|
87
91
|
`section` formam um grupo, e `advancedFilters.columns` distribui os grupos em até três colunas.
|
|
88
92
|
Use `advancedFilters.sectionIcons` quando os grupos representarem dimensões recorrentes; a chave é
|
|
89
|
-
o mesmo rótulo de `section`, e
|
|
93
|
+
o mesmo rótulo de `section`, e cada grupo usa `Content as="fieldset"` para manter uma legenda
|
|
94
|
+
nativa. Quando os grupos precisarem de limites mais explícitos,
|
|
95
|
+
`advancedFilters.sectionVariant: 'framed'` envolve cada seção e pede ao `Content` o acabamento
|
|
96
|
+
`frameVariant="legend"`, integrando visualmente a legenda à borda sem atribuir esse design à
|
|
97
|
+
semântica de `fieldset`.
|
|
90
98
|
Um filtro `select` pode declarar `searchable: true` quando a lista fechada for longa; a opção geral
|
|
91
99
|
continua disponível e participa da busca. Campos de data avançados usam calendário. A antiga `advanced: true` continua aceita durante a
|
|
92
100
|
migração e equivale a `placement: 'advanced'`.
|
|
@@ -123,9 +131,10 @@ filters: {
|
|
|
123
131
|
input={{}}
|
|
124
132
|
advancedFilters={{
|
|
125
133
|
columns: 2,
|
|
134
|
+
sectionVariant: 'framed',
|
|
126
135
|
sectionIcons: {
|
|
127
|
-
Relacionamento: <
|
|
128
|
-
Datas: <
|
|
136
|
+
Relacionamento: <IconTile size="xs"><UserRound /></IconTile>,
|
|
137
|
+
Datas: <IconTile size="xs"><CalendarDays /></IconTile>,
|
|
129
138
|
},
|
|
130
139
|
}}
|
|
131
140
|
/>
|
|
@@ -356,31 +365,31 @@ render(
|
|
|
356
365
|
|
|
357
366
|
## Propriedades de ActionFilterBar
|
|
358
367
|
|
|
359
|
-
| Propriedade | Tipo
|
|
360
|
-
| ----------------- |
|
|
361
|
-
| `action` | `Pick<ListAction, 'filters' \| 'text' \| 'periods'>`
|
|
362
|
-
| `state` | `ActionFilterState`
|
|
363
|
-
| `onStateChange` | `(next) => void`
|
|
364
|
-
| `filterOptions` | `Record<string, SelectOption[]>`
|
|
365
|
-
| `filterDefaults` | `Record<string, string>`
|
|
366
|
-
| `filterIcons` | `Record<string, ReactNode>`
|
|
367
|
-
| `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode
|
|
368
|
-
| `periodPosition` | `'before' \| 'after' \| { afterFilter: string }`
|
|
369
|
-
| `period` | `{ basisFilter: string }`
|
|
370
|
-
| `onRefresh` | `() => Promise<void> \| void`
|
|
371
|
-
| `refreshing` | `boolean`
|
|
372
|
-
| `controls` | `ReactNode`
|
|
373
|
-
| `actions` | `ReactNode`
|
|
368
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
369
|
+
| ----------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
370
|
+
| `action` | `Pick<ListAction, 'filters' \| 'text' \| 'periods'>` | | Declara busca, filtros e períodos disponíveis. |
|
|
371
|
+
| `state` | `ActionFilterState` | | Estado atual da barra. |
|
|
372
|
+
| `onStateChange` | `(next) => void` | | Recebe o estado completo depois de cada alteração. |
|
|
373
|
+
| `filterOptions` | `Record<string, SelectOption[]>` | | Fornece opções de runtime para filtros select e lookup. |
|
|
374
|
+
| `filterDefaults` | `Record<string, string>` | | Define o valor de repouso de filtros simples e o destino da ação de limpar. |
|
|
375
|
+
| `filterIcons` | `Record<string, ReactNode>` | | Associa um ícone leading ao controle de cada filtro. |
|
|
376
|
+
| `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento das seções; o Opus deriva a largura correspondente. |
|
|
377
|
+
| `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
|
|
378
|
+
| `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
|
|
379
|
+
| `onRefresh` | `() => Promise<void> \| void` | | Exibe a ação de recarregar e executa a consulta do consumidor. |
|
|
380
|
+
| `refreshing` | `boolean` | `false` | Desabilita e anima a ação de recarregar durante a consulta. |
|
|
381
|
+
| `controls` | `ReactNode` | | Controles auxiliares agrupados com recarregar em um `ButtonGroup` espaçado. |
|
|
382
|
+
| `actions` | `ReactNode` | | Ações do consumidor exibidas depois do grupo de controles, com um intervalo maior. |
|
|
374
383
|
|
|
375
384
|
## Declaração na ListAction
|
|
376
385
|
|
|
377
|
-
| Chave | O que declara
|
|
378
|
-
| --------- |
|
|
379
|
-
| `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.
|
|
386
|
+
| Chave | O que declara |
|
|
387
|
+
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
388
|
+
| `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. |
|
|
380
389
|
| `filters` | `{ [nome]: { label, type, options?, multiple?, 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. `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. |
|
|
381
|
-
| `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.
|
|
382
|
-
| `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`.
|
|
383
|
-
| `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.
|
|
390
|
+
| `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. |
|
|
391
|
+
| `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
|
|
392
|
+
| `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. |
|
|
384
393
|
|
|
385
394
|
## useListAction
|
|
386
395
|
|
|
@@ -392,31 +401,31 @@ chamar a action.
|
|
|
392
401
|
|
|
393
402
|
## Propriedades de ActionList
|
|
394
403
|
|
|
395
|
-
| Propriedade | Tipo
|
|
396
|
-
| ----------------------- |
|
|
397
|
-
| `action` | `ListAction<TInput, TItem>`
|
|
398
|
-
| `input` | `TInput`
|
|
399
|
-
| `layout` | `'flow' \| 'fill'`
|
|
400
|
-
| `cells` | `Record<string, (item) => ReactNode>`
|
|
401
|
-
| `filterOptions` | `Record<string, SelectOption[]>`
|
|
402
|
-
| `filterDefaults` | `Record<string, string>`
|
|
403
|
-
| `filterIcons` | `Record<string, ReactNode>`
|
|
404
|
-
| `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode
|
|
405
|
-
| `periodPosition` | `'before' \| 'after' \| { afterFilter: string }`
|
|
406
|
-
| `period` | `{ basisFilter: string }`
|
|
407
|
-
| `views` | `Record<string, ActionListView<TItem>>`
|
|
408
|
-
| `beforeContent` | `ReactNode`
|
|
409
|
-
| `columns` | `ActionListColumn<TItem>[]`
|
|
410
|
-
| `children` | `(items, refetch) => ReactNode`
|
|
411
|
-
| `batch` | `ActionListBatchAction[]`
|
|
412
|
-
| `rowId` | `(item) => string`
|
|
413
|
-
| `pageSize` | `number`
|
|
414
|
-
| `state / onStateChange` | `ActionListState / (next) => void`
|
|
415
|
-
| `empty` | `(items) => boolean`
|
|
416
|
-
| `loading` | `boolean`
|
|
417
|
-
| `emptyMessage` | `string`
|
|
418
|
-
| `errorMessage` | `string`
|
|
419
|
-
| `retryLabel` | `string`
|
|
420
|
-
| `onRowClick` | `(item: TItem) => void`
|
|
421
|
-
| `toolbarActions` | `ReactNode`
|
|
422
|
-
| `rowActions` | `(item: TItem) => ReactNode`
|
|
404
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
405
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
406
|
+
| `action` | `ListAction<TInput, TItem>` | | A ListAction do Opus (kind list, output = item). A paginação envia `limit`/`page` e usa o `total` devolvido. |
|
|
407
|
+
| `input` | `TInput` | | O ESCOPO BASE (ex.: { workspaceId }) — a toolbar soma por cima, nunca sobrescreve. |
|
|
408
|
+
| `layout` | `'flow' \| 'fill'` | `'flow'` | Em `fill`, ocupa a altura disponível e mantém a rolagem dentro da região de resultados. |
|
|
409
|
+
| `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
|
|
410
|
+
| `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para os filtros select/lookup (chave = nome do filtro). |
|
|
411
|
+
| `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
|
|
412
|
+
| `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
|
|
413
|
+
| `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
|
|
414
|
+
| `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
|
|
415
|
+
| `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
|
|
416
|
+
| `views` | `Record<string, ActionListView<TItem>>` | | Visualizações alternativas `{ label, icon?, render }`; o seletor alterna entre a tabela e estas. |
|
|
417
|
+
| `beforeContent` | `ReactNode` | | Conteúdo entre a toolbar e o corpo, como um resumo que reage ao recorte atual. |
|
|
418
|
+
| `columns` | `ActionListColumn<TItem>[]` | | Tabela EXPLÍCITA — sobrepõe as colunas do contrato (escape hatch). |
|
|
419
|
+
| `children` | `(items, refetch) => ReactNode` | | Modo composição: layout livre; a toolbar segue. Tem precedência sobre columns. |
|
|
420
|
+
| `batch` | `ActionListBatchAction[]` | | Ações em lote: `{ label, can?, run, confirm?: { title, message? }, destructive? }` — liga a multi-seleção. |
|
|
421
|
+
| `rowId` | `(item) => string` | `item.id` | Identidade da linha para seleção. |
|
|
422
|
+
| `pageSize` | `number` | `50` | Itens por página padrão (vira `limit`/`page` no input; handler devolve `total`). O usuário troca no popover de exibição. |
|
|
423
|
+
| `state / onStateChange` | `ActionListState / (next) => void` | interno | Estado da toolbar controlado — para quem embala sincronizar com a URL. |
|
|
424
|
+
| `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado, por exemplo quando um formulário inline aberto conta como conteúdo. |
|
|
425
|
+
| `loading` | `boolean` | | Carga extra agregada à da consulta, como uma query irmã de que os `children` dependem. |
|
|
426
|
+
| `emptyMessage` | `string` | `'Nenhum resultado.'` | Frase do estado vazio (o `Empty` do `DataState`). |
|
|
427
|
+
| `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
|
|
428
|
+
| `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
|
|
429
|
+
| `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
|
|
430
|
+
| `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. |
|
|
431
|
+
| `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram num grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
|
|
@@ -20,6 +20,79 @@ render(
|
|
|
20
20
|
);
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Região com moldura
|
|
24
|
+
|
|
25
|
+
Use `framed` quando título e corpo precisarem formar uma única região delimitada. A moldura pertence
|
|
26
|
+
ao `Content`; componentes internos, como `DetailGroup`, continuam responsáveis somente pelo layout
|
|
27
|
+
e pela semântica dos próprios dados.
|
|
28
|
+
|
|
29
|
+
```tsx preview col
|
|
30
|
+
render(
|
|
31
|
+
<Content
|
|
32
|
+
framed
|
|
33
|
+
title={
|
|
34
|
+
<span className="flex items-center gap-2">
|
|
35
|
+
<UserRound
|
|
36
|
+
className="size-4 text-muted-foreground"
|
|
37
|
+
aria-hidden="true"
|
|
38
|
+
/>
|
|
39
|
+
Cliente
|
|
40
|
+
</span>
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
<DetailGroup columns={2}>
|
|
44
|
+
<DetailField label="Nome" value="Carla Souza" />
|
|
45
|
+
<DetailField label="Telefone" value="+55 11 97333-4455" />
|
|
46
|
+
</DetailGroup>
|
|
47
|
+
</Content>,
|
|
48
|
+
);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Grupo de campos
|
|
52
|
+
|
|
53
|
+
Quando a região nomear um conjunto de campos relacionados, use `as="fieldset"`. O `Content`
|
|
54
|
+
renderiza o título como `legend`, preserva as props nativas do `fieldset` e mantém o mesmo contrato
|
|
55
|
+
de título e corpo. Essa escolha altera somente a semântica e o comportamento nativo; com `framed`,
|
|
56
|
+
a moldura permanece contínua. Nesse modo, contador, descrição e ações não entram na legenda.
|
|
57
|
+
|
|
58
|
+
```tsx preview col
|
|
59
|
+
render(
|
|
60
|
+
<Content as="fieldset" framed title="Proprietário">
|
|
61
|
+
<FieldGroup>
|
|
62
|
+
<Field>
|
|
63
|
+
<FieldLabel htmlFor="owner-name">Nome</FieldLabel>
|
|
64
|
+
<Input id="owner-name" />
|
|
65
|
+
</Field>
|
|
66
|
+
<Field>
|
|
67
|
+
<FieldLabel htmlFor="owner-document">CPF/CNPJ</FieldLabel>
|
|
68
|
+
<Input id="owner-document" />
|
|
69
|
+
</Field>
|
|
70
|
+
</FieldGroup>
|
|
71
|
+
</Content>,
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Quando a legenda precisar integrar visualmente a borda, declare essa escolha separadamente. Use
|
|
76
|
+
`frameVariant="legend"` em agrupamentos compactos, como as seções de filtros avançados.
|
|
77
|
+
|
|
78
|
+
```tsx preview col
|
|
79
|
+
render(
|
|
80
|
+
<Content
|
|
81
|
+
as="fieldset"
|
|
82
|
+
framed
|
|
83
|
+
frameVariant="legend"
|
|
84
|
+
title="Dados do veículo"
|
|
85
|
+
>
|
|
86
|
+
<FieldGroup>
|
|
87
|
+
<Field>
|
|
88
|
+
<FieldLabel htmlFor="vehicle-plate">Placa</FieldLabel>
|
|
89
|
+
<Input id="vehicle-plate" />
|
|
90
|
+
</Field>
|
|
91
|
+
</FieldGroup>
|
|
92
|
+
</Content>,
|
|
93
|
+
);
|
|
94
|
+
```
|
|
95
|
+
|
|
23
96
|
## Composição explícita
|
|
24
97
|
|
|
25
98
|
Use os slots quando o header precisar de composição própria. A árvore aceita exatamente um
|
|
@@ -63,14 +136,17 @@ gatilho repete esse título e a edição usa `Editar recurso`.
|
|
|
63
136
|
|
|
64
137
|
## Propriedades de Content
|
|
65
138
|
|
|
66
|
-
| Propriedade
|
|
67
|
-
|
|
|
68
|
-
| `title`
|
|
69
|
-
| `count`
|
|
70
|
-
| `description`
|
|
71
|
-
| `actions`
|
|
72
|
-
| `level`
|
|
73
|
-
| `variant`
|
|
139
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
140
|
+
| -------------- | ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
141
|
+
| `title` | `ReactNode` | | Forma curta: título da região (vira o heading ligado à `section`). |
|
|
142
|
+
| `count` | `number` | | Forma curta: total de itens ao lado do título da região. |
|
|
143
|
+
| `description` | `ReactNode` | | Forma curta: frase de apoio sob o título. |
|
|
144
|
+
| `actions` | `ReactNode` | | Forma curta: ações sobre a região inteira; em `page`, ficam no extremo oposto ao título. |
|
|
145
|
+
| `level` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `2` | Nível semântico do heading, independente do destaque visual. |
|
|
146
|
+
| `variant` | `'page' \| 'section'` | `'section'` | Hierarquia visual: `page` reproduz o cabeçalho de `Page`; `section` é a região dentro de uma superfície. |
|
|
147
|
+
| `as` | `'section' \| 'fieldset'` | `'section'` | Usa uma região com heading ou um grupo de campos cujo título é a legenda nativa. |
|
|
148
|
+
| `framed` | `boolean` | `false` | Envolve o header e o body com a moldura estrutural da região. |
|
|
149
|
+
| `frameVariant` | `'default' \| 'legend'` | `'default'` | Em `fieldset` com moldura, mantém a borda contínua ou integra visualmente a legenda à borda. Não altera a semântica. |
|
|
74
150
|
|
|
75
151
|
Na composição explícita, `ContentHeader` recebe `ContentTitle`, `ContentMeta`, `ContentDescription` e
|
|
76
152
|
`ContentActions`, e `ContentBody` recebe o conteúdo; nenhum desses slots aceita `title` ou `level`
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Acompanhamento de execuções
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Acompanhamento de execuções
|
|
6
|
+
|
|
7
|
+
Use `executions` para reunir progresso e resultado de tarefas executadas por motores diferentes.
|
|
8
|
+
O módulo é experimental e opt-in: instalar o Opus não passa a descobrir crons, filas ou workers.
|
|
9
|
+
O executor continua dono do estado; este recurso mantém uma fotografia para consulta.
|
|
10
|
+
|
|
11
|
+
Para colocar trabalho em segundo plano, use [Filas](/docs/queue). Para acompanhar trabalho já
|
|
12
|
+
existente, adapte seu executor à projeção abaixo. Não usar a projeção para decidir reenvio ou retry.
|
|
13
|
+
|
|
14
|
+
## Publicar uma fotografia
|
|
15
|
+
|
|
16
|
+
O executor precisa conservar um ID e uma revisão crescente, inclusive entre tentativas e
|
|
17
|
+
reinícios. Grave essa revisão com o estado autoritativo; não use `Date.now()` nem um contador
|
|
18
|
+
exclusivo da memória do processo. O exemplo usa o driver em memória somente para demonstrar o
|
|
19
|
+
protocolo:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { memoryExecutions } from '@softize/opus/executions/memory'
|
|
23
|
+
|
|
24
|
+
const executions = memoryExecutions()
|
|
25
|
+
await executions.observe({
|
|
26
|
+
id: 'export-42',
|
|
27
|
+
tenantId: 'organization-7',
|
|
28
|
+
operation: 'catalog.export',
|
|
29
|
+
revision: 3,
|
|
30
|
+
attempt: 1,
|
|
31
|
+
status: 'running',
|
|
32
|
+
trigger: 'schedule',
|
|
33
|
+
createdAt: '2026-09-21T10:00:00.000Z',
|
|
34
|
+
updatedAt: '2026-09-21T10:01:00.000Z',
|
|
35
|
+
heartbeatAt: '2026-09-21T10:01:00.000Z',
|
|
36
|
+
progress: { completed: 150, total: 500 },
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`observe` retorna `false` para uma revisão repetida ou obsoleta e para uma tentativa de trocar
|
|
41
|
+
tenant, operação, criação ou disparo do mesmo ID. O ID é único em todo o store, não por tenant.
|
|
42
|
+
Cron que enfileira trabalho continua com
|
|
43
|
+
`trigger: 'schedule'`; entrar na fila não cria outra execução. Retomada mantém o ID e avança
|
|
44
|
+
revisão/tentativa conforme o executor. A projeção não valida transições de negócio.
|
|
45
|
+
|
|
46
|
+
Uma falha de projeção lança erro para o adaptador tratar. Registrar essa falha e reconciliar
|
|
47
|
+
depois a partir do executor; não deixar o erro provocar retry de um efeito já realizado.
|
|
48
|
+
`observe` não contém proteção automática para o fluxo de negócio do consumidor.
|
|
49
|
+
|
|
50
|
+
## Persistência e processos separados
|
|
51
|
+
|
|
52
|
+
Use `pgExecutions({ pool, table?, channel? })` para persistir. O pool é fornecido pelo consumidor,
|
|
53
|
+
sem dependência de conexão global. `executionTableSql(table?)` devolve o DDL para incluir na
|
|
54
|
+
migration; não executa a migration, grants ou retenção. Nomes de tabela/canal aceitam um
|
|
55
|
+
identificador SQL simples. Tabela padrão: `execution_snapshots`; canal: `opus_executions`.
|
|
56
|
+
|
|
57
|
+
O driver atualiza somente uma revisão maior e publica NOTIFY no mesmo statement. Em uma
|
|
58
|
+
transação externa, o aviso só sai no commit. Rollback não publica progresso. O snapshot é
|
|
59
|
+
durável; o aviso não é histórico nem fila de entrega.
|
|
60
|
+
|
|
61
|
+
`pgExecutionChanges({ connect, onError, channel?, reconnectMs? })` compartilha uma conexão
|
|
62
|
+
LISTEN entre seus assinantes. `connect` deve entregar uma conexão nova, já conectada, dedicada
|
|
63
|
+
e com timeout de conexão configurado. Não fornecer uma conexão emprestada por um pooler
|
|
64
|
+
transacional. O driver fecha a conexão quando sai o último assinante, registra falhas por
|
|
65
|
+
`onError` e tenta reconectar após `reconnectMs` (padrão: 1.000 ms; mínimo: 100 ms).
|
|
66
|
+
|
|
67
|
+
Após cada conexão, o assinante recebe `null`: refaça a fotografia. Uma conexão nova não recupera
|
|
68
|
+
avisos perdidos. O consumidor precisa reconciliar também snapshots que não foram gravados por
|
|
69
|
+
falha de banco; reconectar SSE não corrige uma projeção que nunca foi atualizada.
|
|
70
|
+
|
|
71
|
+
## Atualizar a interface por SSE
|
|
72
|
+
|
|
73
|
+
`executionStream` devolve uma `Response` Web para montar em uma rota do servidor. O consumidor
|
|
74
|
+
fornece autenticação e autorização; nenhum token é recebido pela URL ou pelo protocolo do SDK.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { executionStream } from '@softize/opus/executions/stream'
|
|
78
|
+
import type { ExecutionChanges, ExecutionNotice } from '@softize/opus/executions'
|
|
79
|
+
|
|
80
|
+
async function openProgress(
|
|
81
|
+
request: Request,
|
|
82
|
+
changes: ExecutionChanges,
|
|
83
|
+
authorize: (notice: ExecutionNotice | null) => Promise<boolean>,
|
|
84
|
+
logError: (error: unknown) => void,
|
|
85
|
+
) {
|
|
86
|
+
return executionStream({ changes, authorize, signal: request.signal, onError: logError })
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Para `authorize(null)`, verificar sessão e direito de acompanhar. Para um aviso, verificar
|
|
91
|
+
também tenant, operação e recurso exato. Essa verificação ocorre novamente durante o stream,
|
|
92
|
+
inclusive nos heartbeats e antes de emitir uma invalidação. Heartbeats revalidam a sessão
|
|
93
|
+
independentemente da fila de avisos: a função pode receber um aviso e `null` concorrentemente.
|
|
94
|
+
As actions de leitura devem aplicar a mesma política: filtrar a
|
|
95
|
+
consulta por tenant não substitui autorização.
|
|
96
|
+
|
|
97
|
+
O cliente recebe somente `event: invalidate` com `{}`. Ao recebê-lo, invalidar as queries das
|
|
98
|
+
actions autorizadas. Manter uma conexão compartilhada no provider da aplicação, não uma por
|
|
99
|
+
card. A primeira invalidação e a reconexão exigem nova fotografia; não há `Last-Event-ID` nem
|
|
100
|
+
replay. O navegador pode usar `EventSource` com cookies same-origin e sua reconexão nativa.
|
|
101
|
+
|
|
102
|
+
Heartbeat padrão: 15 segundos. Duração máxima padrão: cinco minutos, seguida de reconexão.
|
|
103
|
+
O stream encerra na revogação de acesso, abort, falha de autorização, perda de LISTEN ou cliente lento. Há limites
|
|
104
|
+
de 128 avisos pendentes e 16 frames na saída; excedê-los encerra a conexão para nova fotografia,
|
|
105
|
+
sem acumular um log em memória. Sessão é reavaliada no máximo a cada heartbeat enquanto a
|
|
106
|
+
autorização responde; configure timeout no serviço de autorização para não mantê-la pendente.
|
|
107
|
+
|
|
108
|
+
IDs, tenant e operação nos avisos são metadados operacionais internos. Não usar CPF, telefone,
|
|
109
|
+
e-mail ou segredo nesses identificadores. PostgreSQL NOTIFY não é uma fronteira de autorização:
|
|
110
|
+
restringir as credenciais e o acesso ao banco. Nada do aviso é copiado para o SSE.
|
|
111
|
+
|
|
112
|
+
## Detectar ausência de sinal
|
|
113
|
+
|
|
114
|
+
`executionLiveness(snapshot, nowMs, staleAfterMs)` retorna `inactive` quando não está em
|
|
115
|
+
execução, `unknown` quando não há heartbeat confiável, `fresh` com sinal recente e `stale`
|
|
116
|
+
quando o limite foi atingido. Heartbeat futuro é `unknown`, para não esconder diferença de
|
|
117
|
+
relógio. O chamador define a tolerância de acordo com a frequência do worker.
|
|
118
|
+
|
|
119
|
+
`stale` não altera `status`, não confirma falha e não autoriza retomar. Pausar, cancelar e
|
|
120
|
+
repetir continuam comandos do domínio. Logs completos e notificações pessoais também
|
|
121
|
+
continuam fora desta projeção.
|
|
122
|
+
|
|
123
|
+
## Referência de ExecutionSnapshot
|
|
124
|
+
|
|
125
|
+
| Campo | Contrato |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `id`, `tenantId`, `operation` | ID operacional, tenant explícito ou `null` e código da operação; IDs textuais limitados a 200 caracteres |
|
|
128
|
+
| `revision`, `attempt` | Inteiros seguros; revisão positiva e tentativa não negativa |
|
|
129
|
+
| `status` | `queued`, `running`, `paused`, `succeeded`, `failed` ou `cancelled` |
|
|
130
|
+
| `trigger` | `manual`, `schedule`, `queue` ou `system`; imutável para o mesmo ID |
|
|
131
|
+
| `createdAt`, `updatedAt` | Datas UTC; atualização não pode anteceder criação |
|
|
132
|
+
| `startedAt`, `finishedAt`, `heartbeatAt` | Datas UTC opcionais |
|
|
133
|
+
| `progress` | `completed` não negativo e `total` opcional, não inferior ao concluído; ambos inteiros seguros |
|
|
134
|
+
|
|
135
|
+
`executionSnapshot` valida, normaliza datas e seleciona somente os campos acima.
|
|
136
|
+
`executionNotice` extrai ID, tenant, operação e revisão. `parseExecutionNotice` valida dados
|
|
137
|
+
recebidos pelo transporte e remove propriedades extras.
|
|
138
|
+
|
|
139
|
+
## Referência de ExecutionStore e ExecutionChanges
|
|
140
|
+
|
|
141
|
+
| Método | Contrato |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `observe(snapshot)` | Grava somente revisão nova; retorna se alterou a projeção |
|
|
144
|
+
| `get(id, tenantId)` | Busca escopada; devolve snapshot ou `null` |
|
|
145
|
+
| `list({ tenantId, operations, limit? })` | Lista por atualização decrescente e ID; operações obrigatórias, limite padrão 50 e máximo 200 |
|
|
146
|
+
| `subscribe(listener, onUnavailable?)` | Assina aviso ou `null` de reconexão; comunica perda do transporte ao segundo callback e devolve função de saída |
|
|
147
|
+
|
|
148
|
+
`tenantId: null` seleciona somente execuções sem tenant, não todos os tenants. Lista de operações
|
|
149
|
+
vazia não retorna registros. `executionQuery` valida esse recorte. O catálogo de operações,
|
|
150
|
+
autorização, paginação completa do histórico e retenção pertencem ao consumidor; esta leitura
|
|
151
|
+
limitada atende os snapshots recentes, não substitui uma action `kind: 'list'`.
|
|
152
|
+
|
|
153
|
+
## Limites desta etapa
|
|
154
|
+
|
|
155
|
+
Não há adaptadores automáticos de runtime, BullMQ ou scheduler, heartbeat automático,
|
|
156
|
+
reconciliação agendada, UI pronta ou mecanismo genérico de pausa. Esses consumidores precisam
|
|
157
|
+
ser implementados e testados antes de afirmar que todos os processos estão acompanhados.
|