feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-15 14:06:02 +02:00
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);
}