feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)
This commit is contained in:
1 parent
0a0b3a12c9
commit
3b2fc2527e
181 files changed
+18472
-4364
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { customJsxRequestSchema } from "../core";
|
||||
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "dynamicRequestId"
|
||||
| "invalidRequestManifest"
|
||||
| "legacyNetworkProps"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
| "unknownRequest";
|
||||
|
||||
export interface EditorDiagnostic {
|
||||
code: EditorDiagnosticCode;
|
||||
severity: "error" | "warning";
|
||||
line?: number;
|
||||
column?: number;
|
||||
index?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface AnalyzeJsxTemplateOptions {
|
||||
apiVersion?: 1 | 2;
|
||||
requestIds?: string[];
|
||||
}
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
|
||||
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
|
||||
|
||||
export function analyzeJsxTemplate(
|
||||
template: string,
|
||||
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT)
|
||||
diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 });
|
||||
diagnostics.push(
|
||||
...validateCustomJsxTemplate(template).map((entry) => ({
|
||||
code: "ast" as const,
|
||||
severity: entry.severity,
|
||||
line: entry.line,
|
||||
column: entry.column,
|
||||
index: entry.index,
|
||||
value: entry.message,
|
||||
})),
|
||||
);
|
||||
if (apiVersion !== 2) return diagnostics;
|
||||
const knownIds = new Set(requestIds);
|
||||
for (const match of template.matchAll(networkComponentPattern)) {
|
||||
const props = match[2] ?? "";
|
||||
const line = lineAt(template, match.index);
|
||||
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
|
||||
}
|
||||
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
|
||||
if (staticRequest?.[1] && !knownIds.has(staticRequest[1])) {
|
||||
diagnostics.push({
|
||||
code: "unknownRequest",
|
||||
severity: "error",
|
||||
line,
|
||||
index: match.index,
|
||||
value: staticRequest[1],
|
||||
});
|
||||
} else if (!staticRequest && /\brequestId\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index });
|
||||
} else if (!staticRequest) {
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
try {
|
||||
const result = customJsxRequestSchema.array().safeParse(JSON.parse(value) as unknown);
|
||||
if (result.success) return [];
|
||||
return result.error.issues.map((issue) => ({
|
||||
code: "invalidRequestManifest",
|
||||
severity: "error",
|
||||
line: 1,
|
||||
value: `${issue.path.map(String).join(".") || "requests"}: ${issue.message}`,
|
||||
}));
|
||||
} catch {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequestManifest(value: string): unknown[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Button, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconComponents, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { enabledCustomJsxComponents } from "../core/component-registry";
|
||||
|
||||
export interface ComponentReferenceMessages {
|
||||
action: string;
|
||||
search: string;
|
||||
empty: string;
|
||||
count(count: number): string;
|
||||
}
|
||||
|
||||
export function ComponentReference({ messages }: { messages: ComponentReferenceMessages }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalized.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalized) ||
|
||||
component.category.toLocaleLowerCase().includes(normalized),
|
||||
);
|
||||
}, [query]);
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button type="button" size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{messages.action}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={messages.search}
|
||||
aria-label={messages.search}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{messages.empty}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.count(enabledCustomJsxComponents.length)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { buildDisplayFormValues } from "../core";
|
||||
|
||||
const requiredScalarFields: Record<string, string[]> = {
|
||||
singleValue: ["jsonPath"],
|
||||
table: ["tablePath"],
|
||||
raw: ["rawJsonPath"],
|
||||
actionButton: ["buttonLabel"],
|
||||
customJsx: ["template"],
|
||||
};
|
||||
const requiredCollectionFields: Record<string, { collection: string; fields: string[] }> = {
|
||||
keyValue: { collection: "mappings", fields: ["label", "jsonPath"] },
|
||||
table: { collection: "columns", fields: ["header", "jsonPath"] },
|
||||
statGrid: { collection: "statGridItems", fields: ["label", "jsonPath"] },
|
||||
progressBars: { collection: "progressBars", fields: ["label", "valuePath"] },
|
||||
statusIndicator: { collection: "statusItems", fields: ["label", "jsonPath", "goodValues"] },
|
||||
countGrid: { collection: "countGridItems", fields: ["label", "jsonPath"] },
|
||||
};
|
||||
|
||||
const requiredString = z.string();
|
||||
export const customWidgetFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: requiredString,
|
||||
iconUrl: requiredString,
|
||||
url: z.string().trim().min(1),
|
||||
authType: requiredString,
|
||||
headerName: requiredString,
|
||||
method: requiredString,
|
||||
requestBody: requiredString,
|
||||
displayType: requiredString,
|
||||
jsonPath: requiredString,
|
||||
label: requiredString,
|
||||
unit: requiredString,
|
||||
valueSize: requiredString,
|
||||
labelPosition: requiredString,
|
||||
mappings: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
|
||||
kvLayout: requiredString,
|
||||
kvColumns: z.number(),
|
||||
tablePath: requiredString,
|
||||
columns: z.array(z.object({ header: requiredString, jsonPath: requiredString })),
|
||||
striped: z.boolean(),
|
||||
compact: z.boolean(),
|
||||
statGridItems: z.array(
|
||||
z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString, color: requiredString }),
|
||||
),
|
||||
statGridColumns: z.number(),
|
||||
cardStyle: requiredString,
|
||||
progressBars: z.array(
|
||||
z.object({
|
||||
label: requiredString,
|
||||
valuePath: requiredString,
|
||||
maxPath: requiredString,
|
||||
unit: requiredString,
|
||||
color: requiredString,
|
||||
}),
|
||||
),
|
||||
showPercentage: z.boolean(),
|
||||
barSize: requiredString,
|
||||
statusItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, goodValues: requiredString })),
|
||||
statusLayout: requiredString,
|
||||
dotSize: requiredString,
|
||||
countGridItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
|
||||
countGridColumns: z.number(),
|
||||
countValueSize: requiredString,
|
||||
rawJsonPath: requiredString,
|
||||
rawMaxHeight: z.number(),
|
||||
buttonLabel: requiredString,
|
||||
buttonColor: requiredString,
|
||||
confirmText: requiredString,
|
||||
successMessage: requiredString,
|
||||
template: requiredString,
|
||||
jsxApiVersion: requiredString,
|
||||
networkScope: requiredString,
|
||||
requestManifest: requiredString,
|
||||
secrets: z.array(z.object({ kind: requiredString, value: requiredString, hasValue: z.boolean().optional() })),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
for (const field of requiredScalarFields[data.displayType] ?? []) {
|
||||
if (!data[field as keyof typeof data]) addRequiredIssue(ctx, [field], data[field as keyof typeof data]);
|
||||
}
|
||||
const collection = requiredCollectionFields[data.displayType];
|
||||
if (collection) {
|
||||
const entries = data[collection.collection as keyof typeof data];
|
||||
if (Array.isArray(entries))
|
||||
entries.forEach((entry, index) => {
|
||||
for (const field of collection.fields) {
|
||||
const value = (entry as Record<string, unknown>)[field];
|
||||
if (!value) addRequiredIssue(ctx, [collection.collection, index, field], value);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (data.displayType === "customJsx" && data.jsxApiVersion === "2" && !isJsonArray(data.requestManifest)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "invalidRequestManifest",
|
||||
input: data.requestManifest,
|
||||
path: ["requestManifest"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomWidgetFormValues = z.infer<typeof customWidgetFormSchema>;
|
||||
|
||||
export const CUSTOM_WIDGET_AUTH_SECRET_FIELDS: Record<
|
||||
string,
|
||||
Array<{ kind: string; labelKey: string; isPassword: boolean }>
|
||||
> = {
|
||||
bearer: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
|
||||
basic: [
|
||||
{ kind: "username", labelKey: "username", isPassword: false },
|
||||
{ kind: "password", labelKey: "password", isPassword: true },
|
||||
],
|
||||
apiKeyHeader: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
|
||||
apiKeyQuery: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
|
||||
};
|
||||
|
||||
export const CUSTOM_WIDGET_AUTH_USES_HEADER_NAME: Record<string, boolean> = { apiKeyHeader: true, apiKeyQuery: true };
|
||||
|
||||
export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
url: "",
|
||||
authType: "none",
|
||||
headerName: "",
|
||||
method: "GET",
|
||||
requestBody: "",
|
||||
...buildDisplayFormValues("singleValue", {}),
|
||||
secrets: [],
|
||||
};
|
||||
|
||||
function addRequiredIssue(ctx: z.core.$RefinementCtx, path: PropertyKey[], input: unknown): void {
|
||||
ctx.addIssue({ code: "too_small", minimum: 1, origin: "string", inclusive: true, input, path });
|
||||
}
|
||||
|
||||
function isJsonArray(value: string): boolean {
|
||||
try {
|
||||
return Array.isArray(JSON.parse(value));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Alert, Badge, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { ImportReview } from "../core/import";
|
||||
|
||||
export interface ImportReviewMessages {
|
||||
title: string;
|
||||
description: string;
|
||||
name: string;
|
||||
origin: string;
|
||||
authentication: string;
|
||||
networkScope: string;
|
||||
methods: string;
|
||||
permissions: string;
|
||||
actionWarningTitle: string;
|
||||
actionWarningDescription: string;
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
permission(permission: string): string;
|
||||
}
|
||||
|
||||
export interface ImportReviewDialogProps {
|
||||
opened: boolean;
|
||||
review: ImportReview | null;
|
||||
pending: boolean;
|
||||
messages: ImportReviewMessages;
|
||||
onClose(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Code>{value || "—"}</Code>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportReviewDialog({ opened, review, pending, messages, onClose, onConfirm }: ImportReviewDialogProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={messages.title} centered size="lg">
|
||||
{review && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{messages.description}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={messages.name} value={review.name} />
|
||||
<ImportFact label={messages.origin} value={review.origin} />
|
||||
<ImportFact label={messages.authentication} value={review.authType} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScope} />
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.methods}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.permissions}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{messages.permission(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.actionWarningTitle}
|
||||
</Text>
|
||||
<Text size="sm">{messages.actionWarningDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" onClick={onClose} disabled={pending}>
|
||||
{messages.cancel}
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} loading={pending}>
|
||||
{messages.confirm}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export * from "./analyzer";
|
||||
export * from "./response-tree";
|
||||
export * from "./import-review";
|
||||
export * from "./ports";
|
||||
export * from "./component-reference";
|
||||
export * from "./preview-error-boundary";
|
||||
export * from "./preview-response-panel";
|
||||
export * from "./preview-request-panel";
|
||||
export * from "./preview-diagnostics-panel";
|
||||
export * from "./preview-header";
|
||||
export * from "./preview-utils";
|
||||
export {
|
||||
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
|
||||
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
customWidgetFormSchema,
|
||||
} from "./form-schema";
|
||||
export type { CustomWidgetFormValues } from "./form-schema";
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CustomWidgetFormValues } from "./form-schema";
|
||||
|
||||
export interface WorkbenchPreviewResult {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
rawResponse?: string | null;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchJournalEntry {
|
||||
requestId: string;
|
||||
method: string;
|
||||
status: number | null;
|
||||
durationMs: number;
|
||||
simulated: boolean;
|
||||
}
|
||||
|
||||
export interface CustomWidgetWorkbenchPort {
|
||||
preview(values: CustomWidgetFormValues, signal?: AbortSignal): Promise<WorkbenchPreviewResult>;
|
||||
queryPreview(
|
||||
input: { sessionId: string; requestId: string; params: Record<string, string | number | boolean> },
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown>;
|
||||
simulateAction(input: {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
params: Record<string, string | number | boolean>;
|
||||
}): Promise<unknown>;
|
||||
setLiveActions(input: { sessionId: string; enabled: boolean }): Promise<void>;
|
||||
readJournal(sessionId: string, signal?: AbortSignal): Promise<readonly WorkbenchJournalEntry[]>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Alert, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
|
||||
interface PreviewDiagnosticsMessages {
|
||||
templateSize: string;
|
||||
characters: (count: number) => string;
|
||||
namedRequests: string;
|
||||
methods: string;
|
||||
networkScope: string;
|
||||
ready: string;
|
||||
diagnostic: (diagnostic: EditorDiagnostic) => string;
|
||||
}
|
||||
|
||||
interface PreviewDiagnosticsPanelProps {
|
||||
templateLength: number;
|
||||
namedRequestCount: number;
|
||||
methods: string;
|
||||
networkScope: string;
|
||||
diagnostics: EditorDiagnostic[];
|
||||
messages: PreviewDiagnosticsMessages;
|
||||
}
|
||||
|
||||
export function PreviewDiagnosticsPanel({
|
||||
templateLength,
|
||||
namedRequestCount,
|
||||
methods,
|
||||
networkScope,
|
||||
diagnostics,
|
||||
messages,
|
||||
}: PreviewDiagnosticsPanelProps) {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<DiagnosticFact label={messages.templateSize} value={messages.characters(templateLength)} />
|
||||
<DiagnosticFact label={messages.namedRequests} value={String(namedRequestCount)} />
|
||||
<DiagnosticFact label={messages.methods} value={methods} />
|
||||
<DiagnosticFact label={messages.networkScope} value={networkScope} />
|
||||
</SimpleGrid>
|
||||
{diagnostics.length === 0 ? (
|
||||
<Alert color="green" variant="light" p="xs" icon={<IconCheck size={15} />}>
|
||||
<Text size="xs">{messages.ready}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
diagnostics.map((diagnostic, index) => (
|
||||
<Alert
|
||||
key={`${diagnostic.code}-${index}`}
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">{messages.diagnostic(diagnostic)}</Text>
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} lineClamp={2}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { Button, Card, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
export interface PreviewErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
}
|
||||
|
||||
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, { hasError: boolean }> {
|
||||
public state = { hasError: false };
|
||||
public static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
public componentDidCatch(_error: Error, _info: ErrorInfo) {
|
||||
// Imported templates and response data are intentionally not logged.
|
||||
}
|
||||
public render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<Card p="xl" role="alert">
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconAlertTriangle size={28} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fw={600} ta="center">
|
||||
{this.props.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||
{this.props.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Button type="button" variant="light" onClick={() => this.setState({ hasError: false })}>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Alert, Badge, Button, Group, Loader, Popover, Stack, Switch, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
interface PreviewHeaderMessages {
|
||||
title: string;
|
||||
interactive: string;
|
||||
capabilitiesTitle: string;
|
||||
capabilitiesDescription: string;
|
||||
liveActions: string;
|
||||
liveActionsDescription: string;
|
||||
runTestFirst: string;
|
||||
simulated: string;
|
||||
test: string;
|
||||
mutationDisabled: string;
|
||||
staleTitle: string;
|
||||
staleDescription: string;
|
||||
}
|
||||
|
||||
interface PreviewHeaderProps {
|
||||
method: string;
|
||||
url: string;
|
||||
methods: string[];
|
||||
hasNamedActions: boolean;
|
||||
hasPreviewSession: boolean;
|
||||
liveActions: boolean;
|
||||
isUpdatingLiveActions: boolean;
|
||||
isTesting: boolean;
|
||||
isSampleStale: boolean;
|
||||
testError?: string | null;
|
||||
responseError?: string;
|
||||
responseStatus?: { status: number; statusText: string } | null;
|
||||
onTest: () => void;
|
||||
onSetLiveActions?: (enabled: boolean) => void;
|
||||
methodColor: (method: string) => string;
|
||||
messages: PreviewHeaderMessages;
|
||||
}
|
||||
|
||||
export function PreviewHeader({
|
||||
method,
|
||||
url,
|
||||
methods,
|
||||
hasNamedActions,
|
||||
hasPreviewSession,
|
||||
liveActions,
|
||||
isUpdatingLiveActions,
|
||||
isTesting,
|
||||
isSampleStale,
|
||||
testError,
|
||||
responseError,
|
||||
responseStatus,
|
||||
onTest,
|
||||
onSetLiveActions,
|
||||
methodColor,
|
||||
messages,
|
||||
}: PreviewHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{messages.title}</Title>
|
||||
{methods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconNetwork size={14} />}
|
||||
>
|
||||
{messages.interactive}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.capabilitiesTitle}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.capabilitiesDescription}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{methods.map((requestMethod) => (
|
||||
<Badge key={requestMethod} size="xs" color={methodColor(requestMethod)} variant="light">
|
||||
{requestMethod}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={messages.liveActions}
|
||||
description={hasPreviewSession ? messages.liveActionsDescription : messages.runTestFirst}
|
||||
checked={liveActions}
|
||||
disabled={!hasPreviewSession || !onSetLiveActions || isUpdatingLiveActions}
|
||||
onChange={(event) => onSetLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{messages.simulated}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={onTest}
|
||||
loading={isTesting}
|
||||
disabled={method !== "GET" || !url}
|
||||
>
|
||||
{messages.test}
|
||||
</Button>
|
||||
</Group>
|
||||
{method !== "GET" && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconInfoCircle size={15} />}>
|
||||
<Text size="xs">{messages.mutationDisabled}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{isSampleStale && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconAlertTriangle size={15} />}>
|
||||
<Text size="xs" fw={600}>
|
||||
{messages.staleTitle}
|
||||
</Text>
|
||||
<Text size="xs">{messages.staleDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{responseError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{responseError}
|
||||
</Text>
|
||||
{responseStatus && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{responseStatus.status} {responseStatus.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Alert, Badge, Group, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconClipboard } from "@tabler/icons-react";
|
||||
|
||||
export interface PreviewNamedRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
minimumBoardPermission?: string;
|
||||
}
|
||||
|
||||
export interface PreviewJournalEntry {
|
||||
id: string;
|
||||
requestId: string;
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
simulated: boolean;
|
||||
status: number | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface PreviewRequestPanelMessages {
|
||||
method: string;
|
||||
authentication: string;
|
||||
endpoint: string;
|
||||
status: string;
|
||||
notRun: string;
|
||||
named: string;
|
||||
journal: string;
|
||||
journalEmpty: string;
|
||||
simulated: string;
|
||||
redacted: string;
|
||||
permission: (permission: string) => string;
|
||||
duration: (duration: number) => string;
|
||||
}
|
||||
|
||||
interface PreviewRequestPanelProps {
|
||||
method: string;
|
||||
authentication: string;
|
||||
endpoint: string;
|
||||
status?: number;
|
||||
namedRequests: PreviewNamedRequest[];
|
||||
journal?: PreviewJournalEntry[];
|
||||
methodColor: (method: string) => string;
|
||||
messages: PreviewRequestPanelMessages;
|
||||
}
|
||||
|
||||
const journalColor = ({ simulated, status }: PreviewJournalEntry): string => {
|
||||
if (simulated) return "yellow";
|
||||
if (status !== null && status >= 200 && status < 400) return "green";
|
||||
return "red";
|
||||
};
|
||||
|
||||
export function PreviewRequestPanel({
|
||||
method,
|
||||
authentication,
|
||||
endpoint,
|
||||
status,
|
||||
namedRequests,
|
||||
journal,
|
||||
methodColor,
|
||||
messages,
|
||||
}: PreviewRequestPanelProps) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact label={messages.method} value={method} />
|
||||
<RequestFact label={messages.authentication} value={authentication} />
|
||||
<RequestFact label={messages.endpoint} value={endpoint} />
|
||||
<RequestFact label={messages.status} value={status === undefined ? messages.notRun : String(status)} />
|
||||
</SimpleGrid>
|
||||
{namedRequests.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{messages.named}
|
||||
</Text>
|
||||
{namedRequests.map((request) => (
|
||||
<Paper key={request.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{request.id}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{request.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={methodColor(request.method)}>
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{messages.permission(request.minimumBoardPermission ?? "view")}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{journal && journal.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{messages.journal}
|
||||
</Text>
|
||||
{journal.map((entry) => (
|
||||
<Paper key={entry.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{entry.requestId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" truncate>
|
||||
{entry.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColor(entry.method)} variant="light">
|
||||
{entry.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color={journalColor(entry)}>
|
||||
{entry.simulated ? messages.simulated : (entry.status ?? "—")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" w={56} ta="right">
|
||||
{messages.duration(entry.durationMs)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.journalEmpty}
|
||||
</Text>
|
||||
)}
|
||||
<Alert color="blue" variant="light" p="xs" icon={<IconClipboard size={15} />}>
|
||||
<Text size="xs">{messages.redacted}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} lineClamp={2}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Collapse, CopyButton, Group, Paper, ScrollArea, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { ResponseTree } from "./response-tree";
|
||||
|
||||
export interface PreviewResponseMessages {
|
||||
empty: string;
|
||||
sampleHint: string;
|
||||
editSample: string;
|
||||
addSample: string;
|
||||
copied: string;
|
||||
copy: string;
|
||||
sampleLabel: string;
|
||||
sampleDescription: string;
|
||||
invalidSample: string;
|
||||
cancelSample: string;
|
||||
applySample: string;
|
||||
copyPath: string;
|
||||
pathCopied: string;
|
||||
insertPath: string;
|
||||
openRaw: string;
|
||||
}
|
||||
|
||||
export interface PreviewResponsePanelProps {
|
||||
value: unknown;
|
||||
rawResponse?: string | null;
|
||||
onInsertDataPath?(path: string): void;
|
||||
onSampleDataChange?(value: unknown): void;
|
||||
messages: PreviewResponseMessages;
|
||||
}
|
||||
|
||||
function openRawResponse(rawResponse: string) {
|
||||
const url = URL.createObjectURL(new Blob([rawResponse], { type: "application/json" }));
|
||||
window.open(url);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export function PreviewResponsePanel({
|
||||
value,
|
||||
rawResponse,
|
||||
onInsertDataPath,
|
||||
onSampleDataChange,
|
||||
messages,
|
||||
}: PreviewResponsePanelProps) {
|
||||
const responseJson = value == null ? "" : JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(responseJson);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const error = useMemo(() => {
|
||||
if (!draft.trim()) return null;
|
||||
try {
|
||||
JSON.parse(draft);
|
||||
return null;
|
||||
} catch {
|
||||
return messages.invalidSample;
|
||||
}
|
||||
}, [draft, messages.invalidSample]);
|
||||
const edit = () => {
|
||||
setDraft(responseJson || JSON.stringify({ name: "Sample service", status: "online", value: 42 }, null, 2));
|
||||
setEditing(true);
|
||||
};
|
||||
const apply = () => {
|
||||
if (!draft.trim() || error) return;
|
||||
onSampleDataChange?.(JSON.parse(draft) as unknown);
|
||||
setEditing(false);
|
||||
};
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{responseJson ? messages.sampleHint : messages.empty}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{onSampleDataChange && (
|
||||
<Button type="button" size="compact-xs" variant="light" onClick={edit}>
|
||||
{responseJson ? messages.editSample : messages.addSample}
|
||||
</Button>
|
||||
)}
|
||||
{responseJson && (
|
||||
<CopyButton value={responseJson}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? messages.copied : messages.copy}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse expanded={editing}>
|
||||
<Paper p="sm" bg="var(--mantine-color-default-hover)">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label={messages.sampleLabel}
|
||||
description={messages.sampleDescription}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
error={error}
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={18}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" size="xs" onClick={() => setEditing(false)}>
|
||||
{messages.cancelSample}
|
||||
</Button>
|
||||
<Button type="button" size="xs" onClick={apply} disabled={!draft.trim() || Boolean(error)}>
|
||||
{messages.applySample}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
{responseJson ? (
|
||||
<>
|
||||
<ScrollArea h={360} type="auto">
|
||||
<ResponseTree
|
||||
value={value}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
labels={{ copyPath: messages.copyPath, pathCopied: messages.pathCopied, insertPath: messages.insertPath }}
|
||||
/>
|
||||
</ScrollArea>
|
||||
{rawResponse && (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => openRawResponse(rawResponse)}
|
||||
>
|
||||
{messages.openRaw}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center" py="xl">
|
||||
{messages.empty}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PreviewNamedRequest } from "./preview-request-panel";
|
||||
|
||||
export const redactPreviewUrl = (value: string): string => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
for (const key of url.searchParams.keys()) url.searchParams.set(key, "redacted");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreviewNamedRequests = (displayConfig: Record<string, unknown>): PreviewNamedRequest[] => {
|
||||
if (!Array.isArray(displayConfig.requests)) return [];
|
||||
return displayConfig.requests.filter(
|
||||
(request): request is PreviewNamedRequest =>
|
||||
request !== null &&
|
||||
typeof request === "object" &&
|
||||
"id" in request &&
|
||||
typeof request.id === "string" &&
|
||||
"kind" in request &&
|
||||
(request.kind === "query" || request.kind === "action") &&
|
||||
"method" in request &&
|
||||
typeof request.method === "string" &&
|
||||
"pathTemplate" in request &&
|
||||
typeof request.pathTemplate === "string",
|
||||
);
|
||||
};
|
||||
|
||||
export const getPreviewLegacyMethods = (template: string): string[] => {
|
||||
const methods = new Set<string>();
|
||||
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
|
||||
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
|
||||
methods.add(method?.toUpperCase() ?? (match[0].startsWith("<SubFetch") ? "GET" : "POST"));
|
||||
}
|
||||
return [...methods];
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Code, CopyButton, Group, Text, Tree, getTreeExpandedState, useTree } from "@mantine/core";
|
||||
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import { IconCheck, IconChevronDown, IconChevronRight, IconCopy, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
export interface ResponseTreeLabels {
|
||||
copyPath: string;
|
||||
pathCopied: string;
|
||||
insertPath: string;
|
||||
}
|
||||
export interface ResponseTreeProps {
|
||||
value: unknown;
|
||||
labels: ResponseTreeLabels;
|
||||
onInsertDataPath?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface ResponseTreeData extends TreeNodeData {
|
||||
path: string;
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
export function ResponseTree({ value, labels, onInsertDataPath }: ResponseTreeProps) {
|
||||
const data = useMemo(() => [createResponseTreeNode(value, "data", "data")], [value]);
|
||||
const tree = useTree({ initialExpandedState: getTreeExpandedState(data, ["data"]) });
|
||||
return (
|
||||
<Tree
|
||||
data={data}
|
||||
tree={tree}
|
||||
levelOffset={14}
|
||||
expandOnClick
|
||||
selectOnClick={false}
|
||||
renderNode={(payload) => <ResponseTreeNode {...payload} labels={labels} onInsertDataPath={onInsertDataPath} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
labels,
|
||||
onInsertDataPath,
|
||||
}: RenderTreeNodePayload & { labels: ResponseTreeLabels; onInsertDataPath?: (path: string) => void }) {
|
||||
const responseNode = node as ResponseTreeData;
|
||||
return (
|
||||
<Group {...elementProps} gap={4} wrap="nowrap" mih={28} className="response-tree-row">
|
||||
<div style={{ width: 16, flexShrink: 0 }}>
|
||||
{hasChildren && (expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />)}
|
||||
</div>
|
||||
<Code style={{ flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", fontSize: 11 }}>
|
||||
{responseNode.label}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{responseNode.displayValue}
|
||||
</Text>
|
||||
<Group gap={2} wrap="nowrap" className="response-tree-actions">
|
||||
<CopyButton value={responseNode.path}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color={copied ? "green" : "gray"}
|
||||
size={24}
|
||||
aria-label={copied ? labels.pathCopied : labels.copyPath}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
{onInsertDataPath && (
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size={24}
|
||||
aria-label={labels.insertPath}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onInsertDataPath(responseNode.path);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function createResponseTreeNode(value: unknown, path: string, label: string): ResponseTreeData {
|
||||
const collection = value !== null && typeof value === "object";
|
||||
const entries = collection ? Object.entries(value as Record<string, unknown>) : [];
|
||||
return {
|
||||
value: path,
|
||||
label,
|
||||
path,
|
||||
displayValue: collection
|
||||
? Array.isArray(value)
|
||||
? `[${entries.length}]`
|
||||
: `{${entries.length}}`
|
||||
: formatValue(value),
|
||||
children: entries.map(([key, child]) =>
|
||||
createResponseTreeNode(child, appendDataPath(path, key, Array.isArray(value)), key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendDataPath(parent: string, key: string, isArray: boolean): string {
|
||||
if (isArray) return `${parent}[${key}]`;
|
||||
return /^[A-Za-z_$][\w$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === undefined) return "undefined";
|
||||
return String(value);
|
||||
}
|
||||
Reference in new issue
Block a user