@softize/opus 16.1.0 → 17.0.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 +30 -0
- package/bin/lib/check.mjs +9 -13
- package/bin/lib/copy.mjs +29 -4
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +6 -3
- package/docs/adr/0009-page-title-does-not-carry-a-counter.md +5 -2
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +12 -14
- package/docs/adr/0012-modal-header-only-names-the-surface.md +8 -4
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +35 -0
- package/package.json +2 -2
- package/registry/skills/build-opus-ui/references/evaluations.md +1 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +16 -17
- package/registry/templates/app/package.json +1 -1
- package/src/core/presentation.ts +142 -13
- package/src/ui/components/patterns/form.tsx +29 -10
- package/src/ui/components/patterns/page.tsx +93 -48
- package/src/ui/components/patterns/presentation.tsx +459 -329
- package/src/ui/components/patterns/surface-header.tsx +4 -3
- package/src/ui/components/patterns/trigger.tsx +8 -1
- package/src/ui/components/patterns/view.tsx +2 -2
- package/src/ui/components/primitives/command.tsx +82 -42
- package/src/ui/components/primitives/dialog.tsx +180 -97
- package/src/ui/components/primitives/drawer.tsx +63 -22
- package/src/ui/docs/content/command.md +3 -1
- package/src/ui/docs/content/content.md +1 -1
- package/src/ui/docs/content/dialog.md +9 -9
- package/src/ui/docs/content/drawer.md +4 -4
- package/src/ui/docs/content/page.md +14 -29
- package/src/ui/docs/content/presentation.md +54 -76
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +1 -8
|
@@ -1,47 +1,49 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createContext,
|
|
3
2
|
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useId,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
3
|
useState,
|
|
10
4
|
type ReactElement,
|
|
11
5
|
type ReactNode,
|
|
12
6
|
} from "react";
|
|
13
|
-
import {
|
|
7
|
+
import { createPortal } from "react-dom";
|
|
8
|
+
import { ArrowLeft, Braces } from "lucide-react";
|
|
14
9
|
import type {
|
|
10
|
+
PresentationActionRegistry,
|
|
11
|
+
PresentationBindingContext,
|
|
12
|
+
PresentationCommand,
|
|
15
13
|
PresentationDiagnostic,
|
|
16
14
|
PresentationDefinition,
|
|
17
15
|
PresentationInvocation,
|
|
16
|
+
PresentationJsonValue,
|
|
18
17
|
PresentationSurface,
|
|
19
18
|
} from "../../../core/presentation.ts";
|
|
20
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
applyPresentationEffects,
|
|
21
|
+
createPresentationInspectionSnapshot,
|
|
22
|
+
openPresentation,
|
|
23
|
+
resolvePresentationBindings,
|
|
24
|
+
} from "../../../core/presentation.ts";
|
|
25
|
+
import type {
|
|
26
|
+
FormContract,
|
|
27
|
+
ListContract,
|
|
28
|
+
SimpleContract,
|
|
29
|
+
ViewContract,
|
|
30
|
+
} from "../../../core/contracts.ts";
|
|
21
31
|
import { cn } from "../../lib/cn.ts";
|
|
22
32
|
import { Button, buttonVariants } from "../primitives/button.tsx";
|
|
23
33
|
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
24
34
|
import { Copyable } from "../primitives/copyable.tsx";
|
|
35
|
+
import { DetailField, DetailGroup } from "../primitives/detail.tsx";
|
|
25
36
|
import {
|
|
26
37
|
Dialog,
|
|
27
38
|
DialogBody,
|
|
28
|
-
DialogClose,
|
|
29
39
|
DialogContent,
|
|
30
40
|
DialogFooter,
|
|
31
41
|
DialogHeader,
|
|
32
42
|
DialogTitle,
|
|
33
43
|
} from "../primitives/dialog.tsx";
|
|
34
|
-
import {
|
|
35
|
-
Menu,
|
|
36
|
-
MenuContent,
|
|
37
|
-
MenuItem,
|
|
38
|
-
MenuLabel,
|
|
39
|
-
MenuTrigger,
|
|
40
|
-
} from "../primitives/menu.tsx";
|
|
41
44
|
import {
|
|
42
45
|
Drawer,
|
|
43
46
|
DrawerBody,
|
|
44
|
-
DrawerClose,
|
|
45
47
|
DrawerContent,
|
|
46
48
|
DrawerFooter,
|
|
47
49
|
DrawerHeader,
|
|
@@ -53,29 +55,30 @@ import {
|
|
|
53
55
|
PageBody,
|
|
54
56
|
PageFooter,
|
|
55
57
|
PageHeader,
|
|
56
|
-
PageIntro,
|
|
57
58
|
PageNavigation,
|
|
58
59
|
PageTitle,
|
|
59
60
|
useInsidePageShell,
|
|
60
61
|
} from "./page.tsx";
|
|
62
|
+
import { ActionForm } from "./form.tsx";
|
|
63
|
+
import { ActionList, type ActionListState } from "./list.tsx";
|
|
64
|
+
import { ActionTrigger } from "./trigger.tsx";
|
|
65
|
+
import { ActionView } from "./view.tsx";
|
|
61
66
|
|
|
62
|
-
interface
|
|
67
|
+
interface PresentationFrameProps {
|
|
63
68
|
surface: PresentationSurface;
|
|
64
69
|
title: ReactNode;
|
|
65
70
|
navigation?: ReactNode;
|
|
66
71
|
headerActions?: ReactNode;
|
|
67
72
|
footerActions?: ReactNode;
|
|
68
|
-
children: ReactNode;
|
|
73
|
+
children: ReactNode | ((footerTarget: HTMLElement | null) => ReactNode);
|
|
74
|
+
hasBodyFooter?: boolean;
|
|
75
|
+
blocked?: boolean;
|
|
76
|
+
open?: boolean;
|
|
77
|
+
onOpenChange?: (open: boolean) => void;
|
|
69
78
|
className?: string;
|
|
70
79
|
bodyClassName?: string;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
export type PresentationProps = PresentationBaseProps &
|
|
74
|
-
(
|
|
75
|
-
| { open?: undefined; onOpenChange?: (open: boolean) => void }
|
|
76
|
-
| { open: boolean; onOpenChange: (open: boolean) => void }
|
|
77
|
-
);
|
|
78
|
-
|
|
79
82
|
const bodyClassName =
|
|
80
83
|
"h-full min-h-full [&>[data-slot=data-state]]:flex [&>[data-slot=data-state]]:min-h-full [&>[data-slot=data-state]]:items-center [&>[data-slot=data-state]]:justify-center";
|
|
81
84
|
|
|
@@ -93,49 +96,46 @@ function PresentationActions({
|
|
|
93
96
|
);
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
function CloseAction({
|
|
97
|
-
surface,
|
|
98
|
-
}: {
|
|
99
|
-
surface: "dialog" | "drawer";
|
|
100
|
-
}): ReactElement {
|
|
101
|
-
const button = (
|
|
102
|
-
<Button size="icon-sm" variant="ghost" aria-label="Fechar">
|
|
103
|
-
<X aria-hidden />
|
|
104
|
-
</Button>
|
|
105
|
-
);
|
|
106
|
-
return surface === "dialog" ? (
|
|
107
|
-
<DialogClose asChild>{button}</DialogClose>
|
|
108
|
-
) : (
|
|
109
|
-
<DrawerClose asChild>{button}</DrawerClose>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
99
|
/** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
|
|
114
|
-
|
|
100
|
+
function PresentationFrame({
|
|
115
101
|
surface,
|
|
116
102
|
title,
|
|
117
103
|
navigation,
|
|
118
104
|
headerActions,
|
|
119
105
|
footerActions,
|
|
120
106
|
children,
|
|
121
|
-
|
|
107
|
+
hasBodyFooter = false,
|
|
108
|
+
blocked = false,
|
|
109
|
+
open = true,
|
|
122
110
|
onOpenChange,
|
|
123
111
|
className,
|
|
124
112
|
bodyClassName: bodyClassNameProp,
|
|
125
|
-
}:
|
|
113
|
+
}: PresentationFrameProps): ReactElement {
|
|
126
114
|
const insidePageShell = useInsidePageShell();
|
|
127
|
-
const [
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
115
|
+
const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
|
|
116
|
+
const setFooterNode = useCallback((node: HTMLSpanElement | null) => {
|
|
117
|
+
setFooterTarget(node);
|
|
118
|
+
}, []);
|
|
119
|
+
const hasFooter = hasBodyFooter || footerActions !== undefined;
|
|
120
|
+
const renderFooterActions = (
|
|
121
|
+
equal = false,
|
|
122
|
+
): ReactElement => (
|
|
123
|
+
<PresentationActions equal={equal}>
|
|
124
|
+
{hasBodyFooter ? (
|
|
125
|
+
<span
|
|
126
|
+
ref={setFooterNode}
|
|
127
|
+
className="contents [&>*]:min-w-0 [&>*]:w-full [&>*]:flex-1"
|
|
128
|
+
/>
|
|
129
|
+
) : null}
|
|
130
|
+
{footerActions}
|
|
131
|
+
</PresentationActions>
|
|
132
|
+
);
|
|
133
133
|
const body = (
|
|
134
134
|
<div
|
|
135
135
|
data-slot="presentation-body"
|
|
136
136
|
className={cn(bodyClassName, bodyClassNameProp)}
|
|
137
137
|
>
|
|
138
|
-
{children}
|
|
138
|
+
{typeof children === "function" ? children(footerTarget) : children}
|
|
139
139
|
</div>
|
|
140
140
|
);
|
|
141
141
|
|
|
@@ -143,7 +143,7 @@ export function Presentation({
|
|
|
143
143
|
if (insidePageShell) {
|
|
144
144
|
return (
|
|
145
145
|
<Page className={cn("flex min-h-full flex-col", className)}>
|
|
146
|
-
<
|
|
146
|
+
<PageHeader>
|
|
147
147
|
{navigation === undefined ? null : (
|
|
148
148
|
<PageNavigation>{navigation}</PageNavigation>
|
|
149
149
|
)}
|
|
@@ -153,13 +153,9 @@ export function Presentation({
|
|
|
153
153
|
<PresentationActions>{headerActions}</PresentationActions>
|
|
154
154
|
</PageActions>
|
|
155
155
|
)}
|
|
156
|
-
</
|
|
156
|
+
</PageHeader>
|
|
157
157
|
<PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
|
|
158
|
-
{
|
|
159
|
-
<PageFooter>
|
|
160
|
-
<PresentationActions>{footerActions}</PresentationActions>
|
|
161
|
-
</PageFooter>
|
|
162
|
-
)}
|
|
158
|
+
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
163
159
|
</Page>
|
|
164
160
|
);
|
|
165
161
|
}
|
|
@@ -184,290 +180,456 @@ export function Presentation({
|
|
|
184
180
|
<PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
|
|
185
181
|
{body}
|
|
186
182
|
</PageBody>
|
|
187
|
-
{
|
|
188
|
-
<PageFooter>
|
|
189
|
-
<PresentationActions>{footerActions}</PresentationActions>
|
|
190
|
-
</PageFooter>
|
|
191
|
-
)}
|
|
183
|
+
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
192
184
|
</Page>
|
|
193
185
|
);
|
|
194
186
|
}
|
|
195
187
|
|
|
196
188
|
if (surface === "dialog") {
|
|
197
189
|
return (
|
|
198
|
-
<Dialog
|
|
190
|
+
<Dialog
|
|
191
|
+
open={open}
|
|
192
|
+
onOpenChange={(nextOpen) => {
|
|
193
|
+
if (!nextOpen && blocked) return;
|
|
194
|
+
onOpenChange?.(nextOpen);
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
199
197
|
<DialogContent
|
|
200
198
|
className={className}
|
|
201
|
-
showCloseButton={false}
|
|
202
199
|
aria-describedby={undefined}
|
|
200
|
+
closeDisabled={blocked}
|
|
201
|
+
onEscapeKeyDown={(event) => {
|
|
202
|
+
if (blocked) event.preventDefault();
|
|
203
|
+
}}
|
|
204
|
+
onPointerDownOutside={(event) => {
|
|
205
|
+
if (blocked) event.preventDefault();
|
|
206
|
+
}}
|
|
203
207
|
>
|
|
204
208
|
<DialogHeader>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
<PresentationActions>
|
|
211
|
-
{headerActions}
|
|
212
|
-
<CloseAction surface="dialog" />
|
|
213
|
-
</PresentationActions>
|
|
214
|
-
</div>
|
|
209
|
+
{navigation}
|
|
210
|
+
<DialogTitle className="truncate">{title}</DialogTitle>
|
|
211
|
+
{headerActions === undefined ? null : (
|
|
212
|
+
<PresentationActions>{headerActions}</PresentationActions>
|
|
213
|
+
)}
|
|
215
214
|
</DialogHeader>
|
|
216
|
-
<DialogBody
|
|
217
|
-
{
|
|
218
|
-
<DialogFooter>
|
|
219
|
-
<PresentationActions equal>{footerActions}</PresentationActions>
|
|
220
|
-
</DialogFooter>
|
|
221
|
-
)}
|
|
215
|
+
<DialogBody>{body}</DialogBody>
|
|
216
|
+
{hasFooter ? <DialogFooter>{renderFooterActions(true)}</DialogFooter> : null}
|
|
222
217
|
</DialogContent>
|
|
223
218
|
</Dialog>
|
|
224
219
|
);
|
|
225
220
|
}
|
|
226
221
|
|
|
227
222
|
return (
|
|
228
|
-
<Drawer
|
|
223
|
+
<Drawer
|
|
224
|
+
open={open}
|
|
225
|
+
onOpenChange={(nextOpen) => {
|
|
226
|
+
if (!nextOpen && blocked) return;
|
|
227
|
+
onOpenChange?.(nextOpen);
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
229
230
|
<DrawerContent
|
|
230
231
|
className={className}
|
|
231
|
-
showCloseButton={false}
|
|
232
232
|
aria-describedby={undefined}
|
|
233
|
+
closeDisabled={blocked}
|
|
234
|
+
onEscapeKeyDown={(event) => {
|
|
235
|
+
if (blocked) event.preventDefault();
|
|
236
|
+
}}
|
|
237
|
+
onPointerDownOutside={(event) => {
|
|
238
|
+
if (blocked) event.preventDefault();
|
|
239
|
+
}}
|
|
233
240
|
>
|
|
234
241
|
<DrawerHeader>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
<PresentationActions>
|
|
241
|
-
{headerActions}
|
|
242
|
-
<CloseAction surface="drawer" />
|
|
243
|
-
</PresentationActions>
|
|
244
|
-
</div>
|
|
242
|
+
{navigation}
|
|
243
|
+
<DrawerTitle className="truncate">{title}</DrawerTitle>
|
|
244
|
+
{headerActions === undefined ? null : (
|
|
245
|
+
<PresentationActions>{headerActions}</PresentationActions>
|
|
246
|
+
)}
|
|
245
247
|
</DrawerHeader>
|
|
246
|
-
<DrawerBody
|
|
247
|
-
{
|
|
248
|
-
<DrawerFooter>
|
|
249
|
-
<PresentationActions equal>{footerActions}</PresentationActions>
|
|
250
|
-
</DrawerFooter>
|
|
251
|
-
)}
|
|
248
|
+
<DrawerBody>{body}</DrawerBody>
|
|
249
|
+
{hasFooter ? <DrawerFooter>{renderFooterActions(true)}</DrawerFooter> : null}
|
|
252
250
|
</DrawerContent>
|
|
253
251
|
</Drawer>
|
|
254
252
|
);
|
|
255
253
|
}
|
|
256
254
|
|
|
257
|
-
export interface
|
|
258
|
-
|
|
259
|
-
invocation?: PresentationInvocation | unknown;
|
|
260
|
-
resolved?: unknown;
|
|
261
|
-
diagnostics?: readonly PresentationDiagnostic[];
|
|
262
|
-
triggerLabel?: string;
|
|
263
|
-
title?: string;
|
|
264
|
-
/** Apresenta o gatilho como FAB; o consumidor continua responsável por posicioná-lo. */
|
|
265
|
-
floating?: boolean;
|
|
266
|
-
className?: string;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export interface PresentationRegistration
|
|
270
|
-
extends Pick<
|
|
271
|
-
PresentationInspectorProps,
|
|
272
|
-
"invocation" | "resolved" | "diagnostics"
|
|
273
|
-
> {
|
|
255
|
+
export interface PresentationProps {
|
|
256
|
+
/** Definição estática registrada e publicada no manifest. */
|
|
274
257
|
definition: PresentationDefinition;
|
|
275
|
-
/**
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
258
|
+
/** Definições alcançáveis por open, navigate ou back. */
|
|
259
|
+
definitions: readonly PresentationDefinition[];
|
|
260
|
+
/** Contratos compartilháveis das actions referenciadas pela definição. */
|
|
261
|
+
actions: PresentationActionRegistry;
|
|
262
|
+
/** Estado serializável da exibição atual. */
|
|
263
|
+
invocation: PresentationInvocation;
|
|
264
|
+
/** Dados disponíveis para resolver bindings além do input da invocação. */
|
|
265
|
+
bindingContext?: PresentationBindingContext;
|
|
266
|
+
/** Recebe navegação, retorno e fechamento produzidos pelo artefato. */
|
|
267
|
+
onInvocationChange?: (invocation: PresentationInvocation | null) => void;
|
|
268
|
+
/** Recebe cada invalidação declarada por um efeito refresh. */
|
|
269
|
+
onRefresh?: (action: string | null) => void;
|
|
270
|
+
/** Estado de recorte da action list quando a aplicação o sincroniza com a URL. */
|
|
271
|
+
listState?: ActionListState;
|
|
272
|
+
onListStateChange?: (state: ActionListState) => void;
|
|
273
|
+
open?: boolean;
|
|
274
|
+
onOpenChange?: (open: boolean) => void;
|
|
275
|
+
className?: string;
|
|
276
|
+
bodyClassName?: string;
|
|
282
277
|
}
|
|
283
278
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
279
|
+
function actionLabel(action: { name: string; label?: unknown }): string {
|
|
280
|
+
if (typeof action.label === "string") return action.label;
|
|
281
|
+
if (
|
|
282
|
+
action.label !== null &&
|
|
283
|
+
typeof action.label === "object" &&
|
|
284
|
+
"default" in action.label &&
|
|
285
|
+
typeof (action.label as { default?: unknown }).default === "string"
|
|
286
|
+
) {
|
|
287
|
+
return (action.label as { default: string }).default;
|
|
288
|
+
}
|
|
289
|
+
return action.name;
|
|
287
290
|
}
|
|
288
291
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
function readField(record: unknown, key: string): unknown {
|
|
293
|
+
if (record === null || typeof record !== "object") return undefined;
|
|
294
|
+
return (record as Record<string, unknown>)[key];
|
|
292
295
|
}
|
|
293
296
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
function PresentationCommandTrigger({
|
|
298
|
+
command,
|
|
299
|
+
action,
|
|
300
|
+
input,
|
|
301
|
+
disabled,
|
|
302
|
+
onLoadingChange,
|
|
303
|
+
onSuccess,
|
|
304
|
+
}: {
|
|
305
|
+
command: PresentationCommand;
|
|
306
|
+
action: SimpleContract<Record<string, unknown>, unknown>;
|
|
307
|
+
input: Record<string, unknown>;
|
|
308
|
+
disabled: boolean;
|
|
309
|
+
onLoadingChange: (command: PresentationCommand, loading: boolean) => void;
|
|
310
|
+
onSuccess: (data: unknown) => void;
|
|
311
|
+
}): ReactElement {
|
|
312
|
+
const reportLoading = useCallback(
|
|
313
|
+
(loading: boolean) => onLoadingChange(command, loading),
|
|
314
|
+
[command, onLoadingChange],
|
|
315
|
+
);
|
|
316
|
+
return (
|
|
317
|
+
<ActionTrigger
|
|
318
|
+
action={action}
|
|
319
|
+
input={input}
|
|
320
|
+
variant="ghost"
|
|
321
|
+
disabled={disabled}
|
|
322
|
+
onLoadingChange={reportLoading}
|
|
323
|
+
onSuccess={onSuccess}
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
301
326
|
}
|
|
302
327
|
|
|
303
|
-
/**
|
|
304
|
-
export function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
/** Renderiza uma definição declarativa delegando cada kind ao pattern Opus correspondente. */
|
|
329
|
+
export function Presentation({
|
|
330
|
+
definition,
|
|
331
|
+
definitions,
|
|
332
|
+
actions,
|
|
333
|
+
invocation,
|
|
334
|
+
bindingContext,
|
|
335
|
+
onInvocationChange,
|
|
336
|
+
onRefresh,
|
|
337
|
+
listState,
|
|
338
|
+
onListStateChange,
|
|
339
|
+
open = true,
|
|
340
|
+
onOpenChange,
|
|
341
|
+
className,
|
|
342
|
+
bodyClassName: bodyClassNameProp,
|
|
343
|
+
}: PresentationProps): ReactElement {
|
|
344
|
+
if (definition.id !== invocation.presentationId) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`A invocação “${invocation.presentationId}” não corresponde à Presentation “${definition.id}”.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const [loadingCommands, setLoadingCommands] = useState<
|
|
350
|
+
ReadonlySet<PresentationCommand>
|
|
351
|
+
>(() => new Set());
|
|
352
|
+
const [bodyLoading, setBodyLoading] = useState(false);
|
|
353
|
+
const context: PresentationBindingContext = {
|
|
354
|
+
...bindingContext,
|
|
355
|
+
route: bindingContext?.route ?? invocation.input,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const applyEffects = useCallback(
|
|
359
|
+
(
|
|
360
|
+
effects: PresentationDefinition["body"]["onSuccess"],
|
|
361
|
+
result?: unknown,
|
|
362
|
+
): void => {
|
|
363
|
+
const next = applyPresentationEffects(invocation, effects, {
|
|
364
|
+
...context,
|
|
365
|
+
...(result !== undefined && result !== null && typeof result === "object"
|
|
366
|
+
? { result: result as Record<string, unknown> }
|
|
367
|
+
: {}),
|
|
368
|
+
});
|
|
369
|
+
for (const action of next.refresh) onRefresh?.(action);
|
|
370
|
+
if (next.invocation !== invocation || next.exit !== undefined) {
|
|
371
|
+
onInvocationChange?.(next.invocation);
|
|
372
|
+
}
|
|
316
373
|
},
|
|
317
|
-
[],
|
|
374
|
+
[context, invocation, onInvocationChange, onRefresh],
|
|
318
375
|
);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
376
|
+
|
|
377
|
+
const reportLoading = useCallback(
|
|
378
|
+
(command: PresentationCommand, loading: boolean): void => {
|
|
379
|
+
setLoadingCommands((current) => {
|
|
380
|
+
const next = new Set(current);
|
|
381
|
+
if (loading) next.add(command);
|
|
382
|
+
else next.delete(command);
|
|
383
|
+
return next;
|
|
384
|
+
});
|
|
385
|
+
},
|
|
325
386
|
[],
|
|
326
387
|
);
|
|
327
|
-
const registration = useMemo(() => ({ upsert, remove }), [upsert, remove]);
|
|
328
|
-
const devtools = useMemo(() => ({ keys, read }), [keys, read]);
|
|
329
388
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
<PresentationDevtoolsContext.Provider value={devtools}>
|
|
333
|
-
{children}
|
|
334
|
-
</PresentationDevtoolsContext.Provider>
|
|
335
|
-
</PresentationRegistrationContext.Provider>
|
|
389
|
+
const hasSurfaceBlock = [...loadingCommands].some(
|
|
390
|
+
(active) => active.blocking === "surface",
|
|
336
391
|
);
|
|
337
|
-
|
|
392
|
+
const surfaceBlocked = bodyLoading || hasSurfaceBlock;
|
|
393
|
+
const isBlocked = (command: PresentationCommand): boolean =>
|
|
394
|
+
bodyLoading ||
|
|
395
|
+
[...loadingCommands].some(
|
|
396
|
+
(active) =>
|
|
397
|
+
active.blocking === "surface" ||
|
|
398
|
+
(active.blocking === "group" && active.placement === command.placement),
|
|
399
|
+
);
|
|
338
400
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
401
|
+
const openTarget = (
|
|
402
|
+
presentationId: string,
|
|
403
|
+
surface: PresentationSurface | undefined,
|
|
404
|
+
bindings: PresentationCommand["input"],
|
|
405
|
+
targetContext: PresentationBindingContext = context,
|
|
406
|
+
): void => {
|
|
407
|
+
const target = definitions.find((candidate) => candidate.id === presentationId);
|
|
408
|
+
if (target === undefined) {
|
|
409
|
+
throw new Error(`Presentation “${presentationId}” não registrada.`);
|
|
410
|
+
}
|
|
411
|
+
onInvocationChange?.(
|
|
412
|
+
openPresentation(invocation, {
|
|
413
|
+
presentationId: target.id,
|
|
414
|
+
surface: surface ?? invocation.surface,
|
|
415
|
+
input: resolvePresentationBindings(bindings, targetContext) as Record<
|
|
416
|
+
string,
|
|
417
|
+
PresentationJsonValue
|
|
418
|
+
>,
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
};
|
|
351
422
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
remove?.(key);
|
|
357
|
-
return;
|
|
423
|
+
const renderCommand = (command: PresentationCommand): ReactElement => {
|
|
424
|
+
const action = actions[command.action];
|
|
425
|
+
if (action === undefined) {
|
|
426
|
+
throw new Error(`Action “${command.action}” não registrada.`);
|
|
358
427
|
}
|
|
359
|
-
|
|
360
|
-
|
|
428
|
+
const input = resolvePresentationBindings(command.input, context) as Record<
|
|
429
|
+
string,
|
|
430
|
+
unknown
|
|
431
|
+
>;
|
|
432
|
+
if (action.kind === "simple") {
|
|
433
|
+
return (
|
|
434
|
+
<PresentationCommandTrigger
|
|
435
|
+
key={`${command.placement}:${command.action}`}
|
|
436
|
+
command={command}
|
|
437
|
+
action={action as SimpleContract<Record<string, unknown>, unknown>}
|
|
438
|
+
input={input}
|
|
439
|
+
disabled={isBlocked(command)}
|
|
440
|
+
onLoadingChange={reportLoading}
|
|
441
|
+
onSuccess={(data) => applyEffects(command.onSuccess, data)}
|
|
442
|
+
/>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (command.target === undefined) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`A action “${command.action}” precisa declarar uma Presentation de destino.`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return (
|
|
451
|
+
<Button
|
|
452
|
+
key={`${command.placement}:${command.action}`}
|
|
453
|
+
variant="ghost"
|
|
454
|
+
disabled={isBlocked(command)}
|
|
455
|
+
onClick={() =>
|
|
456
|
+
openTarget(
|
|
457
|
+
command.target!.presentation,
|
|
458
|
+
command.target!.surface,
|
|
459
|
+
command.input,
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
>
|
|
463
|
+
{actionLabel(action)}
|
|
464
|
+
</Button>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
361
467
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
468
|
+
const bodyAction = actions[definition.body.action];
|
|
469
|
+
if (bodyAction === undefined) {
|
|
470
|
+
throw new Error(`Action “${definition.body.action}” não registrada.`);
|
|
471
|
+
}
|
|
472
|
+
const bodyInput = resolvePresentationBindings(
|
|
473
|
+
definition.body.input,
|
|
474
|
+
context,
|
|
475
|
+
) as Record<string, unknown>;
|
|
476
|
+
const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
|
|
477
|
+
if (bodyAction.kind === "form") {
|
|
478
|
+
return (
|
|
479
|
+
<ActionForm
|
|
480
|
+
action={bodyAction as FormContract<Record<string, unknown>, unknown>}
|
|
481
|
+
defaultValues={bodyInput}
|
|
482
|
+
submitLabel={definition.body.submitLabel}
|
|
483
|
+
onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
|
|
484
|
+
disabled={hasSurfaceBlock}
|
|
485
|
+
onLoadingChange={setBodyLoading}
|
|
486
|
+
footer={(actions) =>
|
|
487
|
+
footerTarget === null ? null : createPortal(actions, footerTarget)
|
|
488
|
+
}
|
|
489
|
+
/>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (bodyAction.kind === "list") {
|
|
493
|
+
return (
|
|
494
|
+
<ActionList<Record<string, unknown>, Record<string, unknown>>
|
|
495
|
+
action={bodyAction as ListContract<
|
|
496
|
+
Record<string, unknown>,
|
|
497
|
+
Record<string, unknown>
|
|
498
|
+
>}
|
|
499
|
+
input={bodyInput}
|
|
500
|
+
state={listState}
|
|
501
|
+
onStateChange={onListStateChange}
|
|
502
|
+
{...(definition.body.open === undefined
|
|
503
|
+
? {}
|
|
504
|
+
: {
|
|
505
|
+
onRowClick: (item: Record<string, unknown>) =>
|
|
506
|
+
openTarget(
|
|
507
|
+
definition.body.open!.presentation,
|
|
508
|
+
definition.body.open!.surface,
|
|
509
|
+
definition.body.open!.input,
|
|
510
|
+
{ ...context, item },
|
|
511
|
+
),
|
|
512
|
+
})}
|
|
513
|
+
/>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (bodyAction.kind === "view") {
|
|
517
|
+
const fields = definition.body.fields ?? [];
|
|
518
|
+
return (
|
|
519
|
+
<ActionView
|
|
520
|
+
action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
|
|
521
|
+
input={bodyInput}
|
|
522
|
+
>
|
|
523
|
+
{(data) => (
|
|
524
|
+
<DetailGroup>
|
|
525
|
+
{fields.map((field) => (
|
|
526
|
+
<DetailField
|
|
527
|
+
key={field.key}
|
|
528
|
+
label={field.label}
|
|
529
|
+
value={readField(data, field.key) as ReactNode}
|
|
530
|
+
empty={field.empty}
|
|
531
|
+
/>
|
|
532
|
+
))}
|
|
533
|
+
</DetailGroup>
|
|
534
|
+
)}
|
|
535
|
+
</ActionView>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
throw new Error(`A action simple “${bodyAction.name}” não pode ocupar o body.`);
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const headerActions = definition.actions
|
|
542
|
+
.filter((command) => command.placement === "header")
|
|
543
|
+
.map(renderCommand);
|
|
544
|
+
const footerActions = definition.actions
|
|
545
|
+
.filter((command) => command.placement === "footer")
|
|
546
|
+
.map(renderCommand);
|
|
547
|
+
const navigation =
|
|
548
|
+
invocation.stack.length === 0 ? undefined : (
|
|
549
|
+
<Button
|
|
550
|
+
size="icon-sm"
|
|
551
|
+
variant="ghost"
|
|
552
|
+
disabled={surfaceBlocked}
|
|
553
|
+
aria-label="Voltar"
|
|
554
|
+
title="Voltar"
|
|
555
|
+
onClick={() => applyEffects([{ effect: "back" }])}
|
|
556
|
+
>
|
|
557
|
+
<ArrowLeft aria-hidden />
|
|
558
|
+
</Button>
|
|
559
|
+
);
|
|
369
560
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
561
|
+
return (
|
|
562
|
+
<PresentationFrame
|
|
563
|
+
surface={invocation.surface}
|
|
564
|
+
title={definition.title}
|
|
565
|
+
navigation={navigation}
|
|
566
|
+
headerActions={headerActions.length === 0 ? undefined : headerActions}
|
|
567
|
+
footerActions={footerActions.length === 0 ? undefined : footerActions}
|
|
568
|
+
open={open}
|
|
569
|
+
onOpenChange={(nextOpen) => {
|
|
570
|
+
onOpenChange?.(nextOpen);
|
|
571
|
+
if (!nextOpen && invocation.surface !== "page") {
|
|
572
|
+
applyEffects([{ effect: "close" }]);
|
|
573
|
+
}
|
|
574
|
+
}}
|
|
575
|
+
className={className}
|
|
576
|
+
bodyClassName={bodyClassNameProp}
|
|
577
|
+
hasBodyFooter={bodyAction.kind === "form"}
|
|
578
|
+
blocked={surfaceBlocked}
|
|
579
|
+
>
|
|
580
|
+
{renderBody}
|
|
581
|
+
</PresentationFrame>
|
|
582
|
+
);
|
|
374
583
|
}
|
|
375
584
|
|
|
376
|
-
export interface
|
|
377
|
-
|
|
585
|
+
export interface PresentationInspectorProps {
|
|
586
|
+
definition: PresentationDefinition | unknown;
|
|
587
|
+
invocation?: PresentationInvocation | unknown;
|
|
588
|
+
resolved?: unknown;
|
|
589
|
+
diagnostics?: readonly PresentationDiagnostic[];
|
|
378
590
|
triggerLabel?: string;
|
|
591
|
+
title?: string;
|
|
379
592
|
className?: string;
|
|
380
593
|
}
|
|
381
594
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
setSelected(null);
|
|
399
|
-
}
|
|
400
|
-
}, [registry?.keys, selected]);
|
|
401
|
-
|
|
402
|
-
if (registrations.length === 0) return null;
|
|
403
|
-
|
|
404
|
-
function inspect(key: string): void {
|
|
405
|
-
const registration = registry?.read(key);
|
|
406
|
-
if (registration === undefined) return;
|
|
407
|
-
const json =
|
|
408
|
-
JSON.stringify(createPresentationInspectionSnapshot(registration), null, 2) ??
|
|
409
|
-
"null";
|
|
410
|
-
setSelected({ key, title: registration.definition.title, json });
|
|
411
|
-
}
|
|
595
|
+
function PresentationInspectionDialog({
|
|
596
|
+
registration,
|
|
597
|
+
open,
|
|
598
|
+
onOpenChange,
|
|
599
|
+
title = "JSON da Presentation",
|
|
600
|
+
}: {
|
|
601
|
+
registration: Pick<
|
|
602
|
+
PresentationInspectorProps,
|
|
603
|
+
"definition" | "invocation" | "resolved" | "diagnostics"
|
|
604
|
+
>;
|
|
605
|
+
open: boolean;
|
|
606
|
+
onOpenChange: (open: boolean) => void;
|
|
607
|
+
title?: string;
|
|
608
|
+
}): ReactElement {
|
|
609
|
+
const snapshot = createPresentationInspectionSnapshot(registration);
|
|
610
|
+
const json = JSON.stringify(snapshot, null, 2) ?? "null";
|
|
412
611
|
|
|
413
612
|
return (
|
|
414
|
-
|
|
415
|
-
<
|
|
416
|
-
<
|
|
417
|
-
<
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
613
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
614
|
+
<DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
|
|
615
|
+
<DialogHeader>
|
|
616
|
+
<DialogTitle>{title}</DialogTitle>
|
|
617
|
+
</DialogHeader>
|
|
618
|
+
<DialogBody>
|
|
619
|
+
<pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
|
|
620
|
+
{json}
|
|
621
|
+
</pre>
|
|
622
|
+
</DialogBody>
|
|
623
|
+
<DialogFooter>
|
|
624
|
+
<Copyable
|
|
625
|
+
value={json}
|
|
626
|
+
className={buttonVariants({ variant: "outline" })}
|
|
425
627
|
>
|
|
426
|
-
|
|
427
|
-
</
|
|
428
|
-
</
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
{[...registrations].reverse().map((registration) => (
|
|
432
|
-
<MenuItem key={registration.key} onSelect={() => inspect(registration.key)}>
|
|
433
|
-
<span className="min-w-0 flex-1 truncate">
|
|
434
|
-
{registration.definition.title}
|
|
435
|
-
</span>
|
|
436
|
-
<span className="text-xs text-muted-foreground">
|
|
437
|
-
{registration.invocation === undefined ||
|
|
438
|
-
registration.invocation === null ||
|
|
439
|
-
typeof registration.invocation !== "object" ||
|
|
440
|
-
!("surface" in registration.invocation)
|
|
441
|
-
? "Presentation"
|
|
442
|
-
: String(registration.invocation.surface)}
|
|
443
|
-
</span>
|
|
444
|
-
</MenuItem>
|
|
445
|
-
))}
|
|
446
|
-
</MenuContent>
|
|
447
|
-
</Menu>
|
|
448
|
-
{selected === null ? null : (
|
|
449
|
-
<Dialog open onOpenChange={(open) => !open && setSelected(null)}>
|
|
450
|
-
<DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
|
|
451
|
-
<DialogHeader>
|
|
452
|
-
<DialogTitle>{selected.title}</DialogTitle>
|
|
453
|
-
</DialogHeader>
|
|
454
|
-
<DialogBody>
|
|
455
|
-
<pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
|
|
456
|
-
{selected.json}
|
|
457
|
-
</pre>
|
|
458
|
-
</DialogBody>
|
|
459
|
-
<DialogFooter>
|
|
460
|
-
<Copyable
|
|
461
|
-
value={selected.json}
|
|
462
|
-
className={buttonVariants({ variant: "outline" })}
|
|
463
|
-
>
|
|
464
|
-
Copiar JSON
|
|
465
|
-
</Copyable>
|
|
466
|
-
</DialogFooter>
|
|
467
|
-
</DialogContent>
|
|
468
|
-
</Dialog>
|
|
469
|
-
)}
|
|
470
|
-
</>
|
|
628
|
+
Copiar JSON
|
|
629
|
+
</Copyable>
|
|
630
|
+
</DialogFooter>
|
|
631
|
+
</DialogContent>
|
|
632
|
+
</Dialog>
|
|
471
633
|
);
|
|
472
634
|
}
|
|
473
635
|
|
|
@@ -479,61 +641,29 @@ export function PresentationInspector({
|
|
|
479
641
|
diagnostics,
|
|
480
642
|
triggerLabel = "Ver JSON da Presentation",
|
|
481
643
|
title = "JSON da Presentation",
|
|
482
|
-
floating = false,
|
|
483
644
|
className,
|
|
484
645
|
}: PresentationInspectorProps): ReactElement {
|
|
485
646
|
const [open, setOpen] = useState(false);
|
|
486
|
-
const [json, setJson] = useState<string | null>(null);
|
|
487
|
-
|
|
488
|
-
function inspect(): void {
|
|
489
|
-
const snapshot = createPresentationInspectionSnapshot({
|
|
490
|
-
definition,
|
|
491
|
-
invocation,
|
|
492
|
-
resolved,
|
|
493
|
-
diagnostics,
|
|
494
|
-
});
|
|
495
|
-
setJson(JSON.stringify(snapshot, null, 2) ?? "null");
|
|
496
|
-
setOpen(true);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function handleOpenChange(nextOpen: boolean): void {
|
|
500
|
-
setOpen(nextOpen);
|
|
501
|
-
if (!nextOpen) setJson(null);
|
|
502
|
-
}
|
|
503
647
|
|
|
504
648
|
return (
|
|
505
649
|
<>
|
|
506
650
|
<Button
|
|
507
|
-
size=
|
|
508
|
-
variant=
|
|
509
|
-
data-appearance=
|
|
510
|
-
className={
|
|
651
|
+
size="icon-sm"
|
|
652
|
+
variant="ghost"
|
|
653
|
+
data-appearance="inline"
|
|
654
|
+
className={className}
|
|
511
655
|
aria-label={triggerLabel}
|
|
512
656
|
title={triggerLabel}
|
|
513
|
-
onClick={
|
|
657
|
+
onClick={() => setOpen(true)}
|
|
514
658
|
>
|
|
515
659
|
<Braces aria-hidden />
|
|
516
660
|
</Button>
|
|
517
|
-
<
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
<pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
|
|
524
|
-
{json ?? ""}
|
|
525
|
-
</pre>
|
|
526
|
-
</DialogBody>
|
|
527
|
-
<DialogFooter>
|
|
528
|
-
<Copyable
|
|
529
|
-
value={json ?? ""}
|
|
530
|
-
className={buttonVariants({ variant: "outline" })}
|
|
531
|
-
>
|
|
532
|
-
Copiar JSON
|
|
533
|
-
</Copyable>
|
|
534
|
-
</DialogFooter>
|
|
535
|
-
</DialogContent>
|
|
536
|
-
</Dialog>
|
|
661
|
+
<PresentationInspectionDialog
|
|
662
|
+
registration={{ definition, invocation, resolved, diagnostics }}
|
|
663
|
+
open={open}
|
|
664
|
+
onOpenChange={setOpen}
|
|
665
|
+
title={title}
|
|
666
|
+
/>
|
|
537
667
|
</>
|
|
538
668
|
);
|
|
539
669
|
}
|