@softize/opus 30.0.0 → 31.1.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 +123 -0
- package/bin/lib/check.mjs +109 -101
- package/bin/lib/copy.mjs +2973 -1801
- package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +52 -0
- package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +27 -2
- package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +142 -0
- package/docs/adr/0064-action-list-com-itens-e-fontes-externas.md +47 -0
- package/docs/adr/0065-presentation-composes-settings-sections.md +295 -0
- package/docs/adr/0066-civil-date-input-and-calendar-list.md +28 -0
- package/docs/adr/README.md +6 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +21 -19
- package/registry/skills/build-opus-ui/references/evaluations.md +13 -11
- package/registry/skills/build-opus-ui/references/ui-patterns.md +24 -27
- package/src/core/presentation.ts +938 -32
- package/src/core/types.ts +2 -0
- package/src/ui/components/patterns/form.tsx +22 -1
- package/src/ui/components/patterns/grouped-list.ts +2 -2
- package/src/ui/components/patterns/list-calendar.tsx +129 -0
- package/src/ui/components/patterns/list.tsx +612 -382
- package/src/ui/components/patterns/page-heading-context.tsx +12 -5
- package/src/ui/components/patterns/page.tsx +227 -197
- package/src/ui/components/patterns/presentation-fields.tsx +246 -0
- package/src/ui/components/patterns/presentation-sections.tsx +494 -0
- package/src/ui/components/patterns/presentation.tsx +1142 -229
- package/src/ui/components/patterns/structural-header.tsx +23 -32
- package/src/ui/components/patterns/surface-anatomy.ts +27 -217
- package/src/ui/components/patterns/surface.tsx +19 -29
- package/src/ui/components/patterns/trigger.tsx +10 -2
- package/src/ui/components/primitives/calendar.tsx +39 -18
- package/src/ui/components/primitives/close-button.tsx +11 -3
- package/src/ui/components/primitives/command.tsx +2 -8
- package/src/ui/components/primitives/date-input.tsx +276 -0
- package/src/ui/components/primitives/detail.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +19 -81
- package/src/ui/components/primitives/drawer.tsx +17 -79
- package/src/ui/components/primitives/field.tsx +1 -1
- package/src/ui/components/primitives/pagination.tsx +3 -2
- package/src/ui/components/primitives/table.tsx +1 -1
- package/src/ui/components/primitives/tabs.tsx +13 -2
- package/src/ui/docs/content/action-form.md +6 -1
- package/src/ui/docs/content/action-list.md +156 -68
- package/src/ui/docs/content/action-trigger.md +5 -0
- package/src/ui/docs/content/button.md +5 -6
- package/src/ui/docs/content/calendar-month-view.md +50 -0
- package/src/ui/docs/content/calendar.md +6 -1
- package/src/ui/docs/content/command.md +62 -50
- package/src/ui/docs/content/compositions.md +129 -459
- package/src/ui/docs/content/date-input.md +67 -0
- package/src/ui/docs/content/dialog.md +44 -112
- package/src/ui/docs/content/drawer.md +44 -103
- package/src/ui/docs/content/field.md +2 -1
- package/src/ui/docs/content/input.md +3 -0
- package/src/ui/docs/content/page.md +204 -412
- package/src/ui/docs/content/presentation.md +371 -23
- package/src/ui/docs/content/separator.md +1 -1
- package/src/ui/docs/content/surface.md +65 -120
- package/src/ui/docs/content/tokens.md +18 -4
- package/src/ui/docs/registry.tsx +38 -9
- package/src/ui/drivers/context.tsx +11 -4
- package/src/ui/drivers/react.tsx +27 -7
- package/src/ui/meta.ts +17 -6
- package/src/ui/react.tsx +41 -8
- package/src/ui/theme.css +44 -33
package/src/core/presentation.ts
CHANGED
|
@@ -29,7 +29,12 @@ export const presentationSurfaceSchema = z.enum(["page", "dialog", "drawer"]);
|
|
|
29
29
|
export type PresentationSurface = z.infer<typeof presentationSurfaceSchema>;
|
|
30
30
|
|
|
31
31
|
export const presentationBindingSchema = z.discriminatedUnion("source", [
|
|
32
|
-
z
|
|
32
|
+
z
|
|
33
|
+
.object({
|
|
34
|
+
source: z.literal("route"),
|
|
35
|
+
param: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
|
|
36
|
+
})
|
|
37
|
+
.strict(),
|
|
33
38
|
z.object({ source: z.literal("record"), field: z.string().min(1) }).strict(),
|
|
34
39
|
z.object({ source: z.literal("item"), field: z.string().min(1) }).strict(),
|
|
35
40
|
z.object({ source: z.literal("selection") }).strict(),
|
|
@@ -71,10 +76,139 @@ const presentationTargetSchema = z
|
|
|
71
76
|
})
|
|
72
77
|
.strict();
|
|
73
78
|
|
|
79
|
+
const presentationConditionSchema = z.discriminatedUnion("op", [
|
|
80
|
+
z
|
|
81
|
+
.object({
|
|
82
|
+
field: z.string().min(1),
|
|
83
|
+
op: z.enum(["eq", "neq"]),
|
|
84
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
|
85
|
+
})
|
|
86
|
+
.strict(),
|
|
87
|
+
z
|
|
88
|
+
.object({
|
|
89
|
+
field: z.string().min(1),
|
|
90
|
+
op: z.enum(["in", "notIn"]),
|
|
91
|
+
values: z
|
|
92
|
+
.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
|
93
|
+
.min(1),
|
|
94
|
+
})
|
|
95
|
+
.strict(),
|
|
96
|
+
]);
|
|
97
|
+
export type PresentationCondition = z.infer<typeof presentationConditionSchema>;
|
|
98
|
+
export function matchesPresentationConditions(
|
|
99
|
+
conditions: readonly PresentationCondition[] | undefined,
|
|
100
|
+
item: Record<string, unknown>,
|
|
101
|
+
): boolean {
|
|
102
|
+
return (conditions ?? []).every((condition) => {
|
|
103
|
+
const value = item[condition.field];
|
|
104
|
+
if (value === undefined) return false;
|
|
105
|
+
if (condition.op === "eq") return value === condition.value;
|
|
106
|
+
if (condition.op === "neq") return value !== condition.value;
|
|
107
|
+
const matches = ("values" in condition ? condition.values : []).some(
|
|
108
|
+
(candidate) => candidate === value,
|
|
109
|
+
);
|
|
110
|
+
return condition.op === "in" ? matches : !matches;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const presentationRequirementSchema = z.union([
|
|
114
|
+
z.string().min(1),
|
|
115
|
+
z.array(z.string().min(1)).min(1),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/** Permissões alternativas seguem a semântica de requires dos contratos (qualquer uma). */
|
|
119
|
+
export function meetsPresentationRequirement(
|
|
120
|
+
requires: string | readonly string[] | undefined,
|
|
121
|
+
can?: (permission: string) => boolean,
|
|
122
|
+
): boolean {
|
|
123
|
+
if (requires === undefined) return true;
|
|
124
|
+
return (typeof requires === "string" ? [requires] : requires).some(
|
|
125
|
+
(permission) => can?.(permission) === true,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Visibilidade não substitui authorize: requisitos da página e da action são cumulativos. */
|
|
130
|
+
export function canAccessPresentation(
|
|
131
|
+
definition: PresentationDefinition,
|
|
132
|
+
actions: PresentationActionRegistry,
|
|
133
|
+
can?: (permission: string) => boolean,
|
|
134
|
+
): boolean {
|
|
135
|
+
if (!meetsPresentationRequirement(definition.requires, can)) return false;
|
|
136
|
+
if (definition.source !== undefined) {
|
|
137
|
+
const source = actions[definition.source.action];
|
|
138
|
+
if (!source || !meetsPresentationRequirement(source.requires, can))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
isPresentationComponentBody(definition.body) &&
|
|
143
|
+
definition.body.contract
|
|
144
|
+
) {
|
|
145
|
+
const contract = actions[definition.body.contract];
|
|
146
|
+
return (
|
|
147
|
+
contract !== undefined &&
|
|
148
|
+
meetsPresentationRequirement(contract.requires, can)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!isPresentationActionBody(definition.body)) return true;
|
|
152
|
+
const action = actions[definition.body.action];
|
|
153
|
+
return (
|
|
154
|
+
action !== undefined && meetsPresentationRequirement(action.requires, can)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const presentationOpenSchema = z
|
|
159
|
+
.object({
|
|
160
|
+
presentation: identifierSchema,
|
|
161
|
+
surface: presentationSurfaceSchema.optional(),
|
|
162
|
+
input: z.record(presentationBindingSchema).default({}),
|
|
163
|
+
requires: presentationRequirementSchema.optional(),
|
|
164
|
+
when: z.array(presentationConditionSchema).optional(),
|
|
165
|
+
})
|
|
166
|
+
.strict();
|
|
167
|
+
const presentationColumnSchema = z.discriminatedUnion("kind", [
|
|
168
|
+
z
|
|
169
|
+
.object({
|
|
170
|
+
kind: z.literal("text"),
|
|
171
|
+
prefix: z.string().optional(),
|
|
172
|
+
empty: z.string().optional(),
|
|
173
|
+
icon: identifierSchema.optional(),
|
|
174
|
+
labels: z.record(z.string()).optional(),
|
|
175
|
+
fallback: z.string().optional(),
|
|
176
|
+
})
|
|
177
|
+
.strict(),
|
|
178
|
+
z
|
|
179
|
+
.object({
|
|
180
|
+
kind: z.literal("switch"),
|
|
181
|
+
action: z.string().min(1),
|
|
182
|
+
input: z.record(presentationBindingSchema),
|
|
183
|
+
valueField: z.string().min(1),
|
|
184
|
+
label: z.string().min(1),
|
|
185
|
+
itemLabel: z.string().min(1),
|
|
186
|
+
requires: presentationRequirementSchema.optional(),
|
|
187
|
+
})
|
|
188
|
+
.strict(),
|
|
189
|
+
]);
|
|
190
|
+
export type PresentationColumn = z.infer<typeof presentationColumnSchema>;
|
|
191
|
+
|
|
74
192
|
const presentationCommandSchema = z
|
|
75
193
|
.object({
|
|
76
194
|
action: z.string().min(1),
|
|
77
|
-
|
|
195
|
+
id: identifierSchema.optional(),
|
|
196
|
+
accessibleLabel: z.string().min(1).optional(),
|
|
197
|
+
successMessage: z.string().min(1).optional(),
|
|
198
|
+
requires: presentationRequirementSchema.optional(),
|
|
199
|
+
when: z.array(presentationConditionSchema).optional(),
|
|
200
|
+
confirm: z
|
|
201
|
+
.object({
|
|
202
|
+
title: z.string().min(1),
|
|
203
|
+
body: z.string().min(1).optional(),
|
|
204
|
+
actionLabel: z.string().min(1).optional(),
|
|
205
|
+
})
|
|
206
|
+
.strict()
|
|
207
|
+
.optional(),
|
|
208
|
+
placement: z.enum(["header", "toolbar", "footer", "row", "section"]),
|
|
209
|
+
icon: identifierSchema.optional(),
|
|
210
|
+
itemLabel: z.string().min(1).optional(),
|
|
211
|
+
label: z.string().min(1).optional(),
|
|
78
212
|
input: z.record(presentationBindingSchema).default({}),
|
|
79
213
|
target: presentationTargetSchema.optional(),
|
|
80
214
|
blocking: z.enum(["action", "group", "surface"]).default("action"),
|
|
@@ -87,6 +221,7 @@ const presentationFieldSchema = z
|
|
|
87
221
|
key: z.string().min(1),
|
|
88
222
|
label: z.string().min(1),
|
|
89
223
|
empty: z.string().min(1).optional(),
|
|
224
|
+
format: z.enum(["text", "pre", "html"]).optional(),
|
|
90
225
|
})
|
|
91
226
|
.strict();
|
|
92
227
|
|
|
@@ -97,14 +232,60 @@ const presentationActionBodySchema = z
|
|
|
97
232
|
onSuccess: z.array(presentationEffectSchema).default([]),
|
|
98
233
|
submitLabel: z.string().min(1).optional(),
|
|
99
234
|
fields: z.array(presentationFieldSchema).min(1).optional(),
|
|
100
|
-
|
|
235
|
+
cells: identifierSchema.optional(),
|
|
236
|
+
rowKey: z.string().min(1).optional(),
|
|
237
|
+
beforeContent: identifierSchema.optional(),
|
|
238
|
+
rowContent: identifierSchema.optional(),
|
|
239
|
+
collection: identifierSchema.optional(),
|
|
240
|
+
toolbarContent: identifierSchema.optional(),
|
|
241
|
+
filterDefaults: z.record(z.string()).optional(),
|
|
242
|
+
columns: z.record(presentationColumnSchema).optional(),
|
|
243
|
+
intro: z.string().min(1).optional(),
|
|
244
|
+
defaultsFromRecord: z.boolean().optional(),
|
|
245
|
+
initialValues: z.record(presentationJsonValueSchema).optional(),
|
|
246
|
+
formFields: z.array(z.string().min(1)).min(1).optional(),
|
|
247
|
+
formGroups: z
|
|
248
|
+
.array(
|
|
249
|
+
z
|
|
250
|
+
.object({
|
|
251
|
+
title: z.string().min(1),
|
|
252
|
+
fields: z.array(z.string().min(1)).min(1),
|
|
253
|
+
})
|
|
254
|
+
.strict(),
|
|
255
|
+
)
|
|
256
|
+
.min(1)
|
|
257
|
+
.optional(),
|
|
258
|
+
fieldComponents: z.record(identifierSchema).optional(),
|
|
259
|
+
lookups: z
|
|
260
|
+
.record(
|
|
261
|
+
z
|
|
262
|
+
.object({
|
|
263
|
+
action: z.string().min(1),
|
|
264
|
+
label: z.string().min(1),
|
|
265
|
+
value: z.string().min(1),
|
|
266
|
+
format: identifierSchema.optional(),
|
|
267
|
+
prepend: z
|
|
268
|
+
.array(
|
|
269
|
+
z
|
|
270
|
+
.object({ value: z.string(), label: z.string().min(1) })
|
|
271
|
+
.strict(),
|
|
272
|
+
)
|
|
273
|
+
.optional(),
|
|
274
|
+
})
|
|
275
|
+
.strict(),
|
|
276
|
+
)
|
|
277
|
+
.optional(),
|
|
278
|
+
inspect: z
|
|
101
279
|
.object({
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
280
|
+
component: identifierSchema,
|
|
281
|
+
requires: presentationRequirementSchema.optional(),
|
|
282
|
+
when: z.array(presentationConditionSchema).optional(),
|
|
105
283
|
})
|
|
106
284
|
.strict()
|
|
107
285
|
.optional(),
|
|
286
|
+
opens: z.array(presentationOpenSchema).min(1).optional(),
|
|
287
|
+
emptyMessage: z.string().min(1).optional(),
|
|
288
|
+
open: presentationOpenSchema.optional(),
|
|
108
289
|
})
|
|
109
290
|
.strict();
|
|
110
291
|
|
|
@@ -112,14 +293,203 @@ const presentationComponentBodySchema = z
|
|
|
112
293
|
.object({
|
|
113
294
|
/** Boundary estático do conteúdo React fornecido pelo consumidor. */
|
|
114
295
|
component: identifierSchema,
|
|
296
|
+
contract: z.string().min(1).optional(),
|
|
297
|
+
footer: z.boolean().optional(),
|
|
298
|
+
input: z.record(presentationBindingSchema).optional(),
|
|
115
299
|
/** Define se o heading principal nasce na casca ou no conteúdo React. */
|
|
116
300
|
heading: z.enum(["shell", "content"]).default("shell"),
|
|
117
301
|
})
|
|
118
302
|
.strict();
|
|
119
303
|
|
|
304
|
+
const presentationSectionBase = {
|
|
305
|
+
id: identifierSchema,
|
|
306
|
+
title: z.string().min(1).optional(),
|
|
307
|
+
feedback: identifierSchema.optional(),
|
|
308
|
+
notices: z
|
|
309
|
+
.array(
|
|
310
|
+
z
|
|
311
|
+
.object({
|
|
312
|
+
text: z.string().min(1),
|
|
313
|
+
when: z.array(presentationConditionSchema).optional(),
|
|
314
|
+
})
|
|
315
|
+
.strict(),
|
|
316
|
+
)
|
|
317
|
+
.optional(),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const presentationSectionSchema = z.discriminatedUnion("kind", [
|
|
321
|
+
z
|
|
322
|
+
.object({
|
|
323
|
+
...presentationSectionBase,
|
|
324
|
+
kind: z.literal("content"),
|
|
325
|
+
layout: z.enum(["flow", "fill"]).optional(),
|
|
326
|
+
content: identifierSchema,
|
|
327
|
+
actions: identifierSchema.optional(),
|
|
328
|
+
})
|
|
329
|
+
.strict(),
|
|
330
|
+
z
|
|
331
|
+
.object({
|
|
332
|
+
...presentationSectionBase,
|
|
333
|
+
kind: z.literal("items"),
|
|
334
|
+
intro: identifierSchema.optional(),
|
|
335
|
+
actions: identifierSchema.optional(),
|
|
336
|
+
items: z
|
|
337
|
+
.array(
|
|
338
|
+
z
|
|
339
|
+
.object({
|
|
340
|
+
id: identifierSchema,
|
|
341
|
+
title: z.string().min(1),
|
|
342
|
+
description: z.string().min(1).optional(),
|
|
343
|
+
content: identifierSchema.optional(),
|
|
344
|
+
value: z
|
|
345
|
+
.object({
|
|
346
|
+
field: z.string().min(1),
|
|
347
|
+
empty: z.string().optional(),
|
|
348
|
+
suffix: z.string().optional(),
|
|
349
|
+
})
|
|
350
|
+
.strict()
|
|
351
|
+
.optional(),
|
|
352
|
+
labelId: z.string().min(1).optional(),
|
|
353
|
+
placement: z.enum(["body", "actions", "below"]).optional(),
|
|
354
|
+
})
|
|
355
|
+
.strict(),
|
|
356
|
+
)
|
|
357
|
+
.min(1),
|
|
358
|
+
})
|
|
359
|
+
.strict(),
|
|
360
|
+
z
|
|
361
|
+
.object({
|
|
362
|
+
...presentationSectionBase,
|
|
363
|
+
kind: z.literal("list"),
|
|
364
|
+
source: identifierSchema,
|
|
365
|
+
item: z
|
|
366
|
+
.object({
|
|
367
|
+
title: z.string().min(1),
|
|
368
|
+
description: z.string().min(1).optional(),
|
|
369
|
+
})
|
|
370
|
+
.strict()
|
|
371
|
+
.optional(),
|
|
372
|
+
emptyMessage: z.string().min(1).optional(),
|
|
373
|
+
errorMessage: z.string().min(1).optional(),
|
|
374
|
+
})
|
|
375
|
+
.strict(),
|
|
376
|
+
]);
|
|
377
|
+
export type PresentationSection = z.infer<typeof presentationSectionSchema>;
|
|
378
|
+
export type PresentationListSection = Extract<
|
|
379
|
+
PresentationSection,
|
|
380
|
+
{ kind: "list" }
|
|
381
|
+
>;
|
|
382
|
+
|
|
383
|
+
const presentationSectionsBodySchema = z
|
|
384
|
+
.object({
|
|
385
|
+
sections: z.array(presentationSectionSchema).min(1),
|
|
386
|
+
aside: z
|
|
387
|
+
.object({
|
|
388
|
+
content: identifierSchema,
|
|
389
|
+
label: z.string().min(1),
|
|
390
|
+
width: z.enum(["narrow", "default", "wide"]).optional(),
|
|
391
|
+
})
|
|
392
|
+
.strict()
|
|
393
|
+
.optional(),
|
|
394
|
+
overlays: z.array(identifierSchema).optional(),
|
|
395
|
+
})
|
|
396
|
+
.strict()
|
|
397
|
+
.superRefine((body, context) => {
|
|
398
|
+
const seen = new Set<string>();
|
|
399
|
+
body.sections.forEach((section, index) => {
|
|
400
|
+
if (seen.has(section.id))
|
|
401
|
+
context.addIssue({
|
|
402
|
+
code: "custom",
|
|
403
|
+
message: "Id de seção duplicado.",
|
|
404
|
+
path: ["sections", index, "id"],
|
|
405
|
+
});
|
|
406
|
+
seen.add(section.id);
|
|
407
|
+
if (section.kind === "items") {
|
|
408
|
+
const items = new Set<string>();
|
|
409
|
+
section.items.forEach((item, itemIndex) => {
|
|
410
|
+
if (items.has(item.id))
|
|
411
|
+
context.addIssue({
|
|
412
|
+
code: "custom",
|
|
413
|
+
message: "Id de item duplicado.",
|
|
414
|
+
path: ["sections", index, "items", itemIndex, "id"],
|
|
415
|
+
});
|
|
416
|
+
items.add(item.id);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
export type PresentationSectionsBody = z.infer<
|
|
422
|
+
typeof presentationSectionsBodySchema
|
|
423
|
+
>;
|
|
424
|
+
|
|
425
|
+
const presentationTabsBodySchema = z
|
|
426
|
+
.object({
|
|
427
|
+
tabLabel: z.string().min(1),
|
|
428
|
+
framed: z.boolean().optional(),
|
|
429
|
+
distribution: z.enum(["content", "equal"]).optional(),
|
|
430
|
+
defaultTab: identifierSchema,
|
|
431
|
+
tabs: z
|
|
432
|
+
.array(
|
|
433
|
+
z
|
|
434
|
+
.object({
|
|
435
|
+
id: identifierSchema,
|
|
436
|
+
label: z.string().min(1),
|
|
437
|
+
icon: identifierSchema.optional(),
|
|
438
|
+
content: identifierSchema.optional(),
|
|
439
|
+
presentation: identifierSchema.optional(),
|
|
440
|
+
route: z
|
|
441
|
+
.string()
|
|
442
|
+
.regex(
|
|
443
|
+
/^\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*)(?:\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*))*$/,
|
|
444
|
+
)
|
|
445
|
+
.optional(),
|
|
446
|
+
})
|
|
447
|
+
.strict(),
|
|
448
|
+
)
|
|
449
|
+
.min(1),
|
|
450
|
+
})
|
|
451
|
+
.strict()
|
|
452
|
+
.superRefine((body, context) => {
|
|
453
|
+
const seen = new Set<string>();
|
|
454
|
+
const routes = new Set<string>();
|
|
455
|
+
body.tabs.forEach((tab, index) => {
|
|
456
|
+
if ((tab.content === undefined) === (tab.presentation === undefined))
|
|
457
|
+
context.addIssue({
|
|
458
|
+
code: "custom",
|
|
459
|
+
message: "A aba exige content ou presentation, exclusivamente.",
|
|
460
|
+
path: ["tabs", index],
|
|
461
|
+
});
|
|
462
|
+
if (seen.has(tab.id))
|
|
463
|
+
context.addIssue({
|
|
464
|
+
code: "custom",
|
|
465
|
+
message: "Id de aba duplicado.",
|
|
466
|
+
path: ["tabs", index, "id"],
|
|
467
|
+
});
|
|
468
|
+
seen.add(tab.id);
|
|
469
|
+
if (tab.route !== undefined) {
|
|
470
|
+
if (routes.has(tab.route))
|
|
471
|
+
context.addIssue({
|
|
472
|
+
code: "custom",
|
|
473
|
+
message: "Rota de aba duplicada.",
|
|
474
|
+
path: ["tabs", index, "route"],
|
|
475
|
+
});
|
|
476
|
+
routes.add(tab.route);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
if (!seen.has(body.defaultTab))
|
|
480
|
+
context.addIssue({
|
|
481
|
+
code: "custom",
|
|
482
|
+
message: "A aba inicial precisa existir na composição.",
|
|
483
|
+
path: ["defaultTab"],
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
export type PresentationTabsBody = z.infer<typeof presentationTabsBodySchema>;
|
|
487
|
+
|
|
120
488
|
const presentationBodySchema = z.union([
|
|
121
489
|
presentationActionBodySchema,
|
|
122
490
|
presentationComponentBodySchema,
|
|
491
|
+
presentationSectionsBodySchema,
|
|
492
|
+
presentationTabsBodySchema,
|
|
123
493
|
]);
|
|
124
494
|
|
|
125
495
|
const presentationAssistantSchema = z
|
|
@@ -132,31 +502,115 @@ const presentationAssistantSchema = z
|
|
|
132
502
|
const presentationRouteRecordSchema = z
|
|
133
503
|
.object({
|
|
134
504
|
presentation: identifierSchema,
|
|
135
|
-
|
|
505
|
+
fallbackFields: z.array(z.string().min(1)).min(1).optional(),
|
|
506
|
+
param: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
|
|
136
507
|
field: z.string().min(1),
|
|
137
508
|
})
|
|
138
509
|
.strict();
|
|
139
510
|
|
|
511
|
+
const presentationRouteQuerySchema = z
|
|
512
|
+
.object({
|
|
513
|
+
param: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
|
|
514
|
+
input: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
|
|
515
|
+
})
|
|
516
|
+
.strict();
|
|
140
517
|
export const presentationRouteSchema = z
|
|
141
518
|
.object({
|
|
519
|
+
parent: identifierSchema.optional(),
|
|
520
|
+
params: z.record(z.array(z.string().min(1)).min(1)).optional(),
|
|
521
|
+
query: z
|
|
522
|
+
.union([
|
|
523
|
+
presentationRouteQuerySchema,
|
|
524
|
+
z.array(presentationRouteQuerySchema).min(1),
|
|
525
|
+
])
|
|
526
|
+
.optional(),
|
|
142
527
|
path: z
|
|
143
528
|
.string()
|
|
144
529
|
.regex(
|
|
145
|
-
/^\/(?:[a-z0-9-]+|:[a-z][a-
|
|
530
|
+
/^\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*)(?:\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*))*$/,
|
|
146
531
|
),
|
|
147
532
|
surface: presentationSurfaceSchema,
|
|
148
533
|
record: presentationRouteRecordSchema.optional(),
|
|
534
|
+
alternatives: z
|
|
535
|
+
.array(
|
|
536
|
+
z
|
|
537
|
+
.object({
|
|
538
|
+
when: z.array(presentationConditionSchema).min(1),
|
|
539
|
+
presentation: identifierSchema,
|
|
540
|
+
})
|
|
541
|
+
.strict(),
|
|
542
|
+
)
|
|
543
|
+
.optional(),
|
|
149
544
|
})
|
|
150
545
|
.strict();
|
|
151
546
|
export type PresentationRoute = z.infer<typeof presentationRouteSchema>;
|
|
152
547
|
|
|
548
|
+
/** Parâmetros necessários para selecionar esta rota; filtros livres não participam. */
|
|
549
|
+
export function presentationRouteQueries(
|
|
550
|
+
route: PresentationRoute | undefined,
|
|
551
|
+
): Array<z.infer<typeof presentationRouteQuerySchema>> {
|
|
552
|
+
return route?.query
|
|
553
|
+
? Array.isArray(route.query)
|
|
554
|
+
? route.query
|
|
555
|
+
: [route.query]
|
|
556
|
+
: [];
|
|
557
|
+
}
|
|
558
|
+
|
|
153
559
|
export const presentationSchema = z
|
|
154
560
|
.object({
|
|
155
561
|
schemaVersion: z.literal(1),
|
|
156
562
|
id: identifierSchema,
|
|
157
563
|
title: z.string().min(1),
|
|
564
|
+
requires: presentationRequirementSchema.optional(),
|
|
565
|
+
width: z.enum(["default", "wide"]).optional(),
|
|
158
566
|
route: presentationRouteSchema.optional(),
|
|
159
567
|
body: presentationBodySchema,
|
|
568
|
+
source: z
|
|
569
|
+
.object({
|
|
570
|
+
action: z.string().min(1),
|
|
571
|
+
retainOnError: z.boolean().optional(),
|
|
572
|
+
errorMessage: z.string().min(1).optional(),
|
|
573
|
+
input: z.record(presentationBindingSchema).default({}),
|
|
574
|
+
record: z
|
|
575
|
+
.object({ field: z.string().min(1), param: z.string().min(1) })
|
|
576
|
+
.strict()
|
|
577
|
+
.optional(),
|
|
578
|
+
})
|
|
579
|
+
.strict()
|
|
580
|
+
.optional(),
|
|
581
|
+
externalSource: identifierSchema.optional(),
|
|
582
|
+
links: z
|
|
583
|
+
.array(
|
|
584
|
+
z
|
|
585
|
+
.object({
|
|
586
|
+
id: identifierSchema,
|
|
587
|
+
label: z.string().min(1),
|
|
588
|
+
presentation: identifierSchema,
|
|
589
|
+
icon: identifierSchema.optional(),
|
|
590
|
+
showLabel: z.boolean().optional(),
|
|
591
|
+
placement: z.enum(["header", "section"]),
|
|
592
|
+
input: z.record(presentationBindingSchema).default({}),
|
|
593
|
+
})
|
|
594
|
+
.strict(),
|
|
595
|
+
)
|
|
596
|
+
.default([]),
|
|
597
|
+
header: z
|
|
598
|
+
.object({
|
|
599
|
+
title: identifierSchema.optional(),
|
|
600
|
+
description: identifierSchema.optional(),
|
|
601
|
+
meta: identifierSchema.optional(),
|
|
602
|
+
actions: identifierSchema.optional(),
|
|
603
|
+
})
|
|
604
|
+
.strict()
|
|
605
|
+
.optional(),
|
|
606
|
+
page: z
|
|
607
|
+
.object({
|
|
608
|
+
width: z.enum(["narrow", "default", "wide"]).optional(),
|
|
609
|
+
layout: z.enum(["flow", "fill"]).optional(),
|
|
610
|
+
flush: z.boolean().optional(),
|
|
611
|
+
})
|
|
612
|
+
.strict()
|
|
613
|
+
.optional(),
|
|
160
614
|
assistant: presentationAssistantSchema.optional(),
|
|
161
615
|
actions: z.array(presentationCommandSchema).default([]),
|
|
162
616
|
})
|
|
@@ -243,7 +697,11 @@ type DefinedPresentationComponentBody<Body> =
|
|
|
243
697
|
type DefinedPresentationBody<Body extends PresentationDefinitionInput["body"]> =
|
|
244
698
|
Body extends { component: string }
|
|
245
699
|
? DefinedPresentationComponentBody<Body>
|
|
246
|
-
:
|
|
700
|
+
: Body extends { sections: unknown }
|
|
701
|
+
? PresentationSectionsBody
|
|
702
|
+
: Body extends { tabs: unknown }
|
|
703
|
+
? PresentationTabsBody
|
|
704
|
+
: DefinedPresentationActionBody<Body>;
|
|
247
705
|
type DefinedPresentationCommand<Command> = Command extends object
|
|
248
706
|
? Omit<Command, "input" | "blocking" | "onSuccess"> &
|
|
249
707
|
Omit<PresentationCommand, "input" | "blocking" | "onSuccess"> & {
|
|
@@ -333,6 +791,18 @@ export function isPresentationComponentBody(
|
|
|
333
791
|
return "component" in body;
|
|
334
792
|
}
|
|
335
793
|
|
|
794
|
+
export function isPresentationSectionsBody(
|
|
795
|
+
body: PresentationDefinition["body"],
|
|
796
|
+
): body is PresentationSectionsBody {
|
|
797
|
+
return "sections" in body;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function isPresentationTabsBody(
|
|
801
|
+
body: PresentationDefinition["body"],
|
|
802
|
+
): body is PresentationTabsBody {
|
|
803
|
+
return "tabs" in body;
|
|
804
|
+
}
|
|
805
|
+
|
|
336
806
|
export interface PresentationRouteMatch {
|
|
337
807
|
definition: PresentationDefinition;
|
|
338
808
|
input: Record<string, string>;
|
|
@@ -359,8 +829,10 @@ function decodeRouteSegment(segment: string): string | null {
|
|
|
359
829
|
export function matchPresentationRoute(
|
|
360
830
|
definitions: readonly PresentationDefinition[],
|
|
361
831
|
pathname: string,
|
|
832
|
+
search: string | URLSearchParams = "",
|
|
362
833
|
): PresentationRouteMatch | null {
|
|
363
834
|
const actual = routeSegments(pathname);
|
|
835
|
+
const params = new URLSearchParams(search);
|
|
364
836
|
const matches = definitions.flatMap((definition) => {
|
|
365
837
|
if (definition.route === undefined) return [];
|
|
366
838
|
const pattern = routeSegments(definition.route.path);
|
|
@@ -375,6 +847,17 @@ export function matchPresentationRoute(
|
|
|
375
847
|
input[expected.slice(1)] = decoded;
|
|
376
848
|
} else if (expected !== received) return [];
|
|
377
849
|
}
|
|
850
|
+
if (
|
|
851
|
+
Object.entries(definition.route.params ?? {}).some(
|
|
852
|
+
([key, values]) => !values.includes(input[key]!),
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
return [];
|
|
856
|
+
for (const query of presentationRouteQueries(definition.route)) {
|
|
857
|
+
const value = params.get(query.param);
|
|
858
|
+
if (!value) return [];
|
|
859
|
+
input[query.input] = value;
|
|
860
|
+
}
|
|
378
861
|
return [{ definition, input }];
|
|
379
862
|
});
|
|
380
863
|
matches.sort((left, right) => {
|
|
@@ -382,7 +865,13 @@ export function matchPresentationRoute(
|
|
|
382
865
|
routeSegments(entry.definition.route!.path).filter(
|
|
383
866
|
(segment) => !segment.startsWith(":"),
|
|
384
867
|
).length;
|
|
385
|
-
return
|
|
868
|
+
return (
|
|
869
|
+
staticCount(right) - staticCount(left) ||
|
|
870
|
+
presentationRouteQueries(right.definition.route).length -
|
|
871
|
+
presentationRouteQueries(left.definition.route).length ||
|
|
872
|
+
Object.keys(right.definition.route?.params ?? {}).length -
|
|
873
|
+
Object.keys(left.definition.route?.params ?? {}).length
|
|
874
|
+
);
|
|
386
875
|
});
|
|
387
876
|
return matches[0] ?? null;
|
|
388
877
|
}
|
|
@@ -399,15 +888,30 @@ export function pathForPresentationInvocation(
|
|
|
399
888
|
`Presentation “${invocation.presentationId}” não declara route.`,
|
|
400
889
|
);
|
|
401
890
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
891
|
+
const path = definition.route.path.replace(
|
|
892
|
+
/:([a-z][a-zA-Z0-9]*)/g,
|
|
893
|
+
(_, param) => {
|
|
894
|
+
const value = invocation.input[param];
|
|
895
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`A rota de “${invocation.presentationId}” exige o parâmetro “${param}”.`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
const allowed = definition.route!.params?.[param];
|
|
901
|
+
if (allowed && !allowed.includes(value)) {
|
|
902
|
+
throw new Error(`Valor inválido para o parâmetro “${param}” da rota.`);
|
|
903
|
+
}
|
|
904
|
+
return encodeURIComponent(value);
|
|
905
|
+
},
|
|
906
|
+
);
|
|
907
|
+
const search = new URLSearchParams();
|
|
908
|
+
for (const query of presentationRouteQueries(definition.route)) {
|
|
909
|
+
const value = invocation.input[query.input];
|
|
910
|
+
if (typeof value !== "string" || !value)
|
|
911
|
+
throw new Error(`A rota exige o parâmetro “${query.input}”.`);
|
|
912
|
+
search.set(query.param, value);
|
|
913
|
+
}
|
|
914
|
+
return `${path}${search.size ? `?${search}` : ""}`;
|
|
411
915
|
}
|
|
412
916
|
|
|
413
917
|
export interface PresentationBindingContext {
|
|
@@ -580,16 +1084,90 @@ export function validatePresentations(
|
|
|
580
1084
|
const parsed = z.array(presentationSchema).parse(input);
|
|
581
1085
|
const byId = new Map<string, PresentationDefinition>();
|
|
582
1086
|
const problems: string[] = [];
|
|
583
|
-
const routePatterns = new
|
|
1087
|
+
const routePatterns = new Map<string, PresentationDefinition[]>();
|
|
584
1088
|
|
|
585
1089
|
for (const presentation of parsed) {
|
|
586
1090
|
if (byId.has(presentation.id))
|
|
587
1091
|
problems.push(`Presentation “${presentation.id}” duplicada.`);
|
|
588
1092
|
byId.set(presentation.id, presentation);
|
|
1093
|
+
if (presentation.source && presentation.externalSource)
|
|
1094
|
+
problems.push("Declare apenas uma fonte de registro.");
|
|
1095
|
+
if (presentation.source) {
|
|
1096
|
+
const source = actions[presentation.source.action];
|
|
1097
|
+
if (source?.kind !== "view" && source?.kind !== "list")
|
|
1098
|
+
problems.push(
|
|
1099
|
+
`Fonte de registro exige action view ou list: ${presentation.source.action}.`,
|
|
1100
|
+
);
|
|
1101
|
+
else if ((source.kind === "list") !== Boolean(presentation.source.record))
|
|
1102
|
+
problems.push(
|
|
1103
|
+
"Fonte list exige seleção de registro; fonte view não aceita seleção.",
|
|
1104
|
+
);
|
|
1105
|
+
else {
|
|
1106
|
+
validateActionBindings(
|
|
1107
|
+
presentation.source.input,
|
|
1108
|
+
source,
|
|
1109
|
+
`${presentation.id}.source.input`,
|
|
1110
|
+
problems,
|
|
1111
|
+
);
|
|
1112
|
+
if (presentation.source.record) {
|
|
1113
|
+
const { field, param } = presentation.source.record;
|
|
1114
|
+
const output = unwrapObjectSchema(source.output)?.shape;
|
|
1115
|
+
if (!output || !(field in output))
|
|
1116
|
+
problems.push(`Campo de seleção inexistente: ${field}.`);
|
|
1117
|
+
if (
|
|
1118
|
+
presentation.route &&
|
|
1119
|
+
!presentation.route.path.includes(`:${param}`) &&
|
|
1120
|
+
!presentationRouteQueries(presentation.route).some(
|
|
1121
|
+
(query) => query.input === param,
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
problems.push(
|
|
1125
|
+
`Parâmetro da fonte não declarado na rota: ${param}.`,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (isPresentationSectionsBody(presentation.body)) {
|
|
1131
|
+
const output = presentation.source
|
|
1132
|
+
? unwrapObjectSchema(actions[presentation.source.action]?.output)?.shape
|
|
1133
|
+
: undefined;
|
|
1134
|
+
for (const section of presentation.body.sections) {
|
|
1135
|
+
if (section.kind !== "items") continue;
|
|
1136
|
+
for (const item of section.items) {
|
|
1137
|
+
if (item.content && item.value)
|
|
1138
|
+
problems.push(
|
|
1139
|
+
`Item ${item.id} não pode declarar content e value juntos.`,
|
|
1140
|
+
);
|
|
1141
|
+
if (
|
|
1142
|
+
item.value &&
|
|
1143
|
+
!presentation.externalSource &&
|
|
1144
|
+
(!output || !(item.value.field in output))
|
|
1145
|
+
)
|
|
1146
|
+
problems.push(
|
|
1147
|
+
`Campo de registro inexistente: ${item.value.field}.`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
589
1152
|
|
|
590
|
-
if (
|
|
591
|
-
|
|
592
|
-
|
|
1153
|
+
if (
|
|
1154
|
+
isPresentationComponentBody(presentation.body) &&
|
|
1155
|
+
presentation.body.contract
|
|
1156
|
+
) {
|
|
1157
|
+
const contract = actions[presentation.body.contract];
|
|
1158
|
+
if (!contract)
|
|
1159
|
+
problems.push(
|
|
1160
|
+
`Contrato de componente inexistente: ${presentation.body.contract}.`,
|
|
1161
|
+
);
|
|
1162
|
+
else
|
|
1163
|
+
validateActionBindings(
|
|
1164
|
+
presentation.body.input ?? {},
|
|
1165
|
+
contract,
|
|
1166
|
+
`${presentation.id}.body.input`,
|
|
1167
|
+
problems,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
if (isPresentationActionBody(presentation.body)) {
|
|
593
1171
|
const bodyAction = actions[presentation.body.action];
|
|
594
1172
|
if (bodyAction === undefined) {
|
|
595
1173
|
problems.push(
|
|
@@ -614,6 +1192,13 @@ export function validatePresentations(
|
|
|
614
1192
|
`A Presentation “${presentation.id}” precisa declarar fields para a view.`,
|
|
615
1193
|
);
|
|
616
1194
|
}
|
|
1195
|
+
if (
|
|
1196
|
+
bodyAction.kind !== "list" &&
|
|
1197
|
+
(presentation.body.cells !== undefined ||
|
|
1198
|
+
presentation.body.emptyMessage !== undefined)
|
|
1199
|
+
) {
|
|
1200
|
+
problems.push(`Cells e emptyMessage exigem uma action list no body.`);
|
|
1201
|
+
}
|
|
617
1202
|
if (
|
|
618
1203
|
bodyAction.kind !== "view" &&
|
|
619
1204
|
presentation.body.fields !== undefined
|
|
@@ -637,7 +1222,68 @@ export function validatePresentations(
|
|
|
637
1222
|
}
|
|
638
1223
|
}
|
|
639
1224
|
|
|
1225
|
+
for (const entry of [...presentation.actions, ...presentation.links]) {
|
|
1226
|
+
if (
|
|
1227
|
+
entry.placement === "section" &&
|
|
1228
|
+
!isPresentationSectionsBody(presentation.body)
|
|
1229
|
+
)
|
|
1230
|
+
problems.push(
|
|
1231
|
+
`O placement section de “${presentation.id}” exige sections no body.`,
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
const sectionSlots = [
|
|
1235
|
+
...presentation.actions
|
|
1236
|
+
.filter((command) => command.placement === "section")
|
|
1237
|
+
.map((command) => command.id ?? command.action),
|
|
1238
|
+
...presentation.links
|
|
1239
|
+
.filter((link) => link.placement === "section")
|
|
1240
|
+
.map((link) => link.id),
|
|
1241
|
+
];
|
|
1242
|
+
if (new Set(sectionSlots).size !== sectionSlots.length)
|
|
1243
|
+
problems.push(
|
|
1244
|
+
`Slots de ações e links de seção devem ser únicos em “${presentation.id}”.`,
|
|
1245
|
+
);
|
|
640
1246
|
for (const command of presentation.actions) {
|
|
1247
|
+
if (command.target !== undefined && command.successMessage !== undefined)
|
|
1248
|
+
problems.push(
|
|
1249
|
+
`SuccessMessage só pode ser declarado em comando de execução direta.`,
|
|
1250
|
+
);
|
|
1251
|
+
if (
|
|
1252
|
+
(command.placement === "toolbar" || command.placement === "row") &&
|
|
1253
|
+
(!isPresentationActionBody(presentation.body) ||
|
|
1254
|
+
actions[presentation.body.action]?.kind !== "list")
|
|
1255
|
+
) {
|
|
1256
|
+
problems.push(
|
|
1257
|
+
`O placement ${command.placement} de “${presentation.id}” exige uma action list no body.`,
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
if (command.placement !== "row" && command.itemLabel !== undefined) {
|
|
1261
|
+
problems.push(`ItemLabel só está disponível para comandos de linha.`);
|
|
1262
|
+
}
|
|
1263
|
+
if (command.placement === "row") {
|
|
1264
|
+
if (command.blocking !== "action")
|
|
1265
|
+
problems.push(`Comandos de linha usam blocking action.`);
|
|
1266
|
+
const list = isPresentationActionBody(presentation.body)
|
|
1267
|
+
? actions[presentation.body.action]
|
|
1268
|
+
: undefined;
|
|
1269
|
+
const output =
|
|
1270
|
+
list?.kind === "list" ? unwrapObjectSchema(list.output) : null;
|
|
1271
|
+
if (output !== null) {
|
|
1272
|
+
const fields = [
|
|
1273
|
+
command.itemLabel,
|
|
1274
|
+
...(command.when ?? []).map((condition) => condition.field),
|
|
1275
|
+
...Object.values(command.input).flatMap((binding) =>
|
|
1276
|
+
binding.source === "item" ? [binding.field] : [],
|
|
1277
|
+
),
|
|
1278
|
+
];
|
|
1279
|
+
for (const field of fields) {
|
|
1280
|
+
if (field !== undefined && !Object.hasOwn(output.shape, field))
|
|
1281
|
+
problems.push(
|
|
1282
|
+
`O campo “${field}” do comando de linha não existe na saída de “${list!.name}”.`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
641
1287
|
const action = actions[command.action];
|
|
642
1288
|
if (action === undefined) {
|
|
643
1289
|
problems.push(`A action “${command.action}” não está disponível.`);
|
|
@@ -648,7 +1294,11 @@ export function validatePresentations(
|
|
|
648
1294
|
`${presentation.id}.actions.${command.action}.input`,
|
|
649
1295
|
problems,
|
|
650
1296
|
);
|
|
651
|
-
if (
|
|
1297
|
+
if (
|
|
1298
|
+
action.kind === "simple" &&
|
|
1299
|
+
action.effect !== "read" &&
|
|
1300
|
+
command.target !== undefined
|
|
1301
|
+
) {
|
|
652
1302
|
problems.push(
|
|
653
1303
|
`A action simple “${command.action}” executa diretamente e não aceita target.`,
|
|
654
1304
|
);
|
|
@@ -663,16 +1313,73 @@ export function validatePresentations(
|
|
|
663
1313
|
|
|
664
1314
|
for (const presentation of parsed) {
|
|
665
1315
|
if (presentation.route !== undefined) {
|
|
666
|
-
const
|
|
667
|
-
|
|
1316
|
+
for (const alternative of presentation.route.alternatives ?? []) {
|
|
1317
|
+
const target = byId.get(alternative.presentation);
|
|
1318
|
+
if (
|
|
1319
|
+
!presentation.route.record ||
|
|
1320
|
+
!target ||
|
|
1321
|
+
target.route?.surface !== presentation.route.surface ||
|
|
1322
|
+
target.route?.alternatives
|
|
1323
|
+
)
|
|
1324
|
+
problems.push(
|
|
1325
|
+
"Alternativa de rota exige registro e destino da mesma superfície, sem alternativas encadeadas.",
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
const queries = presentationRouteQueries(presentation.route);
|
|
1329
|
+
const pathParams = routeSegments(presentation.route.path)
|
|
1330
|
+
.filter((part) => part.startsWith(":"))
|
|
1331
|
+
.map((part) => part.slice(1));
|
|
1332
|
+
if (
|
|
1333
|
+
new Set(queries.map((query) => query.param)).size !== queries.length ||
|
|
1334
|
+
new Set([...pathParams, ...queries.map((query) => query.input)])
|
|
1335
|
+
.size !==
|
|
1336
|
+
pathParams.length + queries.length
|
|
1337
|
+
) {
|
|
668
1338
|
problems.push(
|
|
669
|
-
|
|
1339
|
+
"Os parâmetros e bindings de busca da rota precisam ser distintos.",
|
|
670
1340
|
);
|
|
671
1341
|
}
|
|
672
|
-
|
|
1342
|
+
const pattern = `${routePattern(presentation.route.path)}?${presentationRouteQueries(
|
|
1343
|
+
presentation.route,
|
|
1344
|
+
)
|
|
1345
|
+
.map((query) => query.param)
|
|
1346
|
+
.sort()
|
|
1347
|
+
.join("&")}`;
|
|
1348
|
+
const siblings = routePatterns.get(pattern) ?? [];
|
|
1349
|
+
for (const key of Object.keys(presentation.route.params ?? {}))
|
|
1350
|
+
if (!routeSegments(presentation.route.path).includes(`:${key}`))
|
|
1351
|
+
problems.push(`Restrição de parâmetro inexistente na rota: ${key}.`);
|
|
1352
|
+
for (const sibling of siblings) {
|
|
1353
|
+
const current = presentation.route.params ?? {};
|
|
1354
|
+
const previous = sibling.route!.params ?? {};
|
|
1355
|
+
const constrained = Object.keys(current).length > 0;
|
|
1356
|
+
const siblingConstrained = Object.keys(previous).length > 0;
|
|
1357
|
+
const disjoint = routeSegments(presentation.route.path).some(
|
|
1358
|
+
(segment, index) => {
|
|
1359
|
+
const values = current[segment.slice(1)];
|
|
1360
|
+
const siblingKey = routeSegments(sibling.route!.path)[index]!.slice(
|
|
1361
|
+
1,
|
|
1362
|
+
);
|
|
1363
|
+
const other = previous[siblingKey];
|
|
1364
|
+
return (
|
|
1365
|
+
values && other && values.every((value) => !other.includes(value))
|
|
1366
|
+
);
|
|
1367
|
+
},
|
|
1368
|
+
);
|
|
1369
|
+
if (constrained === siblingConstrained && !disjoint)
|
|
1370
|
+
problems.push(
|
|
1371
|
+
`A route “${presentation.route.path}” conflita com outra route declarada.`,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
routePatterns.set(pattern, [...siblings, presentation]);
|
|
673
1375
|
const record = presentation.route.record;
|
|
674
1376
|
if (record !== undefined) {
|
|
675
|
-
if (
|
|
1377
|
+
if (
|
|
1378
|
+
!presentation.route.path.includes(`:${record.param}`) &&
|
|
1379
|
+
!presentationRouteQueries(presentation.route).some(
|
|
1380
|
+
(query) => query.input === record.param,
|
|
1381
|
+
)
|
|
1382
|
+
) {
|
|
676
1383
|
problems.push(
|
|
677
1384
|
`A route de “${presentation.id}” não declara o parâmetro “${record.param}” usado por record.`,
|
|
678
1385
|
);
|
|
@@ -703,6 +1410,180 @@ export function validatePresentations(
|
|
|
703
1410
|
}
|
|
704
1411
|
}
|
|
705
1412
|
}
|
|
1413
|
+
if (
|
|
1414
|
+
presentation.route?.parent !== undefined &&
|
|
1415
|
+
!byId.has(presentation.route.parent)
|
|
1416
|
+
)
|
|
1417
|
+
problems.push(`Pai de rota inexistente: ${presentation.route.parent}.`);
|
|
1418
|
+
if (isPresentationTabsBody(presentation.body)) {
|
|
1419
|
+
for (const tab of presentation.body.tabs) {
|
|
1420
|
+
if (tab.presentation !== undefined) {
|
|
1421
|
+
const target = byId.get(tab.presentation);
|
|
1422
|
+
if (
|
|
1423
|
+
target === undefined ||
|
|
1424
|
+
(!isPresentationActionBody(target.body) &&
|
|
1425
|
+
!isPresentationSectionsBody(target.body))
|
|
1426
|
+
)
|
|
1427
|
+
problems.push(
|
|
1428
|
+
`A aba ${tab.id} precisa referenciar uma Presentation de action ou seções.`,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (isPresentationActionBody(presentation.body)) {
|
|
1434
|
+
const body = presentation.body;
|
|
1435
|
+
const owner = actions[body.action];
|
|
1436
|
+
const shape = unwrapObjectSchema(owner?.input)?.shape;
|
|
1437
|
+
if (
|
|
1438
|
+
body.rowKey &&
|
|
1439
|
+
!Object.hasOwn(
|
|
1440
|
+
unwrapObjectSchema(owner?.output)?.shape ?? {},
|
|
1441
|
+
body.rowKey,
|
|
1442
|
+
)
|
|
1443
|
+
)
|
|
1444
|
+
problems.push(`Chave de linha inexistente: ${body.rowKey}.`);
|
|
1445
|
+
if (
|
|
1446
|
+
(body.lookups ||
|
|
1447
|
+
body.formFields ||
|
|
1448
|
+
body.formGroups ||
|
|
1449
|
+
body.fieldComponents ||
|
|
1450
|
+
body.defaultsFromRecord ||
|
|
1451
|
+
body.initialValues) &&
|
|
1452
|
+
owner?.kind !== "form"
|
|
1453
|
+
)
|
|
1454
|
+
problems.push("Lookups e campos de formulário exigem form.");
|
|
1455
|
+
if (
|
|
1456
|
+
(body.rowKey ||
|
|
1457
|
+
body.columns ||
|
|
1458
|
+
body.opens ||
|
|
1459
|
+
body.inspect ||
|
|
1460
|
+
body.filterDefaults ||
|
|
1461
|
+
body.beforeContent ||
|
|
1462
|
+
body.rowContent ||
|
|
1463
|
+
body.collection ||
|
|
1464
|
+
body.toolbarContent) &&
|
|
1465
|
+
owner?.kind !== "list"
|
|
1466
|
+
)
|
|
1467
|
+
problems.push("Colunas e destinos de linha exigem list.");
|
|
1468
|
+
for (const field of [
|
|
1469
|
+
...Object.keys(body.initialValues ?? {}),
|
|
1470
|
+
...Object.keys(body.lookups ?? {}),
|
|
1471
|
+
...(body.formFields ?? []),
|
|
1472
|
+
...(body.formGroups?.flatMap((group) => group.fields) ?? []),
|
|
1473
|
+
...Object.keys(body.fieldComponents ?? {}),
|
|
1474
|
+
])
|
|
1475
|
+
if (!shape || !(field in shape))
|
|
1476
|
+
problems.push(`Campo de formulário inexistente: ${field}.`);
|
|
1477
|
+
for (const field of Object.keys(body.fieldComponents ?? {})) {
|
|
1478
|
+
if (body.lookups?.[field])
|
|
1479
|
+
problems.push(
|
|
1480
|
+
`Campo não pode declarar lookup e componente: ${field}.`,
|
|
1481
|
+
);
|
|
1482
|
+
if (body.formFields && !body.formFields.includes(field))
|
|
1483
|
+
problems.push(`Componente fora dos campos do formulário: ${field}.`);
|
|
1484
|
+
}
|
|
1485
|
+
for (const lookup of Object.values(body.lookups ?? {})) {
|
|
1486
|
+
const action = actions[lookup.action];
|
|
1487
|
+
const output = unwrapObjectSchema(action?.output)?.shape;
|
|
1488
|
+
if (
|
|
1489
|
+
action?.kind !== "list" ||
|
|
1490
|
+
!output ||
|
|
1491
|
+
!(lookup.label in output) ||
|
|
1492
|
+
!(lookup.value in output)
|
|
1493
|
+
)
|
|
1494
|
+
problems.push(`Lookup inválido: ${lookup.action}.`);
|
|
1495
|
+
if (action?.kind === "list" && action.paginate !== false)
|
|
1496
|
+
problems.push(
|
|
1497
|
+
`Lookup “${lookup.action}” exige paginate false para apresentar todas as opções.`,
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
const output = unwrapObjectSchema(owner?.output)?.shape;
|
|
1501
|
+
if (body.open !== undefined && body.opens !== undefined)
|
|
1502
|
+
problems.push("Declare open ou opens, sem destinos concorrentes.");
|
|
1503
|
+
if (body.inspect && (body.open || body.opens))
|
|
1504
|
+
problems.push("Inspect não pode coexistir com open ou opens.");
|
|
1505
|
+
for (const condition of body.inspect?.when ?? [])
|
|
1506
|
+
if (!output || !(condition.field in output))
|
|
1507
|
+
problems.push(`Campo de inspeção inexistente: ${condition.field}.`);
|
|
1508
|
+
for (const opening of [
|
|
1509
|
+
...(body.opens ?? []),
|
|
1510
|
+
...(body.open ? [body.open] : []),
|
|
1511
|
+
]) {
|
|
1512
|
+
const fields = [
|
|
1513
|
+
...(opening.when ?? []).map((condition) => condition.field),
|
|
1514
|
+
...Object.values(opening.input).flatMap((binding) =>
|
|
1515
|
+
binding.source === "item" ? [binding.field] : [],
|
|
1516
|
+
),
|
|
1517
|
+
];
|
|
1518
|
+
for (const field of fields)
|
|
1519
|
+
if (!output || !(field in output))
|
|
1520
|
+
problems.push(`Campo de destino de linha inexistente: ${field}.`);
|
|
1521
|
+
const target = byId.get(opening.presentation);
|
|
1522
|
+
if (
|
|
1523
|
+
opening.surface &&
|
|
1524
|
+
target?.route &&
|
|
1525
|
+
opening.surface !== target.route.surface
|
|
1526
|
+
)
|
|
1527
|
+
problems.push(
|
|
1528
|
+
`Superfície incompatível no destino de linha: ${opening.presentation}.`,
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
for (const [field, column] of Object.entries(body.columns ?? {})) {
|
|
1532
|
+
if (!output || !(field in output))
|
|
1533
|
+
problems.push(`Coluna inexistente: ${field}.`);
|
|
1534
|
+
if (column.kind === "switch") {
|
|
1535
|
+
const action = actions[column.action];
|
|
1536
|
+
const input = unwrapObjectSchema(action?.input)?.shape;
|
|
1537
|
+
for (const source of [
|
|
1538
|
+
column.itemLabel,
|
|
1539
|
+
...Object.values(column.input).flatMap((binding) =>
|
|
1540
|
+
binding.source === "item" ? [binding.field] : [],
|
|
1541
|
+
),
|
|
1542
|
+
])
|
|
1543
|
+
if (!output || !(source in output))
|
|
1544
|
+
problems.push(`Campo de switch inexistente: ${source}.`);
|
|
1545
|
+
if (output?.[field] && !output[field].safeParse(false).success)
|
|
1546
|
+
problems.push(`Coluna de switch precisa ser booleana: ${field}.`);
|
|
1547
|
+
if (
|
|
1548
|
+
!action ||
|
|
1549
|
+
!["form", "simple"].includes(action.kind) ||
|
|
1550
|
+
!input ||
|
|
1551
|
+
!(column.valueField in input) ||
|
|
1552
|
+
!input[column.valueField].safeParse(false).success
|
|
1553
|
+
)
|
|
1554
|
+
problems.push(`Action de switch inválida: ${column.action}.`);
|
|
1555
|
+
else
|
|
1556
|
+
validateActionBindings(
|
|
1557
|
+
{
|
|
1558
|
+
...column.input,
|
|
1559
|
+
[column.valueField]: { source: "fixed", value: false },
|
|
1560
|
+
},
|
|
1561
|
+
action,
|
|
1562
|
+
`${presentation.id}.columns.${field}`,
|
|
1563
|
+
problems,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
for (const open of body.opens ?? []) {
|
|
1568
|
+
const target = byId.get(open.presentation);
|
|
1569
|
+
if (
|
|
1570
|
+
!target ||
|
|
1571
|
+
(!isPresentationActionBody(target.body) &&
|
|
1572
|
+
!isPresentationComponentBody(target.body))
|
|
1573
|
+
)
|
|
1574
|
+
problems.push(`Destino de linha inexistente: ${open.presentation}.`);
|
|
1575
|
+
else if (
|
|
1576
|
+
isPresentationActionBody(target.body) &&
|
|
1577
|
+
actions[target.body.action]
|
|
1578
|
+
)
|
|
1579
|
+
validateActionBindings(
|
|
1580
|
+
open.input,
|
|
1581
|
+
actions[target.body.action]!,
|
|
1582
|
+
`${presentation.id}.opens`,
|
|
1583
|
+
problems,
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
706
1587
|
const open = isPresentationActionBody(presentation.body)
|
|
707
1588
|
? presentation.body.open
|
|
708
1589
|
: undefined;
|
|
@@ -805,11 +1686,21 @@ export function validatePresentations(
|
|
|
805
1686
|
problems.push(
|
|
806
1687
|
`A action “${command.action}” abre a Presentation inexistente “${command.target.presentation}”.`,
|
|
807
1688
|
);
|
|
808
|
-
} else if (
|
|
1689
|
+
} else if (
|
|
1690
|
+
!isPresentationActionBody(target.body) &&
|
|
1691
|
+
!target.source &&
|
|
1692
|
+
!(
|
|
1693
|
+
isPresentationComponentBody(target.body) &&
|
|
1694
|
+
target.body.contract === command.action
|
|
1695
|
+
)
|
|
1696
|
+
) {
|
|
809
1697
|
problems.push(
|
|
810
1698
|
`A Presentation “${target.id}” usa conteúdo de componente e não pode ser aberta pela action “${command.action}”.`,
|
|
811
1699
|
);
|
|
812
|
-
} else if (
|
|
1700
|
+
} else if (
|
|
1701
|
+
isPresentationActionBody(target.body) &&
|
|
1702
|
+
target.body.action !== command.action
|
|
1703
|
+
) {
|
|
813
1704
|
problems.push(
|
|
814
1705
|
`A Presentation “${target.id}” usa “${target.body.action}”, mas foi aberta por “${command.action}”.`,
|
|
815
1706
|
);
|
|
@@ -826,6 +1717,14 @@ export function validatePresentations(
|
|
|
826
1717
|
}
|
|
827
1718
|
|
|
828
1719
|
if (problems.length > 0) throw new Error(problems.join("\n"));
|
|
1720
|
+
for (const presentation of parsed) {
|
|
1721
|
+
for (const link of presentation.links) {
|
|
1722
|
+
if (!byId.get(link.presentation)?.route)
|
|
1723
|
+
throw new Error(
|
|
1724
|
+
`Destino de navegação não registrado: ${link.presentation}.`,
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
829
1728
|
return parsed;
|
|
830
1729
|
}
|
|
831
1730
|
|
|
@@ -838,8 +1737,15 @@ function unwrapObjectSchema(schema: unknown): z.ZodObject<any> | null {
|
|
|
838
1737
|
!visited.has(current)
|
|
839
1738
|
) {
|
|
840
1739
|
visited.add(current);
|
|
841
|
-
if (current instanceof z.ZodObject) return current;
|
|
842
1740
|
const definition = (current as { _def?: Record<string, unknown> })._def;
|
|
1741
|
+
// Contratos podem vir de outra instância de Zod no loader da CLI.
|
|
1742
|
+
if (
|
|
1743
|
+
current instanceof z.ZodObject ||
|
|
1744
|
+
(definition?.typeName === "ZodObject" &&
|
|
1745
|
+
"shape" in current &&
|
|
1746
|
+
typeof current.shape === "object")
|
|
1747
|
+
)
|
|
1748
|
+
return current as z.ZodObject<any>;
|
|
843
1749
|
current = definition?.innerType ?? definition?.schema ?? null;
|
|
844
1750
|
}
|
|
845
1751
|
return null;
|