@autoview/cli 0.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/LICENSE +661 -0
- package/README.md +407 -0
- package/lib/AutoViewAgent.d.ts +109 -0
- package/lib/AutoViewAgent.js +123 -0
- package/lib/AutoViewAgent.js.map +1 -0
- package/lib/agent/emitMcpServer.d.ts +15 -0
- package/lib/agent/emitMcpServer.js +157 -0
- package/lib/agent/emitMcpServer.js.map +1 -0
- package/lib/agent/emitReport.d.ts +14 -0
- package/lib/agent/emitReport.js +85 -0
- package/lib/agent/emitReport.js.map +1 -0
- package/lib/agent/toolSurface.d.ts +130 -0
- package/lib/agent/toolSurface.js +342 -0
- package/lib/agent/toolSurface.js.map +1 -0
- package/lib/agent/verifyAgentTasks.d.ts +87 -0
- package/lib/agent/verifyAgentTasks.js +126 -0
- package/lib/agent/verifyAgentTasks.js.map +1 -0
- package/lib/cli/main.d.ts +2 -0
- package/lib/cli/main.js +295 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/compiler/AutoViewInterfaceCompiler.d.ts +27 -0
- package/lib/compiler/AutoViewInterfaceCompiler.js +68 -0
- package/lib/compiler/AutoViewInterfaceCompiler.js.map +1 -0
- package/lib/constants/AutoViewFrontendTemplate.d.ts +1 -0
- package/lib/constants/AutoViewFrontendTemplate.js +46 -0
- package/lib/constants/AutoViewFrontendTemplate.js.map +1 -0
- package/lib/constants/AutoViewSystemPromptConstant.d.ts +5 -0
- package/lib/constants/AutoViewSystemPromptConstant.js +4 -0
- package/lib/constants/AutoViewSystemPromptConstant.js.map +1 -0
- package/lib/context/IAutoViewAgentContext.d.ts +60 -0
- package/lib/context/IAutoViewAgentContext.js +3 -0
- package/lib/context/IAutoViewAgentContext.js.map +1 -0
- package/lib/fromSwagger.d.ts +53 -0
- package/lib/fromSwagger.js +513 -0
- package/lib/fromSwagger.js.map +1 -0
- package/lib/generateDeterministic.d.ts +26 -0
- package/lib/generateDeterministic.js +75 -0
- package/lib/generateDeterministic.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +41 -0
- package/lib/index.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoView.d.ts +17 -0
- package/lib/orchestrate/orchestrateAutoView.js +491 -0
- package/lib/orchestrate/orchestrateAutoView.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewProductPlan.d.ts +37 -0
- package/lib/orchestrate/orchestrateAutoViewProductPlan.js +109 -0
- package/lib/orchestrate/orchestrateAutoViewProductPlan.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewRender.d.ts +133 -0
- package/lib/orchestrate/orchestrateAutoViewRender.js +943 -0
- package/lib/orchestrate/orchestrateAutoViewRender.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewRenderDeterministic.d.ts +24 -0
- package/lib/orchestrate/orchestrateAutoViewRenderDeterministic.js +92 -0
- package/lib/orchestrate/orchestrateAutoViewRenderDeterministic.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewReview.d.ts +48 -0
- package/lib/orchestrate/orchestrateAutoViewReview.js +328 -0
- package/lib/orchestrate/orchestrateAutoViewReview.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewScaffold.d.ts +45 -0
- package/lib/orchestrate/orchestrateAutoViewScaffold.js +586 -0
- package/lib/orchestrate/orchestrateAutoViewScaffold.js.map +1 -0
- package/lib/orchestrate/orchestrateAutoViewSdkStudy.d.ts +26 -0
- package/lib/orchestrate/orchestrateAutoViewSdkStudy.js +85 -0
- package/lib/orchestrate/orchestrateAutoViewSdkStudy.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewProductPlan.d.ts +96 -0
- package/lib/orchestrate/structures/IAutoViewProductPlan.js +3 -0
- package/lib/orchestrate/structures/IAutoViewProductPlan.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewProductPlanApplication.d.ts +38 -0
- package/lib/orchestrate/structures/IAutoViewProductPlanApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewProductPlanApplication.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewRenderApplication.d.ts +38 -0
- package/lib/orchestrate/structures/IAutoViewRenderApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewRenderApplication.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewReviewApplication.d.ts +40 -0
- package/lib/orchestrate/structures/IAutoViewReviewApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewReviewApplication.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewSdkMap.d.ts +63 -0
- package/lib/orchestrate/structures/IAutoViewSdkMap.js +3 -0
- package/lib/orchestrate/structures/IAutoViewSdkMap.js.map +1 -0
- package/lib/orchestrate/structures/IAutoViewSdkStudyApplication.d.ts +37 -0
- package/lib/orchestrate/structures/IAutoViewSdkStudyApplication.js +3 -0
- package/lib/orchestrate/structures/IAutoViewSdkStudyApplication.js.map +1 -0
- package/lib/orchestrate/utils/HistoryMessage.d.ts +10 -0
- package/lib/orchestrate/utils/HistoryMessage.js +25 -0
- package/lib/orchestrate/utils/HistoryMessage.js.map +1 -0
- package/lib/orchestrate/utils/auditFrontendRuntime.d.ts +53 -0
- package/lib/orchestrate/utils/auditFrontendRuntime.js +362 -0
- package/lib/orchestrate/utils/auditFrontendRuntime.js.map +1 -0
- package/lib/orchestrate/utils/buildDeterministicPlan.d.ts +4 -0
- package/lib/orchestrate/utils/buildDeterministicPlan.js +233 -0
- package/lib/orchestrate/utils/buildDeterministicPlan.js.map +1 -0
- package/lib/orchestrate/utils/buildDeterministicSdkMap.d.ts +22 -0
- package/lib/orchestrate/utils/buildDeterministicSdkMap.js +154 -0
- package/lib/orchestrate/utils/buildDeterministicSdkMap.js.map +1 -0
- package/lib/orchestrate/utils/cacheNodeModules.d.ts +31 -0
- package/lib/orchestrate/utils/cacheNodeModules.js +134 -0
- package/lib/orchestrate/utils/cacheNodeModules.js.map +1 -0
- package/lib/orchestrate/utils/describeEndpointPropsShape.d.ts +37 -0
- package/lib/orchestrate/utils/describeEndpointPropsShape.js +192 -0
- package/lib/orchestrate/utils/describeEndpointPropsShape.js.map +1 -0
- package/lib/orchestrate/utils/describeEndpointRequestBodyShape.d.ts +22 -0
- package/lib/orchestrate/utils/describeEndpointRequestBodyShape.js +29 -0
- package/lib/orchestrate/utils/describeEndpointRequestBodyShape.js.map +1 -0
- package/lib/orchestrate/utils/describeEndpointResponseShape.d.ts +19 -0
- package/lib/orchestrate/utils/describeEndpointResponseShape.js +30 -0
- package/lib/orchestrate/utils/describeEndpointResponseShape.js.map +1 -0
- package/lib/orchestrate/utils/executeCachedBatch.d.ts +22 -0
- package/lib/orchestrate/utils/executeCachedBatch.js +64 -0
- package/lib/orchestrate/utils/executeCachedBatch.js.map +1 -0
- package/lib/orchestrate/utils/loadShoppingFixture.d.ts +33 -0
- package/lib/orchestrate/utils/loadShoppingFixture.js +17 -0
- package/lib/orchestrate/utils/loadShoppingFixture.js.map +1 -0
- package/lib/orchestrate/utils/normalizeProductPlanPaths.d.ts +24 -0
- package/lib/orchestrate/utils/normalizeProductPlanPaths.js +77 -0
- package/lib/orchestrate/utils/normalizeProductPlanPaths.js.map +1 -0
- package/lib/orchestrate/utils/renderJsonSchema.d.ts +23 -0
- package/lib/orchestrate/utils/renderJsonSchema.js +122 -0
- package/lib/orchestrate/utils/renderJsonSchema.js.map +1 -0
- package/lib/orchestrate/utils/renderResourcePage.d.ts +36 -0
- package/lib/orchestrate/utils/renderResourcePage.js +1415 -0
- package/lib/orchestrate/utils/renderResourcePage.js.map +1 -0
- package/lib/orchestrate/utils/validateFrontendTypecheck.d.ts +109 -0
- package/lib/orchestrate/utils/validateFrontendTypecheck.js +274 -0
- package/lib/orchestrate/utils/validateFrontendTypecheck.js.map +1 -0
- package/lib/preview/renderPreview.d.ts +22 -0
- package/lib/preview/renderPreview.js +198 -0
- package/lib/preview/renderPreview.js.map +1 -0
- package/lib/typings/compiler.d.ts +39 -0
- package/lib/typings/compiler.js +3 -0
- package/lib/typings/compiler.js.map +1 -0
- package/lib/typings/events.d.ts +106 -0
- package/lib/typings/events.js +3 -0
- package/lib/typings/events.js.map +1 -0
- package/lib/typings/index.d.ts +10 -0
- package/lib/typings/index.js +27 -0
- package/lib/typings/index.js.map +1 -0
- package/lib/typings/misc.d.ts +78 -0
- package/lib/typings/misc.js +3 -0
- package/lib/typings/misc.js.map +1 -0
- package/lib/utils/ArrayUtil.d.ts +8 -0
- package/lib/utils/ArrayUtil.js +30 -0
- package/lib/utils/ArrayUtil.js.map +1 -0
- package/lib/utils/StringUtil.d.ts +11 -0
- package/lib/utils/StringUtil.js +28 -0
- package/lib/utils/StringUtil.js.map +1 -0
- package/lib/utils/classifyEndpoints.d.ts +62 -0
- package/lib/utils/classifyEndpoints.js +216 -0
- package/lib/utils/classifyEndpoints.js.map +1 -0
- package/lib/utils/endpointFilter.d.ts +26 -0
- package/lib/utils/endpointFilter.js +0 -0
- package/lib/utils/endpointFilter.js.map +1 -0
- package/lib/utils/extractFields.d.ts +85 -0
- package/lib/utils/extractFields.js +231 -0
- package/lib/utils/extractFields.js.map +1 -0
- package/lib/utils/index.d.ts +13 -0
- package/lib/utils/index.js +30 -0
- package/lib/utils/index.js.map +1 -0
- package/lib/utils/normalizeForNestia.d.ts +34 -0
- package/lib/utils/normalizeForNestia.js +133 -0
- package/lib/utils/normalizeForNestia.js.map +1 -0
- package/lib/utils/resourcePlan.d.ts +39 -0
- package/lib/utils/resourcePlan.js +95 -0
- package/lib/utils/resourcePlan.js.map +1 -0
- package/lib/utils/sliceDocument.d.ts +17 -0
- package/lib/utils/sliceDocument.js +114 -0
- package/lib/utils/sliceDocument.js.map +1 -0
- package/lib/utils/toEndpoints.d.ts +90 -0
- package/lib/utils/toEndpoints.js +227 -0
- package/lib/utils/toEndpoints.js.map +1 -0
- package/lib/verify/runWorkflows.d.ts +25 -0
- package/lib/verify/runWorkflows.js +366 -0
- package/lib/verify/runWorkflows.js.map +1 -0
- package/lib/verify/workflows.d.ts +53 -0
- package/lib/verify/workflows.js +107 -0
- package/lib/verify/workflows.js.map +1 -0
- package/package.json +82 -0
- package/prompts/AUTOVIEW_RENDER.md +398 -0
- package/prompts/AUTOVIEW_REVIEW.md +60 -0
- package/prompts/AUTOVIEW_SDK_STUDY.md +89 -0
- package/src/AutoViewAgent.ts +222 -0
- package/src/agent/emitMcpServer.integration.test.ts +168 -0
- package/src/agent/emitMcpServer.test.ts +51 -0
- package/src/agent/emitMcpServer.ts +178 -0
- package/src/agent/emitReport.ts +117 -0
- package/src/agent/toolSurface.test.ts +243 -0
- package/src/agent/toolSurface.ts +501 -0
- package/src/agent/verifyAgentTasks.test.ts +106 -0
- package/src/agent/verifyAgentTasks.ts +171 -0
- package/src/cli/main.ts +363 -0
- package/src/compiler/AutoViewInterfaceCompiler.ts +69 -0
- package/src/constants/AutoViewFrontendTemplate.ts +42 -0
- package/src/constants/AutoViewSystemPromptConstant.ts +6 -0
- package/src/context/IAutoViewAgentContext.ts +84 -0
- package/src/fromSwagger.test.ts +269 -0
- package/src/fromSwagger.ts +500 -0
- package/src/generateDeterministic.test.ts +39 -0
- package/src/generateDeterministic.ts +77 -0
- package/src/index.ts +30 -0
- package/src/orchestrate/orchestrateAutoView.ts +590 -0
- package/src/orchestrate/orchestrateAutoViewProductPlan.ts +121 -0
- package/src/orchestrate/orchestrateAutoViewRender.ts +1117 -0
- package/src/orchestrate/orchestrateAutoViewRenderDeterministic.ts +101 -0
- package/src/orchestrate/orchestrateAutoViewReview.ts +272 -0
- package/src/orchestrate/orchestrateAutoViewScaffold.ts +627 -0
- package/src/orchestrate/orchestrateAutoViewSdkStudy.ts +90 -0
- package/src/orchestrate/renderNavTs.test.ts +74 -0
- package/src/orchestrate/structures/IAutoViewProductPlan.ts +119 -0
- package/src/orchestrate/structures/IAutoViewProductPlanApplication.ts +41 -0
- package/src/orchestrate/structures/IAutoViewRenderApplication.ts +40 -0
- package/src/orchestrate/structures/IAutoViewReviewApplication.ts +42 -0
- package/src/orchestrate/structures/IAutoViewSdkMap.ts +72 -0
- package/src/orchestrate/structures/IAutoViewSdkStudyApplication.ts +40 -0
- package/src/orchestrate/utils/HistoryMessage.ts +41 -0
- package/src/orchestrate/utils/auditFrontendRuntime.test.ts +18 -0
- package/src/orchestrate/utils/auditFrontendRuntime.ts +454 -0
- package/src/orchestrate/utils/buildDeterministicPlan.test.ts +170 -0
- package/src/orchestrate/utils/buildDeterministicPlan.ts +289 -0
- package/src/orchestrate/utils/buildDeterministicSdkMap.test.ts +90 -0
- package/src/orchestrate/utils/buildDeterministicSdkMap.ts +169 -0
- package/src/orchestrate/utils/cacheNodeModules.ts +136 -0
- package/src/orchestrate/utils/describeEndpointPropsShape.test.ts +86 -0
- package/src/orchestrate/utils/describeEndpointPropsShape.ts +202 -0
- package/src/orchestrate/utils/describeEndpointRequestBodyShape.test.ts +87 -0
- package/src/orchestrate/utils/describeEndpointRequestBodyShape.ts +31 -0
- package/src/orchestrate/utils/describeEndpointResponseShape.test.ts +70 -0
- package/src/orchestrate/utils/describeEndpointResponseShape.ts +32 -0
- package/src/orchestrate/utils/executeCachedBatch.ts +59 -0
- package/src/orchestrate/utils/loadShoppingFixture.ts +52 -0
- package/src/orchestrate/utils/normalizeProductPlanPaths.ts +92 -0
- package/src/orchestrate/utils/renderJsonSchema.test.ts +162 -0
- package/src/orchestrate/utils/renderJsonSchema.ts +133 -0
- package/src/orchestrate/utils/renderResourcePage.test.ts +468 -0
- package/src/orchestrate/utils/renderResourcePage.ts +1624 -0
- package/src/orchestrate/utils/validateFrontendTypecheck.test.ts +32 -0
- package/src/orchestrate/utils/validateFrontendTypecheck.ts +335 -0
- package/src/preview/renderPreview.ts +273 -0
- package/src/typings/compiler.ts +47 -0
- package/src/typings/events.ts +155 -0
- package/src/typings/index.ts +10 -0
- package/src/typings/misc.ts +93 -0
- package/src/utils/ArrayUtil.ts +16 -0
- package/src/utils/StringUtil.ts +29 -0
- package/src/utils/classifyEndpoints.test.ts +86 -0
- package/src/utils/classifyEndpoints.ts +291 -0
- package/src/utils/endpointFilter.test.ts +50 -0
- package/src/utils/endpointFilter.ts +0 -0
- package/src/utils/extractFields.test.ts +82 -0
- package/src/utils/extractFields.ts +306 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/normalizeForNestia.test.ts +93 -0
- package/src/utils/normalizeForNestia.ts +139 -0
- package/src/utils/resourcePlan.test.ts +104 -0
- package/src/utils/resourcePlan.ts +180 -0
- package/src/utils/sliceDocument.test.ts +85 -0
- package/src/utils/sliceDocument.ts +119 -0
- package/src/utils/toEndpoints.test.ts +251 -0
- package/src/utils/toEndpoints.ts +343 -0
- package/src/verify/runWorkflows.ts +403 -0
- package/src/verify/workflows.test.ts +117 -0
- package/src/verify/workflows.ts +154 -0
- package/template/CLAUDE.md +140 -0
- package/template/Dockerfile +31 -0
- package/template/PROMPT.md +80 -0
- package/template/SANDBOX.md +70 -0
- package/template/app/api/health/route.ts +10 -0
- package/template/app/globals.css +97 -0
- package/template/app/layout.tsx +30 -0
- package/template/app/page.tsx +19 -0
- package/template/components/AppShell.tsx +114 -0
- package/template/components/auto/CatalogGrid.tsx +159 -0
- package/template/components/auto/ConfirmButton.tsx +67 -0
- package/template/components/auto/EmbeddedCollection.tsx +144 -0
- package/template/components/auto/ResourceDashboard.tsx +104 -0
- package/template/components/auto/ResourceDetail.tsx +93 -0
- package/template/components/auto/ResourceForm.tsx +235 -0
- package/template/components/auto/ResourceIcon.tsx +88 -0
- package/template/components/auto/ResourceLanding.tsx +155 -0
- package/template/components/auto/ResourceTable.tsx +223 -0
- package/template/components/auto/formatValue.tsx +186 -0
- package/template/components/auto/types.ts +42 -0
- package/template/components/ui/badge.tsx +40 -0
- package/template/components/ui/button.tsx +57 -0
- package/template/components/ui/card.tsx +86 -0
- package/template/components/ui/dialog.tsx +119 -0
- package/template/components/ui/input.tsx +23 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/pagination.tsx +117 -0
- package/template/components/ui/select.tsx +92 -0
- package/template/components/ui/sheet.tsx +135 -0
- package/template/components/ui/skeleton.tsx +15 -0
- package/template/components/ui/table.tsx +120 -0
- package/template/components/ui/tabs.tsx +55 -0
- package/template/lib/utils.ts +35 -0
- package/template/next.config.mjs +52 -0
- package/template/package.json +46 -0
- package/template/postcss.config.js +6 -0
- package/template/scripts/start-shopping-backend.sh +56 -0
- package/template/tailwind.config.ts +96 -0
- package/template/tsconfig.json +29 -0
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
import { OpenApi } from "@typia/interface";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractFields,
|
|
5
|
+
extractFieldsFromSchema,
|
|
6
|
+
findCollectionField,
|
|
7
|
+
IFieldSpec,
|
|
8
|
+
imageFieldOf,
|
|
9
|
+
resolveProperties,
|
|
10
|
+
titleFieldOf,
|
|
11
|
+
} from "../../utils/extractFields";
|
|
12
|
+
import { IAutoViewEndpoint } from "../../utils/toEndpoints";
|
|
13
|
+
import { IAutoViewProductPlan } from "../structures/IAutoViewProductPlan";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deterministic page generator — the replacement for the LLM Render phase.
|
|
17
|
+
*
|
|
18
|
+
* Given one planned screen and the operations it composes, emit a complete,
|
|
19
|
+
* runnable `page.tsx` that wires the typed SDK call to one of the universal
|
|
20
|
+
* `Resource*` components (table / detail / form / landing). The component set is
|
|
21
|
+
* static (shipped in the template); this only produces the thin per-route page:
|
|
22
|
+
*
|
|
23
|
+
* - column / field metadata baked from {@link extractFields} (every property of
|
|
24
|
+
* the row / response / request type → one column / row / input — dense and
|
|
25
|
+
* complete by construction, never an LLM-chosen subset),
|
|
26
|
+
* - a typed SDK call whose `props` argument is cast to
|
|
27
|
+
* `Parameters<typeof api.functional.X.method>[1]` so the generated project
|
|
28
|
+
* typechecks against Nestia's exact generated types without this generator
|
|
29
|
+
* needing to import them.
|
|
30
|
+
*
|
|
31
|
+
* Same swagger → same pages, every run. No LLM, no retries, no typecheck
|
|
32
|
+
* roulette.
|
|
33
|
+
*/
|
|
34
|
+
export function renderResourcePage(
|
|
35
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
36
|
+
endpoints: IAutoViewEndpoint[],
|
|
37
|
+
document: OpenApi.IDocument,
|
|
38
|
+
allScreens: IAutoViewProductPlan.IScreen[],
|
|
39
|
+
): string {
|
|
40
|
+
switch (screen.uiPattern) {
|
|
41
|
+
case "landing":
|
|
42
|
+
return renderLanding(screen, allScreens, endpoints, document);
|
|
43
|
+
case "table":
|
|
44
|
+
return renderTable(screen, endpoints, document, allScreens);
|
|
45
|
+
case "catalog":
|
|
46
|
+
return renderCatalog(screen, endpoints, document, allScreens);
|
|
47
|
+
case "detail":
|
|
48
|
+
return renderDetail(screen, endpoints, document, allScreens);
|
|
49
|
+
case "form":
|
|
50
|
+
return renderForm(screen, endpoints, document);
|
|
51
|
+
default:
|
|
52
|
+
// dashboard / wizard not modeled deterministically yet — fall back to a
|
|
53
|
+
// table off the primary endpoint so the screen still shows real data.
|
|
54
|
+
return renderTable(screen, endpoints, document, allScreens);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* -------------------------------------------------------------------------- */
|
|
59
|
+
/* shared helpers */
|
|
60
|
+
/* -------------------------------------------------------------------------- */
|
|
61
|
+
|
|
62
|
+
const lit = (value: unknown): string => JSON.stringify(value);
|
|
63
|
+
|
|
64
|
+
/** A JS property name reachable with dot notation. */
|
|
65
|
+
const JS_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Emit a member access that is valid even when the key is not a JS identifier.
|
|
69
|
+
* Inline response collections key the array off whatever the API named it, and
|
|
70
|
+
* real swaggers use names like `1-clicks` (DigitalOcean) that nestia sanitizes
|
|
71
|
+
* in the SDK accessor (`_1_clicks`) but that survive verbatim as the response
|
|
72
|
+
* object key (`res["1_clicks"]`). Dot access there is a hard syntax error, so
|
|
73
|
+
* fall back to bracket access for any non-identifier key.
|
|
74
|
+
*/
|
|
75
|
+
function memberOf(base: string, key: string): string {
|
|
76
|
+
return JS_IDENT.test(key) ? `${base}.${key}` : `${base}[${lit(key)}]`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Turn an accessor segment (`cancel`, `bulkErase`) into a button label. */
|
|
80
|
+
function titleizeSegment(segment: string): string {
|
|
81
|
+
const spaced = segment
|
|
82
|
+
.replace(/[_-]+/g, " ")
|
|
83
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
84
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Primary operation for a screen = the first accessor in `endpoints`. */
|
|
88
|
+
function primaryOp(
|
|
89
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
90
|
+
endpoints: IAutoViewEndpoint[],
|
|
91
|
+
): IAutoViewEndpoint | null {
|
|
92
|
+
const primary = screen.endpoints[0];
|
|
93
|
+
if (primary === undefined) return null;
|
|
94
|
+
return (
|
|
95
|
+
endpoints.find((op) => op.accessor.join(".") === primary) ?? null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Field names that identify a record — shown first in a dense table. */
|
|
100
|
+
const LABEL_NAMES = new Set([
|
|
101
|
+
"name",
|
|
102
|
+
"title",
|
|
103
|
+
"label",
|
|
104
|
+
"code",
|
|
105
|
+
"nickname",
|
|
106
|
+
"email",
|
|
107
|
+
"status",
|
|
108
|
+
"state",
|
|
109
|
+
"type",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/** Column display rank: readable scalars first, heavy nested shapes last. */
|
|
113
|
+
function columnRank(f: IFieldSpec): number {
|
|
114
|
+
const n = f.name.toLowerCase();
|
|
115
|
+
if (LABEL_NAMES.has(n)) return 0;
|
|
116
|
+
if (n === "id" || n.endsWith("_id")) return 1;
|
|
117
|
+
if (f.kind === "enum" || f.kind === "boolean") return 2;
|
|
118
|
+
if (f.kind === "number") return 3;
|
|
119
|
+
if (f.kind === "string") return 4; // includes dates
|
|
120
|
+
// ref / object / array / union / unknown — the noisy `{…}` / count cells last.
|
|
121
|
+
return 6;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Order table columns so the human-readable fields (name, status, code, id,
|
|
126
|
+
* enums, dates) come first and the heavy nested shapes (refs, objects, arrays)
|
|
127
|
+
* trail at the end. Stable within a rank, so the swagger's field order is kept
|
|
128
|
+
* as the tiebreaker. Keeps EVERY column (density) but makes the left edge — what
|
|
129
|
+
* the user sees without scrolling — the useful part.
|
|
130
|
+
*/
|
|
131
|
+
function orderColumns(fields: IFieldSpec[]): IFieldSpec[] {
|
|
132
|
+
return fields
|
|
133
|
+
.map((f, i) => ({ f, i }))
|
|
134
|
+
.sort((a, b) => columnRank(a.f) - columnRank(b.f) || a.i - b.i)
|
|
135
|
+
.map((x) => x.f);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Subset of a field spec carried into the page as a `ColumnSpec` literal. */
|
|
139
|
+
/** A type name's base, dropping the variant suffix: `IShoppingSale.ISummary`
|
|
140
|
+
* → `IShoppingSale`, so a ref field's variant still resolves to its resource. */
|
|
141
|
+
function baseTypeName(name: string): string {
|
|
142
|
+
return name.split(".")[0] ?? name;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Map a record TYPE to the list route whose detail screen renders it, so a ref
|
|
147
|
+
* field holding that type can link to its detail page. Built from the detail
|
|
148
|
+
* screens' primary response types — purely structural, no domain knowledge.
|
|
149
|
+
*/
|
|
150
|
+
function resourceLinkIndex(
|
|
151
|
+
allScreens: IAutoViewProductPlan.IScreen[],
|
|
152
|
+
endpoints: IAutoViewEndpoint[],
|
|
153
|
+
): Map<string, string> {
|
|
154
|
+
const index = new Map<string, string>();
|
|
155
|
+
for (const s of allScreens) {
|
|
156
|
+
if (s.uiPattern !== "detail") continue;
|
|
157
|
+
const typeName = primaryOp(s, endpoints)?.responseBody?.typeName;
|
|
158
|
+
if (typeName === undefined) continue;
|
|
159
|
+
const base = s.path.replace(/\/\[[^\]]+\]$/, ""); // /sales/[id] → /sales
|
|
160
|
+
// Only top-level resources: a nested detail's base still carries unfilled
|
|
161
|
+
// parent brackets (`/channels/[channelsId]/categories`), which would make
|
|
162
|
+
// `${base}/${id}` a broken bracket href. Link only to clean routes.
|
|
163
|
+
if (base.includes("[")) continue;
|
|
164
|
+
const key = baseTypeName(typeName);
|
|
165
|
+
if (!index.has(key)) index.set(key, base);
|
|
166
|
+
}
|
|
167
|
+
return index;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toColumnSpec(
|
|
171
|
+
f: IFieldSpec,
|
|
172
|
+
linkIndex?: Map<string, string>,
|
|
173
|
+
): Record<string, unknown> {
|
|
174
|
+
const spec: Record<string, unknown> = { name: f.name, kind: f.kind };
|
|
175
|
+
if (f.format !== undefined) spec.format = f.format;
|
|
176
|
+
if (f.ref !== undefined) spec.ref = f.ref;
|
|
177
|
+
if (f.itemKind !== undefined) spec.itemKind = f.itemKind;
|
|
178
|
+
if (f.enumValues !== undefined) spec.enumValues = f.enumValues;
|
|
179
|
+
// A ref whose type resolves to a browsable resource becomes a link to that
|
|
180
|
+
// resource's detail (`/sellers/${value.id}`). Skipped for catalog cards
|
|
181
|
+
// (which are already wrapped in a link) by not passing an index there.
|
|
182
|
+
if (linkIndex !== undefined && f.kind === "ref" && f.ref !== undefined) {
|
|
183
|
+
const base = linkIndex.get(baseTypeName(f.ref));
|
|
184
|
+
if (base !== undefined) spec.hrefBase = base;
|
|
185
|
+
}
|
|
186
|
+
return spec;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toFieldInput(f: IFieldSpec): Record<string, unknown> {
|
|
190
|
+
return { ...toColumnSpec(f), required: f.required };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A runtime default literal for a required field, so live calls don't 400. */
|
|
194
|
+
function defaultLiteralForField(f: IFieldSpec): string {
|
|
195
|
+
switch (f.kind) {
|
|
196
|
+
case "number":
|
|
197
|
+
return "0";
|
|
198
|
+
case "boolean":
|
|
199
|
+
return "false";
|
|
200
|
+
case "enum":
|
|
201
|
+
return f.enumValues && f.enumValues.length > 0
|
|
202
|
+
? lit(f.enumValues[0])
|
|
203
|
+
: '""';
|
|
204
|
+
case "string":
|
|
205
|
+
return '""';
|
|
206
|
+
case "array":
|
|
207
|
+
return "[]";
|
|
208
|
+
default:
|
|
209
|
+
return "{}";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Required-field defaults for a named component schema, as an object literal. */
|
|
214
|
+
function bodyDefaultsLiteral(typeName: string, doc: OpenApi.IDocument): string {
|
|
215
|
+
const required = extractFields(typeName, doc).filter((f) => f.required);
|
|
216
|
+
if (required.length === 0) return "{}";
|
|
217
|
+
const parts = required.map(
|
|
218
|
+
(f) => `${lit(f.name)}: ${defaultLiteralForField(f)}`,
|
|
219
|
+
);
|
|
220
|
+
return `{ ${parts.join(", ")} }`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A default literal for a query schema property. */
|
|
224
|
+
function defaultFromSchema(
|
|
225
|
+
schema: OpenApi.IJsonSchema,
|
|
226
|
+
doc?: OpenApi.IDocument,
|
|
227
|
+
): string {
|
|
228
|
+
// A schema-declared `default` wins — it is the value the API author intended
|
|
229
|
+
// (e.g. petstore's `status` defaults to `available`), and a missing query
|
|
230
|
+
// param the server treats as required would otherwise 400.
|
|
231
|
+
const declared = (schema as { default?: unknown }).default;
|
|
232
|
+
if (declared !== undefined) return lit(declared as string | number | boolean);
|
|
233
|
+
// A bare `const` (a single-value enum / discriminant, `{ const: "pending" }`)
|
|
234
|
+
// is itself the concrete value.
|
|
235
|
+
if ("const" in schema)
|
|
236
|
+
return lit((schema as { const: string | number | boolean }).const);
|
|
237
|
+
if ("$ref" in schema && typeof schema.$ref === "string") {
|
|
238
|
+
// Resolve a named enum/type so its value can be read, when we have the doc.
|
|
239
|
+
const target = doc?.components?.schemas?.[schema.$ref.split("/").pop() ?? ""];
|
|
240
|
+
return target !== undefined ? defaultFromSchema(target, doc) : "{}";
|
|
241
|
+
}
|
|
242
|
+
if ("oneOf" in schema && Array.isArray(schema.oneOf)) {
|
|
243
|
+
// upgradeDocument normalizes a string enum into `oneOf: [{ const }, …]` —
|
|
244
|
+
// the first non-null const is a safe concrete value.
|
|
245
|
+
for (const branch of schema.oneOf) {
|
|
246
|
+
if (branch && "const" in branch) return lit(branch.const);
|
|
247
|
+
}
|
|
248
|
+
return "{}";
|
|
249
|
+
}
|
|
250
|
+
if (!("type" in schema)) return "{}";
|
|
251
|
+
switch (schema.type) {
|
|
252
|
+
case "boolean":
|
|
253
|
+
return "false";
|
|
254
|
+
case "integer":
|
|
255
|
+
case "number":
|
|
256
|
+
return "0";
|
|
257
|
+
case "string": {
|
|
258
|
+
const enumValues = (schema as { enum?: Array<string | number> }).enum;
|
|
259
|
+
if (Array.isArray(enumValues) && enumValues.length > 0)
|
|
260
|
+
return lit(enumValues[0]);
|
|
261
|
+
return '""';
|
|
262
|
+
}
|
|
263
|
+
case "array":
|
|
264
|
+
return "[]";
|
|
265
|
+
default:
|
|
266
|
+
return "{}";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** True when a property carries a usable default-ish value (default or enum). */
|
|
271
|
+
function hasUsableDefault(schema: OpenApi.IJsonSchema): boolean {
|
|
272
|
+
if ((schema as { default?: unknown }).default !== undefined) return true;
|
|
273
|
+
if ("const" in schema) return true;
|
|
274
|
+
const enumValues = (schema as { enum?: unknown[] }).enum;
|
|
275
|
+
if (Array.isArray(enumValues) && enumValues.length > 0) return true;
|
|
276
|
+
if ("oneOf" in schema && Array.isArray(schema.oneOf))
|
|
277
|
+
return schema.oneOf.some((b) => b && "const" in b);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Default literal for an SDK call's `query` object. Resolves `$ref` / `allOf`
|
|
283
|
+
* (nestia promotes a query into a named component like `IApiX.GetQuery`, so the
|
|
284
|
+
* schema is a `$ref` with no inline `properties`), then fills every property
|
|
285
|
+
* that is REQUIRED or carries a schema default. The latter matters because a
|
|
286
|
+
* spec-optional param with a `default` is often server-required (petstore's
|
|
287
|
+
* `status`) — sending it keeps the list call from 400-ing on first load.
|
|
288
|
+
*/
|
|
289
|
+
function queryDefaultsLiteral(
|
|
290
|
+
schema: OpenApi.IJsonSchema,
|
|
291
|
+
doc: OpenApi.IDocument,
|
|
292
|
+
): string {
|
|
293
|
+
const { properties, required } = resolveProperties(schema, doc);
|
|
294
|
+
const requiredSet = new Set(required);
|
|
295
|
+
const parts: string[] = [];
|
|
296
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
297
|
+
if (!requiredSet.has(name) && !hasUsableDefault(prop)) continue;
|
|
298
|
+
parts.push(`${lit(name)}: ${defaultFromSchema(prop, doc)}`);
|
|
299
|
+
}
|
|
300
|
+
return parts.length > 0 ? `{ ${parts.join(", ")} }` : "{}";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Build the `props` object literal for an SDK call.
|
|
305
|
+
*
|
|
306
|
+
* - path parameters reference the `const <name>` variables the page declares
|
|
307
|
+
* from `useParams()`,
|
|
308
|
+
* - `query` / `body` are filled with required-field defaults (or, for a form,
|
|
309
|
+
* the live `values` object),
|
|
310
|
+
*
|
|
311
|
+
* Returns `null` when the operation takes neither params, query, nor body — the
|
|
312
|
+
* caller then emits `fn(connection)` with no second argument (Nestia drops it).
|
|
313
|
+
*/
|
|
314
|
+
function buildProps(
|
|
315
|
+
op: IAutoViewEndpoint,
|
|
316
|
+
doc: OpenApi.IDocument,
|
|
317
|
+
opts: { bodyExpr?: string } = {},
|
|
318
|
+
): string | null {
|
|
319
|
+
const parts: string[] = [];
|
|
320
|
+
for (const p of op.parameters) parts.push(`${p.name}: ${p.name}`);
|
|
321
|
+
if (op.query !== null) parts.push(`query: ${queryDefaultsLiteral(op.query, doc)}`);
|
|
322
|
+
if (op.requestBody !== null) {
|
|
323
|
+
parts.push(
|
|
324
|
+
`body: ${opts.bodyExpr ?? bodyDefaultsLiteral(op.requestBody.typeName, doc)}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return parts.length > 0 ? `{ ${parts.join(", ")} }` : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* `api.functional.<accessor>(connection[, props])`. The props argument is cast
|
|
332
|
+
* through `unknown` to `Parameters<typeof fn>[1]` — a path param sourced from
|
|
333
|
+
* `useParams()` is a string, while the SDK may type an id as `number`, and a
|
|
334
|
+
* form body is a `Record`, while the SDK types it as a specific interface.
|
|
335
|
+
* Casting through `unknown` guarantees the generated project compiles against
|
|
336
|
+
* ANY swagger's exact Nestia types; the values themselves are schema-correct
|
|
337
|
+
* (numeric params are coerced with `Number(...)`, bodies use schema defaults).
|
|
338
|
+
*/
|
|
339
|
+
function buildCall(op: IAutoViewEndpoint, props: string | null): string {
|
|
340
|
+
const fn = `api.functional.${op.accessor.join(".")}`;
|
|
341
|
+
if (props === null) return `${fn}(connection)`;
|
|
342
|
+
return `${fn}(connection, (${props}) as unknown as Parameters<typeof ${fn}>[1])`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Dynamic route segment names in path order: `/sales/[id]/questions` → ["id"]. */
|
|
346
|
+
function routeParamNames(routePath: string): string[] {
|
|
347
|
+
return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]!);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Map each of an operation's path params to a route segment, aligned to the
|
|
352
|
+
* TAIL of the route. The route segment name and the SDK param name can differ —
|
|
353
|
+
* the route is `/sales/[id]/questions` while the SDK function expects `saleId` —
|
|
354
|
+
* so we map by position rather than by name. Tail-alignment matters when the
|
|
355
|
+
* screen route carries more `[segment]`s than the op has params: a resource may
|
|
356
|
+
* be nested under an ancestor it does not parametrize (GitHub's flat
|
|
357
|
+
* `/projects/columns/{column_id}` rendered under
|
|
358
|
+
* `/projects/[projectsId]/columns/[columnsId]`). The op's own params are always
|
|
359
|
+
* the DEEPEST segments, so the leading ancestor segments are the unmapped ones —
|
|
360
|
+
* never the op's id, which would otherwise be sourced from the wrong segment.
|
|
361
|
+
*/
|
|
362
|
+
function paramSourceMap(
|
|
363
|
+
op: IAutoViewEndpoint,
|
|
364
|
+
routePath: string,
|
|
365
|
+
): Map<string, string> {
|
|
366
|
+
const routeNames = routeParamNames(routePath);
|
|
367
|
+
const offset = Math.max(0, routeNames.length - op.parameters.length);
|
|
368
|
+
const map = new Map<string, string>();
|
|
369
|
+
op.parameters.forEach((p, i) =>
|
|
370
|
+
map.set(p.name, routeNames[offset + i] ?? p.name),
|
|
371
|
+
);
|
|
372
|
+
return map;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Path param names typed as `integer`/`number` by the schema (so the page
|
|
376
|
+
* coerces them with `Number(...)`, not `String(...)` — DigitalOcean and many
|
|
377
|
+
* real APIs use numeric ids, which `typia.assert` rejects as strings). */
|
|
378
|
+
function numericParams(ops: readonly IAutoViewEndpoint[]): Set<string> {
|
|
379
|
+
const set = new Set<string>();
|
|
380
|
+
for (const op of ops) {
|
|
381
|
+
for (const p of op.parameters) {
|
|
382
|
+
const type = (p.schema as { type?: unknown }).type;
|
|
383
|
+
if (type === "integer" || type === "number") set.add(p.name);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return set;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** `const <opParam> = <Number|String>(params.<routeSegmentAtSamePosition>)`. */
|
|
390
|
+
function declareParams(
|
|
391
|
+
names: readonly string[],
|
|
392
|
+
source: Map<string, string>,
|
|
393
|
+
numeric: Set<string>,
|
|
394
|
+
): string {
|
|
395
|
+
return names
|
|
396
|
+
.map((n) => {
|
|
397
|
+
const src = `(params as Record<string, unknown>).${source.get(n) ?? n}`;
|
|
398
|
+
return numeric.has(n)
|
|
399
|
+
? ` const ${n} = Number(${src});`
|
|
400
|
+
: ` const ${n} = String(${src} ?? "");`;
|
|
401
|
+
})
|
|
402
|
+
.join("\n");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Declarations sourcing every path param of `op` from the route, by position. */
|
|
406
|
+
function paramDeclarations(op: IAutoViewEndpoint, routePath: string): string {
|
|
407
|
+
if (op.parameters.length === 0) return "";
|
|
408
|
+
return declareParams(
|
|
409
|
+
op.parameters.map((p) => p.name),
|
|
410
|
+
paramSourceMap(op, routePath),
|
|
411
|
+
numericParams([op]),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Rewrite a route's `[segment]` brackets into `${var}` interpolations, where
|
|
417
|
+
* `var` is the page's declared variable that sources that segment. Used to build
|
|
418
|
+
* hrefs (edit link, row → detail, parent → child) — the route segment is named
|
|
419
|
+
* by the canonical scheme (`[salesId]`) while the page variable is the SDK param
|
|
420
|
+
* (`saleId`), so we map back through the same positional source map.
|
|
421
|
+
*/
|
|
422
|
+
function substituteRouteParams(
|
|
423
|
+
routePath: string,
|
|
424
|
+
op: IAutoViewEndpoint,
|
|
425
|
+
): string {
|
|
426
|
+
const inverse = new Map<string, string>();
|
|
427
|
+
for (const [opParam, src] of paramSourceMap(op, routePath)) {
|
|
428
|
+
inverse.set(src, opParam);
|
|
429
|
+
}
|
|
430
|
+
return routePath.replace(/\[([^\]]+)\]/g, (_, seg: string) => {
|
|
431
|
+
const mapped = inverse.get(seg);
|
|
432
|
+
// A mapped segment interpolates the page's declared op-param variable. An
|
|
433
|
+
// UNMAPPED segment (a hierarchy ancestor the op does not parametrize) has no
|
|
434
|
+
// such variable — emitting the bare segment name would reference an
|
|
435
|
+
// undeclared identifier (a tsc error). Read it straight from `useParams()`
|
|
436
|
+
// instead, so the href always carries a real value. `params` is in scope
|
|
437
|
+
// wherever a route has `[segment]`s (see `needsParams`).
|
|
438
|
+
return mapped !== undefined
|
|
439
|
+
? "${" + mapped + "}"
|
|
440
|
+
: "${String((params as Record<string, unknown>)[" + lit(seg) + '] ?? "")}';
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* A `router.push` / `Link` target built ENTIRELY from `useParams()` — every
|
|
446
|
+
* `[segment]` reads its value off the route params. Used for the
|
|
447
|
+
* navigate-to-ancestor-list redirect after a delete, where the remaining
|
|
448
|
+
* segments are all ancestors (the entity's own id was stripped) whose values
|
|
449
|
+
* live in `params`, not in any declared op variable. Returns a string literal
|
|
450
|
+
* when the path has no dynamic segment, a template literal otherwise.
|
|
451
|
+
*/
|
|
452
|
+
function routeHrefFromParams(routePath: string): string {
|
|
453
|
+
if (!routePath.includes("[")) return lit(routePath);
|
|
454
|
+
const body = routePath.replace(
|
|
455
|
+
/\[([^\]]+)\]/g,
|
|
456
|
+
(_, seg: string) =>
|
|
457
|
+
"${String((params as Record<string, unknown>)[" + lit(seg) + '] ?? "")}',
|
|
458
|
+
);
|
|
459
|
+
return "`" + body + "`";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* -------------------------------------------------------------------------- */
|
|
463
|
+
/* table */
|
|
464
|
+
/* -------------------------------------------------------------------------- */
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Top-level response fields from the best available source: the named type, or
|
|
468
|
+
* the raw inline `responseSchema` when the named type is empty/dead (nestia's
|
|
469
|
+
* dead `*.GetResponse` ref) or absent entirely (a purely inline response).
|
|
470
|
+
*/
|
|
471
|
+
function responseTopFields(
|
|
472
|
+
op: IAutoViewEndpoint,
|
|
473
|
+
doc: OpenApi.IDocument,
|
|
474
|
+
): IFieldSpec[] {
|
|
475
|
+
const named =
|
|
476
|
+
op.responseBody !== null ? extractFields(op.responseBody.typeName, doc) : [];
|
|
477
|
+
if (named.length > 0) return named;
|
|
478
|
+
return op.responseSchema !== null
|
|
479
|
+
? extractFieldsFromSchema(op.responseSchema, doc)
|
|
480
|
+
: [];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The browsable ROW collection a response carries, or `null` for a single object
|
|
485
|
+
* / void. Shared by the screen classifier (list vs detail) and the table
|
|
486
|
+
* renderer so they never disagree. Recognizes a bare array, a generically named
|
|
487
|
+
* wrapper (`data`/`entries`), a wrapper named after the resource
|
|
488
|
+
* (`{ droplets: [...] }`, DigitalOcean), and an element that is a `$ref` type OR
|
|
489
|
+
* an INLINE object (DO's droplet shape is inline).
|
|
490
|
+
*/
|
|
491
|
+
export function responseCollection(
|
|
492
|
+
op: IAutoViewEndpoint,
|
|
493
|
+
doc: OpenApi.IDocument,
|
|
494
|
+
): { field: string | null; columns: IFieldSpec[] } | null {
|
|
495
|
+
if (op.responseBody !== null && op.responseBody.isArray) {
|
|
496
|
+
const columns = extractFields(op.responseBody.typeName, doc);
|
|
497
|
+
return columns.length > 0 ? { field: null, columns } : null;
|
|
498
|
+
}
|
|
499
|
+
const top = responseTopFields(op, doc);
|
|
500
|
+
if (top.length === 0) return null;
|
|
501
|
+
const dataField =
|
|
502
|
+
findCollectionField(top) ?? top.find((f) => f.kind === "array");
|
|
503
|
+
if (dataField !== undefined) {
|
|
504
|
+
const columns =
|
|
505
|
+
dataField.ref !== undefined
|
|
506
|
+
? extractFields(dataField.ref, doc) // named element type
|
|
507
|
+
: (dataField.itemFields ?? []); // inline element
|
|
508
|
+
if (columns.length > 0) return { field: dataField.name, columns };
|
|
509
|
+
}
|
|
510
|
+
return null; // a single object, not a collection
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolve a list endpoint's table columns + the JS expression that reads the row
|
|
515
|
+
* array off the response. accessExpr always guards null/non-array so the
|
|
516
|
+
* simulator omitting the field cannot crash the table on `rows.length`.
|
|
517
|
+
*/
|
|
518
|
+
function tableResponse(
|
|
519
|
+
op: IAutoViewEndpoint,
|
|
520
|
+
doc: OpenApi.IDocument,
|
|
521
|
+
): { columns: IFieldSpec[]; accessExpr: string } {
|
|
522
|
+
const collection = responseCollection(op, doc);
|
|
523
|
+
if (collection !== null) {
|
|
524
|
+
return {
|
|
525
|
+
columns: collection.columns,
|
|
526
|
+
accessExpr:
|
|
527
|
+
collection.field === null
|
|
528
|
+
? "(Array.isArray(res) ? res : [])"
|
|
529
|
+
: `(${memberOf("res", collection.field)} ?? [])`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
// Single object — show it as a one-row table (or empty when there is nothing).
|
|
533
|
+
const top = responseTopFields(op, doc);
|
|
534
|
+
return top.length > 0
|
|
535
|
+
? { columns: top, accessExpr: "(res ? [res] : [])" }
|
|
536
|
+
: { columns: [], accessExpr: "[]" };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Page size for the generated pager — also the "is there a next page?" guess. */
|
|
540
|
+
const PAGE_SIZE = 20;
|
|
541
|
+
const PAGE_NAMES = new Set(["page", "offset", "skip", "page_number"]);
|
|
542
|
+
const LIMIT_NAMES = new Set([
|
|
543
|
+
"limit",
|
|
544
|
+
"per_page",
|
|
545
|
+
"page_size",
|
|
546
|
+
"pagesize",
|
|
547
|
+
"size",
|
|
548
|
+
"take",
|
|
549
|
+
]);
|
|
550
|
+
const SEARCH_NAMES = new Set([
|
|
551
|
+
"search",
|
|
552
|
+
"q",
|
|
553
|
+
"keyword",
|
|
554
|
+
"query",
|
|
555
|
+
"search_text",
|
|
556
|
+
"term",
|
|
557
|
+
]);
|
|
558
|
+
|
|
559
|
+
interface IReqField {
|
|
560
|
+
name: string;
|
|
561
|
+
location: "query" | "body";
|
|
562
|
+
}
|
|
563
|
+
interface IListControls {
|
|
564
|
+
page?: IReqField & { offsetBased: boolean };
|
|
565
|
+
limit?: IReqField;
|
|
566
|
+
search?: IReqField;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* A schema's effective primitive type, unwrapping the nullable form
|
|
571
|
+
* `upgradeDocument` produces (`oneOf: [{ type: "null" }, { type: "integer" }]`)
|
|
572
|
+
* — so a `page?: number | null` param is still seen as a number.
|
|
573
|
+
*/
|
|
574
|
+
function effectiveType(schema: OpenApi.IJsonSchema): unknown {
|
|
575
|
+
if ("oneOf" in schema && Array.isArray(schema.oneOf)) {
|
|
576
|
+
const real = schema.oneOf.filter(
|
|
577
|
+
(b) => (b as { type?: unknown }).type !== "null",
|
|
578
|
+
);
|
|
579
|
+
if (real.length === 1) return (real[0] as { type?: unknown }).type;
|
|
580
|
+
}
|
|
581
|
+
return (schema as { type?: unknown }).type;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Numeric? a param whose effective type is integer/number. */
|
|
585
|
+
function isNumericSchema(schema: OpenApi.IJsonSchema): boolean {
|
|
586
|
+
const t = effectiveType(schema);
|
|
587
|
+
return t === "integer" || t === "number";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** True when a schema is (possibly nullable) plain string — not an object. */
|
|
591
|
+
function isStringSchema(schema: OpenApi.IJsonSchema): boolean {
|
|
592
|
+
return effectiveType(schema) === "string";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The list-control params an endpoint exposes — a page/offset, a page size, and
|
|
597
|
+
* a text search — found across its query AND request body. Lets the table wire
|
|
598
|
+
* real pagination + search to whatever the swagger calls them (`page`/`offset`,
|
|
599
|
+
* `limit`/`per_page`, `search`/`q`), in either location. Search is only taken
|
|
600
|
+
* when it is a plain string param (shopping's object-shaped `search` is skipped).
|
|
601
|
+
*/
|
|
602
|
+
function requestControls(
|
|
603
|
+
op: IAutoViewEndpoint,
|
|
604
|
+
doc: OpenApi.IDocument,
|
|
605
|
+
): IListControls {
|
|
606
|
+
const fields: Array<IReqField & { schema: OpenApi.IJsonSchema }> = [];
|
|
607
|
+
if (op.query !== null) {
|
|
608
|
+
for (const [name, schema] of Object.entries(
|
|
609
|
+
resolveProperties(op.query, doc).properties,
|
|
610
|
+
))
|
|
611
|
+
fields.push({ name, location: "query", schema });
|
|
612
|
+
}
|
|
613
|
+
if (op.requestBody !== null) {
|
|
614
|
+
const comp = (doc.components?.schemas ?? {})[op.requestBody.typeName];
|
|
615
|
+
if (comp !== undefined) {
|
|
616
|
+
for (const [name, schema] of Object.entries(
|
|
617
|
+
resolveProperties(comp, doc).properties,
|
|
618
|
+
))
|
|
619
|
+
fields.push({ name, location: "body", schema });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const controls: IListControls = {};
|
|
623
|
+
for (const f of fields) {
|
|
624
|
+
const lower = f.name.toLowerCase();
|
|
625
|
+
if (
|
|
626
|
+
controls.page === undefined &&
|
|
627
|
+
PAGE_NAMES.has(lower) &&
|
|
628
|
+
isNumericSchema(f.schema)
|
|
629
|
+
) {
|
|
630
|
+
controls.page = {
|
|
631
|
+
name: f.name,
|
|
632
|
+
location: f.location,
|
|
633
|
+
offsetBased: lower === "offset" || lower === "skip",
|
|
634
|
+
};
|
|
635
|
+
} else if (
|
|
636
|
+
controls.limit === undefined &&
|
|
637
|
+
LIMIT_NAMES.has(lower) &&
|
|
638
|
+
isNumericSchema(f.schema)
|
|
639
|
+
) {
|
|
640
|
+
controls.limit = { name: f.name, location: f.location };
|
|
641
|
+
} else if (
|
|
642
|
+
controls.search === undefined &&
|
|
643
|
+
SEARCH_NAMES.has(lower) &&
|
|
644
|
+
isStringSchema(f.schema)
|
|
645
|
+
) {
|
|
646
|
+
controls.search = { name: f.name, location: f.location };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return controls;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function renderTable(
|
|
653
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
654
|
+
endpoints: IAutoViewEndpoint[],
|
|
655
|
+
doc: OpenApi.IDocument,
|
|
656
|
+
allScreens: IAutoViewProductPlan.IScreen[],
|
|
657
|
+
): string {
|
|
658
|
+
const op = primaryOp(screen, endpoints);
|
|
659
|
+
if (op === null) return renderLanding(screen, allScreens, endpoints, doc);
|
|
660
|
+
|
|
661
|
+
const { columns: rawColumns, accessExpr } = tableResponse(op, doc);
|
|
662
|
+
const columns = orderColumns(rawColumns);
|
|
663
|
+
const linkIndex = resourceLinkIndex(allScreens, endpoints);
|
|
664
|
+
const columnsLit = `[\n${columns
|
|
665
|
+
.map((c) => ` ${lit(toColumnSpec(c, linkIndex))}`)
|
|
666
|
+
.join(",\n")}\n]`;
|
|
667
|
+
|
|
668
|
+
// `|| path has [segment]`: even when this list op takes no path param, an
|
|
669
|
+
// ancestor segment in the route is read from `useParams()` to build row/child
|
|
670
|
+
// hrefs (see substituteRouteParams), so `params` must be declared.
|
|
671
|
+
const needsParams =
|
|
672
|
+
op.parameters.length > 0 || screen.path.includes("[");
|
|
673
|
+
const controls = requestControls(op, doc);
|
|
674
|
+
const hasPage = controls.page !== undefined;
|
|
675
|
+
const hasSearch = controls.search !== undefined;
|
|
676
|
+
const hasControls = hasPage || hasSearch;
|
|
677
|
+
|
|
678
|
+
// Build the SDK call. With pagination/search, merge the page/search STATE into
|
|
679
|
+
// the request (query or body — wherever the param lives); otherwise the plain
|
|
680
|
+
// schema defaults.
|
|
681
|
+
let call: string;
|
|
682
|
+
if (hasControls) {
|
|
683
|
+
const pageValue = controls.page
|
|
684
|
+
? controls.page.offsetBased
|
|
685
|
+
? "(page - 1) * PAGE_SIZE"
|
|
686
|
+
: "page"
|
|
687
|
+
: null;
|
|
688
|
+
const overridesFor = (loc: "query" | "body"): string[] => {
|
|
689
|
+
const out: string[] = [];
|
|
690
|
+
if (controls.page?.location === loc && pageValue !== null)
|
|
691
|
+
out.push(`${lit(controls.page.name)}: ${pageValue}`);
|
|
692
|
+
if (controls.limit?.location === loc)
|
|
693
|
+
out.push(`${lit(controls.limit.name)}: PAGE_SIZE`);
|
|
694
|
+
if (controls.search?.location === loc)
|
|
695
|
+
out.push(`${lit(controls.search.name)}: search`);
|
|
696
|
+
return out;
|
|
697
|
+
};
|
|
698
|
+
const parts: string[] = op.parameters.map((p) => `${p.name}: ${p.name}`);
|
|
699
|
+
if (op.query !== null) {
|
|
700
|
+
const defaults = queryDefaultsLiteral(op.query, doc);
|
|
701
|
+
const ov = overridesFor("query");
|
|
702
|
+
parts.push(
|
|
703
|
+
`query: ${ov.length > 0 ? `{ ...${defaults}, ${ov.join(", ")} }` : defaults}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (op.requestBody !== null) {
|
|
707
|
+
const defaults = bodyDefaultsLiteral(op.requestBody.typeName, doc);
|
|
708
|
+
const ov = overridesFor("body");
|
|
709
|
+
parts.push(
|
|
710
|
+
`body: ${ov.length > 0 ? `{ ...${defaults}, ${ov.join(", ")} }` : defaults}`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
call = buildCall(op, parts.length > 0 ? `{ ${parts.join(", ")} }` : null);
|
|
714
|
+
} else {
|
|
715
|
+
call = buildCall(op, buildProps(op, doc));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// A nested sub-collection (e.g. `/sales/{saleId}/reviews`) whose route does
|
|
719
|
+
// not carry those path params cannot be fetched standalone — firing the call
|
|
720
|
+
// with empty ids would 400/404 and show a scary error card. Render the schema
|
|
721
|
+
// (dense column headers — still on-target for "complete tables") with an
|
|
722
|
+
// honest empty state, and skip the doomed fetch entirely.
|
|
723
|
+
const unsatisfiable = needsParams && !screen.path.includes("[");
|
|
724
|
+
if (unsatisfiable) {
|
|
725
|
+
const parents = op.parameters.map((p) => p.name).join(", ");
|
|
726
|
+
return renderStaticTable(
|
|
727
|
+
screen,
|
|
728
|
+
columnsLit,
|
|
729
|
+
`This is a nested collection — it needs a parent (${parents}). Open the parent record to view its ${screen.title.toLowerCase()}.`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Row → detail link, when a detail screen exists for this resource. The
|
|
734
|
+
// parent segments of the list route (`/sales/[salesId]/questions`) are
|
|
735
|
+
// substituted with the page's param vars; the row's own id is appended.
|
|
736
|
+
const detailScreen = allScreens.find(
|
|
737
|
+
(s) => s.uiPattern === "detail" && s.path.startsWith(`${screen.path}/[`),
|
|
738
|
+
);
|
|
739
|
+
const rowBase = substituteRouteParams(screen.path, op);
|
|
740
|
+
const rowHref = detailScreen
|
|
741
|
+
? `\n rowHref={(row) => {\n const id = row.id;\n return id === undefined || id === null ? null : ${"`"}${rowBase}/${"${String(id)}"}${"`"};\n }}`
|
|
742
|
+
: "";
|
|
743
|
+
|
|
744
|
+
const paramHook = needsParams
|
|
745
|
+
? ` const params = useParams();\n${paramDeclarations(op, screen.path)}\n`
|
|
746
|
+
: "";
|
|
747
|
+
const depExtra = [hasPage ? "page" : null, hasSearch ? "search" : null]
|
|
748
|
+
.filter((s): s is string => s !== null)
|
|
749
|
+
.join(", ");
|
|
750
|
+
const baseDeps = needsParams
|
|
751
|
+
? `tick, ${op.parameters.map((p) => p.name).join(", ")}`
|
|
752
|
+
: "tick";
|
|
753
|
+
const effectDeps = `[${baseDeps}${depExtra ? `, ${depExtra}` : ""}]`;
|
|
754
|
+
|
|
755
|
+
// Typing a search resets to page 1 so results start from the top.
|
|
756
|
+
const searchHandler = hasPage
|
|
757
|
+
? "(v) => { setPage(1); setSearch(v); }"
|
|
758
|
+
: "(v) => setSearch(v)";
|
|
759
|
+
const searchProp = hasSearch
|
|
760
|
+
? `\n search={{ value: search, onChange: ${searchHandler} }}`
|
|
761
|
+
: "";
|
|
762
|
+
const paginationProp = hasPage
|
|
763
|
+
? `\n pagination={{ page, onPrev: () => setPage((p) => Math.max(1, p - 1)), onNext: () => setPage((p) => p + 1), hasNext }}`
|
|
764
|
+
: "";
|
|
765
|
+
|
|
766
|
+
return [
|
|
767
|
+
`"use client";`,
|
|
768
|
+
``,
|
|
769
|
+
`import * as React from "react";`,
|
|
770
|
+
needsParams ? `import { useParams } from "next/navigation";` : null,
|
|
771
|
+
``,
|
|
772
|
+
`import api from "@/src/lib/api";`,
|
|
773
|
+
`import { connection } from "@/src/lib/connection";`,
|
|
774
|
+
`import { ResourceTable } from "@/components/auto/ResourceTable";`,
|
|
775
|
+
`import type { ColumnSpec } from "@/components/auto/types";`,
|
|
776
|
+
``,
|
|
777
|
+
`const COLUMNS: ColumnSpec[] = ${columnsLit};`,
|
|
778
|
+
hasControls ? `const PAGE_SIZE = ${PAGE_SIZE};` : null,
|
|
779
|
+
``,
|
|
780
|
+
`export default function Page() {`,
|
|
781
|
+
paramHook ? paramHook : null,
|
|
782
|
+
` const [rows, setRows] = React.useState<readonly unknown[]>([]);`,
|
|
783
|
+
` const [loading, setLoading] = React.useState(true);`,
|
|
784
|
+
` const [error, setError] = React.useState<string | null>(null);`,
|
|
785
|
+
` const [tick, setTick] = React.useState(0);`,
|
|
786
|
+
hasPage ? ` const [page, setPage] = React.useState(1);` : null,
|
|
787
|
+
hasPage ? ` const [hasNext, setHasNext] = React.useState(false);` : null,
|
|
788
|
+
hasSearch ? ` const [search, setSearch] = React.useState("");` : null,
|
|
789
|
+
``,
|
|
790
|
+
` React.useEffect(() => {`,
|
|
791
|
+
` let alive = true;`,
|
|
792
|
+
` setLoading(true);`,
|
|
793
|
+
` setError(null);`,
|
|
794
|
+
` ${call}`,
|
|
795
|
+
` .then((res) => {`,
|
|
796
|
+
` if (!alive) return;`,
|
|
797
|
+
` const data = ${accessExpr} as readonly unknown[];`,
|
|
798
|
+
` setRows(data);`,
|
|
799
|
+
hasPage ? ` setHasNext(data.length >= PAGE_SIZE);` : null,
|
|
800
|
+
` })`,
|
|
801
|
+
` .catch((e) => {`,
|
|
802
|
+
` if (alive) setError(e instanceof Error ? e.message : String(e));`,
|
|
803
|
+
` })`,
|
|
804
|
+
` .finally(() => {`,
|
|
805
|
+
` if (alive) setLoading(false);`,
|
|
806
|
+
` });`,
|
|
807
|
+
` return () => {`,
|
|
808
|
+
` alive = false;`,
|
|
809
|
+
` };`,
|
|
810
|
+
` }, ${effectDeps});`,
|
|
811
|
+
``,
|
|
812
|
+
` return (`,
|
|
813
|
+
` <ResourceTable`,
|
|
814
|
+
` title=${lit(screen.title)}`,
|
|
815
|
+
` description=${lit(screen.purpose)}`,
|
|
816
|
+
` columns={COLUMNS}`,
|
|
817
|
+
` rows={rows}`,
|
|
818
|
+
` loading={loading}`,
|
|
819
|
+
` error={error}`,
|
|
820
|
+
` onRetry={() => setTick((t) => t + 1)}${rowHref}${searchProp}${paginationProp}`,
|
|
821
|
+
` />`,
|
|
822
|
+
` );`,
|
|
823
|
+
`}`,
|
|
824
|
+
``,
|
|
825
|
+
]
|
|
826
|
+
.filter((line): line is string => line !== null)
|
|
827
|
+
.join("\n");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* A table that shows its schema (column headers) but fetches nothing — used for
|
|
832
|
+
* nested sub-collections that cannot be loaded without a parent selection. Keeps
|
|
833
|
+
* the endpoint visible as a real screen with dense headers and an honest empty
|
|
834
|
+
* state, instead of a broken error card from a doomed fetch.
|
|
835
|
+
*/
|
|
836
|
+
function renderStaticTable(
|
|
837
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
838
|
+
columnsLit: string,
|
|
839
|
+
hint: string,
|
|
840
|
+
): string {
|
|
841
|
+
return [
|
|
842
|
+
`"use client";`,
|
|
843
|
+
``,
|
|
844
|
+
`import * as React from "react";`,
|
|
845
|
+
``,
|
|
846
|
+
`import { ResourceTable } from "@/components/auto/ResourceTable";`,
|
|
847
|
+
`import type { ColumnSpec } from "@/components/auto/types";`,
|
|
848
|
+
``,
|
|
849
|
+
`const COLUMNS: ColumnSpec[] = ${columnsLit};`,
|
|
850
|
+
``,
|
|
851
|
+
`export default function Page() {`,
|
|
852
|
+
` return (`,
|
|
853
|
+
` <ResourceTable`,
|
|
854
|
+
` title=${lit(screen.title)}`,
|
|
855
|
+
` description=${lit(screen.purpose)}`,
|
|
856
|
+
` columns={COLUMNS}`,
|
|
857
|
+
` rows={[]}`,
|
|
858
|
+
` loading={false}`,
|
|
859
|
+
` error={null}`,
|
|
860
|
+
` emptyHint=${lit(hint)}`,
|
|
861
|
+
` />`,
|
|
862
|
+
` );`,
|
|
863
|
+
`}`,
|
|
864
|
+
``,
|
|
865
|
+
].join("\n");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/* -------------------------------------------------------------------------- */
|
|
869
|
+
/* catalog */
|
|
870
|
+
/* -------------------------------------------------------------------------- */
|
|
871
|
+
|
|
872
|
+
/** Secondary columns shown under a catalog card's title. */
|
|
873
|
+
const CATALOG_META_LIMIT = 4;
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Catalog (card grid) page — same fetch scaffold as the table, but renders
|
|
877
|
+
* `CatalogGrid` with an image + title + a few meta fields baked from the schema.
|
|
878
|
+
* Reached only for collections the plan flagged as image+title bearing; falls
|
|
879
|
+
* back to a table if the signals are somehow absent or the list needs a parent.
|
|
880
|
+
*/
|
|
881
|
+
function renderCatalog(
|
|
882
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
883
|
+
endpoints: IAutoViewEndpoint[],
|
|
884
|
+
doc: OpenApi.IDocument,
|
|
885
|
+
allScreens: IAutoViewProductPlan.IScreen[],
|
|
886
|
+
): string {
|
|
887
|
+
const op = primaryOp(screen, endpoints);
|
|
888
|
+
if (op === null) return renderTable(screen, endpoints, doc, allScreens);
|
|
889
|
+
|
|
890
|
+
const { columns: rawColumns, accessExpr } = tableResponse(op, doc);
|
|
891
|
+
const columns = orderColumns(rawColumns);
|
|
892
|
+
const image = imageFieldOf(columns);
|
|
893
|
+
const title = titleFieldOf(columns);
|
|
894
|
+
// `|| path has [segment]`: even when this list op takes no path param, an
|
|
895
|
+
// ancestor segment in the route is read from `useParams()` to build row/child
|
|
896
|
+
// hrefs (see substituteRouteParams), so `params` must be declared.
|
|
897
|
+
const needsParams =
|
|
898
|
+
op.parameters.length > 0 || screen.path.includes("[");
|
|
899
|
+
// Missing signal or a nested list that needs a parent → the table renderer
|
|
900
|
+
// handles both (dense columns + honest empty state) better than a broken grid.
|
|
901
|
+
if (
|
|
902
|
+
image === undefined ||
|
|
903
|
+
title === undefined ||
|
|
904
|
+
(needsParams && !screen.path.includes("["))
|
|
905
|
+
) {
|
|
906
|
+
return renderTable(screen, endpoints, doc, allScreens);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const meta = columns
|
|
910
|
+
.filter((c) => c.name !== image.name && c.name !== title.name)
|
|
911
|
+
.slice(0, CATALOG_META_LIMIT);
|
|
912
|
+
const specLit = lit({
|
|
913
|
+
imageField: image.name,
|
|
914
|
+
imageIsArray: image.kind === "array",
|
|
915
|
+
titleField: title.name,
|
|
916
|
+
meta: meta.map((c) => toColumnSpec(c)),
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const call = buildCall(op, buildProps(op, doc));
|
|
920
|
+
|
|
921
|
+
const detailScreen = allScreens.find(
|
|
922
|
+
(s) => s.uiPattern === "detail" && s.path.startsWith(`${screen.path}/[`),
|
|
923
|
+
);
|
|
924
|
+
const rowBase = substituteRouteParams(screen.path, op);
|
|
925
|
+
const rowHref = detailScreen
|
|
926
|
+
? `\n rowHref={(row) => {\n const id = row.id;\n return id === undefined || id === null ? null : ${"`"}${rowBase}/${"${String(id)}"}${"`"};\n }}`
|
|
927
|
+
: "";
|
|
928
|
+
|
|
929
|
+
const paramHook = needsParams
|
|
930
|
+
? ` const params = useParams();\n${paramDeclarations(op, screen.path)}\n`
|
|
931
|
+
: "";
|
|
932
|
+
const effectDeps = needsParams
|
|
933
|
+
? `[tick, ${op.parameters.map((p) => p.name).join(", ")}]`
|
|
934
|
+
: "[tick]";
|
|
935
|
+
|
|
936
|
+
return [
|
|
937
|
+
`"use client";`,
|
|
938
|
+
``,
|
|
939
|
+
`import * as React from "react";`,
|
|
940
|
+
needsParams ? `import { useParams } from "next/navigation";` : null,
|
|
941
|
+
``,
|
|
942
|
+
`import api from "@/src/lib/api";`,
|
|
943
|
+
`import { connection } from "@/src/lib/connection";`,
|
|
944
|
+
`import { CatalogGrid } from "@/components/auto/CatalogGrid";`,
|
|
945
|
+
`import type { CatalogSpec } from "@/components/auto/CatalogGrid";`,
|
|
946
|
+
``,
|
|
947
|
+
`const SPEC: CatalogSpec = ${specLit};`,
|
|
948
|
+
``,
|
|
949
|
+
`export default function Page() {`,
|
|
950
|
+
paramHook ? paramHook : null,
|
|
951
|
+
` const [rows, setRows] = React.useState<readonly unknown[]>([]);`,
|
|
952
|
+
` const [loading, setLoading] = React.useState(true);`,
|
|
953
|
+
` const [error, setError] = React.useState<string | null>(null);`,
|
|
954
|
+
` const [tick, setTick] = React.useState(0);`,
|
|
955
|
+
``,
|
|
956
|
+
` React.useEffect(() => {`,
|
|
957
|
+
` let alive = true;`,
|
|
958
|
+
` setLoading(true);`,
|
|
959
|
+
` setError(null);`,
|
|
960
|
+
` ${call}`,
|
|
961
|
+
` .then((res) => {`,
|
|
962
|
+
` if (!alive) return;`,
|
|
963
|
+
` setRows(${accessExpr} as readonly unknown[]);`,
|
|
964
|
+
` })`,
|
|
965
|
+
` .catch((e) => {`,
|
|
966
|
+
` if (alive) setError(e instanceof Error ? e.message : String(e));`,
|
|
967
|
+
` })`,
|
|
968
|
+
` .finally(() => {`,
|
|
969
|
+
` if (alive) setLoading(false);`,
|
|
970
|
+
` });`,
|
|
971
|
+
` return () => {`,
|
|
972
|
+
` alive = false;`,
|
|
973
|
+
` };`,
|
|
974
|
+
` }, ${effectDeps});`,
|
|
975
|
+
``,
|
|
976
|
+
` return (`,
|
|
977
|
+
` <CatalogGrid`,
|
|
978
|
+
` title=${lit(screen.title)}`,
|
|
979
|
+
` description=${lit(screen.purpose)}`,
|
|
980
|
+
` spec={SPEC}`,
|
|
981
|
+
` rows={rows}`,
|
|
982
|
+
` loading={loading}`,
|
|
983
|
+
` error={error}`,
|
|
984
|
+
` onRetry={() => setTick((t) => t + 1)}${rowHref}`,
|
|
985
|
+
` />`,
|
|
986
|
+
` );`,
|
|
987
|
+
`}`,
|
|
988
|
+
``,
|
|
989
|
+
]
|
|
990
|
+
.filter((line): line is string => line !== null)
|
|
991
|
+
.join("\n");
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* -------------------------------------------------------------------------- */
|
|
995
|
+
/* detail */
|
|
996
|
+
/* -------------------------------------------------------------------------- */
|
|
997
|
+
|
|
998
|
+
function renderDetail(
|
|
999
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
1000
|
+
endpoints: IAutoViewEndpoint[],
|
|
1001
|
+
doc: OpenApi.IDocument,
|
|
1002
|
+
allScreens: IAutoViewProductPlan.IScreen[],
|
|
1003
|
+
): string {
|
|
1004
|
+
const op = primaryOp(screen, endpoints);
|
|
1005
|
+
if (op === null) return renderLanding(screen, allScreens, endpoints, doc);
|
|
1006
|
+
|
|
1007
|
+
const typeName = op.responseBody?.typeName ?? null;
|
|
1008
|
+
const fields = typeName ? extractFields(typeName, doc) : [];
|
|
1009
|
+
const linkIndex = resourceLinkIndex(allScreens, endpoints);
|
|
1010
|
+
const fieldsLit = `[\n${fields
|
|
1011
|
+
.map((f) => ` ${lit(toColumnSpec(f, linkIndex))}`)
|
|
1012
|
+
.join(",\n")}\n]`;
|
|
1013
|
+
|
|
1014
|
+
const props = buildProps(op, doc);
|
|
1015
|
+
const call = buildCall(op, props);
|
|
1016
|
+
|
|
1017
|
+
// Edit-link action, when an edit screen exists for this entity.
|
|
1018
|
+
const editScreen = allScreens.find(
|
|
1019
|
+
(s) => s.uiPattern === "form" && s.path === `${screen.path}/edit`,
|
|
1020
|
+
);
|
|
1021
|
+
// The detail route is dynamic (`/orders/[ordersId]`). The edit link must
|
|
1022
|
+
// substitute the id VALUE into the bracket segment, not leave a literal
|
|
1023
|
+
// `[ordersId]` — Next.js rejects a `<Link href>` that still contains a bracket
|
|
1024
|
+
// at runtime ("Dynamic href found in <Link>"), a defect tsc + next build
|
|
1025
|
+
// cannot see.
|
|
1026
|
+
const editHref = `${substituteRouteParams(screen.path, op)}/edit`;
|
|
1027
|
+
|
|
1028
|
+
// Secondary endpoints the detail composes (its update / delete / action
|
|
1029
|
+
// operations) become real buttons: DELETE → a destructive button, POST → an
|
|
1030
|
+
// action button labeled by the verb. PUT/PATCH updates are covered by the
|
|
1031
|
+
// Edit link, so they are not duplicated here. This is what makes the detail
|
|
1032
|
+
// page functional — you can act on the record, not just look at it.
|
|
1033
|
+
const secondaryOps = screen.endpoints
|
|
1034
|
+
.slice(1)
|
|
1035
|
+
.map((a) => endpoints.find((e) => e.accessor.join(".") === a))
|
|
1036
|
+
.filter((o): o is IAutoViewEndpoint => o !== undefined);
|
|
1037
|
+
const actionOps = secondaryOps.filter((o) => {
|
|
1038
|
+
const m = o.method.toUpperCase();
|
|
1039
|
+
return m === "DELETE" || m === "POST";
|
|
1040
|
+
});
|
|
1041
|
+
const hasActions = actionOps.length > 0;
|
|
1042
|
+
|
|
1043
|
+
// Direct child collections (`/sales/[id]/questions`) — embedded inline below
|
|
1044
|
+
// the record as preview tables, turning a flat detail into a master-detail
|
|
1045
|
+
// view. Their list endpoints reuse this page's id params (a child collection
|
|
1046
|
+
// adds no new bracket of its own).
|
|
1047
|
+
const childCollectionScreens = allScreens.filter((s) => {
|
|
1048
|
+
if (s.uiPattern !== "table" && s.uiPattern !== "catalog") return false;
|
|
1049
|
+
if (!s.path.startsWith(`${screen.path}/`)) return false;
|
|
1050
|
+
const rest = s.path.slice(screen.path.length + 1);
|
|
1051
|
+
return rest.length > 0 && !rest.includes("/"); // direct child collection only
|
|
1052
|
+
});
|
|
1053
|
+
const childOps = childCollectionScreens
|
|
1054
|
+
.map((c) => primaryOp(c, endpoints))
|
|
1055
|
+
.filter((o): o is IAutoViewEndpoint => o !== null);
|
|
1056
|
+
|
|
1057
|
+
// Declare every path param any operation on this page needs (primary detail,
|
|
1058
|
+
// each action, each embedded child list), sourced from useParams — the route
|
|
1059
|
+
// may name a segment `[id]` while the SDK calls it `saleId`. The primary op's
|
|
1060
|
+
// order matches the route; actions and child lists share its leading params.
|
|
1061
|
+
const paramNames = Array.from(
|
|
1062
|
+
new Set(
|
|
1063
|
+
[op, ...actionOps, ...childOps].flatMap((o) =>
|
|
1064
|
+
o.parameters.map((p) => p.name),
|
|
1065
|
+
),
|
|
1066
|
+
),
|
|
1067
|
+
);
|
|
1068
|
+
const needsParams = paramNames.length > 0;
|
|
1069
|
+
// Source every param from the route segment at ITS OWN op's position, unioned
|
|
1070
|
+
// across all ops. Different ops name the same id differently — the detail's
|
|
1071
|
+
// `sales.at` calls it `id`, the child `sales.snapshots.index` calls it
|
|
1072
|
+
// `saleId`, an action `categories.create` calls it `channelCode` — but they
|
|
1073
|
+
// all address the same leading route segment (`[salesId]` / `[channelsId]`).
|
|
1074
|
+
// Mapping each op's params positionally and merging declares every alias from
|
|
1075
|
+
// the right segment, instead of leaving a child/action id sourced from a
|
|
1076
|
+
// route param name that does not exist (→ empty string → failed fetch).
|
|
1077
|
+
const allParamOps = [op, ...actionOps, ...childOps];
|
|
1078
|
+
const mergedParamSource = new Map<string, string>();
|
|
1079
|
+
for (const o of allParamOps) {
|
|
1080
|
+
for (const [param, src] of paramSourceMap(o, screen.path)) {
|
|
1081
|
+
if (!mergedParamSource.has(param)) mergedParamSource.set(param, src);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const paramDecls = declareParams(
|
|
1085
|
+
paramNames,
|
|
1086
|
+
mergedParamSource,
|
|
1087
|
+
numericParams(allParamOps),
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
// Dedupe by visible label — actor-scoped duplicates (admin + seller `erase`)
|
|
1091
|
+
// collapse to one route here, so both would render an identical "Delete"
|
|
1092
|
+
// button. Keep the first; track labels so child links don't repeat them.
|
|
1093
|
+
// List route this record belongs to (`/sales/[salesId]` → `/sales`) — where a
|
|
1094
|
+
// successful delete navigates, since the record no longer exists.
|
|
1095
|
+
const listPath = screen.path.replace(/\/\[[^\]]+\]$/, "") || "/";
|
|
1096
|
+
|
|
1097
|
+
const usedLabels = new Set<string>();
|
|
1098
|
+
const actionButtons: string[] = [];
|
|
1099
|
+
let hasDelete = false;
|
|
1100
|
+
for (const o of actionOps) {
|
|
1101
|
+
const isDelete = o.method.toUpperCase() === "DELETE";
|
|
1102
|
+
// Label from the last STATIC path segment (`/orders/{id}/cancel` → "Cancel"),
|
|
1103
|
+
// not the accessor's auto-generated tail (`postById`) which reads as noise.
|
|
1104
|
+
const verb =
|
|
1105
|
+
o.path
|
|
1106
|
+
.split("/")
|
|
1107
|
+
.filter((s) => s.length > 0 && !s.startsWith("{"))
|
|
1108
|
+
.pop() ?? "Action";
|
|
1109
|
+
const label = isDelete ? "Delete" : titleizeSegment(verb);
|
|
1110
|
+
if (usedLabels.has(label)) continue;
|
|
1111
|
+
usedLabels.add(label);
|
|
1112
|
+
const acall = buildCall(o, buildProps(o, doc));
|
|
1113
|
+
if (isDelete) {
|
|
1114
|
+
// Confirmed delete → on success navigate to the list (record is gone).
|
|
1115
|
+
hasDelete = true;
|
|
1116
|
+
actionButtons.push(
|
|
1117
|
+
` <ConfirmButton label=${lit(label)} disabled={busy} onConfirm={() => del(() => ${acall})} />`,
|
|
1118
|
+
);
|
|
1119
|
+
} else {
|
|
1120
|
+
// A mutating action re-fetches the record so the change is visible.
|
|
1121
|
+
actionButtons.push(
|
|
1122
|
+
` <Button variant="outline" size="sm" disabled={busy} onClick={() => act(() => ${acall})}>${label}</Button>`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Inline child-collection embeds. Each direct child list is rendered as a
|
|
1128
|
+
// compact preview table below the record (master-detail), not a button that
|
|
1129
|
+
// jumps away. Skip a child whose label already appears as an action button (a
|
|
1130
|
+
// POST `/sales/{id}/questions` action + the `/sales/[id]/questions` list both
|
|
1131
|
+
// read "Questions"). The child list reuses this page's id params, substituted
|
|
1132
|
+
// by name through `buildProps`.
|
|
1133
|
+
const childEmbeds = childCollectionScreens
|
|
1134
|
+
.map((c) => {
|
|
1135
|
+
const cop = primaryOp(c, endpoints);
|
|
1136
|
+
if (cop === null) return null;
|
|
1137
|
+
const label = titleizeSegment(c.path.slice(screen.path.length + 1));
|
|
1138
|
+
if (usedLabels.has(label)) return null;
|
|
1139
|
+
usedLabels.add(label);
|
|
1140
|
+
const { columns, accessExpr } = tableResponse(cop, doc);
|
|
1141
|
+
const columnsLit = `[\n${orderColumns(columns)
|
|
1142
|
+
.map((f) => ` ${lit(toColumnSpec(f, linkIndex))}`)
|
|
1143
|
+
.join(",\n")}\n ]`;
|
|
1144
|
+
return {
|
|
1145
|
+
title: label,
|
|
1146
|
+
href: substituteRouteParams(c.path, op),
|
|
1147
|
+
columnsLit,
|
|
1148
|
+
call: buildCall(cop, buildProps(cop, doc)),
|
|
1149
|
+
accessExpr,
|
|
1150
|
+
};
|
|
1151
|
+
})
|
|
1152
|
+
.filter((x): x is NonNullable<typeof x> => x !== null);
|
|
1153
|
+
const hasEmbeds = childEmbeds.length > 0;
|
|
1154
|
+
|
|
1155
|
+
const embedsJsx = childEmbeds
|
|
1156
|
+
.map((e) =>
|
|
1157
|
+
[
|
|
1158
|
+
` <EmbeddedCollection`,
|
|
1159
|
+
` title=${lit(e.title)}`,
|
|
1160
|
+
` href={${"`"}${e.href}${"`"}}`,
|
|
1161
|
+
` columns={${e.columnsLit}}`,
|
|
1162
|
+
` load={() =>`,
|
|
1163
|
+
` ${e.call}`,
|
|
1164
|
+
` .then((res) => (${e.accessExpr}) as readonly unknown[])`,
|
|
1165
|
+
` }`,
|
|
1166
|
+
` />`,
|
|
1167
|
+
].join("\n"),
|
|
1168
|
+
)
|
|
1169
|
+
.join("\n");
|
|
1170
|
+
|
|
1171
|
+
const actionInner = [
|
|
1172
|
+
editScreen
|
|
1173
|
+
? ` <Button asChild variant="outline" size="sm">\n <Link href={${"`"}${editHref}${"`"}}>Edit</Link>\n </Button>`
|
|
1174
|
+
: null,
|
|
1175
|
+
...actionButtons,
|
|
1176
|
+
hasActions
|
|
1177
|
+
? ` {actionMsg ? <span className="self-center text-xs text-muted-foreground">{actionMsg}</span> : null}`
|
|
1178
|
+
: null,
|
|
1179
|
+
]
|
|
1180
|
+
.filter((l): l is string => l !== null)
|
|
1181
|
+
.join("\n");
|
|
1182
|
+
const needButton = editScreen !== undefined || hasActions;
|
|
1183
|
+
const needLink = editScreen !== undefined;
|
|
1184
|
+
const actions = needButton
|
|
1185
|
+
? `\n actions={\n <>\n${actionInner}\n </>\n }`
|
|
1186
|
+
: "";
|
|
1187
|
+
|
|
1188
|
+
const effectDeps = needsParams ? `[tick, ${paramNames.join(", ")}]` : "[tick]";
|
|
1189
|
+
|
|
1190
|
+
const navNames = [
|
|
1191
|
+
needsParams ? "useParams" : null,
|
|
1192
|
+
hasDelete ? "useRouter" : null,
|
|
1193
|
+
].filter((s): s is string => s !== null);
|
|
1194
|
+
const navImport =
|
|
1195
|
+
navNames.length > 0
|
|
1196
|
+
? `import { ${navNames.join(", ")} } from "next/navigation";`
|
|
1197
|
+
: null;
|
|
1198
|
+
|
|
1199
|
+
return [
|
|
1200
|
+
`"use client";`,
|
|
1201
|
+
``,
|
|
1202
|
+
`import * as React from "react";`,
|
|
1203
|
+
navImport,
|
|
1204
|
+
needLink ? `import Link from "next/link";` : null,
|
|
1205
|
+
``,
|
|
1206
|
+
`import api from "@/src/lib/api";`,
|
|
1207
|
+
`import { connection } from "@/src/lib/connection";`,
|
|
1208
|
+
`import { ResourceDetail } from "@/components/auto/ResourceDetail";`,
|
|
1209
|
+
hasEmbeds
|
|
1210
|
+
? `import { EmbeddedCollection } from "@/components/auto/EmbeddedCollection";`
|
|
1211
|
+
: null,
|
|
1212
|
+
needButton ? `import { Button } from "@/components/ui/button";` : null,
|
|
1213
|
+
hasDelete
|
|
1214
|
+
? `import { ConfirmButton } from "@/components/auto/ConfirmButton";`
|
|
1215
|
+
: null,
|
|
1216
|
+
`import type { ColumnSpec } from "@/components/auto/types";`,
|
|
1217
|
+
``,
|
|
1218
|
+
`const FIELDS: ColumnSpec[] = ${fieldsLit};`,
|
|
1219
|
+
``,
|
|
1220
|
+
`export default function Page() {`,
|
|
1221
|
+
needsParams ? ` const params = useParams();` : null,
|
|
1222
|
+
hasDelete ? ` const router = useRouter();` : null,
|
|
1223
|
+
needsParams ? paramDecls : null,
|
|
1224
|
+
` const [data, setData] = React.useState<unknown>(null);`,
|
|
1225
|
+
` const [loading, setLoading] = React.useState(true);`,
|
|
1226
|
+
` const [error, setError] = React.useState<string | null>(null);`,
|
|
1227
|
+
` const [tick, setTick] = React.useState(0);`,
|
|
1228
|
+
hasActions ? ` const [busy, setBusy] = React.useState(false);` : null,
|
|
1229
|
+
hasActions
|
|
1230
|
+
? ` const [actionMsg, setActionMsg] = React.useState<string | null>(null);`
|
|
1231
|
+
: null,
|
|
1232
|
+
hasActions ? ` const act = (fn: () => Promise<unknown>) => {` : null,
|
|
1233
|
+
hasActions ? ` setBusy(true);` : null,
|
|
1234
|
+
hasActions ? ` setActionMsg(null);` : null,
|
|
1235
|
+
hasActions ? ` fn()` : null,
|
|
1236
|
+
hasActions
|
|
1237
|
+
? ` .then(() => { setActionMsg("Done."); setTick((t) => t + 1); })`
|
|
1238
|
+
: null,
|
|
1239
|
+
hasActions
|
|
1240
|
+
? ` .catch((e) => setActionMsg(e instanceof Error ? e.message : String(e)))`
|
|
1241
|
+
: null,
|
|
1242
|
+
hasActions ? ` .finally(() => setBusy(false));` : null,
|
|
1243
|
+
hasActions ? ` };` : null,
|
|
1244
|
+
hasDelete ? ` const del = (fn: () => Promise<unknown>) => {` : null,
|
|
1245
|
+
hasDelete ? ` setBusy(true);` : null,
|
|
1246
|
+
hasDelete ? ` setActionMsg(null);` : null,
|
|
1247
|
+
hasDelete ? ` fn()` : null,
|
|
1248
|
+
hasDelete ? ` .then(() => router.push(${routeHrefFromParams(listPath)}))` : null,
|
|
1249
|
+
hasDelete
|
|
1250
|
+
? ` .catch((e) => { setActionMsg(e instanceof Error ? e.message : String(e)); setBusy(false); });`
|
|
1251
|
+
: null,
|
|
1252
|
+
hasDelete ? ` };` : null,
|
|
1253
|
+
``,
|
|
1254
|
+
` React.useEffect(() => {`,
|
|
1255
|
+
` let alive = true;`,
|
|
1256
|
+
` setLoading(true);`,
|
|
1257
|
+
` setError(null);`,
|
|
1258
|
+
` ${call}`,
|
|
1259
|
+
` .then((res) => {`,
|
|
1260
|
+
` if (alive) setData(res);`,
|
|
1261
|
+
` })`,
|
|
1262
|
+
` .catch((e) => {`,
|
|
1263
|
+
` if (alive) setError(e instanceof Error ? e.message : String(e));`,
|
|
1264
|
+
` })`,
|
|
1265
|
+
` .finally(() => {`,
|
|
1266
|
+
` if (alive) setLoading(false);`,
|
|
1267
|
+
` });`,
|
|
1268
|
+
` return () => {`,
|
|
1269
|
+
` alive = false;`,
|
|
1270
|
+
` };`,
|
|
1271
|
+
` }, ${effectDeps});`,
|
|
1272
|
+
``,
|
|
1273
|
+
` return (`,
|
|
1274
|
+
hasEmbeds ? ` <>` : null,
|
|
1275
|
+
` <ResourceDetail`,
|
|
1276
|
+
` title=${lit(screen.title)}`,
|
|
1277
|
+
` fields={FIELDS}`,
|
|
1278
|
+
` data={data}`,
|
|
1279
|
+
` loading={loading}`,
|
|
1280
|
+
` error={error}`,
|
|
1281
|
+
` onRetry={() => setTick((t) => t + 1)}${actions}`,
|
|
1282
|
+
` />`,
|
|
1283
|
+
hasEmbeds ? ` <div className="px-5 pb-8 md:px-8">` : null,
|
|
1284
|
+
hasEmbeds ? ` <div className="max-w-3xl space-y-5">` : null,
|
|
1285
|
+
hasEmbeds ? embedsJsx : null,
|
|
1286
|
+
hasEmbeds ? ` </div>` : null,
|
|
1287
|
+
hasEmbeds ? ` </div>` : null,
|
|
1288
|
+
hasEmbeds ? ` </>` : null,
|
|
1289
|
+
` );`,
|
|
1290
|
+
`}`,
|
|
1291
|
+
``,
|
|
1292
|
+
]
|
|
1293
|
+
.filter((line): line is string => line !== null)
|
|
1294
|
+
.join("\n");
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/* -------------------------------------------------------------------------- */
|
|
1298
|
+
/* form (create / edit) */
|
|
1299
|
+
/* -------------------------------------------------------------------------- */
|
|
1300
|
+
|
|
1301
|
+
function renderForm(
|
|
1302
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
1303
|
+
endpoints: IAutoViewEndpoint[],
|
|
1304
|
+
doc: OpenApi.IDocument,
|
|
1305
|
+
): string {
|
|
1306
|
+
const op = primaryOp(screen, endpoints);
|
|
1307
|
+
if (op === null || op.requestBody === null) {
|
|
1308
|
+
// No request body to drive a form — degrade to a detail-less notice.
|
|
1309
|
+
return renderEmptyForm(screen);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const inputs = extractFields(op.requestBody.typeName, doc);
|
|
1313
|
+
const fieldsLit = `[\n${inputs
|
|
1314
|
+
.map((f) => ` ${lit(toFieldInput(f))}`)
|
|
1315
|
+
.join(",\n")}\n]`;
|
|
1316
|
+
|
|
1317
|
+
const props = buildProps(op, doc, { bodyExpr: "values" });
|
|
1318
|
+
const call = buildCall(op, props);
|
|
1319
|
+
|
|
1320
|
+
// Edit forms prefill from the current record: find the detail GET among the
|
|
1321
|
+
// screen's secondary endpoints and fetch it on mount.
|
|
1322
|
+
const isEdit = screen.path.endsWith("/edit");
|
|
1323
|
+
const detailOp = isEdit
|
|
1324
|
+
? (screen.endpoints
|
|
1325
|
+
.slice(1)
|
|
1326
|
+
.map((a) => endpoints.find((e) => e.accessor.join(".") === a))
|
|
1327
|
+
.find(
|
|
1328
|
+
(e): e is IAutoViewEndpoint =>
|
|
1329
|
+
e !== undefined &&
|
|
1330
|
+
e.method.toLowerCase() === "get" &&
|
|
1331
|
+
e.parameters.length > 0,
|
|
1332
|
+
) ?? null)
|
|
1333
|
+
: null;
|
|
1334
|
+
|
|
1335
|
+
// Params for both the update op and the prefill detail op, sourced by name
|
|
1336
|
+
// from the shared route segments (same merge as the detail page).
|
|
1337
|
+
const paramOps = detailOp !== null ? [op, detailOp] : [op];
|
|
1338
|
+
const paramNames = Array.from(
|
|
1339
|
+
new Set(paramOps.flatMap((o) => o.parameters.map((p) => p.name))),
|
|
1340
|
+
);
|
|
1341
|
+
const needsParams = paramNames.length > 0;
|
|
1342
|
+
const mergedSource = new Map<string, string>();
|
|
1343
|
+
for (const o of paramOps)
|
|
1344
|
+
for (const [pn, src] of paramSourceMap(o, screen.path))
|
|
1345
|
+
if (!mergedSource.has(pn)) mergedSource.set(pn, src);
|
|
1346
|
+
const paramDecls = declareParams(
|
|
1347
|
+
paramNames,
|
|
1348
|
+
mergedSource,
|
|
1349
|
+
numericParams(paramOps),
|
|
1350
|
+
);
|
|
1351
|
+
const paramHook = needsParams
|
|
1352
|
+
? ` const params = useParams();\n${paramDecls}\n`
|
|
1353
|
+
: "";
|
|
1354
|
+
|
|
1355
|
+
const detailCall =
|
|
1356
|
+
detailOp !== null ? buildCall(detailOp, buildProps(detailOp, doc)) : null;
|
|
1357
|
+
|
|
1358
|
+
return [
|
|
1359
|
+
`"use client";`,
|
|
1360
|
+
``,
|
|
1361
|
+
`import * as React from "react";`,
|
|
1362
|
+
needsParams ? `import { useParams } from "next/navigation";` : null,
|
|
1363
|
+
``,
|
|
1364
|
+
`import api from "@/src/lib/api";`,
|
|
1365
|
+
`import { connection } from "@/src/lib/connection";`,
|
|
1366
|
+
`import { ResourceForm } from "@/components/auto/ResourceForm";`,
|
|
1367
|
+
`import type { FieldInput } from "@/components/auto/types";`,
|
|
1368
|
+
``,
|
|
1369
|
+
`const FIELDS: FieldInput[] = ${fieldsLit};`,
|
|
1370
|
+
``,
|
|
1371
|
+
`export default function Page() {`,
|
|
1372
|
+
paramHook ? paramHook : null,
|
|
1373
|
+
` const [submitting, setSubmitting] = React.useState(false);`,
|
|
1374
|
+
` const [error, setError] = React.useState<string | null>(null);`,
|
|
1375
|
+
` const [result, setResult] = React.useState<string | null>(null);`,
|
|
1376
|
+
detailCall !== null
|
|
1377
|
+
? ` const [initial, setInitial] = React.useState<Record<string, unknown> | undefined>(undefined);`
|
|
1378
|
+
: null,
|
|
1379
|
+
detailCall !== null ? `` : null,
|
|
1380
|
+
detailCall !== null ? ` React.useEffect(() => {` : null,
|
|
1381
|
+
detailCall !== null ? ` let alive = true;` : null,
|
|
1382
|
+
detailCall !== null ? ` ${detailCall}` : null,
|
|
1383
|
+
detailCall !== null
|
|
1384
|
+
? // cast through `unknown`: the prefill source may type as `void` (a 204
|
|
1385
|
+
// detail, e.g. GitHub's collaborator check) which does not overlap Record.
|
|
1386
|
+
` .then((res) => { if (alive) setInitial(res as unknown as Record<string, unknown>); })`
|
|
1387
|
+
: null,
|
|
1388
|
+
detailCall !== null ? ` .catch(() => undefined);` : null,
|
|
1389
|
+
detailCall !== null ? ` return () => { alive = false; };` : null,
|
|
1390
|
+
detailCall !== null
|
|
1391
|
+
? ` }, [${paramNames.join(", ")}]);`
|
|
1392
|
+
: null,
|
|
1393
|
+
``,
|
|
1394
|
+
` const onSubmit = (values: Record<string, unknown>) => {`,
|
|
1395
|
+
` setSubmitting(true);`,
|
|
1396
|
+
` setError(null);`,
|
|
1397
|
+
` setResult(null);`,
|
|
1398
|
+
` ${call}`,
|
|
1399
|
+
` .then(() => {`,
|
|
1400
|
+
` setResult("Saved successfully.");`,
|
|
1401
|
+
` })`,
|
|
1402
|
+
` .catch((e) => {`,
|
|
1403
|
+
` setError(e instanceof Error ? e.message : String(e));`,
|
|
1404
|
+
` })`,
|
|
1405
|
+
` .finally(() => {`,
|
|
1406
|
+
` setSubmitting(false);`,
|
|
1407
|
+
` });`,
|
|
1408
|
+
` };`,
|
|
1409
|
+
``,
|
|
1410
|
+
` return (`,
|
|
1411
|
+
` <ResourceForm`,
|
|
1412
|
+
` title=${lit(screen.title)}`,
|
|
1413
|
+
` description=${lit(screen.purpose)}`,
|
|
1414
|
+
` fields={FIELDS}`,
|
|
1415
|
+
` submitting={submitting}`,
|
|
1416
|
+
` error={error}`,
|
|
1417
|
+
` result={result}`,
|
|
1418
|
+
detailCall !== null ? ` initialValues={initial}` : null,
|
|
1419
|
+
` onSubmit={onSubmit}`,
|
|
1420
|
+
` />`,
|
|
1421
|
+
` );`,
|
|
1422
|
+
`}`,
|
|
1423
|
+
``,
|
|
1424
|
+
]
|
|
1425
|
+
.filter((line): line is string => line !== null)
|
|
1426
|
+
.join("\n");
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function renderEmptyForm(screen: IAutoViewProductPlan.IScreen): string {
|
|
1430
|
+
return [
|
|
1431
|
+
`"use client";`,
|
|
1432
|
+
``,
|
|
1433
|
+
`import { ResourceForm } from "@/components/auto/ResourceForm";`,
|
|
1434
|
+
``,
|
|
1435
|
+
`export default function Page() {`,
|
|
1436
|
+
` return (`,
|
|
1437
|
+
` <ResourceForm`,
|
|
1438
|
+
` title=${lit(screen.title)}`,
|
|
1439
|
+
` fields={[]}`,
|
|
1440
|
+
` submitting={false}`,
|
|
1441
|
+
` onSubmit={() => undefined}`,
|
|
1442
|
+
` />`,
|
|
1443
|
+
` );`,
|
|
1444
|
+
`}`,
|
|
1445
|
+
``,
|
|
1446
|
+
].join("\n");
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/* -------------------------------------------------------------------------- */
|
|
1450
|
+
/* landing */
|
|
1451
|
+
/* -------------------------------------------------------------------------- */
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Resource hub-ness — how many DISTINCT child resources nest under a resource,
|
|
1455
|
+
* i.e. how many other resources hang off its id (`/files/{file_id}/metadata`,
|
|
1456
|
+
* `/files/{file_id}/versions`, … → `files` parents `metadata`, `versions`, …).
|
|
1457
|
+
* A resource that parents several others is a genuine domain hub; one only ever
|
|
1458
|
+
* read on its own (`/x/{id}`) parents nothing and scores 0. Deterministic from
|
|
1459
|
+
* the path shape — the producer→consumer graph the IR already carries — used to
|
|
1460
|
+
* float the few real hubs (`files`/`folders`/`users`) above the long tail on the
|
|
1461
|
+
* landing page, instead of every path-param hit (which almost every resource has
|
|
1462
|
+
* from its own detail route and so does not discriminate).
|
|
1463
|
+
*/
|
|
1464
|
+
function resourceHubness(
|
|
1465
|
+
endpoints: IAutoViewEndpoint[],
|
|
1466
|
+
): Map<string, number> {
|
|
1467
|
+
const children = new Map<string, Set<string>>();
|
|
1468
|
+
for (const e of endpoints) {
|
|
1469
|
+
const segs = e.path.split("/").filter((s) => s.length > 0);
|
|
1470
|
+
for (let i = 1; i < segs.length; i++) {
|
|
1471
|
+
if (!segs[i]!.startsWith("{")) continue;
|
|
1472
|
+
const parent = segs[i - 1]!;
|
|
1473
|
+
const child = segs[i + 1];
|
|
1474
|
+
if (child === undefined || child.startsWith("{")) continue;
|
|
1475
|
+
const set = children.get(parent) ?? new Set<string>();
|
|
1476
|
+
set.add(child);
|
|
1477
|
+
children.set(parent, set);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const score = new Map<string, number>();
|
|
1481
|
+
for (const [res, set] of children) score.set(res, set.size);
|
|
1482
|
+
return score;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** How many hub resources get a live count card on the home dashboard. Past
|
|
1486
|
+
* this the cards become noise; the long tail stays in the link grid below. */
|
|
1487
|
+
const DASHBOARD_STAT_LIMIT = 8;
|
|
1488
|
+
|
|
1489
|
+
function renderLanding(
|
|
1490
|
+
screen: IAutoViewProductPlan.IScreen,
|
|
1491
|
+
allScreens: IAutoViewProductPlan.IScreen[],
|
|
1492
|
+
endpoints: IAutoViewEndpoint[],
|
|
1493
|
+
doc: OpenApi.IDocument,
|
|
1494
|
+
): string {
|
|
1495
|
+
const hubness = resourceHubness(endpoints);
|
|
1496
|
+
const resourceOf = (path: string): string =>
|
|
1497
|
+
path.replace(/^\//, "").split("/")[0] ?? "";
|
|
1498
|
+
const weightOf = (path: string): number => hubness.get(resourceOf(path)) ?? 0;
|
|
1499
|
+
|
|
1500
|
+
// Links: every top-level browsable screen (chain depth 1, bracket-less). A
|
|
1501
|
+
// nested resource or param-less sub-view is reached from its parent, never
|
|
1502
|
+
// the home hub.
|
|
1503
|
+
const isTopLevel = (s: IAutoViewProductPlan.IScreen): boolean =>
|
|
1504
|
+
(s.depth === undefined || s.depth === 1) && !s.path.includes("[");
|
|
1505
|
+
const links = allScreens
|
|
1506
|
+
.filter(
|
|
1507
|
+
(s) =>
|
|
1508
|
+
isTopLevel(s) &&
|
|
1509
|
+
(s.uiPattern === "table" ||
|
|
1510
|
+
s.uiPattern === "catalog" ||
|
|
1511
|
+
s.uiPattern === "detail"),
|
|
1512
|
+
)
|
|
1513
|
+
.map((s) => ({
|
|
1514
|
+
href: s.path,
|
|
1515
|
+
title: s.title,
|
|
1516
|
+
weight: weightOf(s.path),
|
|
1517
|
+
}));
|
|
1518
|
+
const linksLit = `[\n${links.map((l) => ` ${lit(l)}`).join(",\n")}\n]`;
|
|
1519
|
+
|
|
1520
|
+
// Stats: the top hub resources whose list is fetchable standalone (no path
|
|
1521
|
+
// params), surfaced as live count cards. The count prefers a known total
|
|
1522
|
+
// field (pagination) and falls back to the fetched array length — both
|
|
1523
|
+
// deterministic from the response shape already resolved by `tableResponse`.
|
|
1524
|
+
const statScreens = allScreens
|
|
1525
|
+
.filter((s) => isTopLevel(s) && s.uiPattern === "table")
|
|
1526
|
+
.map((s) => ({ screen: s, op: primaryOp(s, endpoints) }))
|
|
1527
|
+
.filter(
|
|
1528
|
+
(x): x is { screen: IAutoViewProductPlan.IScreen; op: IAutoViewEndpoint } =>
|
|
1529
|
+
x.op !== null && x.op.parameters.length === 0,
|
|
1530
|
+
)
|
|
1531
|
+
.sort((a, b) => weightOf(b.screen.path) - weightOf(a.screen.path))
|
|
1532
|
+
.slice(0, DASHBOARD_STAT_LIMIT);
|
|
1533
|
+
|
|
1534
|
+
const statTitlesLit = `[\n${statScreens
|
|
1535
|
+
.map((x) => ` ${lit({ href: x.screen.path, title: x.screen.title })}`)
|
|
1536
|
+
.join(",\n")}\n]`;
|
|
1537
|
+
|
|
1538
|
+
const fetchers = statScreens.map((x) => {
|
|
1539
|
+
const call = buildCall(x.op, buildProps(x.op, doc));
|
|
1540
|
+
const { accessExpr } = tableResponse(x.op, doc);
|
|
1541
|
+
const collection = responseCollection(x.op, doc);
|
|
1542
|
+
const titleField =
|
|
1543
|
+
collection !== null ? titleFieldOf(collection.columns)?.name : undefined;
|
|
1544
|
+
const href = lit(x.screen.path);
|
|
1545
|
+
// First couple of record titles as a "recent" preview, when the row type
|
|
1546
|
+
// has a title-ish field; otherwise an empty preview.
|
|
1547
|
+
const recentExpr =
|
|
1548
|
+
titleField !== undefined
|
|
1549
|
+
? `rows.slice(0, 2).map((row) => { const v = (row as Record<string, unknown>)?.[${lit(titleField)}]; return typeof v === "string" || typeof v === "number" ? String(v) : null; }).filter((s): s is string => s !== null)`
|
|
1550
|
+
: "[] as string[]";
|
|
1551
|
+
return [
|
|
1552
|
+
` ${call}`,
|
|
1553
|
+
` .then((res) => {`,
|
|
1554
|
+
` const r = res as unknown as { pagination?: { records?: number; total?: number }; total?: number; count?: number };`,
|
|
1555
|
+
` const t = r?.pagination?.records ?? r?.pagination?.total ?? r?.total ?? r?.count;`,
|
|
1556
|
+
` const rows = (${accessExpr} as readonly unknown[]);`,
|
|
1557
|
+
` const n = typeof t === "number" ? t : rows.length;`,
|
|
1558
|
+
` const recent = ${recentExpr};`,
|
|
1559
|
+
` return [${href}, { count: n, recent }] as const;`,
|
|
1560
|
+
` })`,
|
|
1561
|
+
` .catch(() => [${href}, { count: null, recent: [] as string[] }] as const)`,
|
|
1562
|
+
].join("\n");
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
const hasStats = fetchers.length > 0;
|
|
1566
|
+
const fetchBlock = hasStats
|
|
1567
|
+
? [
|
|
1568
|
+
` const [counts, setCounts] = React.useState<Record<string, { count: number | null; recent: string[] }>>({});`,
|
|
1569
|
+
``,
|
|
1570
|
+
` React.useEffect(() => {`,
|
|
1571
|
+
` let alive = true;`,
|
|
1572
|
+
` Promise.all([`,
|
|
1573
|
+
fetchers.join(",\n"),
|
|
1574
|
+
` ]).then((entries) => {`,
|
|
1575
|
+
` if (!alive) return;`,
|
|
1576
|
+
` setCounts(Object.fromEntries(entries));`,
|
|
1577
|
+
` });`,
|
|
1578
|
+
` return () => {`,
|
|
1579
|
+
` alive = false;`,
|
|
1580
|
+
` };`,
|
|
1581
|
+
` }, []);`,
|
|
1582
|
+
``,
|
|
1583
|
+
` const stats: DashboardStat[] = STAT_TITLES.map((s) => ({`,
|
|
1584
|
+
` href: s.href,`,
|
|
1585
|
+
` title: s.title,`,
|
|
1586
|
+
` count: counts[s.href]?.count ?? null,`,
|
|
1587
|
+
` recent: counts[s.href]?.recent ?? [],`,
|
|
1588
|
+
` }));`,
|
|
1589
|
+
].join("\n")
|
|
1590
|
+
: ` const stats: DashboardStat[] = [];`;
|
|
1591
|
+
|
|
1592
|
+
return [
|
|
1593
|
+
`"use client";`,
|
|
1594
|
+
``,
|
|
1595
|
+
`import * as React from "react";`,
|
|
1596
|
+
``,
|
|
1597
|
+
hasStats ? `import api from "@/src/lib/api";` : null,
|
|
1598
|
+
hasStats ? `import { connection } from "@/src/lib/connection";` : null,
|
|
1599
|
+
`import { ResourceDashboard } from "@/components/auto/ResourceDashboard";`,
|
|
1600
|
+
`import type { DashboardStat } from "@/components/auto/ResourceDashboard";`,
|
|
1601
|
+
`import type { LandingLink } from "@/components/auto/ResourceLanding";`,
|
|
1602
|
+
``,
|
|
1603
|
+
`const LINKS: LandingLink[] = ${linksLit};`,
|
|
1604
|
+
hasStats
|
|
1605
|
+
? `\nconst STAT_TITLES: { href: string; title: string }[] = ${statTitlesLit};`
|
|
1606
|
+
: null,
|
|
1607
|
+
``,
|
|
1608
|
+
`export default function Page() {`,
|
|
1609
|
+
fetchBlock,
|
|
1610
|
+
``,
|
|
1611
|
+
` return (`,
|
|
1612
|
+
` <ResourceDashboard`,
|
|
1613
|
+
` title=${lit(screen.title)}`,
|
|
1614
|
+
` subtitle=${lit(screen.purpose)}`,
|
|
1615
|
+
` stats={stats}`,
|
|
1616
|
+
` links={LINKS}`,
|
|
1617
|
+
` />`,
|
|
1618
|
+
` );`,
|
|
1619
|
+
`}`,
|
|
1620
|
+
``,
|
|
1621
|
+
]
|
|
1622
|
+
.filter((l): l is string => l !== null)
|
|
1623
|
+
.join("\n");
|
|
1624
|
+
}
|