@softize/opus 8.6.6
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 +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- package/src/vite/index.ts +8 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/testing — o harness de teste do protocolo (EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* A unidade natural de teste é a ACTION (o contrato): input → output/erro, bordas do
|
|
5
|
+
* schema, autorização fail-closed. Este módulo dá isso sem montar runtime nem mockar
|
|
6
|
+
* contexto à mão:
|
|
7
|
+
*
|
|
8
|
+
* • `testContext()` — um ActionContext completo com fakes observáveis: `emit`
|
|
9
|
+
* capturado em `emitted`, log capturado em `logged`, `can` configurável,
|
|
10
|
+
* storage plugável (ex.: `memStorage()`).
|
|
11
|
+
* • `runAction(action, input)` — o MESMO pipeline do `runtime.execute`, em unidade:
|
|
12
|
+
* valida input → gate `public` → `authorize` (string DSL ou closure, semântica
|
|
13
|
+
* idêntica, `loaded` incluso) → handler → valida output (pulado em `list`, como
|
|
14
|
+
* no runtime). Falha estoura o MESMO `ActionError` tipado do runtime
|
|
15
|
+
* (`validation.invalid_input`, `auth.unauthenticated`, `auth.forbidden`…).
|
|
16
|
+
* Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
|
|
17
|
+
* (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
|
|
18
|
+
* • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
|
|
19
|
+
* arquivo, com a mesma régua de key dos drivers reais.
|
|
20
|
+
*
|
|
21
|
+
* O que fica de fora (de propósito — é harness de UNIDADE): loaders reais (o teste
|
|
22
|
+
* passa `loaded` pronto), audit, reactions em cadeia e o servidor. `requires` é
|
|
23
|
+
* declarativo no protocolo (o runtime não o executa) — aqui idem; handler que chama
|
|
24
|
+
* `ctx.can` responde ao `can` das options. Pra fluxo completo, teste de integração
|
|
25
|
+
* com o runtime.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { evalExpression } from '../dsl/eval.ts'
|
|
29
|
+
import { parseExpression } from '../dsl/parser.ts'
|
|
30
|
+
import { normalizeKey } from '../storage/index.ts'
|
|
31
|
+
import { error } from '../core/errors.ts'
|
|
32
|
+
// Import direto do runtime (fora do index público): a régua handler×mockHandler
|
|
33
|
+
// tem UMA fonte — o harness nunca pode divergir do execute().
|
|
34
|
+
import { isOpusDesignMode } from '../core/runtime.ts'
|
|
35
|
+
import type {
|
|
36
|
+
ActionContext,
|
|
37
|
+
ActionKind,
|
|
38
|
+
AiAdapter,
|
|
39
|
+
BoundAi,
|
|
40
|
+
AuthorizeSpec,
|
|
41
|
+
HandlerFn,
|
|
42
|
+
Logger,
|
|
43
|
+
Paginated,
|
|
44
|
+
Schema,
|
|
45
|
+
StorageAdapter,
|
|
46
|
+
StorageObject,
|
|
47
|
+
StoragePutOptions,
|
|
48
|
+
User,
|
|
49
|
+
} from '../core/index.ts'
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// testContext
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
export interface EmittedEvent {
|
|
56
|
+
event: string
|
|
57
|
+
data: unknown
|
|
58
|
+
meta?: Record<string, unknown>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LoggedLine {
|
|
62
|
+
level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
|
|
63
|
+
msg: string
|
|
64
|
+
meta?: object
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TestContextOptions {
|
|
68
|
+
user?: User | null
|
|
69
|
+
tenantId?: string | null
|
|
70
|
+
/** Resposta do `ctx.can` pra handlers que consultam permissão. Default: permite tudo. */
|
|
71
|
+
can?: ActionContext['can']
|
|
72
|
+
db?: unknown
|
|
73
|
+
storage?: StorageAdapter | null
|
|
74
|
+
ai?: AiAdapter | null
|
|
75
|
+
meta?: Record<string, unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TestContext {
|
|
79
|
+
ctx: ActionContext
|
|
80
|
+
/** Eventos emitidos pelo handler (`ctx.emit`), na ordem. */
|
|
81
|
+
emitted: EmittedEvent[]
|
|
82
|
+
/** Linhas logadas pelo handler (`ctx.log`), na ordem — o teste afirma o que importa. */
|
|
83
|
+
logged: LoggedLine[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function captureLogger(sink: LoggedLine[]): Logger {
|
|
87
|
+
const log = (level: LoggedLine['level']) => (msg: string, meta?: object) => {
|
|
88
|
+
sink.push({ level, msg, ...(meta !== undefined ? { meta } : {}) })
|
|
89
|
+
}
|
|
90
|
+
const logger: Logger = {
|
|
91
|
+
trace: log('trace'),
|
|
92
|
+
debug: log('debug'),
|
|
93
|
+
info: log('info'),
|
|
94
|
+
warn: log('warn'),
|
|
95
|
+
error: log('error'),
|
|
96
|
+
fatal: log('fatal'),
|
|
97
|
+
child: () => logger,
|
|
98
|
+
}
|
|
99
|
+
return logger
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Liga o adapter cru num `BoundAi` pro `ctx.ai` do teste. Sem runtime/registry, o `run`
|
|
103
|
+
* sai com tools vazias — testar handler agêntico usa `createRuntime` (não este harness). */
|
|
104
|
+
function toBoundAi(a: AiAdapter): BoundAi {
|
|
105
|
+
return {
|
|
106
|
+
complete: (p, o) => a.complete(p, o),
|
|
107
|
+
extract: (p, s, o) => a.extract(p, s, o),
|
|
108
|
+
run: (input, opts) =>
|
|
109
|
+
a.run
|
|
110
|
+
? a.run(input, { ...opts, tools: [], execute: () => ({}) })
|
|
111
|
+
: Promise.reject(new Error('driver de IA sem run (loop agêntico).')),
|
|
112
|
+
runStream: (input, opts) => {
|
|
113
|
+
if (a.runStream === undefined) throw new Error('driver de IA sem runStream (loop em streaming).')
|
|
114
|
+
return a.runStream(input, { ...opts, tools: [], execute: () => ({}) })
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** ActionContext de teste: fakes observáveis, nada de rede/banco/runtime. */
|
|
120
|
+
export function testContext(options: TestContextOptions = {}): TestContext {
|
|
121
|
+
const emitted: EmittedEvent[] = []
|
|
122
|
+
const logged: LoggedLine[] = []
|
|
123
|
+
const ctx: ActionContext = {
|
|
124
|
+
user: options.user ?? null,
|
|
125
|
+
tenantId: options.tenantId ?? null,
|
|
126
|
+
can: options.can ?? (() => true),
|
|
127
|
+
db: options.db,
|
|
128
|
+
log: captureLogger(logged),
|
|
129
|
+
emit: (event, data, meta) => {
|
|
130
|
+
emitted.push({ event, data, ...(meta !== undefined ? { meta } : {}) })
|
|
131
|
+
return Promise.resolve()
|
|
132
|
+
},
|
|
133
|
+
storage: options.storage ?? null,
|
|
134
|
+
ai: options.ai ? toBoundAi(options.ai) : null,
|
|
135
|
+
provenance: { kind: 'system', source: 'test' },
|
|
136
|
+
meta: options.meta ?? {},
|
|
137
|
+
}
|
|
138
|
+
return { ctx, emitted, logged }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// =============================================================================
|
|
142
|
+
// memStorage
|
|
143
|
+
// =============================================================================
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* StorageAdapter em memória, com a régua de key dos drivers reais
|
|
147
|
+
* (`normalizeKey`). Métodos async como nos drivers: key inválida REJEITA,
|
|
148
|
+
* não estoura síncrono.
|
|
149
|
+
*/
|
|
150
|
+
export function memStorage(): StorageAdapter & { keys(): string[] } {
|
|
151
|
+
const objects = new Map<string, StorageObject>()
|
|
152
|
+
return {
|
|
153
|
+
name: 'mem',
|
|
154
|
+
kind: 'storage',
|
|
155
|
+
async put(key: string, data: Uint8Array, opts?: StoragePutOptions): Promise<void> {
|
|
156
|
+
objects.set(normalizeKey(key), {
|
|
157
|
+
data,
|
|
158
|
+
...(opts?.contentType !== undefined ? { contentType: opts.contentType } : {}),
|
|
159
|
+
})
|
|
160
|
+
},
|
|
161
|
+
async get(key: string): Promise<StorageObject | null> {
|
|
162
|
+
return objects.get(normalizeKey(key)) ?? null
|
|
163
|
+
},
|
|
164
|
+
async delete(key: string): Promise<void> {
|
|
165
|
+
objects.delete(normalizeKey(key))
|
|
166
|
+
},
|
|
167
|
+
async url(key: string): Promise<string> {
|
|
168
|
+
return `mem://${normalizeKey(key)}`
|
|
169
|
+
},
|
|
170
|
+
keys(): string[] {
|
|
171
|
+
return [...objects.keys()].sort()
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// fake (fixtures do schema)
|
|
178
|
+
// =============================================================================
|
|
179
|
+
|
|
180
|
+
export { fake, fakeMany, type FakeOptions } from './fake.ts'
|
|
181
|
+
|
|
182
|
+
// =============================================================================
|
|
183
|
+
// runAction
|
|
184
|
+
// =============================================================================
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* O subset estrutural de action que o harness lê. Qualquer action real (defineAction
|
|
188
|
+
* ou objeto literal) satisfaz por dever campos a mais — `loads`, `requires` etc. são
|
|
189
|
+
* ignorados aqui (loaders não rodam em unidade; passe `loaded` nas options).
|
|
190
|
+
*
|
|
191
|
+
* Como no runtime, `authorize` e `handler` recebem o input PARSEADO (`ParsedIn`,
|
|
192
|
+
* o output do schema de input — defaults aplicados); `In` é o lado do fio.
|
|
193
|
+
*/
|
|
194
|
+
export interface TestableAction<In, Out, ParsedIn = In> {
|
|
195
|
+
name: string
|
|
196
|
+
kind?: ActionKind
|
|
197
|
+
public?: boolean
|
|
198
|
+
authorize?: AuthorizeSpec<ParsedIn>
|
|
199
|
+
input: Schema<In, ParsedIn>
|
|
200
|
+
output: Schema<Out> | Schema<unknown, Out>
|
|
201
|
+
/** Opcional: contrato puro (sem handler) executa via `mockHandler` em modo design. */
|
|
202
|
+
handler?: HandlerFn<ParsedIn, Out>
|
|
203
|
+
/** Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime. */
|
|
204
|
+
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Variante list: o schema descreve o ITEM, o handler devolve `Paginated<Item>`. */
|
|
208
|
+
export interface TestableListAction<In, Item, ParsedIn = In> {
|
|
209
|
+
name: string
|
|
210
|
+
kind: 'list'
|
|
211
|
+
public?: boolean
|
|
212
|
+
authorize?: AuthorizeSpec<ParsedIn>
|
|
213
|
+
input: Schema<In, ParsedIn>
|
|
214
|
+
output: Schema<Item> | Schema<unknown, Item>
|
|
215
|
+
handler?: (
|
|
216
|
+
ctx: ActionContext,
|
|
217
|
+
input: ParsedIn,
|
|
218
|
+
loaded?: Record<string, unknown>,
|
|
219
|
+
) => Paginated<Item> | Promise<Paginated<Item>>
|
|
220
|
+
mockHandler?: (
|
|
221
|
+
ctx: ActionContext,
|
|
222
|
+
input: ParsedIn,
|
|
223
|
+
loaded?: Record<string, unknown>,
|
|
224
|
+
) => Paginated<Item> | Promise<Paginated<Item>>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface RunActionOptions extends TestContextOptions {
|
|
228
|
+
/** Substitui os loaders (não rodam em unidade): vai pro handler e pro evalCtx do authorize. */
|
|
229
|
+
loaded?: Record<string, unknown>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface RunActionResult<Out> extends TestContext {
|
|
233
|
+
output: Out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Espelho do `runtime.validate`: mesmo discriminador e mesmo `ActionError`. */
|
|
237
|
+
async function validate<T>(
|
|
238
|
+
schema: Schema<T> | Schema<unknown, T>,
|
|
239
|
+
value: unknown,
|
|
240
|
+
kind: 'input' | 'output',
|
|
241
|
+
): Promise<T> {
|
|
242
|
+
const result = await Promise.resolve(schema['~standard'].validate(value))
|
|
243
|
+
if ('value' in result) return result.value as T
|
|
244
|
+
throw error({
|
|
245
|
+
code: kind === 'input' ? 'validation.invalid_input' : 'validation.invalid_output',
|
|
246
|
+
category: kind === 'input' ? 'validation' : 'internal',
|
|
247
|
+
message: kind === 'input' ? 'Input validation failed' : 'Output validation failed (dev)',
|
|
248
|
+
issues: result.issues.map((iss) => ({
|
|
249
|
+
path: (iss.path ?? []).join('.'),
|
|
250
|
+
code: 'invalid',
|
|
251
|
+
message: iss.message,
|
|
252
|
+
})),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Roda uma action em UNIDADE com o pipeline do `runtime.execute`: valida input →
|
|
258
|
+
* gate `public` → authorize → handler → valida output. Sucesso devolve o output
|
|
259
|
+
* validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
|
|
260
|
+
* `ActionError` do runtime — afirme com `expect(...).rejects`.
|
|
261
|
+
*
|
|
262
|
+
* A escolha do executável segue a régua do `execute()`: em `OPUS_MODE=design`,
|
|
263
|
+
* `mockHandler` presente vence; senão, `handler` real. Contrato puro (sem
|
|
264
|
+
* handler) só roda em design — fora dele, `testing.no_handler`.
|
|
265
|
+
*/
|
|
266
|
+
export async function runAction<In, Item, ParsedIn = In>(
|
|
267
|
+
action: TestableListAction<In, Item, ParsedIn>,
|
|
268
|
+
input: unknown,
|
|
269
|
+
options?: RunActionOptions,
|
|
270
|
+
): Promise<RunActionResult<Paginated<Item>>>
|
|
271
|
+
export async function runAction<In, Out, ParsedIn = In>(
|
|
272
|
+
action: TestableAction<In, Out, ParsedIn>,
|
|
273
|
+
input: unknown,
|
|
274
|
+
options?: RunActionOptions,
|
|
275
|
+
): Promise<RunActionResult<Out>>
|
|
276
|
+
export async function runAction<In, Out, ParsedIn = In>(
|
|
277
|
+
action: TestableAction<In, Out, ParsedIn>,
|
|
278
|
+
input: unknown,
|
|
279
|
+
options: RunActionOptions = {},
|
|
280
|
+
): Promise<RunActionResult<Out>> {
|
|
281
|
+
const t = testContext(options)
|
|
282
|
+
const { ctx } = t
|
|
283
|
+
const loaded = options.loaded
|
|
284
|
+
|
|
285
|
+
const parsed = await validate<ParsedIn>(action.input, input, 'input')
|
|
286
|
+
|
|
287
|
+
if (action.public !== true && ctx.user === null) {
|
|
288
|
+
throw error({
|
|
289
|
+
code: 'auth.unauthenticated',
|
|
290
|
+
category: 'authentication',
|
|
291
|
+
message: 'Authentication required',
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (action.authorize !== undefined) {
|
|
296
|
+
const spec = action.authorize
|
|
297
|
+
const decision =
|
|
298
|
+
typeof spec === 'string'
|
|
299
|
+
? Boolean(evalExpression(parseExpression(spec), { user: ctx.user, input: parsed, ctx, ...(loaded ?? {}) }))
|
|
300
|
+
: await spec(ctx, parsed, loaded)
|
|
301
|
+
if (decision === false) {
|
|
302
|
+
throw error({ code: 'auth.forbidden', category: 'authorization', message: 'Forbidden' })
|
|
303
|
+
}
|
|
304
|
+
if (decision !== true) throw decision
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const handlerToRun =
|
|
308
|
+
isOpusDesignMode() && action.mockHandler !== undefined
|
|
309
|
+
? action.mockHandler
|
|
310
|
+
: action.handler
|
|
311
|
+
if (handlerToRun === undefined) {
|
|
312
|
+
throw error({
|
|
313
|
+
code: 'testing.no_handler',
|
|
314
|
+
category: 'internal',
|
|
315
|
+
message: `Action "${action.name}" não tem handler executável — contrato puro roda em modo design (OPUS_MODE=design) com mockHandler; fora dele, teste o ActionDef do bindAction`,
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
const raw = await handlerToRun(ctx, parsed, loaded)
|
|
319
|
+
|
|
320
|
+
// Mesmo skip do runtime: list devolve Paginated<Item>, o schema descreve Item.
|
|
321
|
+
const output = action.kind === 'list' ? (raw as Out) : await validate<Out>(action.output, raw, 'output')
|
|
322
|
+
|
|
323
|
+
return { output, ...t }
|
|
324
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ActionFormCard /> — o ActionForm dentro de um Card do Opus (header / conteúdo / rodapé),
|
|
3
|
+
* pra estruturar uma seção da página como painel. Segue o padrão do CARD (não a chrome de
|
|
4
|
+
* modal): sem divisor nem faixa, com o respiro do Card. Pra form em overlay, use o
|
|
5
|
+
* ActionFormDialog; pra form cru (sem chrome), o ActionForm direto.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
Card,
|
|
9
|
+
CardContent,
|
|
10
|
+
CardDescription,
|
|
11
|
+
CardFooter,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
} from '../primitives/card.tsx'
|
|
15
|
+
import { ActionForm, type ActionFormProps } from './form.tsx'
|
|
16
|
+
|
|
17
|
+
export interface ActionFormCardProps<TInput extends Record<string, unknown>, TData>
|
|
18
|
+
extends Omit<ActionFormProps<TInput, TData>, 'body' | 'footer'> {
|
|
19
|
+
/** Título do header do card. Sem ele, o card começa direto no conteúdo. */
|
|
20
|
+
title?: string
|
|
21
|
+
/** Subtítulo opcional, abaixo do título. */
|
|
22
|
+
description?: string
|
|
23
|
+
/** Classes do Card (a superfície). `className` vai pro <form>. */
|
|
24
|
+
cardClassName?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
cardClassName,
|
|
31
|
+
...rest
|
|
32
|
+
}: ActionFormCardProps<TInput, TData>) {
|
|
33
|
+
return (
|
|
34
|
+
<Card className={cardClassName}>
|
|
35
|
+
{(title !== undefined || description !== undefined) && (
|
|
36
|
+
<CardHeader>
|
|
37
|
+
{title !== undefined && <CardTitle>{title}</CardTitle>}
|
|
38
|
+
{description !== undefined && <CardDescription>{description}</CardDescription>}
|
|
39
|
+
</CardHeader>
|
|
40
|
+
)}
|
|
41
|
+
<ActionForm
|
|
42
|
+
{...rest}
|
|
43
|
+
body={(fields) => <CardContent>{fields}</CardContent>}
|
|
44
|
+
footer={(actions) => <CardFooter className="justify-end">{actions}</CardFooter>}
|
|
45
|
+
/>
|
|
46
|
+
</Card>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ActionListDialog action input /> — uma ListAction em modal (o modo modal da lista).
|
|
3
|
+
*
|
|
4
|
+
* CASCA sobre o <ActionList /> em modo composição: o dialog põe o chrome (título,
|
|
5
|
+
* descrição, a linha nota + ação de criar) e o ActionList faz o resto — fetch
|
|
6
|
+
* query-backed com refetch por `invalidates`, toolbar declarativa (busca/filtros/
|
|
7
|
+
* período, se o contrato declarar), estados de carga e o rodapé com total/pager.
|
|
8
|
+
*
|
|
9
|
+
* Escape hatches pro que o contrato não sabe (repassados ao ActionList):
|
|
10
|
+
* - `loading` AGREGA carga extra (a query irmã que os children precisam, ex.: papéis).
|
|
11
|
+
* - `empty` sobrepõe o vazio derivado (ex.: form inline de criar aberto ⇒ children).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
import { cn } from '../../lib/cn.ts'
|
|
16
|
+
import {
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogBody,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogHeader,
|
|
22
|
+
DialogTitle,
|
|
23
|
+
} from '../primitives/dialog.tsx'
|
|
24
|
+
import { ActionList } from './list.tsx'
|
|
25
|
+
import type { ListAction } from '../../../core/index.ts'
|
|
26
|
+
|
|
27
|
+
export interface ActionListDialogProps<TItem, TInput extends Record<string, unknown> = Record<string, unknown>> {
|
|
28
|
+
/** A ListAction do Opus (estrutural: name + kind — o mesmo contrato da família). */
|
|
29
|
+
action: { name: string; kind: 'list' }
|
|
30
|
+
/** Input da action (o escopo base; ex.: { workspaceId }). Mudou, re-busca. */
|
|
31
|
+
input?: TInput
|
|
32
|
+
open: boolean
|
|
33
|
+
onOpenChange: (open: boolean) => void
|
|
34
|
+
title: string
|
|
35
|
+
description?: string
|
|
36
|
+
/** Nota à esquerda da linha de ações (contexto curto). O total já mora no rodapé. */
|
|
37
|
+
note?: ReactNode
|
|
38
|
+
/** Ação à direita da linha — em geral o botão de criar. */
|
|
39
|
+
actions?: ReactNode
|
|
40
|
+
emptyText?: string
|
|
41
|
+
/** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto. */
|
|
42
|
+
empty?: (items: TItem[]) => boolean
|
|
43
|
+
/** Carga EXTRA agregada à do fetch (ex.: a query irmã que os children precisam). */
|
|
44
|
+
loading?: boolean
|
|
45
|
+
/** Classes do DialogContent (largura). Default: sm:max-w-3xl (lista pede espaço). */
|
|
46
|
+
className?: string
|
|
47
|
+
/** O layout dos itens (cards/linhas) — os estados já saíram daqui. */
|
|
48
|
+
children: (items: TItem[], refetch: () => Promise<void>) => ReactNode
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ActionListDialog<TItem, TInput extends Record<string, unknown> = Record<string, unknown>>({
|
|
52
|
+
action,
|
|
53
|
+
input,
|
|
54
|
+
open,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
title,
|
|
57
|
+
description,
|
|
58
|
+
note,
|
|
59
|
+
actions,
|
|
60
|
+
emptyText,
|
|
61
|
+
empty,
|
|
62
|
+
loading,
|
|
63
|
+
className,
|
|
64
|
+
children,
|
|
65
|
+
}: ActionListDialogProps<TItem, TInput>): React.ReactElement {
|
|
66
|
+
return (
|
|
67
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
68
|
+
<DialogContent className={cn('sm:max-w-3xl', className)}>
|
|
69
|
+
<DialogHeader>
|
|
70
|
+
<DialogTitle>{title}</DialogTitle>
|
|
71
|
+
{description !== undefined && <DialogDescription>{description}</DialogDescription>}
|
|
72
|
+
</DialogHeader>
|
|
73
|
+
<DialogBody>
|
|
74
|
+
{(note !== undefined || actions !== undefined) && (
|
|
75
|
+
<div data-slot="action-list-dialog-toolbar" className="mb-2 flex min-h-8 items-center justify-between gap-4">
|
|
76
|
+
<div className="min-w-0 text-xs text-muted-foreground/70">{note}</div>
|
|
77
|
+
{actions}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<ActionList<TInput, TItem>
|
|
81
|
+
action={action as unknown as ListAction<TInput, TItem>}
|
|
82
|
+
input={(input ?? {}) as TInput}
|
|
83
|
+
{...(emptyText !== undefined ? { emptyMessage: emptyText } : {})}
|
|
84
|
+
{...(empty !== undefined ? { empty } : {})}
|
|
85
|
+
{...(loading !== undefined ? { loading } : {})}
|
|
86
|
+
>
|
|
87
|
+
{children}
|
|
88
|
+
</ActionList>
|
|
89
|
+
</DialogBody>
|
|
90
|
+
</DialogContent>
|
|
91
|
+
</Dialog>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AppShell /> — o chrome da aplicação: sidebar de navegação + conteúdo, com rail
|
|
3
|
+
* opcional à direita (chat de agente, inspetor).
|
|
4
|
+
*
|
|
5
|
+
* Padroniza o esqueleto que os apps copiavam à mão (mesmas classes, várias vezes):
|
|
6
|
+
* raiz `flex h-screen`, aside `w-64` com header/nav/rodapé (o nav rola; o rodapé
|
|
7
|
+
* ancora), e o conteúdo. Com `rail`, conteúdo e rail viram painéis redimensionáveis
|
|
8
|
+
* (Resizable). Presentacional de propósito: o QUE vai em cada slot (PaneHeader do
|
|
9
|
+
* app, ChatRail, preview…) é do consumidor — o shell é o quadro, não o conteúdo.
|
|
10
|
+
*
|
|
11
|
+
* RECOLHER (`collapsible`): o shell controla a LARGURA e publica o estado; quem decide
|
|
12
|
+
* o que some é o conteúdo, porque o `sidebarNav` é do consumidor. O aside expõe
|
|
13
|
+
* `data-collapsed` e o grupo `sidebar`, então rótulo some por CSS, sem estado no
|
|
14
|
+
* consumidor: `className="group-data-[collapsed=true]/sidebar:hidden"`. Quem precisa do
|
|
15
|
+
* estado em JS usa `useAppShell()`; o botão é o `<AppShellTrigger />`, posicionado pelo
|
|
16
|
+
* consumidor (numa `<AppShellBar>`, por exemplo) — o shell não escolhe por ele.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
|
20
|
+
import { PanelLeft } from 'lucide-react'
|
|
21
|
+
import { cn } from '../../lib/cn.ts'
|
|
22
|
+
import { Button } from '../primitives/button.tsx'
|
|
23
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
|
|
24
|
+
|
|
25
|
+
interface AppShellContextValue {
|
|
26
|
+
/** O shell permite recolher? Sem isso o trigger não se renderiza. */
|
|
27
|
+
collapsible: boolean
|
|
28
|
+
collapsed: boolean
|
|
29
|
+
setCollapsed: (collapsed: boolean) => void
|
|
30
|
+
toggle: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const AppShellContext = createContext<AppShellContextValue | null>(null)
|
|
34
|
+
|
|
35
|
+
/** Estado do shell (recolhido/expandido) pra quem precisa dele em JS. Em CSS, prefira o
|
|
36
|
+
* seletor de grupo (`group-data-[collapsed=true]/sidebar:…`) — não precisa de hook. */
|
|
37
|
+
export function useAppShell(): AppShellContextValue {
|
|
38
|
+
const ctx = useContext(AppShellContext)
|
|
39
|
+
if (ctx === null) throw new Error('useAppShell precisa de um <AppShell> acima na árvore.')
|
|
40
|
+
return ctx
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SidebarSlotValue {
|
|
44
|
+
collapsed: boolean
|
|
45
|
+
}
|
|
46
|
+
/** Contexto presente SÓ dentro dos slots da sidebar (header/nav/rodapé), AUSENTE no
|
|
47
|
+
* conteúdo. `useAppShell` não distingue os dois (o colapso vale a árvore toda); este sim.
|
|
48
|
+
* É como um nav PIVOTÁVEL (o `<ShellNav>`) sabe se está NA sidebar — e se ela está
|
|
49
|
+
* recolhida — sem o app passar isso em duas mãos (fecha a nota n=2 do gb-nav-rail). */
|
|
50
|
+
const SidebarSlotContext = createContext<SidebarSlotValue | null>(null)
|
|
51
|
+
/** Null quando FORA da sidebar (ex.: um `<ShellNav>` dentro do conteúdo, num SectionShell). */
|
|
52
|
+
export function useSidebarSlot(): SidebarSlotValue | null {
|
|
53
|
+
return useContext(SidebarSlotContext)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AppShellProps {
|
|
57
|
+
/** Topo da sidebar (marca, seletor de contexto/usuário). */
|
|
58
|
+
sidebarHeader?: ReactNode
|
|
59
|
+
/** Miolo da sidebar — a navegação; rola sozinho quando passa da altura. */
|
|
60
|
+
sidebarNav: ReactNode
|
|
61
|
+
/** Rodapé da sidebar, ancorado embaixo (status, ações globais). */
|
|
62
|
+
sidebarFooter?: ReactNode
|
|
63
|
+
/** Rail opcional à direita (chat de agente, inspetor). Sem ele, o conteúdo ocupa tudo. */
|
|
64
|
+
rail?: ReactNode
|
|
65
|
+
/** Tamanho inicial (%) do painel de conteúdo quando há rail. Default: 62. */
|
|
66
|
+
mainDefaultSize?: number
|
|
67
|
+
/** Tamanho mínimo (%) do painel de conteúdo. Default: 35. */
|
|
68
|
+
mainMinSize?: number
|
|
69
|
+
/** Tamanho mínimo (%) do rail. Default: 25. */
|
|
70
|
+
railMinSize?: number
|
|
71
|
+
/** Classes da raiz. Default: altura da tela (`h-screen`); em embeds, passe ex. `h-96`. */
|
|
72
|
+
className?: string
|
|
73
|
+
/** Classes do aside. Default: `w-64 shrink-0 flex-col p-2`. Largura passada aqui manda no
|
|
74
|
+
* estado EXPANDIDO; no recolhido quem vence é `sidebarCollapsedClassName` (recolher é
|
|
75
|
+
* estado, não estilo — senão o aside ficaria meio-recolhido). */
|
|
76
|
+
sidebarClassName?: string
|
|
77
|
+
/** Permite recolher a sidebar — OPT-IN: sem isso o shell é o de sempre. */
|
|
78
|
+
collapsible?: boolean
|
|
79
|
+
/** Recolhida? Passe junto com `onCollapsedChange` pra controlar de fora (ex.: persistir
|
|
80
|
+
* a preferência); omita e o shell guarda o estado sozinho. */
|
|
81
|
+
collapsed?: boolean
|
|
82
|
+
/** Avisa que o estado mudou (obrigatório na forma controlada). */
|
|
83
|
+
onCollapsedChange?: (collapsed: boolean) => void
|
|
84
|
+
/** Largura do aside recolhido — só o suficiente pros ícones. Default: `w-14`. */
|
|
85
|
+
sidebarCollapsedClassName?: string
|
|
86
|
+
/** Chrome RENTE ao browser (sem inset): sidebar sem padding e conteúdo full-bleed
|
|
87
|
+
* apartado pelo filete (`border-l`) — o shell NÃO pinta fundo (o canvas vem do
|
|
88
|
+
* body); superfície branca é decisão do conteúdo (pinte `bg-background` no seu
|
|
89
|
+
* contêiner). Combine com `<AppShellBar>` na sidebar e no topo do conteúdo pra
|
|
90
|
+
* linha do header atravessar a tela inteira. Default: false (chrome clássico). */
|
|
91
|
+
flush?: boolean
|
|
92
|
+
children: ReactNode
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function AppShell({
|
|
96
|
+
sidebarHeader,
|
|
97
|
+
sidebarNav,
|
|
98
|
+
sidebarFooter,
|
|
99
|
+
rail,
|
|
100
|
+
mainDefaultSize = 62,
|
|
101
|
+
mainMinSize = 35,
|
|
102
|
+
railMinSize = 25,
|
|
103
|
+
className,
|
|
104
|
+
sidebarClassName,
|
|
105
|
+
collapsible = false,
|
|
106
|
+
collapsed: collapsedProp,
|
|
107
|
+
onCollapsedChange,
|
|
108
|
+
sidebarCollapsedClassName = 'w-14',
|
|
109
|
+
flush = false,
|
|
110
|
+
children,
|
|
111
|
+
}: AppShellProps): React.ReactElement {
|
|
112
|
+
// Controlado quando `collapsed` vem de fora; senão o shell guarda sozinho.
|
|
113
|
+
const [internal, setInternal] = useState(false)
|
|
114
|
+
const isControlled = collapsedProp !== undefined
|
|
115
|
+
const collapsed = collapsible && (isControlled ? collapsedProp : internal)
|
|
116
|
+
|
|
117
|
+
const setCollapsed = useCallback(
|
|
118
|
+
(next: boolean) => {
|
|
119
|
+
if (!isControlled) setInternal(next)
|
|
120
|
+
onCollapsedChange?.(next)
|
|
121
|
+
},
|
|
122
|
+
[isControlled, onCollapsedChange],
|
|
123
|
+
)
|
|
124
|
+
const ctx = useMemo<AppShellContextValue>(
|
|
125
|
+
() => ({ collapsible, collapsed, setCollapsed, toggle: () => setCollapsed(!collapsed) }),
|
|
126
|
+
[collapsible, collapsed, setCollapsed],
|
|
127
|
+
)
|
|
128
|
+
const sidebarSlot = useMemo<SidebarSlotValue>(() => ({ collapsed: collapsible && collapsed }), [collapsible, collapsed])
|
|
129
|
+
|
|
130
|
+
const main = (
|
|
131
|
+
<div
|
|
132
|
+
data-slot="app-shell-main"
|
|
133
|
+
className={cn('flex h-full min-w-0 flex-1 flex-col', flush && 'border-l border-border')}
|
|
134
|
+
>
|
|
135
|
+
{children}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
return (
|
|
139
|
+
<AppShellContext.Provider value={ctx}>
|
|
140
|
+
<div data-slot="app-shell" className={cn('flex h-screen', className)}>
|
|
141
|
+
<aside
|
|
142
|
+
data-slot="app-shell-sidebar"
|
|
143
|
+
// `data-collapsed` + grupo nomeado: o conteúdo do consumidor reage por CSS.
|
|
144
|
+
data-collapsed={collapsible ? String(collapsed) : undefined}
|
|
145
|
+
className={cn(
|
|
146
|
+
'group/sidebar flex shrink-0 flex-col',
|
|
147
|
+
collapsible && 'transition-[width] duration-200 ease-out',
|
|
148
|
+
'w-64',
|
|
149
|
+
flush ? 'p-0' : 'p-2',
|
|
150
|
+
sidebarClassName,
|
|
151
|
+
// DEPOIS do `sidebarClassName` de propósito: recolhido é ESTADO, não estilo
|
|
152
|
+
// default. Quem passa a própria largura (`sidebarClassName="w-72"`) manda no
|
|
153
|
+
// expandido, mas não pode impedir o aside de estreitar — senão o shell ficaria
|
|
154
|
+
// meio-recolhido (rótulo some, largura fica) sem erro nenhum.
|
|
155
|
+
collapsed && sidebarCollapsedClassName,
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
<SidebarSlotContext.Provider value={sidebarSlot}>
|
|
159
|
+
{sidebarHeader}
|
|
160
|
+
<div data-slot="app-shell-nav" className="min-h-0 flex-1 overflow-y-auto">
|
|
161
|
+
{sidebarNav}
|
|
162
|
+
</div>
|
|
163
|
+
{sidebarFooter}
|
|
164
|
+
</SidebarSlotContext.Provider>
|
|
165
|
+
</aside>
|
|
166
|
+
{rail === undefined ? (
|
|
167
|
+
main
|
|
168
|
+
) : (
|
|
169
|
+
<ResizablePanelGroup orientation="horizontal" className="min-w-0 flex-1">
|
|
170
|
+
<ResizablePanel defaultSize={mainDefaultSize} minSize={mainMinSize}>
|
|
171
|
+
{main}
|
|
172
|
+
</ResizablePanel>
|
|
173
|
+
<ResizableHandle withHandle />
|
|
174
|
+
<ResizablePanel defaultSize={100 - mainDefaultSize} minSize={railMinSize}>
|
|
175
|
+
<div data-slot="app-shell-rail" className="h-full">
|
|
176
|
+
{rail}
|
|
177
|
+
</div>
|
|
178
|
+
</ResizablePanel>
|
|
179
|
+
</ResizablePanelGroup>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</AppShellContext.Provider>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* <AppShellTrigger /> — recolhe/expande a sidebar. Some sozinho quando o shell não é
|
|
188
|
+
* `collapsible`, então dá pra deixar fixo no layout sem condicional no consumidor.
|
|
189
|
+
*/
|
|
190
|
+
export function AppShellTrigger({ className }: { className?: string }): React.ReactElement | null {
|
|
191
|
+
const { collapsible, collapsed, toggle } = useAppShell()
|
|
192
|
+
if (!collapsible) return null
|
|
193
|
+
return (
|
|
194
|
+
<Button
|
|
195
|
+
variant="ghost"
|
|
196
|
+
size="icon"
|
|
197
|
+
onClick={toggle}
|
|
198
|
+
aria-label={collapsed ? 'Expandir a barra lateral' : 'Recolher a barra lateral'}
|
|
199
|
+
aria-expanded={!collapsed}
|
|
200
|
+
className={className}
|
|
201
|
+
>
|
|
202
|
+
<PanelLeft className={cn('transition-transform duration-200', collapsed && 'rotate-180')} />
|
|
203
|
+
</Button>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* <AppShellBar /> — a linha de header do chrome `flush`: faixa h-12 com `border-b`.
|
|
209
|
+
* Use UMA na sidebar (a marca) e OUTRA no topo do conteúdo (breadcrumb/ações): as
|
|
210
|
+
* alturas casam e as bordas emendam — uma régua contínua atravessando a tela.
|
|
211
|
+
*/
|
|
212
|
+
export function AppShellBar({
|
|
213
|
+
className,
|
|
214
|
+
children,
|
|
215
|
+
}: {
|
|
216
|
+
className?: string
|
|
217
|
+
children?: ReactNode
|
|
218
|
+
}): React.ReactElement {
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
data-slot="app-shell-bar"
|
|
222
|
+
className={cn('flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-4', className)}
|
|
223
|
+
>
|
|
224
|
+
{children}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|