feat(widgets): wire up tabbed app editing in widget edit modal

- Add hideButtons/formRef props to AppForm for headless embedding
- Add Mantine Tabs to WidgetEditModal for app widgets (permission-gated)
- Pass appId from BoardItemMenu to the modal
- Add translation keys for tab labels and propagation notice
This commit is contained in:
Thomas Camlong committed 2026-05-28 14:55:15 +02:00
1 parent d83e9b2611
commit 3d1bb2f0ba
4 files changed
+167 -93

No files matched your search

@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(t) {
+53 -25
View File
@@ -1,7 +1,7 @@
"use client";
import type { ChangeEventHandler } from "react";
import { useEffect, useRef } from "react";
import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
@@ -17,6 +17,11 @@ import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>;
export interface AppFormHandle {
submit: () => void;
isDirty: () => boolean;
}
interface AppFormProps {
showBackToOverview: boolean;
buttonLabels: {
@@ -26,6 +31,8 @@ interface AppFormProps {
initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
isPending: boolean;
hideButtons?: boolean;
formRef?: React.Ref<AppFormHandle>;
}
export const AppForm = ({
@@ -34,6 +41,8 @@ export const AppForm = ({
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
hideButtons,
formRef,
}: AppFormProps) => {
const t = useI18n();
@@ -62,6 +71,15 @@ export const AppForm = ({
originalHandleSubmit(values, redirect, afterSuccess);
};
useImperativeHandle(
formRef,
() => ({
submit: () => form.onSubmit(handleSubmit)(),
isDirty: () => form.isDirty(),
}),
[form, handleSubmit],
);
const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
const handleClickDifferentUrlPing: ChangeEventHandler<HTMLInputElement> = () => {
@@ -92,32 +110,32 @@ export const AppForm = ({
}
}, [debouncedName, iconsData]);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
autosize
minRows={2}
resize="vertical"
/>
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
const formFields = (
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
autosize
minRows={2}
resize="vertical"
/>
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Checkbox
checked={opened}
onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
mt="md"
/>
<Checkbox
checked={opened}
onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
mt="md"
/>
<Collapse expanded={opened}>
<TextInput {...form.getInputProps("pingUrl")} />
</Collapse>
<Collapse expanded={opened}>
<TextInput {...form.getInputProps("pingUrl")} />
</Collapse>
{!hideButtons && (
<Group justify="end">
{showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps">
@@ -139,7 +157,17 @@ export const AppForm = ({
{buttonLabels.submit}
</Button>
</Group>
</Stack>
)}
</Stack>
);
if (hideButtons) {
return formFields;
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
{formFields}
</form>
);
};
+7
View File
@@ -1296,6 +1296,13 @@
},
"edit": {
"title": "Edit item",
"tab": {
"widget": "Edit widget",
"app": "Edit app"
},
"app": {
"propagationNotice": "Changes to this app will be applied everywhere it is used across all dashboards."
},
"advancedOptions": {
"label": "Advanced options",
"title": "Advanced item options"
+106 -68
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { Button, Group, Stack } from "@mantine/core";
import { useRef, useState } from "react";
import { Button, Group, Stack, Tabs } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -20,6 +21,8 @@ import type { OptionsBuilderResult } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -34,13 +37,15 @@ interface ModalProps<TSort extends WidgetKind> {
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
appId?: string;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
// Translate the error messages
z.config({
customError: zodErrorMap(t),
});
@@ -77,78 +82,111 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
actions.closeModal();
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(innerProps.appId);
const handleSubmit = form.onSubmit((values) => {
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
appEditRef.current?.submitIfDirty();
actions.closeModal();
});
const widgetFormContent = (
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
!Input ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
/>
);
})}
>
<FormProvider form={form}>
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
!Input ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
/>
);
})}
<Group justify="space-between">
<Button
variant="subtle"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
<Group justify="space-between">
<Button
variant="subtle"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
</Button>
{!showAppTab && (
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button type="submit">{t("common.action.saveChanges")}</Button>
</Group>
)}
</Group>
</Stack>
);
return (
<form onSubmit={handleSubmit}>
<FormProvider form={form}>
{showAppTab ? (
<Stack>
<Tabs defaultValue="widget">
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
<Tabs.Panel value="app" pt="md">
<EmbeddedAppEditForm appId={innerProps.appId!} handleRef={appEditRef} />
</Tabs.Panel>
</Tabs>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.saveChanges")}</Button>
</Group>
</Group>
</Stack>
</Stack>
) : (
widgetFormContent
)}
</FormProvider>
</form>
);