@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,235 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardFooter,
|
|
11
|
+
CardHeader,
|
|
12
|
+
CardTitle,
|
|
13
|
+
} from "@/components/ui/card";
|
|
14
|
+
import { Input } from "@/components/ui/input";
|
|
15
|
+
import { Label } from "@/components/ui/label";
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from "@/components/ui/select";
|
|
23
|
+
import { humanizeLabel } from "@/lib/utils";
|
|
24
|
+
|
|
25
|
+
import type { FieldInput } from "./types";
|
|
26
|
+
|
|
27
|
+
export interface ResourceFormProps {
|
|
28
|
+
title: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
/** One input per field of the request body schema. */
|
|
31
|
+
fields: FieldInput[];
|
|
32
|
+
submitting: boolean;
|
|
33
|
+
error?: string | null;
|
|
34
|
+
/** Success message after a submit resolves. */
|
|
35
|
+
result?: string | null;
|
|
36
|
+
/** Current record values to prefill an edit form (matched by field name). */
|
|
37
|
+
initialValues?: Record<string, unknown>;
|
|
38
|
+
onSubmit: (values: Record<string, unknown>) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** HTML input type for a field, so the browser validates format natively. */
|
|
42
|
+
function inputType(field: FieldInput): string {
|
|
43
|
+
if (field.kind === "number") return "number";
|
|
44
|
+
if (field.format === "email") return "email";
|
|
45
|
+
if (field.format === "uri" || field.format === "url") return "url";
|
|
46
|
+
return "text";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Client-side validation message for one field, or `null` when valid. */
|
|
50
|
+
function fieldError(field: FieldInput, raw: string): string | null {
|
|
51
|
+
const v = raw.trim();
|
|
52
|
+
if (field.required && v === "") return "Required";
|
|
53
|
+
if (v === "") return null;
|
|
54
|
+
if (field.kind === "number" && Number.isNaN(Number(v))) return "Must be a number";
|
|
55
|
+
if (field.format === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v))
|
|
56
|
+
return "Invalid email";
|
|
57
|
+
if (
|
|
58
|
+
(field.format === "uri" || field.format === "url") &&
|
|
59
|
+
!/^(https?:\/\/|\/)/.test(v)
|
|
60
|
+
)
|
|
61
|
+
return "Invalid URL";
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Coerce a raw string input back to the JSON type its field expects. */
|
|
66
|
+
function coerce(raw: string, field: FieldInput): unknown {
|
|
67
|
+
switch (field.kind) {
|
|
68
|
+
case "number":
|
|
69
|
+
return Number(raw);
|
|
70
|
+
case "boolean":
|
|
71
|
+
return raw === "true";
|
|
72
|
+
case "array":
|
|
73
|
+
return raw
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((s) => s.trim())
|
|
76
|
+
.filter((s) => s.length > 0);
|
|
77
|
+
default:
|
|
78
|
+
return raw;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Universal create/edit form. Renders one input per field of the request body
|
|
84
|
+
* schema — `enum` → select, `boolean` → true/false select, `number` → numeric
|
|
85
|
+
* input, everything else → text input. Required fields are marked. On submit it
|
|
86
|
+
* coerces every value back to its JSON type and hands a plain object to the
|
|
87
|
+
* page, which forwards it to the SDK.
|
|
88
|
+
*/
|
|
89
|
+
export function ResourceForm(props: ResourceFormProps) {
|
|
90
|
+
const { title, description, fields, submitting, error, result, initialValues, onSubmit } =
|
|
91
|
+
props;
|
|
92
|
+
const [values, setValues] = React.useState<Record<string, string>>({});
|
|
93
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
|
94
|
+
|
|
95
|
+
// Prefill an edit form once the current record arrives — only primitive
|
|
96
|
+
// fields the form actually has an input for.
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
if (initialValues === undefined) return;
|
|
99
|
+
const seed: Record<string, string> = {};
|
|
100
|
+
for (const field of fields) {
|
|
101
|
+
const v = initialValues[field.name];
|
|
102
|
+
if (v === undefined || v === null || typeof v === "object") continue;
|
|
103
|
+
seed[field.name] = String(v);
|
|
104
|
+
}
|
|
105
|
+
setValues(seed);
|
|
106
|
+
}, [initialValues, fields]);
|
|
107
|
+
|
|
108
|
+
const set = (name: string, value: string) => {
|
|
109
|
+
setValues((prev) => ({ ...prev, [name]: value }));
|
|
110
|
+
setErrors((prev) => (prev[name] ? { ...prev, [name]: "" } : prev));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
const found: Record<string, string> = {};
|
|
116
|
+
for (const field of fields) {
|
|
117
|
+
const msg = fieldError(field, values[field.name] ?? "");
|
|
118
|
+
if (msg !== null) found[field.name] = msg;
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(found).length > 0) {
|
|
121
|
+
setErrors(found);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const out: Record<string, unknown> = {};
|
|
125
|
+
for (const field of fields) {
|
|
126
|
+
const raw = values[field.name];
|
|
127
|
+
if (raw === undefined || raw === "") continue;
|
|
128
|
+
out[field.name] = coerce(raw, field);
|
|
129
|
+
}
|
|
130
|
+
onSubmit(out);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="max-w-2xl px-5 py-6 md:px-8">
|
|
135
|
+
<h1 className="mb-5 text-xl font-semibold tracking-tight">{title}</h1>
|
|
136
|
+
<Card>
|
|
137
|
+
<form onSubmit={handleSubmit}>
|
|
138
|
+
<CardHeader>
|
|
139
|
+
<CardTitle className="text-base">Details</CardTitle>
|
|
140
|
+
{description ? <CardDescription>{description}</CardDescription> : null}
|
|
141
|
+
</CardHeader>
|
|
142
|
+
<CardContent className="space-y-4">
|
|
143
|
+
{fields.map((field) => {
|
|
144
|
+
const value = values[field.name] ?? "";
|
|
145
|
+
const label = (
|
|
146
|
+
<Label htmlFor={field.name} className="text-sm">
|
|
147
|
+
{humanizeLabel(field.name)}
|
|
148
|
+
{field.required ? (
|
|
149
|
+
<span className="ml-0.5 text-destructive">*</span>
|
|
150
|
+
) : null}
|
|
151
|
+
</Label>
|
|
152
|
+
);
|
|
153
|
+
if (field.kind === "enum" && field.enumValues) {
|
|
154
|
+
return (
|
|
155
|
+
<div key={field.name} className="space-y-1.5">
|
|
156
|
+
{label}
|
|
157
|
+
<Select
|
|
158
|
+
value={value}
|
|
159
|
+
onValueChange={(v) => set(field.name, v)}
|
|
160
|
+
>
|
|
161
|
+
<SelectTrigger id={field.name}>
|
|
162
|
+
<SelectValue placeholder={`Select ${field.name}`} />
|
|
163
|
+
</SelectTrigger>
|
|
164
|
+
<SelectContent>
|
|
165
|
+
{field.enumValues.map((opt) => (
|
|
166
|
+
<SelectItem key={String(opt)} value={String(opt)}>
|
|
167
|
+
{String(opt)}
|
|
168
|
+
</SelectItem>
|
|
169
|
+
))}
|
|
170
|
+
</SelectContent>
|
|
171
|
+
</Select>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (field.kind === "boolean") {
|
|
176
|
+
return (
|
|
177
|
+
<div key={field.name} className="space-y-1.5">
|
|
178
|
+
{label}
|
|
179
|
+
<Select
|
|
180
|
+
value={value}
|
|
181
|
+
onValueChange={(v) => set(field.name, v)}
|
|
182
|
+
>
|
|
183
|
+
<SelectTrigger id={field.name}>
|
|
184
|
+
<SelectValue placeholder="Select" />
|
|
185
|
+
</SelectTrigger>
|
|
186
|
+
<SelectContent>
|
|
187
|
+
<SelectItem value="true">true</SelectItem>
|
|
188
|
+
<SelectItem value="false">false</SelectItem>
|
|
189
|
+
</SelectContent>
|
|
190
|
+
</Select>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return (
|
|
195
|
+
<div key={field.name} className="space-y-1.5">
|
|
196
|
+
{label}
|
|
197
|
+
<Input
|
|
198
|
+
id={field.name}
|
|
199
|
+
type={inputType(field)}
|
|
200
|
+
value={value}
|
|
201
|
+
aria-invalid={errors[field.name] ? true : undefined}
|
|
202
|
+
className={errors[field.name] ? "border-destructive" : undefined}
|
|
203
|
+
placeholder={
|
|
204
|
+
field.kind === "array" ? "comma,separated,values" : undefined
|
|
205
|
+
}
|
|
206
|
+
onChange={(e) => set(field.name, e.target.value)}
|
|
207
|
+
/>
|
|
208
|
+
{errors[field.name] ? (
|
|
209
|
+
<p className="text-xs text-destructive">{errors[field.name]}</p>
|
|
210
|
+
) : null}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
})}
|
|
214
|
+
{fields.length === 0 ? (
|
|
215
|
+
<p className="text-sm text-muted-foreground">
|
|
216
|
+
This request takes no body fields — submit to send.
|
|
217
|
+
</p>
|
|
218
|
+
) : null}
|
|
219
|
+
</CardContent>
|
|
220
|
+
<CardFooter className="flex items-center gap-3">
|
|
221
|
+
<Button type="submit" disabled={submitting}>
|
|
222
|
+
{submitting ? "Saving…" : "Submit"}
|
|
223
|
+
</Button>
|
|
224
|
+
{error ? (
|
|
225
|
+
<span className="break-words text-sm text-destructive">{error}</span>
|
|
226
|
+
) : null}
|
|
227
|
+
{result ? (
|
|
228
|
+
<span className="text-sm text-emerald-600">{result}</span>
|
|
229
|
+
) : null}
|
|
230
|
+
</CardFooter>
|
|
231
|
+
</form>
|
|
232
|
+
</Card>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Activity,
|
|
3
|
+
BarChart3,
|
|
4
|
+
Bell,
|
|
5
|
+
Box,
|
|
6
|
+
Building2,
|
|
7
|
+
Calendar,
|
|
8
|
+
CreditCard,
|
|
9
|
+
Database,
|
|
10
|
+
FileText,
|
|
11
|
+
Folder,
|
|
12
|
+
Hash,
|
|
13
|
+
Image,
|
|
14
|
+
KeyRound,
|
|
15
|
+
Layers,
|
|
16
|
+
type LucideIcon,
|
|
17
|
+
MapPin,
|
|
18
|
+
MessageSquare,
|
|
19
|
+
Package,
|
|
20
|
+
Receipt,
|
|
21
|
+
Settings,
|
|
22
|
+
ShoppingCart,
|
|
23
|
+
Tag,
|
|
24
|
+
Ticket,
|
|
25
|
+
Truck,
|
|
26
|
+
Users,
|
|
27
|
+
Wallet,
|
|
28
|
+
Webhook,
|
|
29
|
+
} from "lucide-react";
|
|
30
|
+
import * as React from "react";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic resource-name → icon mapping. Pure keyword match against the
|
|
34
|
+
* resource name, no domain config — `orders` gets a cart, `payments` a card,
|
|
35
|
+
* `users` people, and anything unmatched a neutral box. Gives every nav item,
|
|
36
|
+
* stat card, and landing link a glyph without an LLM choosing one.
|
|
37
|
+
*/
|
|
38
|
+
const RULES: Array<readonly [RegExp, LucideIcon]> = [
|
|
39
|
+
[/sale|order|cart|checkout|purchas/, ShoppingCart],
|
|
40
|
+
[/product|commodit|item|catalog|good|inventor/, Package],
|
|
41
|
+
[/user|customer|member|account|citizen|seller|admin|people|person|profile|contact/, Users],
|
|
42
|
+
[/payment|deposit|transaction|invoice|bill|charge|refund|ledger/, CreditCard],
|
|
43
|
+
[/wallet|balance|fund|mileage|point|reward|credit/, Wallet],
|
|
44
|
+
[/deliver|shipment|shipping|fulfil|logistic/, Truck],
|
|
45
|
+
[/folder|directory/, Folder],
|
|
46
|
+
[/file|document|attachment/, FileText],
|
|
47
|
+
[/image|photo|picture|media|gallery/, Image],
|
|
48
|
+
[/channel/, Hash],
|
|
49
|
+
[/section|categor|group|department|taxonom/, Layers],
|
|
50
|
+
[/coupon|discount|promotion|voucher/, Ticket],
|
|
51
|
+
[/tag|label|keyword/, Tag],
|
|
52
|
+
[/message|comment|question|review|reply|chat|note|post/, MessageSquare],
|
|
53
|
+
[/setting|config|system|preference/, Settings],
|
|
54
|
+
[/auth|login|session|token|credential|security/, KeyRound],
|
|
55
|
+
[/performance|metric|stat|analytic|report|dashboard|insight/, BarChart3],
|
|
56
|
+
[/webhook|event|hook|trigger|subscription/, Webhook],
|
|
57
|
+
[/notification|alert|reminder/, Bell],
|
|
58
|
+
[/calendar|schedule|appointment|booking/, Calendar],
|
|
59
|
+
[/location|address|place|region|geo|map/, MapPin],
|
|
60
|
+
[/company|organization|store|shop|brand|tenant|workspace/, Building2],
|
|
61
|
+
[/log|history|audit|activity|trace/, Activity],
|
|
62
|
+
[/receipt|tax/, Receipt],
|
|
63
|
+
[/data|record|entit/, Database],
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function normalize(name: string): string {
|
|
67
|
+
return name
|
|
68
|
+
.replace(/^\//, "")
|
|
69
|
+
.replace(/[_/-]+/g, " ")
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function iconFor(name: string): LucideIcon {
|
|
74
|
+
const norm = normalize(name);
|
|
75
|
+
for (const [re, Icon] of RULES) if (re.test(norm)) return Icon;
|
|
76
|
+
return Box;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ResourceIcon({
|
|
80
|
+
name,
|
|
81
|
+
className,
|
|
82
|
+
}: {
|
|
83
|
+
name: string;
|
|
84
|
+
className?: string;
|
|
85
|
+
}) {
|
|
86
|
+
const Icon = iconFor(name);
|
|
87
|
+
return <Icon className={className} aria-hidden />;
|
|
88
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { ResourceIcon } from "@/components/auto/ResourceIcon";
|
|
7
|
+
|
|
8
|
+
export interface LandingLink {
|
|
9
|
+
href: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Hub-ness — how many distinct child resources nest under this one
|
|
14
|
+
* (`/files/{id}/metadata`, `/files/{id}/versions` → `files` scores 2+). A
|
|
15
|
+
* genuine domain hub scores high; a leaf resource scores 0. Derived
|
|
16
|
+
* deterministically from the producer→consumer graph; used to surface the key
|
|
17
|
+
* resources above the long tail.
|
|
18
|
+
*/
|
|
19
|
+
weight?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ResourceLandingProps {
|
|
23
|
+
title: string;
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
links: LandingLink[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Leading token of a resource path, singularized, for grouping. */
|
|
29
|
+
function tokenOf(href: string): string {
|
|
30
|
+
const resource = href.replace(/^\//, "").split("/")[0] ?? "";
|
|
31
|
+
const first = resource.split("_")[0] ?? resource;
|
|
32
|
+
return first.length > 3 && first.endsWith("s") ? first.slice(0, -1) : first;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Group landing links by their leading resource token, mirroring the sidebar:
|
|
37
|
+
* a token shared by >=2 resources becomes its own section, lone resources fall
|
|
38
|
+
* under "Resources". Returns sections in a stable order (Resources first, then
|
|
39
|
+
* named groups alphabetically).
|
|
40
|
+
*/
|
|
41
|
+
function groupLinks(links: LandingLink[]): Array<[string, LandingLink[]]> {
|
|
42
|
+
const count = new Map<string, number>();
|
|
43
|
+
for (const l of links) count.set(tokenOf(l.href), (count.get(tokenOf(l.href)) ?? 0) + 1);
|
|
44
|
+
const sections = new Map<string, LandingLink[]>();
|
|
45
|
+
for (const l of links) {
|
|
46
|
+
const token = tokenOf(l.href);
|
|
47
|
+
const group =
|
|
48
|
+
(count.get(token) ?? 0) >= 2
|
|
49
|
+
? token.charAt(0).toUpperCase() + token.slice(1)
|
|
50
|
+
: "Resources";
|
|
51
|
+
const bucket = sections.get(group) ?? [];
|
|
52
|
+
bucket.push(l);
|
|
53
|
+
sections.set(group, bucket);
|
|
54
|
+
}
|
|
55
|
+
return [...sections.entries()].sort((a, b) =>
|
|
56
|
+
a[0] === "Resources" ? -1 : b[0] === "Resources" ? 1 : a[0].localeCompare(b[0]),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A resource that parents >=2 distinct child resources is a domain hub. */
|
|
61
|
+
const KEY_WEIGHT = 2;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Order the landing into a hierarchy: the high-centrality "Key resources" lead
|
|
65
|
+
* (the hubs other resources hang off of), then the rest grouped by prefix. This
|
|
66
|
+
* is the IR's producer→consumer graph turned into visual priority — `files`,
|
|
67
|
+
* `folders`, `users` rise above a deep niche resource deterministically, no LLM
|
|
68
|
+
* guessing which resources "matter".
|
|
69
|
+
*/
|
|
70
|
+
function buildSections(links: LandingLink[]): Array<[string, LandingLink[]]> {
|
|
71
|
+
const key = links
|
|
72
|
+
.filter((l) => (l.weight ?? 0) >= KEY_WEIGHT)
|
|
73
|
+
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0) || a.title.localeCompare(b.title));
|
|
74
|
+
const rest = links.filter((l) => (l.weight ?? 0) < KEY_WEIGHT);
|
|
75
|
+
const sections: Array<[string, LandingLink[]]> = [];
|
|
76
|
+
if (key.length > 0) sections.push(["Key resources", key]);
|
|
77
|
+
sections.push(...groupLinks(rest));
|
|
78
|
+
return sections;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The grouped resource-link grid — sections mirroring the sidebar. Extracted so
|
|
83
|
+
* both the plain landing and the dashboard (which adds a stat strip above) reuse
|
|
84
|
+
* the exact same link layout instead of duplicating the grouping logic.
|
|
85
|
+
*/
|
|
86
|
+
export function ResourceLinks({ links }: { links: LandingLink[] }) {
|
|
87
|
+
const sections = buildSections(links);
|
|
88
|
+
if (links.length === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<p className="text-sm text-muted-foreground">
|
|
91
|
+
No resources were found in this API document.
|
|
92
|
+
</p>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
<div className="space-y-7">
|
|
97
|
+
{sections.map(([group, items]) => {
|
|
98
|
+
const isKey = group === "Key resources";
|
|
99
|
+
return (
|
|
100
|
+
<section key={group}>
|
|
101
|
+
<h2 className="mb-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
102
|
+
{group}
|
|
103
|
+
<span className="ml-2 text-muted-foreground/60">{items.length}</span>
|
|
104
|
+
</h2>
|
|
105
|
+
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
106
|
+
{items.map((link) => (
|
|
107
|
+
<Link
|
|
108
|
+
key={link.href}
|
|
109
|
+
href={link.href}
|
|
110
|
+
className={
|
|
111
|
+
isKey
|
|
112
|
+
? "group flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3 text-sm shadow-sm transition-colors hover:border-primary/50 hover:bg-muted/40"
|
|
113
|
+
: "group flex items-center justify-between rounded-md border border-border/70 px-3.5 py-2.5 text-sm transition-colors hover:border-primary/40 hover:bg-muted/40"
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
<span className="flex min-w-0 items-center gap-2.5">
|
|
117
|
+
<ResourceIcon
|
|
118
|
+
name={link.href}
|
|
119
|
+
className="h-4 w-4 shrink-0 text-muted-foreground"
|
|
120
|
+
/>
|
|
121
|
+
<span className="truncate font-medium">{link.title}</span>
|
|
122
|
+
</span>
|
|
123
|
+
<span className="ml-2 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5">
|
|
124
|
+
→
|
|
125
|
+
</span>
|
|
126
|
+
</Link>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
</section>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Universal landing hub. A dense, grouped list of resources — sections mirror
|
|
138
|
+
* the sidebar so a large API reads as a handful of areas, not a flat wall of
|
|
139
|
+
* identical cards. No per-card description: the title already names the
|
|
140
|
+
* resource, so repeating "Browse and search X" 36 times is pure noise.
|
|
141
|
+
*/
|
|
142
|
+
export function ResourceLanding(props: ResourceLandingProps) {
|
|
143
|
+
const { title, subtitle, links } = props;
|
|
144
|
+
return (
|
|
145
|
+
<div className="px-5 py-8 md:px-8">
|
|
146
|
+
<div className="mb-7">
|
|
147
|
+
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
148
|
+
{subtitle ? (
|
|
149
|
+
<p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
|
|
150
|
+
) : null}
|
|
151
|
+
</div>
|
|
152
|
+
<ResourceLinks links={links} />
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Card,
|
|
9
|
+
CardContent,
|
|
10
|
+
CardDescription,
|
|
11
|
+
CardHeader,
|
|
12
|
+
CardTitle,
|
|
13
|
+
} from "@/components/ui/card";
|
|
14
|
+
import { Input } from "@/components/ui/input";
|
|
15
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
16
|
+
import {
|
|
17
|
+
Table,
|
|
18
|
+
TableBody,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableHead,
|
|
21
|
+
TableHeader,
|
|
22
|
+
TableRow,
|
|
23
|
+
} from "@/components/ui/table";
|
|
24
|
+
|
|
25
|
+
import { humanizeLabel } from "@/lib/utils";
|
|
26
|
+
|
|
27
|
+
import { formatCell } from "./formatValue";
|
|
28
|
+
import type { ColumnSpec } from "./types";
|
|
29
|
+
|
|
30
|
+
export interface ResourceTableProps {
|
|
31
|
+
title: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
/** One column per field of the row type — the whole schema, nothing dropped. */
|
|
34
|
+
columns: ColumnSpec[];
|
|
35
|
+
/** Rows as returned by the SDK. Typed `unknown` so the page never casts. */
|
|
36
|
+
rows: readonly unknown[];
|
|
37
|
+
loading: boolean;
|
|
38
|
+
error?: string | null;
|
|
39
|
+
onRetry?: () => void;
|
|
40
|
+
/** Build the detail-screen href for a row, or `null` when none exists. */
|
|
41
|
+
rowHref?: (row: Record<string, unknown>) => string | null;
|
|
42
|
+
emptyHint?: string;
|
|
43
|
+
/** Search box, when the list endpoint exposes a text search param. */
|
|
44
|
+
search?: { value: string; onChange: (value: string) => void };
|
|
45
|
+
/** Pager, when the list endpoint exposes a page/offset param. `hasNext` is a
|
|
46
|
+
* best-effort guess (a full page implies more) since totals are rarely typed. */
|
|
47
|
+
pagination?: {
|
|
48
|
+
page: number;
|
|
49
|
+
onPrev: () => void;
|
|
50
|
+
onNext: () => void;
|
|
51
|
+
hasNext: boolean;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Universal list/table screen. Renders one column per `ColumnSpec` and one row
|
|
57
|
+
* per SDK record. Schema-driven: the column set is the entire row type, so the
|
|
58
|
+
* table is as dense and complete as the swagger allows — no field is ever
|
|
59
|
+
* silently omitted. Handles loading / error / empty / data states.
|
|
60
|
+
*/
|
|
61
|
+
export function ResourceTable(props: ResourceTableProps) {
|
|
62
|
+
const {
|
|
63
|
+
title,
|
|
64
|
+
description,
|
|
65
|
+
columns,
|
|
66
|
+
loading,
|
|
67
|
+
error,
|
|
68
|
+
onRetry,
|
|
69
|
+
rowHref,
|
|
70
|
+
emptyHint,
|
|
71
|
+
search,
|
|
72
|
+
pagination,
|
|
73
|
+
} = props;
|
|
74
|
+
// Defensive: never let a non-array `rows` (an optional collection field the
|
|
75
|
+
// server omitted) crash the table on `.length` / `.map`.
|
|
76
|
+
const rows: readonly unknown[] = Array.isArray(props.rows) ? props.rows : [];
|
|
77
|
+
|
|
78
|
+
const body = (() => {
|
|
79
|
+
if (error !== null && error !== undefined) {
|
|
80
|
+
return (
|
|
81
|
+
<Card className="border-destructive/40 bg-destructive/5">
|
|
82
|
+
<CardHeader>
|
|
83
|
+
<CardTitle className="text-base text-destructive">
|
|
84
|
+
Couldn’t load {title.toLowerCase()}
|
|
85
|
+
</CardTitle>
|
|
86
|
+
<CardDescription className="break-words">{error}</CardDescription>
|
|
87
|
+
</CardHeader>
|
|
88
|
+
{onRetry ? (
|
|
89
|
+
<CardContent>
|
|
90
|
+
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
91
|
+
Retry
|
|
92
|
+
</Button>
|
|
93
|
+
</CardContent>
|
|
94
|
+
) : null}
|
|
95
|
+
</Card>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (loading) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
102
|
+
<Skeleton key={i} className="h-10 w-full" />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (rows.length === 0) {
|
|
108
|
+
return (
|
|
109
|
+
<Card>
|
|
110
|
+
<CardHeader>
|
|
111
|
+
<CardTitle className="text-base">No {title.toLowerCase()} yet</CardTitle>
|
|
112
|
+
<CardDescription>
|
|
113
|
+
{emptyHint ?? "The list came back empty."}
|
|
114
|
+
</CardDescription>
|
|
115
|
+
</CardHeader>
|
|
116
|
+
</Card>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<div className="overflow-x-auto rounded-lg border bg-background shadow-sm">
|
|
121
|
+
<Table>
|
|
122
|
+
<TableHeader className="sticky top-0 bg-muted/60 backdrop-blur">
|
|
123
|
+
<TableRow className="hover:bg-transparent">
|
|
124
|
+
{columns.map((c) => (
|
|
125
|
+
<TableHead
|
|
126
|
+
key={c.name}
|
|
127
|
+
className="h-10 whitespace-nowrap text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
128
|
+
>
|
|
129
|
+
{humanizeLabel(c.name)}
|
|
130
|
+
</TableHead>
|
|
131
|
+
))}
|
|
132
|
+
{rowHref ? <TableHead className="w-0" /> : null}
|
|
133
|
+
</TableRow>
|
|
134
|
+
</TableHeader>
|
|
135
|
+
<TableBody>
|
|
136
|
+
{rows.map((raw, i) => {
|
|
137
|
+
const row = (raw ?? {}) as Record<string, unknown>;
|
|
138
|
+
const href = rowHref ? rowHref(row) : null;
|
|
139
|
+
return (
|
|
140
|
+
<TableRow key={i} className="group">
|
|
141
|
+
{columns.map((c) => (
|
|
142
|
+
<TableCell key={c.name} className="max-w-xs align-top text-sm">
|
|
143
|
+
{formatCell(row[c.name], c)}
|
|
144
|
+
</TableCell>
|
|
145
|
+
))}
|
|
146
|
+
{rowHref ? (
|
|
147
|
+
<TableCell className="w-0 text-right">
|
|
148
|
+
{href ? (
|
|
149
|
+
<Link
|
|
150
|
+
href={href}
|
|
151
|
+
className="inline-flex items-center rounded-md px-2 py-1 text-sm font-medium text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
|
152
|
+
>
|
|
153
|
+
View →
|
|
154
|
+
</Link>
|
|
155
|
+
) : null}
|
|
156
|
+
</TableCell>
|
|
157
|
+
) : null}
|
|
158
|
+
</TableRow>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</TableBody>
|
|
162
|
+
</Table>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
})();
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="px-5 py-6 md:px-8">
|
|
169
|
+
<div className="mb-5 flex items-end justify-between gap-4">
|
|
170
|
+
<div>
|
|
171
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
172
|
+
{description ? (
|
|
173
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
<div className="flex items-center gap-3">
|
|
177
|
+
{search ? (
|
|
178
|
+
<Input
|
|
179
|
+
value={search.value}
|
|
180
|
+
onChange={(e) => search.onChange(e.target.value)}
|
|
181
|
+
placeholder="Search…"
|
|
182
|
+
className="h-9 w-48"
|
|
183
|
+
/>
|
|
184
|
+
) : null}
|
|
185
|
+
{!loading && !error ? (
|
|
186
|
+
<span className="hidden text-xs text-muted-foreground sm:inline">
|
|
187
|
+
{rows.length} row{rows.length === 1 ? "" : "s"} · {columns.length} cols
|
|
188
|
+
</span>
|
|
189
|
+
) : null}
|
|
190
|
+
{onRetry ? (
|
|
191
|
+
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
192
|
+
Refresh
|
|
193
|
+
</Button>
|
|
194
|
+
) : null}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
{body}
|
|
198
|
+
{pagination ? (
|
|
199
|
+
<div className="mt-4 flex items-center justify-end gap-2">
|
|
200
|
+
<span className="mr-1 text-xs text-muted-foreground">
|
|
201
|
+
Page {pagination.page}
|
|
202
|
+
</span>
|
|
203
|
+
<Button
|
|
204
|
+
variant="outline"
|
|
205
|
+
size="sm"
|
|
206
|
+
disabled={pagination.page <= 1}
|
|
207
|
+
onClick={pagination.onPrev}
|
|
208
|
+
>
|
|
209
|
+
← Prev
|
|
210
|
+
</Button>
|
|
211
|
+
<Button
|
|
212
|
+
variant="outline"
|
|
213
|
+
size="sm"
|
|
214
|
+
disabled={!pagination.hasNext}
|
|
215
|
+
onClick={pagination.onNext}
|
|
216
|
+
>
|
|
217
|
+
Next →
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
) : null}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|