@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,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ActionForm action /> — renderiza um FormAction do Opus em UI do Opus.
|
|
3
|
+
*
|
|
4
|
+
* Dois modos, um motor:
|
|
5
|
+
* - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo;
|
|
6
|
+
* o espaçamento entre campos (space-y-6) é deste modo, que é quem diagrama.
|
|
7
|
+
* - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
|
|
8
|
+
* (label/hint/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
|
|
9
|
+
* JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
|
|
10
|
+
* espaçamento é 100% seu — nenhuma margem imposta (o campo não sabe onde será colocado).
|
|
11
|
+
*
|
|
12
|
+
* Auto-detect do Zod (nos dois modos):
|
|
13
|
+
* - z.enum(...) → <Select> com opções inferidas
|
|
14
|
+
* - z.string com max > 200 → <Textarea>
|
|
15
|
+
* - z.boolean → <Checkbox> · z.array → multiselect (Select multiple)
|
|
16
|
+
*
|
|
17
|
+
* Submit via useFormAction (RHF + zodResolver + cache invalidation). Erro do servidor
|
|
18
|
+
* inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createContext, useContext } from 'react'
|
|
22
|
+
import type { UseFormReturn } from 'react-hook-form'
|
|
23
|
+
import { getLogicalType, type FormContract, type OptionsSpec } from '../../../core/index.ts'
|
|
24
|
+
import { useDicts, useFormAction, type DictLike } from '../../drivers/react.tsx'
|
|
25
|
+
import { z, type ZodTypeAny } from 'zod'
|
|
26
|
+
import { cn } from '../../lib/cn.ts'
|
|
27
|
+
import { toast } from '../primitives/sonner.tsx'
|
|
28
|
+
import { Button } from '../primitives/button.tsx'
|
|
29
|
+
import { Input } from '../primitives/input.tsx'
|
|
30
|
+
import { Textarea } from '../primitives/textarea.tsx'
|
|
31
|
+
import { Label } from '../primitives/label.tsx'
|
|
32
|
+
import { Checkbox } from '../primitives/checkbox.tsx'
|
|
33
|
+
import { IconPicker } from '../primitives/icon-picker.tsx'
|
|
34
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
35
|
+
import { Info } from 'lucide-react'
|
|
36
|
+
import { Select, type SelectOption } from '../primitives/select.tsx'
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Inferência de tipo de field a partir do Zod schema
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
type FieldKind =
|
|
43
|
+
| { kind: 'text'; required: boolean }
|
|
44
|
+
| { kind: 'textarea'; required: boolean }
|
|
45
|
+
| { kind: 'checkbox'; required: boolean }
|
|
46
|
+
| { kind: 'select'; required: boolean; options: string[] }
|
|
47
|
+
| { kind: 'multiselect'; required: boolean; options: string[] }
|
|
48
|
+
| { kind: 'lines'; required: boolean }
|
|
49
|
+
| { kind: 'refItems'; required: boolean }
|
|
50
|
+
| { kind: 'icon'; required: boolean }
|
|
51
|
+
|
|
52
|
+
function unwrap(schema: ZodTypeAny): { inner: ZodTypeAny; required: boolean } {
|
|
53
|
+
let inner: ZodTypeAny = schema
|
|
54
|
+
let required = true
|
|
55
|
+
// ZodOptional / ZodDefault / ZodNullable → desce no _def.innerType.
|
|
56
|
+
while (
|
|
57
|
+
inner instanceof z.ZodOptional ||
|
|
58
|
+
inner instanceof z.ZodDefault ||
|
|
59
|
+
inner instanceof z.ZodNullable
|
|
60
|
+
) {
|
|
61
|
+
if (inner instanceof z.ZodOptional || inner instanceof z.ZodDefault) {
|
|
62
|
+
required = false
|
|
63
|
+
}
|
|
64
|
+
const def = (inner as unknown as { _def: { innerType: ZodTypeAny } })._def
|
|
65
|
+
inner = def.innerType
|
|
66
|
+
}
|
|
67
|
+
return { inner, required }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inferFieldKind(schema: ZodTypeAny): FieldKind {
|
|
71
|
+
const { inner, required } = unwrap(schema)
|
|
72
|
+
|
|
73
|
+
if (inner instanceof z.ZodBoolean) {
|
|
74
|
+
return { kind: 'checkbox', required }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (inner instanceof z.ZodEnum) {
|
|
78
|
+
return { kind: 'select', required, options: inner.options as string[] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// z.array(...) → multiselect (Select multiple). Opções estáticas só quando o elemento é
|
|
82
|
+
// z.enum; pra z.array(z.string()) (ids em runtime) as opções vêm de fora (spec).
|
|
83
|
+
if (inner instanceof z.ZodArray) {
|
|
84
|
+
const el = (inner._def as { type: ZodTypeAny }).type
|
|
85
|
+
return { kind: 'multiselect', required, options: el instanceof z.ZodEnum ? (el.options as string[]) : [] }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (inner instanceof z.ZodString) {
|
|
89
|
+
const maxCheck = inner._def.checks.find(
|
|
90
|
+
(c: { kind: string }) => c.kind === 'max',
|
|
91
|
+
) as { value: number } | undefined
|
|
92
|
+
if (maxCheck !== undefined && maxCheck.value > 200) {
|
|
93
|
+
return { kind: 'textarea', required }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { kind: 'text', required }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// FieldSpec (subset usado aqui)
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
interface FieldSpec {
|
|
105
|
+
label?: string
|
|
106
|
+
placeholder?: string
|
|
107
|
+
hint?: string
|
|
108
|
+
/** Ajuda na label: ícone ⓘ + tooltip no hover (explicação mais longa que o hint). */
|
|
109
|
+
help?: string
|
|
110
|
+
/** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
|
|
111
|
+
* Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
|
|
112
|
+
* 'lines' (z.array(z.string()) num textarea, um item por linha), 'refItems'
|
|
113
|
+
* (z.array(z.object({ref, text})) — linhas de referência + texto; as opções do
|
|
114
|
+
* `ref` vêm de fieldOptions[campo]) e 'icon' (string com o nome kebab-case da
|
|
115
|
+
* paleta da casa — renderiza o <IconPicker>). */
|
|
116
|
+
widget?: string
|
|
117
|
+
/** Renderiza o campo só quando true pro input atual (ex.: clientId só se !staff).
|
|
118
|
+
* No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
|
|
119
|
+
showWhen?: (input: Record<string, unknown>) => boolean
|
|
120
|
+
/** Origem declarada das opções (static/dictionary/lookup) — ver a precedência
|
|
121
|
+
* em `ActionFormField`. `dictionary` resolve pelos dicts do TbdlibProvider. */
|
|
122
|
+
options?: OptionsSpec
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Opções declaradas no FieldSpec: static direto; dictionary via provider; lookup
|
|
126
|
+
* fica de fora (origem em action — entra por `fieldOptions`/prop por enquanto). */
|
|
127
|
+
function optionsFromSpec(
|
|
128
|
+
spec: OptionsSpec | undefined,
|
|
129
|
+
dicts: Record<string, DictLike>,
|
|
130
|
+
): SelectOption[] | undefined {
|
|
131
|
+
if (spec === undefined) return undefined
|
|
132
|
+
if (spec.kind === 'static') {
|
|
133
|
+
return spec.items.map((i) => ({
|
|
134
|
+
value: i.value,
|
|
135
|
+
label: typeof i.label === 'string' ? i.label : i.label.default,
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
138
|
+
if (spec.kind === 'dictionary') {
|
|
139
|
+
const dict = dicts[spec.ref]
|
|
140
|
+
if (dict === undefined) return undefined
|
|
141
|
+
return dict.options().map((o) => ({ value: o.value, label: o.label }))
|
|
142
|
+
}
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Fallback zero-config: o schema do contrato CARREGA o vocabulário do `t.dict`
|
|
147
|
+
* (meta `params.keys/entries`) — labels sem registry no provider. Cobre o campo
|
|
148
|
+
* dict e o multiselect com elemento dict. */
|
|
149
|
+
function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
|
|
150
|
+
const { inner } = unwrap(schema)
|
|
151
|
+
const el = inner instanceof z.ZodArray ? (inner._def as { type: ZodTypeAny }).type : inner
|
|
152
|
+
const meta = getLogicalType(el as object)
|
|
153
|
+
if (meta?.logicalType !== 'dict') return undefined
|
|
154
|
+
const params = (meta.params ?? {}) as {
|
|
155
|
+
keys?: unknown
|
|
156
|
+
entries?: Record<string, { label?: unknown }>
|
|
157
|
+
}
|
|
158
|
+
const { keys, entries } = params
|
|
159
|
+
if (!Array.isArray(keys) || entries === undefined) return undefined
|
|
160
|
+
return (keys as string[]).map((k) => {
|
|
161
|
+
const label = entries[k]?.label
|
|
162
|
+
return { value: k, label: typeof label === 'string' ? label : k }
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Ícone de ajuda na label (ⓘ + tooltip Radix). Self-contained (inclui o Provider) — só
|
|
167
|
+
* renderiza quando o FieldSpec define `help`. `tabIndex={-1}`: fica FORA do autofocus do
|
|
168
|
+
* diálogo (senão o modal foca no ícone ao abrir e o tooltip vem aberto); abre só no hover.
|
|
169
|
+
* preventDefault: clicar o ícone não dispara o label (não foca o input / não toggla). */
|
|
170
|
+
function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
|
|
171
|
+
if (help === undefined || help.trim().length === 0) return null
|
|
172
|
+
return (
|
|
173
|
+
<TooltipProvider delayDuration={300}>
|
|
174
|
+
<Tooltip>
|
|
175
|
+
<TooltipTrigger asChild>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
tabIndex={-1}
|
|
179
|
+
aria-label="Ajuda"
|
|
180
|
+
onClick={(e) => {
|
|
181
|
+
// Clicar o ícone não ativa o label (não foca/toggla o campo).
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
e.stopPropagation()
|
|
184
|
+
}}
|
|
185
|
+
className="inline-flex shrink-0 cursor-help text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
|
186
|
+
>
|
|
187
|
+
<Info className="h-3.5 w-3.5" />
|
|
188
|
+
</button>
|
|
189
|
+
</TooltipTrigger>
|
|
190
|
+
{/* max-w-sm: frase de help típica (~50 caracteres) cabe numa linha; o
|
|
191
|
+
text-balance do primitivo só divide o que realmente transborda. */}
|
|
192
|
+
<TooltipContent className="max-w-sm">{help}</TooltipContent>
|
|
193
|
+
</Tooltip>
|
|
194
|
+
</TooltipProvider>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Asterisco de obrigatório na label — derivado do Zod (campo sem optional/default).
|
|
199
|
+
* aria-hidden: leitor de tela valida pelo erro do resolver, não pelo símbolo. */
|
|
200
|
+
function RequiredMark({ required }: { required: boolean }): React.ReactElement | null {
|
|
201
|
+
if (!required) return null
|
|
202
|
+
return (
|
|
203
|
+
<span aria-hidden className="ml-0.5 text-destructive">
|
|
204
|
+
*
|
|
205
|
+
</span>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** action.messages.* pode ser string ou I18nRef ({ key, default }). Resolve pra texto. */
|
|
210
|
+
function msgText(m: unknown, fallback: string): string {
|
|
211
|
+
if (typeof m === 'string') return m
|
|
212
|
+
if (m !== null && typeof m === 'object' && 'default' in m) {
|
|
213
|
+
const d = (m as { default: unknown }).default
|
|
214
|
+
if (typeof d === 'string') return d
|
|
215
|
+
}
|
|
216
|
+
return fallback
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// Contexto — liga o <ActionFormField> ao form/contrato do <ActionForm> pai
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
export interface ActionFormContextValue {
|
|
224
|
+
/** RHF do useFormAction. O shape é dinâmico — os paths entram como string. */
|
|
225
|
+
form: UseFormReturn<Record<string, unknown>>
|
|
226
|
+
shape: Record<string, ZodTypeAny | undefined>
|
|
227
|
+
fields: Record<string, FieldSpec>
|
|
228
|
+
fieldOptions?: Record<string, SelectOption[]> | undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ActionFormContext = createContext<ActionFormContextValue | null>(null)
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* O contexto do `<ActionForm>` pai — a válvula de escape pra CONTROLE CUSTOM no
|
|
235
|
+
* modo composição: um campo com UI própria (grade de permissões, canvas, o que
|
|
236
|
+
* for) participa do form do contrato sem abandonar o ActionForm. Leia com
|
|
237
|
+
* `form.watch(name)`, escreva com `form.setValue(name, v, { shouldValidate:
|
|
238
|
+
* true, shouldDirty: true })` e mostre erro com `form.formState.errors[name]` —
|
|
239
|
+
* zod-resolver, erro inline e submit continuam do contrato.
|
|
240
|
+
*/
|
|
241
|
+
export function useActionFormContext(): ActionFormContextValue {
|
|
242
|
+
const ctx = useContext(ActionFormContext)
|
|
243
|
+
if (ctx === null) throw new Error('useActionFormContext precisa estar dentro de um <ActionForm>.')
|
|
244
|
+
return ctx
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// =============================================================================
|
|
248
|
+
// ActionFormField — um campo do contrato, onde você quiser
|
|
249
|
+
// =============================================================================
|
|
250
|
+
|
|
251
|
+
export interface ActionFormFieldProps {
|
|
252
|
+
/** Nome do campo no input do contrato (a chave em `fields`/schema). */
|
|
253
|
+
name: string
|
|
254
|
+
/** Opções por id de runtime — sobrepõe o fieldOptions do form e o z.enum. */
|
|
255
|
+
options?: SelectOption[]
|
|
256
|
+
/** Classes do invólucro do campo (ex.: col-span-2 numa grid). */
|
|
257
|
+
className?: string
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function ActionFormField({ name, options: optionsProp, className }: ActionFormFieldProps): React.ReactElement | null {
|
|
261
|
+
const ctx = useContext(ActionFormContext)
|
|
262
|
+
if (ctx === null) throw new Error('ActionFormField precisa estar dentro de um <ActionForm>.')
|
|
263
|
+
const dicts = useDicts()
|
|
264
|
+
const { form, shape, fields, fieldOptions } = ctx
|
|
265
|
+
// Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
|
|
266
|
+
const formValues = form.watch()
|
|
267
|
+
|
|
268
|
+
// Clique na label foca o controle — todo controle da casa honra o id (o Select
|
|
269
|
+
// reaplica o dele, que o cmdk sobrescreve).
|
|
270
|
+
const focusControl = (): void => {
|
|
271
|
+
document.getElementById(name)?.focus()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const spec: FieldSpec = fields[name] ?? {}
|
|
275
|
+
const fieldSchema = shape[name]
|
|
276
|
+
if (fieldSchema === undefined) return null
|
|
277
|
+
if (typeof spec.showWhen === 'function' && !spec.showWhen(formValues)) return null
|
|
278
|
+
|
|
279
|
+
const inferred = inferFieldKind(fieldSchema)
|
|
280
|
+
// `widget` no FieldSpec sobrepõe o auto-detect (ex.: textarea pra string sem max).
|
|
281
|
+
const fieldKind: FieldKind =
|
|
282
|
+
spec.widget === 'textarea' || spec.widget === 'code'
|
|
283
|
+
? { kind: 'textarea', required: inferred.required }
|
|
284
|
+
: spec.widget === 'lines'
|
|
285
|
+
? { kind: 'lines', required: inferred.required }
|
|
286
|
+
: spec.widget === 'refItems'
|
|
287
|
+
? { kind: 'refItems', required: inferred.required }
|
|
288
|
+
: spec.widget === 'icon'
|
|
289
|
+
? { kind: 'icon', required: inferred.required }
|
|
290
|
+
: inferred
|
|
291
|
+
const fieldError = form.formState.errors[name]
|
|
292
|
+
const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
|
|
293
|
+
// Precedência das opções: prop do campo > fieldOptions do form > spec.options
|
|
294
|
+
// (dictionary via provider, static direto) > meta do t.dict no schema
|
|
295
|
+
// (zero-config) > chaves cruas do z.enum.
|
|
296
|
+
const runtimeOptions = optionsProp ?? fieldOptions?.[name]
|
|
297
|
+
const specOptions = optionsFromSpec(spec.options, dicts)
|
|
298
|
+
const declaredOptions = runtimeOptions ?? specOptions
|
|
299
|
+
const options: SelectOption[] =
|
|
300
|
+
declaredOptions ??
|
|
301
|
+
dictMetaOptions(fieldSchema) ??
|
|
302
|
+
(fieldKind.kind === 'select' || fieldKind.kind === 'multiselect'
|
|
303
|
+
? fieldKind.options.map((o) => ({ value: o, label: o }))
|
|
304
|
+
: [])
|
|
305
|
+
// Campo texto COM opções declaradas (runtime ou spec) → single-select por-id.
|
|
306
|
+
const effectiveKind: FieldKind =
|
|
307
|
+
fieldKind.kind === 'text' && declaredOptions !== undefined
|
|
308
|
+
? { kind: 'select', required: fieldKind.required, options: [] }
|
|
309
|
+
: fieldKind
|
|
310
|
+
|
|
311
|
+
const setValue = (v: unknown): void =>
|
|
312
|
+
form.setValue(name, v, { shouldValidate: true, shouldDirty: true })
|
|
313
|
+
|
|
314
|
+
// Checkbox tem layout próprio (controle + label na mesma linha).
|
|
315
|
+
if (fieldKind.kind === 'checkbox') {
|
|
316
|
+
return (
|
|
317
|
+
<div className={cn('space-y-2', className)}>
|
|
318
|
+
<label className="flex items-center gap-2">
|
|
319
|
+
<Checkbox
|
|
320
|
+
id={name}
|
|
321
|
+
checked={(formValues[name] as boolean | undefined) ?? false}
|
|
322
|
+
onCheckedChange={(v) => setValue(v === true)}
|
|
323
|
+
/>
|
|
324
|
+
<span className="text-sm font-medium">
|
|
325
|
+
{spec.label ?? name}
|
|
326
|
+
<RequiredMark required={fieldKind.required} />
|
|
327
|
+
</span>
|
|
328
|
+
<LabelHelp help={spec.help} />
|
|
329
|
+
</label>
|
|
330
|
+
{spec.hint !== undefined && <p className="text-xs text-muted-foreground">{spec.hint}</p>}
|
|
331
|
+
{errorMessage !== undefined && <p className="text-xs text-destructive">{errorMessage}</p>}
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<div className={cn('space-y-2', className)}>
|
|
338
|
+
<Label htmlFor={name} onClick={focusControl} className="flex items-center gap-1.5">
|
|
339
|
+
<span>
|
|
340
|
+
{spec.label ?? name}
|
|
341
|
+
<RequiredMark required={fieldKind.required} />
|
|
342
|
+
</span>
|
|
343
|
+
<LabelHelp help={spec.help} />
|
|
344
|
+
</Label>
|
|
345
|
+
|
|
346
|
+
{effectiveKind.kind === 'multiselect' ? (
|
|
347
|
+
<Select
|
|
348
|
+
multiple
|
|
349
|
+
searchable
|
|
350
|
+
id={name}
|
|
351
|
+
value={(formValues[name] as string[] | undefined) ?? []}
|
|
352
|
+
onChange={(v) => setValue(v)}
|
|
353
|
+
options={options}
|
|
354
|
+
placeholder={spec.placeholder ?? 'Selecione…'}
|
|
355
|
+
className="w-full"
|
|
356
|
+
/>
|
|
357
|
+
) : effectiveKind.kind === 'select' ? (
|
|
358
|
+
// w-full: campo de FORM alinha com os inputs (todos cheios) — coluna uniforme.
|
|
359
|
+
<Select
|
|
360
|
+
id={name}
|
|
361
|
+
value={(formValues[name] as string | undefined) ?? ''}
|
|
362
|
+
onChange={(v) => setValue(v)}
|
|
363
|
+
options={options}
|
|
364
|
+
placeholder={spec.placeholder ?? 'Selecione…'}
|
|
365
|
+
className="w-full"
|
|
366
|
+
/>
|
|
367
|
+
) : effectiveKind.kind === 'lines' ? (
|
|
368
|
+
// Lista de strings num textarea: um item por linha. split/join round-trip
|
|
369
|
+
// estável (linhas vazias do meio da digitação são filtradas no servidor).
|
|
370
|
+
<Textarea
|
|
371
|
+
id={name}
|
|
372
|
+
placeholder={spec.placeholder ?? ''}
|
|
373
|
+
rows={4}
|
|
374
|
+
className="max-h-80"
|
|
375
|
+
value={((formValues[name] as string[] | undefined) ?? []).join('\n')}
|
|
376
|
+
onChange={(e) => setValue(e.target.value.split('\n'))}
|
|
377
|
+
/>
|
|
378
|
+
) : effectiveKind.kind === 'refItems' ? (
|
|
379
|
+
// Lista composta {ref, text}: cada linha = select de referência (opções de
|
|
380
|
+
// fieldOptions) + texto. Estruturado de propósito — o ref nunca é texto livre.
|
|
381
|
+
(() => {
|
|
382
|
+
const items = (formValues[name] as Array<{ ref: string; text: string }> | undefined) ?? []
|
|
383
|
+
const setItems = (next: Array<{ ref: string; text: string }>): void => setValue(next)
|
|
384
|
+
return (
|
|
385
|
+
<div className="space-y-2">
|
|
386
|
+
{items.map((item, i) => (
|
|
387
|
+
<div key={i} className="flex items-start gap-2">
|
|
388
|
+
<Select
|
|
389
|
+
value={item.ref}
|
|
390
|
+
onChange={(v) => setItems(items.map((it, j) => (j === i ? { ...it, ref: v } : it)))}
|
|
391
|
+
options={options}
|
|
392
|
+
placeholder="Papel…"
|
|
393
|
+
className="w-44 shrink-0"
|
|
394
|
+
/>
|
|
395
|
+
<Input
|
|
396
|
+
value={item.text}
|
|
397
|
+
placeholder={spec.placeholder ?? 'O quê (com critério)…'}
|
|
398
|
+
onChange={(e) => setItems(items.map((it, j) => (j === i ? { ...it, text: e.target.value } : it)))}
|
|
399
|
+
/>
|
|
400
|
+
<Button
|
|
401
|
+
type="button"
|
|
402
|
+
variant="ghost"
|
|
403
|
+
size="sm"
|
|
404
|
+
className="shrink-0 px-2 text-muted-foreground"
|
|
405
|
+
onClick={() => setItems(items.filter((_, j) => j !== i))}
|
|
406
|
+
aria-label="Remover item"
|
|
407
|
+
>
|
|
408
|
+
×
|
|
409
|
+
</Button>
|
|
410
|
+
</div>
|
|
411
|
+
))}
|
|
412
|
+
<Button
|
|
413
|
+
type="button"
|
|
414
|
+
variant="outline"
|
|
415
|
+
size="sm"
|
|
416
|
+
onClick={() => setItems([...items, { ref: '', text: '' }])}
|
|
417
|
+
>
|
|
418
|
+
Adicionar item
|
|
419
|
+
</Button>
|
|
420
|
+
</div>
|
|
421
|
+
)
|
|
422
|
+
})()
|
|
423
|
+
) : effectiveKind.kind === 'icon' ? (
|
|
424
|
+
// Widget 'icon': o value é o NOME do ícone (kebab-case) — ver <IconPicker>.
|
|
425
|
+
<IconPicker
|
|
426
|
+
id={name}
|
|
427
|
+
value={(formValues[name] as string | undefined) ?? ''}
|
|
428
|
+
onChange={(v) => setValue(v)}
|
|
429
|
+
placeholder={spec.placeholder ?? 'Selecione um ícone…'}
|
|
430
|
+
/>
|
|
431
|
+
) : effectiveKind.kind === 'textarea' ? (
|
|
432
|
+
// max-h: o Textarea v4 auto-cresce (field-sizing-content) — capa e scrolla
|
|
433
|
+
// o CAMPO, não o form/dialog inteiro. widget 'code' = monoespaçado (SKILL.md).
|
|
434
|
+
<Textarea
|
|
435
|
+
id={name}
|
|
436
|
+
placeholder={spec.placeholder ?? ''}
|
|
437
|
+
rows={5}
|
|
438
|
+
className={spec.widget === 'code' ? 'max-h-80 font-mono text-xs' : 'max-h-80'}
|
|
439
|
+
{...form.register(name)}
|
|
440
|
+
/>
|
|
441
|
+
) : (
|
|
442
|
+
<Input
|
|
443
|
+
id={name}
|
|
444
|
+
placeholder={spec.placeholder ?? ''}
|
|
445
|
+
{...form.register(name)}
|
|
446
|
+
/>
|
|
447
|
+
)}
|
|
448
|
+
|
|
449
|
+
{spec.hint !== undefined && (
|
|
450
|
+
<p className="text-xs text-muted-foreground">{spec.hint}</p>
|
|
451
|
+
)}
|
|
452
|
+
{errorMessage !== undefined && (
|
|
453
|
+
<p className="text-xs text-destructive">{errorMessage}</p>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// =============================================================================
|
|
460
|
+
// ActionForm
|
|
461
|
+
// =============================================================================
|
|
462
|
+
|
|
463
|
+
export interface ActionFormProps<TInput extends Record<string, unknown>, TData> {
|
|
464
|
+
action: FormContract<TInput, TData>
|
|
465
|
+
defaultValues?: Partial<TInput>
|
|
466
|
+
onSuccess?: (data: TData) => void
|
|
467
|
+
submitLabel?: string
|
|
468
|
+
cancelLabel?: string
|
|
469
|
+
onCancel?: () => void
|
|
470
|
+
/** Opções por campo pra select/multiselect carregados em runtime (ex.: papéis/skills
|
|
471
|
+
* por id). Sobrepõe as opções estáticas inferidas do z.enum. Chave = nome do campo. */
|
|
472
|
+
fieldOptions?: Record<string, SelectOption[]>
|
|
473
|
+
/** Classes do <form> (layout externo — ex.: coluna flex no dialog). */
|
|
474
|
+
className?: string
|
|
475
|
+
/** Slot do CORPO: recebe os campos montados e decide o invólucro. Default: inline. No
|
|
476
|
+
* dialog, o ActionFormDialog injeta `DialogBody` (a área que rola). */
|
|
477
|
+
body?: (fields: React.ReactNode) => React.ReactNode
|
|
478
|
+
/** Slot do RODAPÉ: recebe os botões (Cancelar/Salvar) — com o estado do form e DENTRO
|
|
479
|
+
* do <form> — e decide o invólucro. Default: faixa de ação simples. No dialog, o
|
|
480
|
+
* ActionFormDialog injeta `DialogFooter` (a faixa da casa). */
|
|
481
|
+
footer?: (actions: React.ReactNode) => React.ReactNode
|
|
482
|
+
/** Modo COMPOSIÇÃO: diagrame os campos com <ActionFormField name/> (grid, seções,
|
|
483
|
+
* condicionais em JSX). Sem children, o modo AUTO monta todos na ordem do contrato. */
|
|
484
|
+
children?: React.ReactNode
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
488
|
+
action,
|
|
489
|
+
defaultValues,
|
|
490
|
+
onSuccess,
|
|
491
|
+
submitLabel = 'Salvar',
|
|
492
|
+
cancelLabel = 'Cancelar',
|
|
493
|
+
onCancel,
|
|
494
|
+
fieldOptions,
|
|
495
|
+
className,
|
|
496
|
+
body,
|
|
497
|
+
footer,
|
|
498
|
+
children,
|
|
499
|
+
}: ActionFormProps<TInput, TData>) {
|
|
500
|
+
const { form, submit, isLoading, error, isSuccess } = useFormAction<TInput, TData>(action, {
|
|
501
|
+
...(defaultValues !== undefined ? { defaultValues: defaultValues as never } : {}),
|
|
502
|
+
onSuccess: (data) => {
|
|
503
|
+
toast.success(msgText(action.messages?.success, 'Concluído'))
|
|
504
|
+
onSuccess?.(data)
|
|
505
|
+
},
|
|
506
|
+
onError: (err) => {
|
|
507
|
+
toast.error(msgText(action.messages?.error, err.message))
|
|
508
|
+
},
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const shape = (action.input as unknown as z.ZodObject<z.ZodRawShape>).shape
|
|
512
|
+
const fields = action.fields as Record<string, FieldSpec>
|
|
513
|
+
|
|
514
|
+
// Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
|
|
515
|
+
// DialogBody/DialogFooter (fonte única do scroll e da faixa). Os botões ficam DENTRO do
|
|
516
|
+
// <form> nos dois casos — o submit exige isso; o que muda é só o invólucro.
|
|
517
|
+
const wrapBody = body ?? ((fields: React.ReactNode) => fields)
|
|
518
|
+
const wrapFooter =
|
|
519
|
+
footer ??
|
|
520
|
+
((actions: React.ReactNode) => (
|
|
521
|
+
<div className="mt-6 flex shrink-0 justify-end gap-2">{actions}</div>
|
|
522
|
+
))
|
|
523
|
+
|
|
524
|
+
// Banner de erro do servidor (chrome do próprio form, nos dois modos). No AUTO o
|
|
525
|
+
// space-y-6 do wrapper espaça; na COMPOSIÇÃO ele carrega a própria separação (mt-6).
|
|
526
|
+
const errorBanner =
|
|
527
|
+
error !== undefined && !isSuccess ? (
|
|
528
|
+
<div
|
|
529
|
+
className={cn(
|
|
530
|
+
'rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive',
|
|
531
|
+
children !== undefined && 'mt-6',
|
|
532
|
+
)}
|
|
533
|
+
>
|
|
534
|
+
<strong>{error.code}</strong>: {error.message}
|
|
535
|
+
</div>
|
|
536
|
+
) : null
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<ActionFormContext.Provider
|
|
540
|
+
value={{
|
|
541
|
+
form: form as unknown as UseFormReturn<Record<string, unknown>>,
|
|
542
|
+
shape,
|
|
543
|
+
fields,
|
|
544
|
+
fieldOptions,
|
|
545
|
+
}}
|
|
546
|
+
>
|
|
547
|
+
<form onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
|
|
548
|
+
{wrapBody(
|
|
549
|
+
children !== undefined ? (
|
|
550
|
+
// COMPOSIÇÃO: nenhum espaçamento imposto — o componente não sabe onde será
|
|
551
|
+
// colocado; a diagramação (grid/gap/space-y) é 100% de quem compõe.
|
|
552
|
+
<>
|
|
553
|
+
{children}
|
|
554
|
+
{errorBanner}
|
|
555
|
+
</>
|
|
556
|
+
) : (
|
|
557
|
+
// AUTO: aqui o ActionForm É o pai que diagrama — o space-y-6 é dele.
|
|
558
|
+
<div className="space-y-6">
|
|
559
|
+
{Object.keys(fields).map((name) => (
|
|
560
|
+
<ActionFormField key={name} name={name} />
|
|
561
|
+
))}
|
|
562
|
+
{errorBanner}
|
|
563
|
+
</div>
|
|
564
|
+
),
|
|
565
|
+
)}
|
|
566
|
+
|
|
567
|
+
{wrapFooter(
|
|
568
|
+
<>
|
|
569
|
+
{onCancel !== undefined && (
|
|
570
|
+
<Button type="button" variant="outline" onClick={onCancel}>
|
|
571
|
+
{cancelLabel}
|
|
572
|
+
</Button>
|
|
573
|
+
)}
|
|
574
|
+
{/* Gated por dirty: só habilita com mudança real (igual aos forms antigos).
|
|
575
|
+
busy = a submissão em andamento (spinner + disable vêm do Button). */}
|
|
576
|
+
<Button type="submit" busy={isLoading} disabled={!form.formState.isDirty}>
|
|
577
|
+
{submitLabel}
|
|
578
|
+
</Button>
|
|
579
|
+
</>,
|
|
580
|
+
)}
|
|
581
|
+
</form>
|
|
582
|
+
</ActionFormContext.Provider>
|
|
583
|
+
)
|
|
584
|
+
}
|