refactor(custom-widgets): simplify authoring flow

This commit is contained in:
Thomas Camlong committed 2026-07-12 14:46:17 +02:00
1 parent 46add018bf
commit c43836e85a
4 files changed
+209 -204

No files matched your search

@@ -48,6 +48,40 @@
background: var(--mantine-color-body);
}
.formFlow {
--flow-marker-size: 2.5rem;
}
.formSection {
position: relative;
padding: 0 0 var(--mantine-spacing-xl) calc(var(--flow-marker-size) + var(--mantine-spacing-lg));
scroll-margin-top: 5rem;
outline: none;
}
.formSection:not(:last-child)::before {
position: absolute;
top: var(--flow-marker-size);
bottom: 0;
left: calc(var(--flow-marker-size) / 2);
width: 1px;
background: var(--mantine-color-default-border);
content: "";
}
.formSection:focus-visible {
border-radius: var(--mantine-radius-md);
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
}
.sectionMarker {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.displayTypeOption {
display: flex;
align-items: flex-start;
@@ -17,7 +17,6 @@ import {
Select,
SimpleGrid,
Stack,
Stepper,
Switch,
Text,
TextInput,
@@ -30,7 +29,6 @@ import {
IconActivityHeartbeat,
IconAlertTriangle,
IconBraces,
IconCheck,
IconCode,
IconHash,
IconLayoutGrid,
@@ -655,8 +653,16 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const [previewJson, setPreviewJson] = useState<unknown>(null);
const [previewFetchResult, setPreviewFetchResult] = useState<PreviewFetchResult | null>(null);
const [lastPreviewFingerprint, setLastPreviewFingerprint] = useState<string | null>(null);
const [activeStep, setActiveStep] = useState(0);
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const formatSectionRef = useRef<HTMLElement>(null);
const connectionSectionRef = useRef<HTMLElement>(null);
const configureSectionRef = useRef<HTMLElement>(null);
const scrollToSection = useCallback((section: number) => {
const element = [formatSectionRef.current, connectionSectionRef.current, configureSectionRef.current][section];
if (!element) return;
element.scrollIntoView({ behavior: "smooth", block: "start" });
window.setTimeout(() => element.focus({ preventScroll: true }), 250);
}, []);
const hasTestedRef = useRef(false);
const mergedInitialValues = { ...defaultCreateValues, ...initialValues };
@@ -771,7 +777,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const focusStepForErrors = (errors: Record<string, unknown>) => {
const fields = Object.keys(errors);
if (fields.some((field) => ["name", "description", "iconUrl", "displayType"].includes(field.split(".")[0] ?? ""))) {
setActiveStep(0);
scrollToSection(0);
return;
}
if (
@@ -779,10 +785,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
["url", "method", "authType", "headerName", "requestBody", "secrets"].includes(field.split(".")[0] ?? ""),
)
) {
setActiveStep(1);
scrollToSection(1);
return;
}
setActiveStep(2);
scrollToSection(2);
};
const handleSubmit = form.onSubmit(async (values) => {
@@ -986,25 +992,12 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
setLastPreviewFingerprint(null);
}, []);
const NEXT_STEP_LABELS: Record<number, string> = {
0: t("steps.connection.label"),
1: t("steps.configure.label"),
};
const STEP_FIELDS: Record<number, string[]> = {
0: ["name"],
1: ["url"],
};
const handleNextStep = () => {
const fieldsToValidate = STEP_FIELDS[activeStep];
if (!fieldsToValidate) {
setActiveStep((step) => Math.min(2, step + 1));
const continueToSection = (currentSection: number, field: string, nextSection: number) => {
if (form.validateField(field).hasError) {
scrollToSection(currentSection);
return;
}
const hasErrors = fieldsToValidate.some((field) => form.validateField(field).hasError);
if (hasErrors) return;
setActiveStep((step) => Math.min(2, step + 1));
scrollToSection(nextSection);
};
return (
@@ -1021,26 +1014,20 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
/>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="xl" className={classes.configuration}>
<Stepper
active={activeStep}
onStepClick={(target) => {
if (target <= activeStep) {
setActiveStep(target);
return;
}
for (let step = activeStep; step < target; step++) {
const fields = STEP_FIELDS[step];
if (fields?.some((field) => form.validateField(field).hasError)) return;
}
setActiveStep(target);
}}
allowNextStepsSelect
keepMounted
size="sm"
completedIcon={<IconCheck size={16} />}
>
<Stepper.Step label={t("steps.format.label")}>
<Stack gap="xl" mt="xl">
<Stack gap={0} className={classes.formFlow}>
<section ref={formatSectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
<IconLayoutGrid size={20} />
</ThemeIcon>
<Stack gap="xl">
<div>
<Text fw={700} size="lg">
{t("steps.format.label")}
</Text>
<Text size="sm" c="dimmed">
{t("steps.format.description")}
</Text>
</div>
<Fieldset legend={t("fieldset.general")}>
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
@@ -1065,141 +1052,158 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
/>
</Stack>
</Fieldset>
<Group justify="flex-end">
<Button variant="light" size="md" onClick={() => continueToSection(0, "name", 1)}>
{t("steps.continueTo", { step: t("steps.connection.label") })}
</Button>
</Group>
</Stack>
</Stepper.Step>
</section>
<Stepper.Step label={t("steps.connection.label")}>
<Fieldset legend={t("fieldset.connection")} mt="xl">
<Stack gap="sm">
<Group align="end" wrap="wrap" gap="xs">
<Select
label={t("field.method")}
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
value,
label: t(`method.${value}` as never),
}))}
w={110}
{...form.getInputProps("method")}
allowDeselect={false}
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
/>
<TextInput
label={t("field.url")}
required
placeholder={t("placeholder.url")}
style={{ flex: "1 1 260px" }}
{...form.getInputProps("url")}
/>
<Button
size="sm"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={() => void handlePreviewTest()}
loading={previewMutation.isPending}
disabled={!form.values.url || form.values.method !== "GET"}
>
{t("preview.test")}
</Button>
</Group>
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.authType")}
</Text>
<Select
hiddenFrom="sm"
value={form.values.authType}
data={authTypeOptions}
onChange={(value) => handleAuthTypeChange(value ?? "none")}
allowDeselect={false}
aria-label={t("field.authType")}
/>
<SegmentedControl
visibleFrom="sm"
fullWidth
value={form.values.authType}
data={authTypeOptions}
onChange={handleAuthTypeChange}
/>
</div>
{showHeaderName[form.values.authType] && (
<TextInput
label={t("field.headerName")}
placeholder={t("placeholder.headerName")}
{...form.getInputProps("headerName")}
/>
)}
{secretFields.map((field) => {
const secretIndex = form.values.secrets.findIndex((s) => s.kind === field.kind);
if (secretIndex === -1) return null;
const secret = form.values.secrets[secretIndex];
if (!secret) return null;
const placeholder =
secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined;
return field.isPassword ? (
<PasswordInput
key={field.kind}
label={t(`secret.${field.labelKey}` as never)}
placeholder={placeholder}
{...form.getInputProps(`secrets.${secretIndex}.value`)}
<section ref={connectionSectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconPlayerPlay size={20} />
</ThemeIcon>
<Stack gap="xl">
<div>
<Text fw={700} size="lg">
{t("steps.connection.label")}
</Text>
<Text size="sm" c="dimmed">
{t("steps.connection.description")}
</Text>
</div>
<Fieldset legend={t("fieldset.connection")}>
<Stack gap="sm">
<Group align="end" wrap="wrap" gap="xs">
<Select
label={t("field.method")}
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
value,
label: t(`method.${value}` as never),
}))}
w={110}
{...form.getInputProps("method")}
allowDeselect={false}
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
/>
) : (
<TextInput
key={field.kind}
label={t(`secret.${field.labelKey}` as never)}
placeholder={placeholder}
{...form.getInputProps(`secrets.${secretIndex}.value`)}
label={t("field.url")}
required
placeholder={t("placeholder.url")}
style={{ flex: "1 1 260px" }}
{...form.getInputProps("url")}
/>
);
})}
{form.values.method !== "GET" && (
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
)}
</Stack>
</Fieldset>
</Stepper.Step>
<Stepper.Step label={t("steps.configure.label")}>
<Fieldset legend={t("fieldset.display")} mt="xl">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" size="lg">
{(() => {
const SelectedIcon =
DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
return SelectedIcon ? <SelectedIcon size={20} /> : null;
})()}
</ThemeIcon>
<Button
size="sm"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={() => void handlePreviewTest()}
loading={previewMutation.isPending}
disabled={!form.values.url || form.values.method !== "GET"}
>
{t("preview.test")}
</Button>
</Group>
<div>
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
<Text size="xs" c="dimmed">
{t(`displayTypeDescription.${form.values.displayType}` as never)}
<Text size="sm" fw={500} mb={4}>
{t("field.authType")}
</Text>
<Select
hiddenFrom="sm"
value={form.values.authType}
data={authTypeOptions}
onChange={(value) => handleAuthTypeChange(value ?? "none")}
allowDeselect={false}
aria-label={t("field.authType")}
/>
<SegmentedControl
visibleFrom="sm"
fullWidth
value={form.values.authType}
data={authTypeOptions}
onChange={handleAuthTypeChange}
/>
</div>
</Group>
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
</Stack>
</Fieldset>
</Stepper.Step>
</Stepper>
{showHeaderName[form.values.authType] && (
<TextInput
label={t("field.headerName")}
placeholder={t("placeholder.headerName")}
{...form.getInputProps("headerName")}
/>
)}
{secretFields.map((field) => {
const secretIndex = form.values.secrets.findIndex((s) => s.kind === field.kind);
if (secretIndex === -1) return null;
const secret = form.values.secrets[secretIndex];
if (!secret) return null;
const placeholder =
secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined;
return field.isPassword ? (
<PasswordInput
key={field.kind}
label={t(`secret.${field.labelKey}` as never)}
placeholder={placeholder}
{...form.getInputProps(`secrets.${secretIndex}.value`)}
/>
) : (
<TextInput
key={field.kind}
label={t(`secret.${field.labelKey}` as never)}
placeholder={placeholder}
{...form.getInputProps(`secrets.${secretIndex}.value`)}
/>
);
})}
{form.values.method !== "GET" && (
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
)}
</Stack>
</Fieldset>
<Group justify="flex-end">
<Button variant="light" size="md" onClick={() => continueToSection(1, "url", 2)}>
{t("steps.continueTo", { step: t("steps.configure.label") })}
</Button>
</Group>
</Stack>
</section>
<Group justify="space-between" mt="md">
<Button
variant="default"
size="md"
onClick={() => setActiveStep((step) => Math.max(0, step - 1))}
disabled={activeStep === 0}
>
{t("steps.back")}
</Button>
<Button
variant="light"
size="md"
onClick={handleNextStep}
disabled={activeStep === 2}
>
{NEXT_STEP_LABELS[activeStep] ?? t("steps.next")}
</Button>
</Group>
<section ref={configureSectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconCode size={20} />
</ThemeIcon>
<Stack gap="xl">
<div>
<Text fw={700} size="lg">
{t("steps.configure.label")}
</Text>
<Text size="sm" c="dimmed">
{t("steps.configure.description")}
</Text>
</div>
<Fieldset legend={t("fieldset.display")}>
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" size="lg">
{(() => {
const SelectedIcon =
DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
return SelectedIcon ? <SelectedIcon size={20} /> : null;
})()}
</ThemeIcon>
<div>
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
<Text size="xs" c="dimmed">
{t(`displayTypeDescription.${form.values.displayType}` as never)}
</Text>
</div>
</Group>
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
</Stack>
</Fieldset>
</Stack>
</section>
</Stack>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>
@@ -2,8 +2,6 @@
import { Component as ReactComponent, useEffect, useMemo, useState } from "react";
import type { ErrorInfo, ReactNode } from "react";
import dynamic from "next/dynamic";
import { json } from "@codemirror/lang-json";
import {
ActionIcon,
Alert,
@@ -29,7 +27,6 @@ import {
Textarea,
Title,
Tree,
useComputedColorScheme,
useTree,
} from "@mantine/core";
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
@@ -55,11 +52,6 @@ import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-d
import { analyzeJsxTemplate } from "./_code-editor";
const JsonEditor = dynamic(() => import("@uiw/react-codemirror").then((m) => m.default), {
ssr: false,
loading: () => <Loader size="sm" />,
});
interface PreviewInput {
url: string;
method: string;
@@ -514,7 +506,6 @@ function CustomWidgetPreviewContent({
<ScrollArea h={360} type="auto">
<ResponseTree value={cachedJson} onInsertDataPath={onInsertDataPath} />
</ScrollArea>
<ResponseJsonViewer json={responseJson} label={t("preview.response.raw")} />
{fetchResult?.rawResponse && (
<Button
size="xs"
@@ -788,31 +779,6 @@ function formatTreeValue(value: unknown) {
return String(value);
}
const JSON_EXTENSIONS = [json()];
function ResponseJsonViewer({ json: jsonStr, label }: { json: string; label: string }) {
const colorScheme = useComputedColorScheme();
const [expanded, setExpanded] = useState(false);
return (
<Stack gap={4}>
<Button size="compact-xs" variant="subtle" onClick={() => setExpanded((v) => !v)}>
{label}
</Button>
{expanded && (
<JsonEditor
value={jsonStr}
readOnly
editable={false}
extensions={JSON_EXTENSIONS}
height="280px"
theme={colorScheme === "dark" ? "dark" : "light"}
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: false }}
/>
)}
</Stack>
);
}
function EmptyPreview({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<Center h={240}>
+2 -1
View File
@@ -892,7 +892,8 @@
"description": "Fields and behavior"
},
"back": "Back",
"next": "Continue"
"next": "Continue",
"continueTo": "Continue to {step}"
},
"workbench": {
"configure": "Configure",