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,214 @@
"use client";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Switch } from "@mantine/core";
import { IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { normalizeParams } from "./data";
import { MigrationRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
play: IconPlayerPlay,
check: IconCheck,
refresh: IconRefresh,
power: IconPower,
trash: IconTrash,
};
function parseBool(value: unknown, fallback = false) {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
function useActionExecutor() {
const runtime = useCustomWidgetRuntime();
const mutation = useMutation({
mutationFn: ({
requestId,
params,
confirmed,
}: {
requestId: string;
params: CustomJsxRuntimeParams;
confirmed: boolean;
}): Promise<CustomWidgetRequestResult> => {
if (!runtime.itemId && !runtime.previewSessionId) {
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.widgetItemUnavailable });
}
if (runtime.isEditMode) {
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.actionsDisabledEditMode });
}
return runtime.port.executeAction({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
requestId,
params,
confirmed,
});
},
});
return { runtime, mutation };
}
export interface ActionButtonProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
label: string;
color?: string;
variant?: string;
size?: string;
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
icon?: string;
invalidate?: string[];
disabled?: boolean | string;
url?: string;
method?: string;
body?: string;
headers?: string;
fullWidth?: boolean | string;
}
export function ActionButton(props: ActionButtonProps) {
const { runtime, mutation } = useActionExecutor();
const params = normalizeParams(props.params);
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
const mustConfirm = runtime.requestCapabilities.some(
(capability) => capability.id === props.requestId && capability.kind === "action" && capability.method === "DELETE",
);
const run = async (confirmed: boolean) => {
if (!props.requestId || !params) return;
try {
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed });
const success = result.ok;
runtime.port.notify({
kind: success ? "success" : "error",
title: props.label,
message: success
? (props.successMessage ??
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
: (props.errorMessage ?? result.error ?? `${runtime.messages.requestFailed} (${result.status})`),
});
if (success)
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: props.invalidate ?? [],
});
} catch {
runtime.port.notify({
kind: "error",
title: props.label,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
}
};
const click = async () => {
const confirmMessage = props.confirmMessage ?? (mustConfirm ? runtime.messages.confirmDelete : undefined);
if (confirmMessage && !(await runtime.port.confirm({ title: props.label, message: confirmMessage }))) return;
await run(Boolean(confirmMessage));
};
if (!props.requestId) return <MigrationRequiredAlert />;
return (
<Button
color={props.color ?? "blue"}
variant={(props.variant ?? "filled") as never}
size={(props.size ?? "sm") as never}
onClick={() => void click()}
loading={mutation.isPending}
leftSection={Icon ? <Icon size={16} /> : undefined}
disabled={
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
}
>
{props.label}
</Button>
);
}
export interface ToggleSwitchProps {
requestId?: string;
onParams?: CustomJsxRuntimeParams;
offParams?: CustomJsxRuntimeParams;
initialValue?: boolean | string;
label?: string;
color?: string;
size?: string;
errorMessage?: string;
invalidate?: string[];
disabled?: boolean | string;
url?: string;
method?: string;
onBody?: string;
offBody?: string;
}
export function ToggleSwitch(props: ToggleSwitchProps) {
const { runtime, mutation } = useActionExecutor();
const initial = parseBool(props.initialValue);
const [checked, setChecked] = useState(initial);
const locked = useRef(false);
const onParams = normalizeParams(props.onParams);
const offParams = normalizeParams(props.offParams);
useEffect(() => {
if (!locked.current) setChecked(initial);
}, [initial]);
const change = async (next: boolean) => {
const params = next ? onParams : offParams;
if (!props.requestId || !params || locked.current) return;
locked.current = true;
const previous = checked;
setChecked(next);
try {
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed: false });
if (!result.ok) {
setChecked(previous);
runtime.port.notify({
kind: "error",
title: props.label ?? runtime.messages.toggle,
message: props.errorMessage ?? result.error ?? runtime.messages.requestFailed,
});
} else {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: props.invalidate ?? [],
});
}
} catch {
setChecked(previous);
runtime.port.notify({
kind: "error",
title: props.label ?? runtime.messages.toggle,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
} finally {
locked.current = false;
}
};
if (!props.requestId) return <MigrationRequiredAlert />;
return (
<Switch
label={props.label}
color={props.color ?? "blue"}
size={(props.size ?? "sm") as never}
checked={checked}
onChange={(event) => void change(event.currentTarget.checked)}
disabled={
parseBool(props.disabled) ||
mutation.isPending ||
runtime.isEditMode ||
(!runtime.itemId && !runtime.previewSessionId) ||
!onParams ||
!offParams
}
/>
);
}
@@ -0,0 +1,125 @@
import { ActionIcon, Code, Group, ScrollArea, SimpleGrid, Stack, Table, Text, Tooltip } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { CustomWidgetDisplayData } from "./display-types";
export function KeyValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }> | undefined) ?? [];
const content = entries.map((entry, index) => (
<Group key={index} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{entry.label}
</Text>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
));
return data.layout === "grid" ? (
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
{content}
</SimpleGrid>
) : (
<Stack h="100%" justify="center" gap="xs" p="sm">
{content}
</Stack>
);
}
export function TableDisplay({ data }: { data: CustomWidgetDisplayData }) {
const columns = (data.columns as string[] | undefined) ?? [];
const rows = (data.rows as unknown[][] | undefined) ?? [];
return (
<ScrollArea>
<Table striped={data.striped !== false} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((column, index) => (
<Table.Th key={index} py={data.compact ? 4 : undefined}>
{column}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, rowIndex) => (
<Table.Tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Td key={cellIndex} py={data.compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
export function StatusIndicatorDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }> | undefined) ?? [];
const size = ({ sm: 8, md: 10, lg: 14 } as Record<string, number>)[String(data.dotSize ?? "md")] ?? 10;
const content = items.map((item, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<div
aria-hidden
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{item.value}
</Text>
</Group>
));
return data.layout === "grid" ? (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{content}
</SimpleGrid>
) : (
<Stack h="100%" justify="center" gap="xs" p="sm">
{content}
</Stack>
);
}
function openJsonInBrowser(value: unknown) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }));
window.open(url);
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function createRawDisplay(openJsonLabel: string) {
return function RawDisplay({ data }: { data: CustomWidgetDisplayData }) {
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label={openJsonLabel}>
<ActionIcon
aria-label={openJsonLabel}
variant="subtle"
size="sm"
onClick={() => openJsonInBrowser(data.data)}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={Number(data.maxHeight ?? 300)}>
<Code block style={{ fontSize: 12 }}>
{JSON.stringify(data.data, null, 2)}
</Code>
</ScrollArea>
</Stack>
);
};
}
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import type { CustomWidgetRuntimeValue } from "./types";
const RuntimeContext = createContext<CustomWidgetRuntimeValue | null>(null);
export function CustomWidgetRuntimeProvider({
children,
...value
}: CustomWidgetRuntimeValue & { children: ReactNode }) {
return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
}
export function useCustomWidgetRuntime(): CustomWidgetRuntimeValue {
const value = useContext(RuntimeContext);
if (!value) throw new Error("Custom Widget runtime components require CustomWidgetRuntimeProvider");
return value;
}
export function useWidgetDefinitionId(): string {
return useCustomWidgetRuntime().definitionId ?? "";
}
@@ -0,0 +1,194 @@
import type { ComponentType, ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { renderSafeJsx } from "../jsx/interpreter";
import type { CustomJsxRequestCapability } from "./types";
const methodColors: Readonly<Record<string, string>> = {
GET: "blue",
POST: "orange",
PUT: "yellow",
DELETE: "red",
PATCH: "grape",
};
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const kinds = new Set(["query", "action"]);
const permissions = new Set(["view", "modify", "full"]);
export interface CustomJsxRendererMessages {
noTemplate: string;
interactive: string;
networkCapabilities: string;
templateWarnings(count: number): string;
}
export interface CustomJsxRendererProps {
template: string;
data: unknown;
requestCapabilities: unknown;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
messages: CustomJsxRendererMessages;
}
export const CUSTOM_JSX_METHOD_COLORS = methodColors;
export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
if (!Array.isArray(value)) return [];
return value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const record = candidate as Record<string, unknown>;
if (
typeof record.id !== "string" ||
typeof record.kind !== "string" ||
!kinds.has(record.kind) ||
typeof record.method !== "string" ||
!methods.has(record.method) ||
typeof record.minimumBoardPermission !== "string" ||
!permissions.has(record.minimumBoardPermission)
)
return [];
return [
{
id: record.id,
kind: record.kind,
method: record.method,
minimumBoardPermission: record.minimumBoardPermission,
} as CustomJsxRequestCapability,
];
});
}
class RendererErrorBoundary extends Component<
{ children: ReactNode; onError(error: Error): void },
{ error: Error | null }
> {
public state = { error: null } as { error: Error | null };
public static getDerivedStateFromError(error: Error) {
return { error };
}
public componentDidCatch(error: Error, _info: ErrorInfo) {
this.props.onError(error);
}
public render() {
return this.state.error ? <ErrorAlert error={this.state.error} /> : this.props.children;
}
}
function ErrorAlert({ error }: { error: Error }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{error.message}</Text>
</Alert>
);
}
export function CustomJsxRenderer({
template,
data,
requestCapabilities: rawCapabilities,
components,
createBindings,
messages,
}: CustomJsxRendererProps) {
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => createBindings(data), [createBindings, data]);
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
const rendered = useMemo(() => {
try {
return { ...renderSafeJsx({ template, components, bindings }), error: null };
} catch (error) {
return { node: null, warnings: [], error: error instanceof Error ? error : new Error(String(error)) };
}
}, [bindings, components, template]);
useEffect(() => setParseErrors([]), [bindings, template]);
const handleError = useCallback(
(error: Error) =>
setParseErrors((current) => {
if (current.length >= 5 || current.includes(error.message)) return current;
return [...current, error.message];
}),
[],
);
useEffect(() => {
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
if (rendered.error) handleError(rendered.error);
}, [handleError, rendered]);
if (!template.trim())
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
{messages.noTemplate}
</Text>
</Alert>
);
return (
<Stack gap={0} h="100%">
{capabilities.length > 0 && (
<Group justify="flex-end" mb={4}>
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
<Popover.Target>
<Badge
component="button"
type="button"
size="sm"
color="gray"
variant="light"
leftSection={<IconNetwork size={12} />}
style={{ cursor: "pointer" }}
>
{messages.interactive}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={6}>
<Text size="sm" fw={600}>
{messages.networkCapabilities}
</Text>
{capabilities.map((capability) => (
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{capability.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={methodColors[capability.method] ?? "gray"} variant="light">
{capability.method}
</Badge>
<Badge size="xs" color="gray" variant="outline">
{capability.minimumBoardPermission}
</Badge>
</Group>
</Group>
))}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
)}
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<RendererErrorBoundary key={template} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
)}
</Box>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{messages.templateWarnings(parseErrors.length)}
</Text>
{parseErrors.map((message) => (
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{message}
</Text>
))}
</Alert>
)}
</Stack>
);
}
@@ -0,0 +1,90 @@
"use client";
import { createContext, useContext } from "react";
import { Badge, Code, Image, Text, Title } from "@mantine/core";
export const SubFetchDataContext = createContext<unknown>(undefined);
const BLOCKED_KEYS = new Set(["constructor", "prototype", "__proto__"]);
export function normalizeParams(value: unknown) {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const result: Record<string, string | number | boolean> = {};
for (const [key, param] of Object.entries(value)) {
if (BLOCKED_KEYS.has(key)) return null;
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
result[key] = param;
}
return result;
}
export function getByPath(value: unknown, path?: string): unknown {
if (!path?.trim()) return value;
return path.split(".").reduce<unknown>((current, key) => {
if (current == null || typeof current !== "object" || BLOCKED_KEYS.has(key)) return undefined;
return Object.hasOwn(current, key) ? (current as Record<string, unknown>)[key] : undefined;
}, value);
}
export function formatDisplayValue(value: unknown): string {
if (value == null) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
export interface SubDataProps {
path?: string;
as?: string;
order?: number;
size?: string;
color?: string;
variant?: string;
fw?: number;
c?: string;
alt?: string;
fit?: string;
w?: string | number;
h?: string | number;
radius?: string | number;
}
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
const value = getByPath(useContext(SubFetchDataContext), path);
if (value === undefined && path) return <Text c="dimmed">—</Text>;
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
if (as === "Title") {
return <Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3}>{displayValue}</Title>;
}
if (as === "Badge") {
return (
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
{displayValue}
</Badge>
);
}
if (as === "Code") return <Code block>{displayValue}</Code>;
if (as === "Image") {
const src = safeDisplayImageUrl(value);
if (!src) return null;
return (
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
);
}
return (
<Text size={props.size as never} fw={props.fw} c={props.c}>
{displayValue}
</Text>
);
}
function safeDisplayImageUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (value.startsWith("/") && !value.startsWith("//")) return value;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,20 @@
import type { CustomWidgetDisplayAdapters, CustomWidgetDisplayComponent } from "./display-types";
import { createRawDisplay, KeyValueDisplay, StatusIndicatorDisplay, TableDisplay } from "./collection-displays";
import { CountGridDisplay, ProgressBarsDisplay, SingleValueDisplay, StatGridDisplay } from "./metric-displays";
export function createCustomWidgetDisplayComponents(
adapters: CustomWidgetDisplayAdapters,
): Record<string, CustomWidgetDisplayComponent> {
return {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: createRawDisplay(adapters.openJsonLabel),
actionButton: adapters.actionButton,
customJsx: adapters.customJsx,
} satisfies Record<string, CustomWidgetDisplayComponent>;
}
@@ -0,0 +1,10 @@
import type { ComponentType } from "react";
export type CustomWidgetDisplayData = Readonly<Record<string, unknown>>;
export type CustomWidgetDisplayComponent = ComponentType<{ data: CustomWidgetDisplayData }>;
export interface CustomWidgetDisplayAdapters {
actionButton: CustomWidgetDisplayComponent;
customJsx: CustomWidgetDisplayComponent;
openJsonLabel: string;
}
@@ -0,0 +1,14 @@
export { ActionButton, ToggleSwitch } from "./actions";
export { CustomWidgetRuntimeProvider, useCustomWidgetRuntime, useWidgetDefinitionId } from "./context";
export { SubData } from "./data";
export { RefreshButton } from "./refresh-button";
export { SubFetch } from "./sub-fetch";
export { createCustomWidgetDisplayComponents } from "./display-registry";
export { CustomJsxRenderer, CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./custom-jsx-renderer";
export type { CustomJsxRendererMessages, CustomJsxRendererProps } from "./custom-jsx-renderer";
export type { ActionButtonProps, ToggleSwitchProps } from "./actions";
export type { SubDataProps } from "./data";
export type { RefreshButtonProps } from "./refresh-button";
export type { SubFetchMetadata, SubFetchProps } from "./sub-fetch";
export type * from "./display-types";
export type * from "./types";
@@ -0,0 +1,124 @@
import { Card, Flex, Group, Progress, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import type { CustomWidgetDisplayData } from "./display-types";
export function SingleValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
const size = String(data.valueSize ?? "lg");
const labelAbove = data.labelPosition === "above";
const label = data.label ? (
<Text c="dimmed" size="sm">
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
{String(data.value ?? "—")}
{data.unit ? ` ${String(data.unit)}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
interface MetricItem {
label: string;
unit: string;
color: string;
value: unknown;
}
function StatGridCard({ item, cardStyle }: { item: MetricItem; cardStyle: string }) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const background = cardStyle === "outline" ? "transparent" : `var(--mantine-color-${item.color}-light)`;
return (
<Card ref={ref} p="sm" radius="md" bg={background} withBorder={cardStyle === "outline"} h="100%">
<Flex h="100%" align="center" justify="center" direction={isWide ? "row" : "column"} gap={isWide ? 8 : 4}>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{height > 38 && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
export function StatGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as MetricItem[] | undefined) ?? [];
const columns = Number(data.columns ?? 2);
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, index) => (
<StatGridCard key={index} item={item} cardStyle={String(data.cardStyle ?? "filled")} />
))}
</SimpleGrid>
);
}
export function ProgressBarsDisplay({ data }: { data: CustomWidgetDisplayData }) {
const bars = (data.bars as Array<MetricItem & { value: number; max?: number }> | undefined) ?? [];
const sizes: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, index) => {
const max = bar.max ?? 100;
const percentage = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
const value =
data.showPercentage !== false ? `${percentage.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`;
return (
<Stack key={index} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{value}
</Text>
</Group>
<Progress
value={percentage}
size={sizes[String(data.barSize ?? "md")] ?? 14}
color={bar.color}
radius="sm"
/>
</Stack>
);
})}
</Stack>
);
}
export function CountGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as MetricItem[] | undefined) ?? [];
return (
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
{items.map((item, index) => (
<Stack key={index} align="center" justify="center" gap={0}>
<Text size={String(data.valueSize ?? "md")} fw={700}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center">
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { Button } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export interface RefreshButtonProps {
label?: string;
color?: string;
variant?: string;
size?: string;
}
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
const runtime = useCustomWidgetRuntime();
const [loading, setLoading] = useState(false);
const refresh = async () => {
if (!runtime.itemId && !runtime.previewSessionId) return;
setLoading(true);
try {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: ["parent", "*"],
});
} finally {
setLoading(false);
}
};
return (
<Button
color={color}
variant={variant as never}
size={size as never}
leftSection={<IconRefresh size={16} />}
onClick={() => void refresh()}
loading={loading}
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
>
{label ?? runtime.messages.refresh}
</Button>
);
}
@@ -0,0 +1,15 @@
"use client";
import { Alert, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export function MigrationRequiredAlert() {
const { messages } = useCustomWidgetRuntime();
return (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{messages.migrationRequired}</Text>
</Alert>
);
}
@@ -0,0 +1,133 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Code, Skeleton, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { formatDisplayValue, getByPath, normalizeParams, SubFetchDataContext } from "./data";
import { MigrationRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams } from "./types";
export interface SubFetchMetadata {
ok: boolean;
status: number;
statusText?: string;
}
export interface SubFetchProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
refreshInterval?: number;
children?: ReactNode;
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
loadingLabel?: string;
errorMessage?: string;
fallback?: ReactNode;
path?: string;
as?: "json" | "text";
trigger?: "auto" | "manual";
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated Request manifests own the method, body, and headers. */
method?: string;
body?: string;
headers?: string;
}
export function SubFetch(props: SubFetchProps) {
const { itemId, previewSessionId, port, messages } = useCustomWidgetRuntime();
const [manualRun, setManualRun] = useState(false);
const params = useMemo(() => normalizeParams(props.params), [props.params]);
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
useEffect(() => setManualRun(false), [paramsKey, props.requestId, props.trigger]);
const enabled = Boolean(
(itemId || previewSessionId) && props.requestId && params && (props.trigger !== "manual" || manualRun),
);
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
const query = useQuery({
queryKey: ["custom-widget", itemId ?? previewSessionId, props.requestId, paramsKey],
queryFn: ({ signal }) =>
port.query({ itemId, previewSessionId, requestId: props.requestId ?? "", params: params ?? {} }, signal),
enabled,
retry: 2,
refetchInterval: refreshMs,
});
if (!props.requestId) return <MigrationRequiredAlert />;
if (!itemId && !previewSessionId) return props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>;
if (!params) return <RequestAlert message={messages.invalidParams} />;
if (props.trigger === "manual" && !manualRun) {
return (
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
{messages.loadRequest}
</Button>
);
}
const result = query.data;
const requestError =
(query.error && !result ? messages.requestFailed : null) ??
(!result?.ok ? (result?.error ?? `${messages.requestFailed} (${result?.status ?? 0})`) : null);
if (!result && query.isPending) return props.fallback ?? <Loading label={props.loadingLabel ?? messages.loading} />;
if (requestError) {
return (
<RequestAlert message={props.errorMessage ?? requestError}>
<Button
size="compact-xs"
variant="subtle"
mt={4}
onClick={() => void query.refetch()}
loading={query.isFetching}
>
{messages.retry}
</Button>
</RequestAlert>
);
}
const content = renderContent(props, result?.data, result);
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
}
function normalizeRefreshInterval(value: number | undefined): number | false {
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
}
function renderContent(props: SubFetchProps, data: unknown, result: SubFetchMetadata | undefined) {
if (props.render) {
return props.render(data, {
ok: result?.ok ?? false,
status: result?.status ?? 0,
statusText: result?.statusText,
});
}
if (props.children) return props.children;
const value = getByPath(data, props.path);
if (props.as === "text") return <Text size="sm">{formatDisplayValue(value)}</Text>;
return (
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(value, null, 2)}
</Code>
);
}
function Loading({ label }: { label: string }) {
return (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text c="dimmed">{label}</Text>
</div>
);
}
function RequestAlert({ message, children }: { message: string; children?: ReactNode }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{message}</Text>
{children}
</Alert>
);
}
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
export type RuntimeParam = string | number | boolean;
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
export interface CustomWidgetRequestResult {
ok: boolean;
status: number;
statusText?: string;
data: unknown;
error?: string;
simulated?: boolean;
}
export interface CustomJsxRequestCapability {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
minimumBoardPermission: "view" | "modify" | "full";
}
export interface RuntimeRequestInput {
itemId?: string;
previewSessionId?: string;
requestId: string;
params: CustomJsxRuntimeParams;
}
export interface RuntimeActionInput extends RuntimeRequestInput {
confirmed: boolean;
}
export interface RuntimeInvalidationInput {
itemId?: string;
previewSessionId?: string;
targets: readonly string[];
}
export interface RuntimeNotification {
kind: "success" | "error";
title: string;
message: string;
}
export interface CustomWidgetRuntimePort {
query(input: RuntimeRequestInput, signal?: AbortSignal): Promise<CustomWidgetRequestResult>;
executeAction(input: RuntimeActionInput): Promise<CustomWidgetRequestResult>;
invalidate(input: RuntimeInvalidationInput): Promise<void>;
confirm(input: { title: string; message: ReactNode }): Promise<boolean>;
notify(notification: RuntimeNotification): void;
}
export interface CustomWidgetRuntimeMessages {
migrationRequired: string;
unsavedPreview: string;
invalidParams: string;
loadRequest: string;
requestFailed: string;
loading: string;
retry: string;
widgetItemUnavailable: string;
actionsDisabledEditMode: string;
actionSimulated: string;
actionCompleted: string;
confirmDelete: string;
toggle: string;
refresh: string;
}
export interface CustomWidgetRuntimeValue {
itemId?: string;
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
isEditMode: boolean;
requestCapabilities: readonly CustomJsxRequestCapability[];
port: CustomWidgetRuntimePort;
messages: CustomWidgetRuntimeMessages;
}