feat(widgets): add in-place app editing in widget edit modal (#5809)

This commit is contained in:
Thomas authored and GitHub committed 2026-06-05 12:31:50 +02:00
1 parent 41fe5928f1
commit 99346f288b
5 files changed
+319 -103

No files matched your search

@@ -0,0 +1,95 @@
"use client";
import { useImperativeHandle, useRef } from "react";
import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { AppFormHandle } from "@homarr/forms-collection";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
export interface EmbeddedAppEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
interface EmbeddedAppEditFormProps {
appId: string;
handleRef: React.Ref<EmbeddedAppEditFormHandle>;
}
export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormProps) => {
const t = useI18n();
const utils = clientApi.useUtils();
const appFormRef = useRef<AppFormHandle>(null);
const { data: app, isPending: isLoadingApp, isError, refetch } = clientApi.app.byId.useQuery({ id: appId });
const { mutateAsync, isPending: isMutating } = clientApi.app.update.useMutation({
onSuccess: async () => {
await utils.app.invalidate();
showSuccessNotification({
title: t("app.page.edit.notification.success.title"),
message: t("app.page.edit.notification.success.message"),
});
},
onError: () => {
showErrorNotification({
title: t("app.page.edit.notification.error.title"),
message: t("app.page.edit.notification.error.message"),
});
},
});
useImperativeHandle(
handleRef,
() => ({
submitIfDirty: async () => {
if (!appFormRef.current?.isDirty()) {
return true;
}
return appFormRef.current.submit();
},
}),
[],
);
if (isLoadingApp) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
if (isError || !app) {
return (
<Stack align="center" gap="md" py="xl">
<Text c="dimmed">{t("app.action.select.notFound")}</Text>
<Button variant="light" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
);
}
return (
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.app.propagationNotice")}
</Alert>
<AppForm
formRef={appFormRef}
hideButtons
showBackToOverview={false}
buttonLabels={{ submit: "" }}
initialValues={app}
handleSubmit={async (values) => {
await mutateAsync({ id: appId, ...values });
}}
isPending={isMutating}
/>
</Stack>
);
};
+126 -69
View File
@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { Button, Group, Stack } from "@mantine/core";
import { useRef, useState } from "react";
import type { FormEvent } 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 +22,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 +38,16 @@ 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);
const [isSubmitting, setIsSubmitting] = useState(false);
// Translate the error messages
z.config({
customError: zodErrorMap(t),
});
@@ -77,78 +84,128 @@ 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 appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
try {
const appSaved = showAppTab ? await (appEditRef.current?.submitIfDirty() ?? true) : true;
if (!appSaved) {
return;
}
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
actions.closeModal();
} finally {
setIsSubmitting(false);
}
});
const onFormSubmit = (event: FormEvent<HTMLFormElement>) => {
handleSubmit(event);
};
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 (
!Input ||
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" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Group>
</Stack>
);
return (
<form onSubmit={onFormSubmit}>
<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">
{appId && <EmbeddedAppEditForm appId={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>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
</Stack>
) : (
widgetFormContent
)}
</FormProvider>
</form>
);