feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, ReactNode } 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 { RequestIdRequiredAlert } 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: async ({
|
||||
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 });
|
||||
}
|
||||
runtime.setQueryState?.(requestId, { data: null, status: { loading: true } });
|
||||
let result: CustomWidgetRequestResult;
|
||||
try {
|
||||
result = await runtime.port.executeAction({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
requestId,
|
||||
params,
|
||||
confirmed,
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.setQueryState?.(requestId, {
|
||||
data: null,
|
||||
status: { loading: false, ok: false, error: runtime.messages.requestFailed },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
// Action responses are local to this widget instance, just like published query data.
|
||||
runtime.setQueryState?.(requestId, {
|
||||
data: result.data,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return { runtime, mutation };
|
||||
}
|
||||
|
||||
export interface ActionButtonProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
confirmMessage?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean | string;
|
||||
fullWidth?: boolean | string;
|
||||
}
|
||||
|
||||
export function ActionButton(props: ActionButtonProps) {
|
||||
const { runtime, mutation } = useActionExecutor();
|
||||
const params = normalizeParams(props.params);
|
||||
const title = props.label ?? (typeof props.children === "string" ? props.children : props.requestId) ?? "Action";
|
||||
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
|
||||
const capability = runtime.requestCapabilities.find(
|
||||
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
|
||||
);
|
||||
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,
|
||||
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: result.invalidates ?? capability?.invalidates ?? [],
|
||||
});
|
||||
} catch {
|
||||
runtime.port.notify({
|
||||
kind: "error",
|
||||
title,
|
||||
message: props.errorMessage ?? runtime.messages.requestFailed,
|
||||
});
|
||||
}
|
||||
};
|
||||
const click = async () => {
|
||||
const confirmMessage =
|
||||
props.confirmMessage ??
|
||||
capability?.confirmation?.message ??
|
||||
(capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
|
||||
if (
|
||||
confirmMessage &&
|
||||
!(await runtime.port.confirm({
|
||||
title: capability?.confirmation?.title ?? title,
|
||||
message: confirmMessage,
|
||||
confirmLabel: capability?.confirmation?.confirmLabel,
|
||||
destructive: capability?.confirmation?.destructive ?? capability?.method === "DELETE",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(Boolean(confirmMessage));
|
||||
};
|
||||
if (!props.requestId) return <RequestIdRequiredAlert />;
|
||||
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
|
||||
}
|
||||
fullWidth={parseBool(props.fullWidth)}
|
||||
>
|
||||
{props.children ?? props.label ?? props.requestId}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
requestId?: string;
|
||||
enabledParams?: CustomJsxRuntimeParams;
|
||||
disabledParams?: CustomJsxRuntimeParams;
|
||||
initialValue?: boolean | string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean | 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 enabledParams = normalizeParams(props.enabledParams);
|
||||
const disabledParams = normalizeParams(props.disabledParams);
|
||||
const capability = runtime.requestCapabilities.find(
|
||||
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!locked.current) setChecked(initial);
|
||||
}, [initial]);
|
||||
const change = async (next: boolean) => {
|
||||
const params = next ? enabledParams : disabledParams;
|
||||
if (!props.requestId || !params || locked.current) return;
|
||||
const confirmation = capability?.confirmation;
|
||||
const confirmMessage =
|
||||
confirmation?.message ?? (capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
|
||||
if (
|
||||
confirmMessage &&
|
||||
!(await runtime.port.confirm({
|
||||
title: confirmation?.title ?? props.label ?? runtime.messages.toggle,
|
||||
message: confirmMessage,
|
||||
confirmLabel: confirmation?.confirmLabel,
|
||||
destructive: confirmation?.destructive ?? capability?.method === "DELETE",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
locked.current = true;
|
||||
const previous = checked;
|
||||
setChecked(next);
|
||||
try {
|
||||
const result = await mutation.mutateAsync({
|
||||
requestId: props.requestId,
|
||||
params,
|
||||
confirmed: confirmMessage !== undefined,
|
||||
});
|
||||
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: result.invalidates ?? capability?.invalidates ?? [],
|
||||
});
|
||||
}
|
||||
} 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 <RequestIdRequiredAlert />;
|
||||
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) ||
|
||||
!enabledParams ||
|
||||
!disabledParams
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,260 @@
|
||||
import type { ComponentType, ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Box, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { renderSafeJsx } from "../jsx/interpreter";
|
||||
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
|
||||
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
|
||||
import { sameInputRecord, sameStringArray, sameTypeRecord } from "./input-state-utils";
|
||||
|
||||
export { CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./request-capabilities";
|
||||
|
||||
const EMPTY_RECORD: Record<string, never> = {};
|
||||
|
||||
export interface CustomJsxRendererMessages {
|
||||
noTemplate: string;
|
||||
templateWarnings(count: number): string;
|
||||
bindingTypeConflict(name: string, firstType: WidgetInputType, secondType: WidgetInputType): string;
|
||||
}
|
||||
|
||||
export interface CustomJsxRendererProps {
|
||||
template: string;
|
||||
data: unknown;
|
||||
status?: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
createBindings(data: unknown): Readonly<Record<string, unknown>>;
|
||||
messages: CustomJsxRendererMessages;
|
||||
}
|
||||
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ children: ReactNode; resetKey: string; onError(error: Error): void },
|
||||
{ error: Error | null; resetKey: string }
|
||||
> {
|
||||
public state = { error: null, resetKey: "" } as { error: Error | null; resetKey: string };
|
||||
public static getDerivedStateFromProps(
|
||||
props: Readonly<{ resetKey: string }>,
|
||||
state: Readonly<{ error: Error | null; resetKey: string }>,
|
||||
) {
|
||||
return props.resetKey === state.resetKey ? null : { error: null, resetKey: props.resetKey };
|
||||
}
|
||||
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" fw={700}>
|
||||
RUNTIME_RENDER_ERROR
|
||||
</Text>
|
||||
<Text size="xs">{error.message}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomJsxRenderer(props: CustomJsxRendererProps) {
|
||||
if (!props.template.trim())
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.messages.noTemplate}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return <CustomJsxRendererSession {...props} />;
|
||||
}
|
||||
|
||||
interface InputRegistration {
|
||||
name: string;
|
||||
type: WidgetInputType;
|
||||
initialValue: WidgetInputValue;
|
||||
}
|
||||
|
||||
interface InputState {
|
||||
values: Record<string, WidgetInputValue>;
|
||||
types: Record<string, WidgetInputType>;
|
||||
}
|
||||
|
||||
function CustomJsxRendererSession({
|
||||
template,
|
||||
data,
|
||||
status = EMPTY_RECORD,
|
||||
options = EMPTY_RECORD,
|
||||
components,
|
||||
createBindings,
|
||||
messages,
|
||||
}: CustomJsxRendererProps) {
|
||||
const inputScopeId = useId();
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
|
||||
const [inputState, setInputState] = useState<InputState>({ values: {}, types: {} });
|
||||
const { values: inputs, types: inputTypes } = inputState;
|
||||
const registrations = useRef(new Map<symbol, InputRegistration>());
|
||||
const registrationVersion = useRef(0);
|
||||
const mounted = useRef(true);
|
||||
const bindingTypeConflict = messages.bindingTypeConflict;
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const reconcileInputs = useCallback(() => {
|
||||
if (!mounted.current) return;
|
||||
const active = new Map<string, InputRegistration>();
|
||||
const conflicts: string[] = [];
|
||||
for (const registration of registrations.current.values()) {
|
||||
const existing = active.get(registration.name);
|
||||
if (!existing) {
|
||||
active.set(registration.name, registration);
|
||||
continue;
|
||||
}
|
||||
if (existing.type === registration.type) continue;
|
||||
const message = bindingTypeConflict(registration.name, existing.type, registration.type);
|
||||
if (!conflicts.includes(message) && conflicts.length < 5) conflicts.push(message);
|
||||
}
|
||||
setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts));
|
||||
setInputState((current) => {
|
||||
const nextTypes: Record<string, WidgetInputType> = {};
|
||||
for (const [name, registration] of active) nextTypes[name] = registration.type;
|
||||
const nextValues: Record<string, WidgetInputValue> = {};
|
||||
for (const [name, registration] of active) {
|
||||
if (current.types[name] === registration.type && Object.hasOwn(current.values, name)) {
|
||||
const previousValue = current.values[name];
|
||||
if (previousValue !== undefined) {
|
||||
nextValues[name] = previousValue;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nextValues[name] = registration.initialValue;
|
||||
}
|
||||
if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current;
|
||||
return { values: nextValues, types: nextTypes };
|
||||
});
|
||||
}, [bindingTypeConflict]);
|
||||
const registerInput = useCallback(
|
||||
(name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
|
||||
const id = Symbol(name);
|
||||
registrations.current.set(id, { name, type, initialValue });
|
||||
registrationVersion.current += 1;
|
||||
reconcileInputs();
|
||||
return () => {
|
||||
registrations.current.delete(id);
|
||||
registrationVersion.current += 1;
|
||||
const version = registrationVersion.current;
|
||||
queueMicrotask(() => {
|
||||
if (registrationVersion.current === version) reconcileInputs();
|
||||
});
|
||||
};
|
||||
},
|
||||
[reconcileInputs],
|
||||
);
|
||||
const setInputValue = useCallback((name: string, type: WidgetInputType, value: WidgetInputValue) => {
|
||||
setInputState((current) => {
|
||||
const existing = current.types[name];
|
||||
if (existing && existing !== type) return current;
|
||||
if (existing === type && Object.is(current.values[name], value)) return current;
|
||||
return {
|
||||
values: { ...current.values, [name]: value },
|
||||
types: existing === type ? current.types : { ...current.types, [name]: type },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const resetInput = useCallback((name: string, type: WidgetInputType) => {
|
||||
let activeRegistration: InputRegistration | undefined;
|
||||
for (const registration of registrations.current.values()) {
|
||||
if (registration.name !== name) continue;
|
||||
activeRegistration = registration;
|
||||
break;
|
||||
}
|
||||
if (!activeRegistration || activeRegistration.type !== type) return;
|
||||
setInputState((current) => {
|
||||
if (current.types[name] !== type || Object.is(current.values[name], activeRegistration.initialValue)) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, values: { ...current.values, [name]: activeRegistration.initialValue } };
|
||||
});
|
||||
}, []);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
const bindings = { ...createBindings(data), status, options, inputs };
|
||||
return {
|
||||
...renderSafeJsx({ template, components, bindings }),
|
||||
boundaryKey: createBoundaryKey(template, bindings),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
node: null,
|
||||
warnings: [],
|
||||
boundaryKey: `${template.length}:error`,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}, [components, createBindings, data, inputs, options, status, template]);
|
||||
useEffect(() => setParseErrors([]), [rendered.boundaryKey, 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]);
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
{rendered.error ? (
|
||||
<ErrorAlert error={rendered.error} />
|
||||
) : (
|
||||
<CustomJsxInputsProvider
|
||||
scopeId={inputScopeId}
|
||||
inputs={inputs}
|
||||
inputTypes={inputTypes}
|
||||
registerInput={registerInput}
|
||||
setInputValue={setInputValue}
|
||||
resetInput={resetInput}
|
||||
>
|
||||
<RendererErrorBoundary resetKey={rendered.boundaryKey} onError={handleError}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
</CustomJsxInputsProvider>
|
||||
)}
|
||||
</Box>
|
||||
{[...parseErrors, ...bindingErrors].length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.templateWarnings(parseErrors.length + bindingErrors.length)}
|
||||
</Text>
|
||||
{[...parseErrors, ...bindingErrors].map((message) => (
|
||||
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{message}
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function createBoundaryKey(template: string, bindings: Readonly<Record<string, unknown>>) {
|
||||
let hash = 0;
|
||||
const value = `${template}\0${JSON.stringify(bindings)}`;
|
||||
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
||||
return `${template.length}:${hash}`;
|
||||
}
|
||||
@@ -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,12 @@
|
||||
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 { 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 "./types";
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
|
||||
|
||||
export function sameStringArray(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
export function sameTypeRecord(left: Record<string, WidgetInputType>, right: Record<string, WidgetInputType>) {
|
||||
const leftEntries = Object.entries(left);
|
||||
const rightEntries = Object.entries(right);
|
||||
return leftEntries.length === rightEntries.length && leftEntries.every(([name, type]) => right[name] === type);
|
||||
}
|
||||
|
||||
export function sameInputRecord(left: Record<string, WidgetInputValue>, right: Record<string, WidgetInputValue>) {
|
||||
const leftEntries = Object.entries(left);
|
||||
const rightEntries = Object.entries(right);
|
||||
return (
|
||||
leftEntries.length === rightEntries.length && leftEntries.every(([name, value]) => Object.is(right[name], value))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
|
||||
export interface RefreshButtonProps {
|
||||
requestId?: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function RefreshButton({
|
||||
requestId,
|
||||
label,
|
||||
color = "gray",
|
||||
variant = "subtle",
|
||||
size = "sm",
|
||||
}: RefreshButtonProps) {
|
||||
const runtime = useCustomWidgetRuntime();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const refresh = async () => {
|
||||
if (!runtime.itemId && !runtime.previewSessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let targets = ["parent", "*"];
|
||||
if (requestId) targets = [requestId];
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ActionIcon
|
||||
aria-label={label ?? runtime.messages.refresh}
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
onClick={() => void refresh()}
|
||||
loading={loading}
|
||||
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
|
||||
>
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CustomJsxRequestCapability } from "./types";
|
||||
|
||||
export const CUSTOM_JSX_METHOD_COLORS: 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"]);
|
||||
|
||||
const parseConfirmation = (value: unknown): CustomJsxRequestCapability["confirmation"] => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.title !== "string" || typeof record.message !== "string") return undefined;
|
||||
if (record.confirmLabel !== undefined && typeof record.confirmLabel !== "string") return undefined;
|
||||
if (record.destructive !== undefined && typeof record.destructive !== "boolean") return undefined;
|
||||
|
||||
const confirmation: NonNullable<CustomJsxRequestCapability["confirmation"]> = {
|
||||
title: record.title,
|
||||
message: record.message,
|
||||
};
|
||||
if (typeof record.confirmLabel === "string") confirmation.confirmLabel = record.confirmLabel;
|
||||
if (typeof record.destructive === "boolean") confirmation.destructive = record.destructive;
|
||||
return confirmation;
|
||||
};
|
||||
|
||||
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,
|
||||
trigger: record.trigger === "load" ? "load" : "manual",
|
||||
minimumBoardPermission: record.minimumBoardPermission,
|
||||
confirmation: parseConfirmation(record.confirmation),
|
||||
invalidates: Array.isArray(record.invalidates)
|
||||
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
} as CustomJsxRequestCapability,
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
|
||||
export function RequestIdRequiredAlert() {
|
||||
const { messages } = useCustomWidgetRuntime();
|
||||
return (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{messages.requestIdRequired}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Box, 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 { RequestIdRequiredAlert } from "./status";
|
||||
import type { CustomJsxRuntimeParams } from "./types";
|
||||
|
||||
export interface SubFetchMetadata {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
loading: false;
|
||||
error?: undefined;
|
||||
}
|
||||
|
||||
export interface SubFetchProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
refreshInterval?: number;
|
||||
children?: ReactNode | ((data: unknown, metadata: SubFetchMetadata) => ReactNode);
|
||||
loadingLabel?: string;
|
||||
errorMessage?: string;
|
||||
fallback?: ReactNode;
|
||||
triggerContent?: ReactNode;
|
||||
triggerAriaLabel?: string;
|
||||
path?: string;
|
||||
as?: "json" | "text";
|
||||
trigger?: "auto" | "manual";
|
||||
}
|
||||
|
||||
// Browser timers use signed 32-bit millisecond delays. Whole-second refresh
|
||||
// intervals are capped at the largest value that remains below that boundary.
|
||||
export const MAX_REFRESH_INTERVAL_SECONDS = 2_147_483;
|
||||
export const MAX_REFRESH_INTERVAL_MS = MAX_REFRESH_INTERVAL_SECONDS * 1_000;
|
||||
|
||||
export function SubFetch(props: SubFetchProps) {
|
||||
const { itemId, previewSessionId, queryCacheKey, queriesDisabled, port, messages, setQueryState } =
|
||||
useCustomWidgetRuntime();
|
||||
const [manualRunKey, setManualRunKey] = useState<string | null>(null);
|
||||
const params = useMemo(() => normalizeParams(props.params), [props.params]);
|
||||
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
|
||||
const manualRequestKey = JSON.stringify([
|
||||
itemId,
|
||||
previewSessionId,
|
||||
queryCacheKey,
|
||||
props.requestId,
|
||||
props.trigger,
|
||||
paramsKey,
|
||||
]);
|
||||
const enabled = Boolean(
|
||||
!queriesDisabled &&
|
||||
(itemId || previewSessionId) &&
|
||||
props.requestId &&
|
||||
params &&
|
||||
(props.trigger !== "manual" || manualRunKey === manualRequestKey),
|
||||
);
|
||||
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
|
||||
const scope = itemId ? "item" : "preview";
|
||||
const scopeId = itemId ?? previewSessionId;
|
||||
const query = useQuery({
|
||||
queryKey: ["custom-widget", scope, scopeId, props.requestId, paramsKey, queryCacheKey],
|
||||
queryFn: ({ signal }) =>
|
||||
port.query({ itemId, previewSessionId, requestId: props.requestId ?? "", params: params ?? {} }, signal),
|
||||
enabled,
|
||||
retry: 2,
|
||||
refetchInterval: refreshMs,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Manual queries are local to their SubFetch instance. Publishing them by request ID makes
|
||||
// repeated cards compete for the same data slot and can remount the active query mid-request.
|
||||
if (!props.requestId || !setQueryState || props.trigger === "manual") return;
|
||||
if (!enabled) {
|
||||
setQueryState(props.requestId, null);
|
||||
return;
|
||||
}
|
||||
const result = query.data;
|
||||
setQueryState(props.requestId, {
|
||||
data: result?.data ?? null,
|
||||
status: {
|
||||
loading: query.isFetching && !result,
|
||||
ok: result?.ok,
|
||||
status: result?.status,
|
||||
statusText: result?.statusText,
|
||||
error: result?.error ?? (query.error ? messages.requestFailed : undefined),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
enabled,
|
||||
messages.requestFailed,
|
||||
props.requestId,
|
||||
props.trigger,
|
||||
query.data,
|
||||
query.error,
|
||||
query.isFetching,
|
||||
setQueryState,
|
||||
]);
|
||||
|
||||
if (!props.requestId) return <RequestIdRequiredAlert />;
|
||||
if (queriesDisabled) return props.trigger === "manual" ? (props.triggerContent ?? null) : null;
|
||||
if (!itemId && !previewSessionId)
|
||||
return props.trigger === "manual" && props.triggerContent
|
||||
? props.triggerContent
|
||||
: (props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>);
|
||||
if (!params) return <RequestAlert message={messages.invalidParams} />;
|
||||
if (props.trigger === "manual" && manualRunKey !== manualRequestKey) {
|
||||
if (props.triggerContent) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label={props.triggerAriaLabel ?? messages.loadRequest}
|
||||
onClick={() => setManualRunKey(manualRequestKey)}
|
||||
style={{
|
||||
appearance: "none",
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
padding: 0,
|
||||
textAlign: "inherit",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{props.triggerContent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button size="compact-sm" variant="light" onClick={() => setManualRunKey(manualRequestKey)}>
|
||||
{messages.loadRequest}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (!props.children && props.path === undefined && props.as === undefined) return null;
|
||||
|
||||
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, {
|
||||
ok: result?.ok ?? false,
|
||||
status: result?.status ?? 0,
|
||||
statusText: result?.statusText,
|
||||
loading: false,
|
||||
});
|
||||
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function normalizeRefreshInterval(value: number | undefined): number | false {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return false;
|
||||
return Math.max(1_000, Math.min(value, MAX_REFRESH_INTERVAL_SECONDS) * 1_000);
|
||||
}
|
||||
|
||||
function renderContent(props: SubFetchProps, data: unknown, metadata: SubFetchMetadata) {
|
||||
if (typeof props.children === "function") return props.children(data, metadata);
|
||||
if (props.children) return props.children;
|
||||
if (props.path === undefined && props.as === undefined) return null;
|
||||
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,102 @@
|
||||
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;
|
||||
invalidates?: string[];
|
||||
}
|
||||
|
||||
export interface CustomJsxRequestCapability {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
trigger: "load" | "manual";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
confirmation?: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
invalidates?: string[];
|
||||
}
|
||||
|
||||
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; confirmLabel?: string; destructive?: boolean }): Promise<boolean>;
|
||||
notify(notification: RuntimeNotification): void;
|
||||
}
|
||||
|
||||
export interface CustomWidgetRuntimeMessages {
|
||||
requestIdRequired: 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;
|
||||
queryCacheKey?: string;
|
||||
previewSessionId?: string;
|
||||
previewLiveActions?: boolean;
|
||||
queriesDisabled?: boolean;
|
||||
isEditMode: boolean;
|
||||
requestCapabilities: readonly CustomJsxRequestCapability[];
|
||||
port: CustomWidgetRuntimePort;
|
||||
messages: CustomWidgetRuntimeMessages;
|
||||
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
|
||||
}
|
||||
|
||||
export interface CustomWidgetPublishedQueryState {
|
||||
data: unknown;
|
||||
status: {
|
||||
loading: boolean;
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user