fix: resolve widget platform CI lint and formatting failures

This commit is contained in:
ajnart committed 2026-09-12 19:36:30 +02:00
1 parent 9ff1019a3d
commit a29ec26c27
12 files changed
+248 -100

No files matched your search

@@ -61,13 +61,19 @@ export function PackageAssistant() {
<Button component={Link} href="/manage/assistant" variant="light" size="xs">
{t("setup")}
</Button>
<Text size="xs" c="dimmed">{t("manual")}</Text>
<Text size="xs" c="dimmed">
{t("manual")}
</Text>
</Stack>
)}
{assistant?.enabled && (
<>
{assistant.isRunning && <Alert mb="xs">{t("busy")}</Alert>}
{sendFailed && <Alert color="yellow" mb="xs">{t("sendFailed")}</Alert>}
{sendFailed && (
<Alert color="yellow" mb="xs">
{t("sendFailed")}
</Alert>
)}
<Text size="xs" c="dimmed" mb="xs">
{t("context")}
</Text>
@@ -87,16 +93,40 @@ export function PackageAssistant() {
</Group>
<Divider my="xs" />
<Stack gap={4}>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("createPrompt"))}>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("createPrompt"))}
>
{t("create")}
</Button>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("improvePrompt"))}>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("improvePrompt"))}
>
{t("improve")}
</Button>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("advancedPrompt"))}>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("advancedPrompt"))}
>
{t("advanced")}
</Button>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("fixPrompt"))}>
<Button
variant="subtle"
size="xs"
justify="start"
disabled={assistant.isRunning}
onClick={() => send(t("fixPrompt"))}
>
{t("fix")}
</Button>
</Stack>
@@ -62,7 +62,9 @@ export function PackageConnections({
/>
{getWidgetConnectionBindingNames(name, requirement, bindings).map((bindingName) => (
<Group key={bindingName} gap="xs">
<Text size="sm" flex={1}>{connections.data?.find((entry) => entry.id === bindings[bindingName])?.name ?? bindingName}</Text>
<Text size="sm" flex={1}>
{connections.data?.find((entry) => entry.id === bindings[bindingName])?.name ?? bindingName}
</Text>
<Button
variant="default"
size="xs"
@@ -104,11 +106,23 @@ export function PackageConnections({
{integrations.error && <Alert color="red">{integrations.error.message}</Alert>}
<Group>
{Object.values(requirements).some((requirement) => requirement.kind === "integration") && (
<Button component={Link} href="/manage/integrations/new" target="_blank" rel="noopener noreferrer" variant="light">
<Button
component={Link}
href="/manage/integrations/new"
target="_blank"
rel="noopener noreferrer"
variant="light"
>
{t("createIntegration")}
</Button>
)}
<Button variant="subtle" onClick={() => { void connections.refetch(); void integrations.refetch(); }}>
<Button
variant="subtle"
onClick={() => {
void connections.refetch();
void integrations.refetch();
}}
>
{t("refresh")}
</Button>
<Button
@@ -176,8 +190,16 @@ function PackageBindingPicker({
mounted.current = false;
};
}, []);
const ids = [...new Set(getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]!))];
const compatible = connections.filter((connection) => isWidgetConnectionCompatible(requirement, connection.configuration));
const ids = [
...new Set(
getWidgetConnectionBindingNames(name, requirement, bindings)
.map((key) => bindings[key])
.filter((id) => typeof id === "string"),
),
];
const compatible = connections.filter((connection) =>
isWidgetConnectionCompatible(requirement, connection.configuration),
);
const choices = compatible.map((connection) => ({ value: connection.id, label: connection.name }));
if (requirement.kind === "integration") {
for (const integration of integrations) {
@@ -189,7 +211,10 @@ function PackageBindingPicker({
// Keep unavailable bindings visible so a removed integration can be diagnosed or replaced.
for (const id of ids) {
if (!choices.some((choice) => choice.value === id))
choices.push({ value: id, label: connections.find((connection) => connection.id === id)?.name ?? t("unavailableConnection") });
choices.push({
value: id,
label: connections.find((connection) => connection.id === id)?.name ?? t("unavailableConnection"),
});
}
const change = async (values: string[]) => {
if (preparing.current || disabled) return;
@@ -91,12 +91,16 @@ export function PackageOperations({
};
return (
<Stack>
<Text size="sm" c="dimmed">{t("useDescription")}</Text>
<Text size="sm" c="dimmed">
{t("useDescription")}
</Text>
{!current && (
<Paper withBorder p="md">
<Stack>
<Text>{t("saveFirst")}</Text>
<Button onClick={onSave} loading={saving}>{t("saveDraft")}</Button>
<Button onClick={onSave} loading={saving}>
{t("saveDraft")}
</Button>
</Stack>
</Paper>
)}
@@ -110,12 +114,18 @@ export function PackageOperations({
<Alert color="yellow">
<Stack gap="xs">
<Text size="sm">{t("saveBeforeActivate")}</Text>
<Button size="xs" onClick={onSave} loading={saving}>{t("saveDraft")}</Button>
<Button size="xs" onClick={onSave} loading={saving}>
{t("saveDraft")}
</Button>
</Stack>
</Alert>
)}
{!dirty && !matchesReviewedSource && <Alert color="yellow">{t("savedSourceChanged")}</Alert>}
<Checkbox checked={trusted} onChange={(event) => onTrustChange(event.currentTarget.checked)} label={t("trustAcknowledgement")} />
<Checkbox
checked={trusted}
onChange={(event) => onTrustChange(event.currentTarget.checked)}
label={t("trustAcknowledgement")}
/>
<Group>
<Button
disabled={dirty || connectionsDirty || !trusted || !source || !matchesReviewedSource}
@@ -151,7 +161,10 @@ export function PackageOperations({
variant="default"
loading={enable.isPending}
onClick={() =>
void run(() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }), t("stateSaved"))
void run(
() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }),
t("stateSaved"),
)
}
>
{current.enabled ? t("disable") : t("enable")}
@@ -176,7 +176,11 @@ export function PackagePreview({ state }: { state: ReturnType<typeof usePackageW
<Badge variant="light">{buildStatus}</Badge>
{previewMutation.error && <Alert color="red">{previewMutation.error.message}</Alert>}
<Text size="sm">{t("browserExecution")}</Text>
<Text size="sm">{parsed.success && parsed.data.manifest.entrypoints.server ? t("serverExecution") : t("browserOnlyExecution")}</Text>
<Text size="sm">
{parsed.success && parsed.data.manifest.entrypoints.server
? t("serverExecution")
: t("browserOnlyExecution")}
</Text>
<Text size="sm">{t("trustedCode")}</Text>
<Text size="sm">{t("runtimeLogs")}</Text>
{detailsOpen && state.installationId && <PackageRuntimeStatus id={state.installationId} />}
@@ -227,7 +231,9 @@ function PackagePreviewControls({ state }: { state: ReturnType<typeof usePackage
<Accordion.Control>{t("customViewport")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Text size="xs" c="dimmed">{t("customViewportDescription")}</Text>
<Text size="xs" c="dimmed">
{t("customViewportDescription")}
</Text>
<Group gap="xs" grow>
<NumberInput
size="xs"
@@ -276,23 +282,39 @@ function PackageRuntimeStatus({ id }: { id: string }) {
const data = status.data;
return (
<Stack gap="xs">
<Text size="sm" fw={600}>{t("title")}</Text>
<Text size="xs" c="dimmed">{t("description")}</Text>
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Text size="xs" c="dimmed">
{t("description")}
</Text>
<Group>
{data && <Badge variant="light">{t(data.state)}</Badge>}
<Button variant="subtle" size="xs" loading={status.isFetching} onClick={() => void status.refetch()}>{t("refresh")}</Button>
<Button variant="subtle" size="xs" loading={status.isFetching} onClick={() => void status.refetch()}>
{t("refresh")}
</Button>
</Group>
{data && "pid" in data && (
<Text size="xs">{t("process", { pid: data.pid ?? "—", active: data.activeRequests, queued: data.queuedRequests })}</Text>
<Text size="xs">
{t("process", { pid: data.pid ?? "—", active: data.activeRequests, queued: data.queuedRequests })}
</Text>
)}
{data && "error" in data && <Alert color="red">{data.error}</Alert>}
{status.error && <Alert color="red">{status.error.message}</Alert>}
<Button component={Link} href="/manage/tools/logs" target="_blank" rel="noopener noreferrer" variant="default" size="xs">{t("logs")}</Button>
<Button
component={Link}
href="/manage/tools/logs"
target="_blank"
rel="noopener noreferrer"
variant="default"
size="xs"
>
{t("logs")}
</Button>
</Stack>
);
}
export function PackageSessionPreview({ previewId }: { previewId: string }) {
const t = useI18n("customWidget.package");
const { data, error, isLoading } = clientApi.customWidget.package.previewGet.useQuery(
@@ -69,47 +69,52 @@ export function PackageSourceEditor({
{filesOpen && (
<Stack gap="xs" className={classes.files}>
<PackageFileTree files={document.files} selected={selected} onSelect={setSelected} />
<Button size="compact-xs" variant="subtle" onClick={() => setManageFiles((value) => !value)} aria-expanded={manageFiles}>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setManageFiles((value) => !value)}
aria-expanded={manageFiles}
>
{t("manageFiles")}
</Button>
<Collapse expanded={manageFiles}>
<Stack gap="xs">
<TextInput
aria-label={t("filePath")}
placeholder={t("filePathExample")}
value={path}
onChange={(event) => setPath(event.currentTarget.value)}
disabled={busy}
/>
<Group gap={4}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
onClick={addFile}
disabled={busy || !path.trim()}
>
{t("addFile")}
</Button>
<Button
size="compact-xs"
variant="subtle"
onClick={renameFile}
disabled={busy || !path.trim() || selected === "/widget.json"}
>
{t("renameFile")}
</Button>
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<IconTrash size={12} />}
onClick={removeFile}
disabled={busy || selected === "/widget.json"}
>
{t("removeFile")}
</Button>
</Group>
<TextInput
aria-label={t("filePath")}
placeholder={t("filePathExample")}
value={path}
onChange={(event) => setPath(event.currentTarget.value)}
disabled={busy}
/>
<Group gap={4}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
onClick={addFile}
disabled={busy || !path.trim()}
>
{t("addFile")}
</Button>
<Button
size="compact-xs"
variant="subtle"
onClick={renameFile}
disabled={busy || !path.trim() || selected === "/widget.json"}
>
{t("renameFile")}
</Button>
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<IconTrash size={12} />}
onClick={removeFile}
disabled={busy || selected === "/widget.json"}
>
{t("removeFile")}
</Button>
</Group>
</Stack>
</Collapse>
</Stack>
@@ -131,7 +136,11 @@ export function PackageSourceEditor({
);
}
function PackageFileTree({ files, selected, onSelect }: {
function PackageFileTree({
files,
selected,
onSelect,
}: {
files: Record<string, string>;
selected: string;
onSelect(path: string): void;
@@ -116,7 +116,9 @@ export function PackageWorkspace({
else editor.undo();
}}
>
<Title order={1} size="h2">{t("workbenchTitle")}</Title>
<Title order={1} size="h2">
{t("workbenchTitle")}
</Title>
<Group justify="space-between" align="end" data-package-document>
<TextInput
flex={1}
@@ -131,7 +133,9 @@ export function PackageWorkspace({
{t("library")}
</Button>
<Badge variant="light">{status}</Badge>
<Text size="xs" c="dimmed">{draftStatus}</Text>
<Text size="xs" c="dimmed">
{draftStatus}
</Text>
<Button
variant="default"
loading={save.isPending}
@@ -140,7 +144,12 @@ export function PackageWorkspace({
>
{t("saveDraft")}
</Button>
<Button onClick={() => { selectTab("installation"); setPane("edit"); }}>
<Button
onClick={() => {
selectTab("installation");
setPane("edit");
}}
>
{t("installation")}
</Button>
</Group>
@@ -149,7 +158,9 @@ export function PackageWorkspace({
<Paper withBorder p="md">
<Stack gap="xs">
<Text fw={600}>{t("gettingStarted")}</Text>
<Text size="sm" c="dimmed">{t("gettingStartedDescription")}</Text>
<Text size="sm" c="dimmed">
{t("gettingStartedDescription")}
</Text>
<Text size="sm">{t("manualSteps")}</Text>
</Stack>
</Paper>
@@ -168,7 +179,12 @@ export function PackageWorkspace({
<Alert color="yellow">
<Group justify="space-between">
<Text size="sm">{t("connectionsUnsaved")}</Text>
<Button size="xs" loading={saveBindings.isPending || save.isPending} disabled={busy} onClick={saveLocalBindings}>
<Button
size="xs"
loading={saveBindings.isPending || save.isPending}
disabled={busy}
onClick={saveLocalBindings}
>
{t("saveBindings")}
</Button>
</Group>
@@ -227,11 +243,7 @@ export function PackageWorkspace({
{ value: "preview", label: t("previewPane") },
]}
/>
<PackageSplit
userId={userId}
pane={pane}
preview={<PackagePreview state={workspace} />}
>
<PackageSplit userId={userId} pane={pane} preview={<PackagePreview state={workspace} />}>
<Stack className={classes.authoring}>
<Tabs value={tab} onChange={selectTab}>
<Tabs.List>
@@ -260,10 +272,16 @@ export function PackageWorkspace({
<Tabs.Panel value="options" pt="md">
{visitedTabs.has("options") && (
<Stack>
<Text size="sm" c="dimmed">{t("optionsDescription")}</Text>
{parsed.success && <PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />}
<Text size="sm" c="dimmed">
{t("optionsDescription")}
</Text>
{parsed.success && (
<PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />
)}
{parsed.success && Object.keys(parsed.data.options).length === 0 && <Alert>{t("noOptions")}</Alert>}
<Button variant="subtle" onClick={() => setOptions({})}>{t("resetOptions")}</Button>
<Button variant="subtle" onClick={() => setOptions({})}>
{t("resetOptions")}
</Button>
</Stack>
)}
</Tabs.Panel>
@@ -288,7 +306,9 @@ export function PackageWorkspace({
<Tabs.Panel value="workshop" pt="md">
{visitedTabs.has("workshop") && (
<Stack>
<Text size="sm" c="dimmed">{t("workshopDescription")}</Text>
<Text size="sm" c="dimmed">
{t("workshopDescription")}
</Text>
{current ? (
<>
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty || busy} />
@@ -299,7 +319,11 @@ export function PackageWorkspace({
onError={setError}
/>
</>
) : <Button onClick={() => void saveDraft()} loading={busy}>{t("saveDraft")}</Button>}
) : (
<Button onClick={() => void saveDraft()} loading={busy}>
{t("saveDraft")}
</Button>
)}
</Stack>
)}
</Tabs.Panel>
@@ -312,9 +336,6 @@ export function PackageWorkspace({
/* oxlint-disable jsx-a11y/prefer-tag-over-role -- A focusable window splitter is an interactive separator, not a thematic break. */
const defaultWidth = 60;
const clampWidth = (value: number, maximum = 75) => Math.min(maximum, Math.max(30, value));
@@ -8,7 +8,11 @@ import type {
WidgetCollectionContents,
WidgetCollectionManifest,
} from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import {
getWidgetConnectionBindingNames,
getWidgetConnectionRequirement,
isWidgetConnectionCompatible,
} from "@homarr/custom-widgets/package";
import { createWidgetCollectionArchive, validateWidgetCollectionArchive } from "@homarr/custom-widgets/package/server";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
@@ -145,8 +149,16 @@ export async function exportWidgetCollection(ctx: PackageContext, manifest: Widg
const bindings = parseBindings(row.bindings);
const mapping: Record<string, string> = {};
for (const [name, requirement] of Object.entries(source.connections)) {
const members = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]).toSorted();
const identity = JSON.stringify([requirement.kind, requirement.serviceType, requirement.integrationKind, requirement.multiple, members.length ? members : [id, name]]);
const members = getWidgetConnectionBindingNames(name, requirement, bindings)
.map((key) => bindings[key])
.toSorted();
const identity = JSON.stringify([
requirement.kind,
requirement.serviceType,
requirement.integrationKind,
requirement.multiple,
members.length ? members : [id, name],
]);
let slot = shared.get(identity);
if (!slot) {
slot = `connection${shared.size + 1}`;
@@ -6,7 +6,13 @@ import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common/server";
import { createId } from "@homarr/common";
import { eq, inArray } from "@homarr/db";
import { customWidgetConnections, groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import {
customWidgetConnections,
groupMembers,
integrationGroupPermissions,
integrations,
integrationUserPermissions,
} from "@homarr/db/schema";
import type { WidgetSdkInvocation } from "@homarr/custom-widgets/package/server";
import { readPackageHistory, widgetHistoryInputSchema } from "./history";
@@ -69,7 +75,9 @@ export function createWidgetSdkBridge(
return {
options: resolved.configuration,
bindingNames: Object.keys(resolved.bindings),
bindingLabels: Object.fromEntries(Object.entries(resolved.bindings).map(([name, id]) => [name, labels.get(id) ?? name])),
bindingLabels: Object.fromEntries(
Object.entries(resolved.bindings).map(([name, id]) => [name, labels.get(id) ?? name]),
),
installationId: resolved.installation.id,
isPreview: Boolean(resolved.preview),
};
@@ -57,11 +57,7 @@ export function downloadPackage(name: string, value: unknown) {
const maximumHistoryBytes = 2_000_000;
const maximumRecoveryBytes = 1_000_000;
export function usePackageDocument(
initial: PackageDocument,
userId: string,
installationId?: string,
) {
export function usePackageDocument(initial: PackageDocument, userId: string, installationId?: string) {
const [document, setDocument] = useState(initial);
const [saved, setSaved] = useState(initial);
const [recovery, setRecovery] = useState<PackageDocument | null>(null);
+1 -4
View File
@@ -21,10 +21,7 @@
"permission": "modify",
"inputSchema": {
"type": "object",
"required": [
"connection",
"episodeIds"
],
"required": ["connection", "episodeIds"],
"additionalProperties": false,
"properties": {
"connection": {
@@ -41,7 +41,9 @@ export function PackageConnectionBindings({
);
if (connection.multiple) {
const names = getWidgetConnectionBindingNames(name, connection, effectiveBindings);
const selected = [...new Set(names.map((key) => effectiveBindings[key]!))];
const selected = [
...new Set(names.map((key) => effectiveBindings[key]).filter((id) => typeof id === "string")),
];
const choices = candidates.map((row) => ({ value: row.id, label: row.name }));
for (const id of selected) {
if (!choices.some((choice) => choice.value === id))
+17 -4
View File
@@ -24,7 +24,11 @@ import classes from "./modals/widget-edit-modal.module.css";
type Size = { width: number; height: number };
/** The same board grid controls and fitted canvas used by Edit Widget and package authoring. */
export function WidgetPreviewFrame({ dimensions, resize, children }: {
export function WidgetPreviewFrame({
dimensions,
resize,
children,
}: {
dimensions: Size & { scale?: number };
resize?: { size: Size; maximumSize: Size; onChange(size: Size): void };
children(dimensions: Size & { scale: number }): ReactNode;
@@ -39,17 +43,26 @@ export function WidgetPreviewFrame({ dimensions, resize, children }: {
if (width > 0 && height > 0) scale = Math.min(width / sourceWidth, height / sourceHeight, scale);
const change = (dimension: keyof Size, value: string | number) => {
if (!resize || typeof value !== "number" || !Number.isFinite(value)) return;
resize.onChange({ ...resize.size, [dimension]: Math.max(1, Math.min(Math.round(value), resize.maximumSize[dimension])) });
resize.onChange({
...resize.size,
[dimension]: Math.max(1, Math.min(Math.round(value), resize.maximumSize[dimension])),
});
};
return (
<Stack className={classes.previewPanel} gap={0}>
<Center ref={ref} className={classes.previewCanvas}>
{resize && (
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
<Text size="xs" fw={600} c="dimmed">{t("item.edit.preview.size")}</Text>
<Text size="xs" fw={600} c="dimmed">
{t("item.edit.preview.size")}
</Text>
{(["width", "height"] as const).map((dimension) => (
<Group key={dimension} gap={6} wrap="nowrap">
{dimension === "height" && <Text size="xs" c="dimmed" aria-hidden>×</Text>}
{dimension === "height" && (
<Text size="xs" c="dimmed" aria-hidden>
×
</Text>
)}
<Tooltip label={t(`item.moveResize.field.${dimension}.label`)}>
<NumberInput
className={classes.previewSizeInput}