diff --git a/apps/docs/docs/management/integrations/index.mdx b/apps/docs/docs/management/integrations/index.mdx index a7ccbe1a6..396b73e16 100644 --- a/apps/docs/docs/management/integrations/index.mdx +++ b/apps/docs/docs/management/integrations/index.mdx @@ -63,9 +63,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a widget returns to its settings with the new integration already selected. -Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a -board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful -changes apply to every app and widget that uses the integration. +Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration** +tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page. +Successful changes apply to every app and widget that uses the integration. When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to create one. diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx index ecf066e56..88ce8d52e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useCallback, useImperativeHandle, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; @@ -27,8 +27,14 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec interface EditIntegrationFormProps { integration: RouterOutputs["integration"]["byId"]; - embedded?: boolean; + hideButtons?: boolean; onSuccess?: () => void; + formRef?: React.Ref; +} + +export interface EditIntegrationFormHandle { + submit: () => Promise; + isDirty: () => boolean; } const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and( @@ -44,7 +50,12 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and( }), ); -export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => { +export const EditIntegrationForm = ({ + integration, + hideButtons = false, + onSuccess, + formRef, +}: EditIntegrationFormProps) => { const t = useI18n(); const { openConfirmModal } = useConfirmModal(); const allSecretKinds = getAllSecretKindOptions(integration.kind); @@ -82,147 +93,172 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess } const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); - const handleSubmitAsync = async ({ app, ...values }: FormType) => { - setError(null); - let url: string; - try { - url = hasUrlSecret - ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin - : values.url; - } catch { - showErrorNotification({ - title: t("integration.page.edit.notification.error.title"), - message: t("integration.page.edit.notification.error.message"), + const handleSubmitAsync = useCallback( + async ({ app, ...values }: FormType) => { + setError(null); + let url: string; + try { + url = hasUrlSecret + ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin + : values.url; + } catch { + showErrorNotification({ + title: t("integration.page.edit.notification.error.title"), + message: t("integration.page.edit.notification.error.message"), + }); + return false; + } + + const data = await mutateAsync({ + id: integration.id, + ...values, + url, + secrets: values.secrets.map((secret) => ({ + kind: secret.kind, + value: secret.value === "" ? null : secret.value, + })), + appId: app?.id ?? null, }); - return; - } - const data = await mutateAsync({ - id: integration.id, - ...values, - url, - secrets: values.secrets.map((secret) => ({ - kind: secret.kind, - value: secret.value === "" ? null : secret.value, - })), - appId: app?.id ?? null, - }); + // We do it this way as we are unable to send a typesafe error through onError + if (data?.error) { + setError(data.error); + showErrorNotification({ + title: t("integration.page.edit.notification.error.title"), + message: t("integration.page.edit.notification.error.message"), + }); + requestAnimationFrame(() => errorRef.current?.focus()); + return false; + } - // We do it this way as we are unable to send a typesafe error through onError - if (data?.error) { - setError(data.error); - showErrorNotification({ - title: t("integration.page.edit.notification.error.title"), - message: t("integration.page.edit.notification.error.message"), + showSuccessNotification({ + title: t("integration.page.edit.notification.success.title"), + message: t("integration.page.edit.notification.success.message"), }); - requestAnimationFrame(() => errorRef.current?.focus()); - return; - } + void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]); + onSuccess?.(); + if (!hideButtons) { + void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); + } + return true; + }, + [hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget], + ); - showSuccessNotification({ - title: t("integration.page.edit.notification.success.title"), - message: t("integration.page.edit.notification.success.message"), - }); - void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]); - onSuccess?.(); - if (!embedded) { - void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); - } - }; + useImperativeHandle( + formRef, + () => ({ + submit: () => + new Promise((resolve) => { + form.onSubmit( + async (values) => { + try { + resolve(await handleSubmitAsync(values)); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + isDirty: () => form.isDirty(), + }), + [form, handleSubmitAsync], + ); const isInitialSecretKinds = initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) && form.values.secrets.length === initialSecretsKinds.length; - return ( -
await handleSubmitAsync(values))}> - - + const formFields = ( + + - {hasUrlSecret ? null : ( - - )} + {hasUrlSecret ? null : ( + + )} -
- - {allSecretKinds.length > 1 && ( - - )} - {!isInitialSecretKinds - ? null - : form.values.secrets.map((secret, index) => ( - - new Promise((resolve) => { - // When nothing changed, just close the secret card - if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) { - return resolve(true); - } - openConfirmModal({ - title: t("integration.secrets.reset.title"), - children: t("integration.secrets.reset.message"), - onCancel: () => resolve(false), - onConfirm: () => { - form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? ""); - resolve(true); - }, - }); - }) - } - > - - - ))} - {isInitialSecretKinds - ? null - : form.values.secrets.map(({ kind }, index) => ( +
+ + {allSecretKinds.length > 1 && ( + + )} + {!isInitialSecretKinds + ? null + : form.values.secrets.map((secret, index) => ( + + new Promise((resolve) => { + // When nothing changed, just close the secret card + if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) { + return resolve(true); + } + openConfirmModal({ + title: t("integration.secrets.reset.title"), + children: t("integration.secrets.reset.message"), + onCancel: () => resolve(false), + onConfirm: () => { + form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? ""); + resolve(true); + }, + }); + }) + } + > - ))} - {form.values.secrets.length === 0 && ( - } color={"blue"}> - {t("integration.secrets.noSecretsRequired.text")} - - )} - -
- - form.setFieldValue("app", app)} /> - - {error !== null && ( -
- -
- )} - - - {!embedded && ( - + + ))} + {isInitialSecretKinds + ? null + : form.values.secrets.map(({ kind }, index) => ( + + ))} + {form.values.secrets.length === 0 && ( + } color={"blue"}> + {t("integration.secrets.noSecretsRequired.text")} + )} +
+
+ + form.setFieldValue("app", app)} /> + + {error !== null && ( +
+ +
+ )} + + {!hideButtons && ( + + -
- + )} +
); + + if (hideButtons) { + return formFields; + } + + return
await handleSubmitAsync(values))}>{formFields}
; }; type FormType = z.infer; diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx index 8dc014b80..fab8d4d2e 100644 --- a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx +++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx @@ -3,16 +3,16 @@ import { Suspense, use } from "react"; import { Center, Loader } from "@mantine/core"; import { ErrorBoundary } from "react-error-boundary"; +import type { FallbackProps } from "react-error-boundary"; import type { WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeForm } from "@homarr/modals"; -import { useModalAction } from "@homarr/modals"; import { WidgetError } from "@homarr/widgets/errors"; import type * as WidgetModalsModule from "@homarr/widgets/modals"; import type { WidgetEditModalProps } from "@homarr/widgets/modals"; -import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal"; -import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal"; +import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form"; +import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form"; let widgetEditModalPromise: Promise | undefined; @@ -29,42 +29,38 @@ export const loadWidgetEditModal = () => { export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); -let integrationEditModalPromise: Promise | undefined; +let integrationEditFormPromise: Promise | undefined; -const loadIntegrationEditModal = () => { - if (integrationEditModalPromise) return integrationEditModalPromise; +const loadIntegrationEditForm = () => { + if (integrationEditFormPromise) return integrationEditFormPromise; - const currentPromise = import("~/components/integration/embedded-integration-edit-modal"); - integrationEditModalPromise = currentPromise; + const currentPromise = import("~/components/integration/embedded-integration-edit-form"); + integrationEditFormPromise = currentPromise; void currentPromise.catch(() => { - if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined; + if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined; }); return currentPromise; }; -const LazyIntegrationEditModalContent = (props: { - actions: { closeModal: () => void }; - innerProps: EmbeddedIntegrationEditModalProps; -}) => { - const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal()); - const Component = EmbeddedIntegrationEditModal.component; - return ; +const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => { + const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm()); + return ; }; -const LazyIntegrationEditModal = createModal((props) => ( - ( -
- { - integrationEditModalPromise = undefined; - resetErrorBoundary(); - }} - /> -
- )} - > +const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( +
+ { + integrationEditFormPromise = undefined; + resetErrorBoundary(); + }} + /> +
+); + +const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => ( + @@ -72,15 +68,10 @@ const LazyIntegrationEditModal = createModal( } > - + -)).withOptions({ - defaultTitle: (t) => t("item.edit.tab.integration"), - size: modalSizeForm, - presentation: "inspector", - closeOnClickOutside: true, -}); +); interface LazyWidgetEditModalContentProps { actions: { closeModal: () => void }; @@ -90,17 +81,12 @@ interface LazyWidgetEditModalContentProps { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const { WidgetEditModal } = use(loadWidgetEditModal()); const Component = WidgetEditModal.component; - const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal); return ( - openIntegrationEditModal({ - integrationId, - onSuccess: props.innerProps.onIntegrationSaved, - }), + integrationEditForm: LazyIntegrationEditForm, }} /> ); diff --git a/apps/nextjs/src/components/integration/embedded-integration-edit-form.tsx b/apps/nextjs/src/components/integration/embedded-integration-edit-form.tsx new file mode 100644 index 000000000..bcd40895e --- /dev/null +++ b/apps/nextjs/src/components/integration/embedded-integration-edit-form.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useImperativeHandle, useRef } from "react"; +import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { getIntegrationName } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; +import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals"; + +import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form"; +import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form"; + +export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals"; + +export const EmbeddedIntegrationEditForm = ({ + integrationId, + handleRef, + onSuccess, +}: EmbeddedIntegrationEditFormProps) => { + const t = useI18n(); + const integrationFormRef = useRef(null); + const { + data: integration, + isPending, + isError, + refetch, + } = clientApi.integration.byId.useQuery({ + id: integrationId, + }); + + useImperativeHandle( + handleRef, + () => ({ + submitIfDirty: async () => { + if (!integrationFormRef.current?.isDirty()) { + return true; + } + + return integrationFormRef.current.submit(); + }, + }), + [], + ); + + if (isPending) { + return ( + + + + + + ); + } + + if (isError || !integration) { + return ( + + + {t("item.edit.integration.loadError")} + + + + ); + } + + return ( + + + + + + {integration.name} + + + {getIntegrationName(integration.kind)} + + + + } color="blue" variant="light"> + {t("item.edit.integration.propagationNotice")} + + + + ); +}; diff --git a/apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx b/apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx deleted file mode 100644 index 7efd63297..000000000 --- a/apps/nextjs/src/components/integration/embedded-integration-edit-modal.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { IconInfoCircle } from "@tabler/icons-react"; - -import { clientApi } from "@homarr/api/client"; -import { getIntegrationName } from "@homarr/definitions"; -import { createModal, modalSizeForm } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar } from "@homarr/ui"; - -import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form"; - -export interface EmbeddedIntegrationEditModalProps { - integrationId: string; - onSuccess?: () => void; -} - -export const EmbeddedIntegrationEditModal = createModal( - ({ actions, innerProps }) => { - const t = useI18n(); - const { - data: integration, - isPending, - isError, - refetch, - } = clientApi.integration.byId.useQuery({ - id: innerProps.integrationId, - }); - - if (isPending) { - return ( - - - - - - ); - } - - if (isError || !integration) { - return ( - - - {t("item.edit.integration.loadError")} - - - - ); - } - - return ( - - - - - - {integration.name} - - - {getIntegrationName(integration.kind)} - - - - } color="blue" variant="light"> - {t("item.edit.integration.propagationNotice")} - - { - innerProps.onSuccess?.(); - actions.closeModal(); - }} - /> - - ); - }, -).withOptions({ - defaultTitle: (t) => t("item.edit.tab.integration"), - size: modalSizeForm, - presentation: "inspector", - closeOnClickOutside: true, -}); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index d840b3076..36ec3815b 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -1,10 +1,10 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import type { FormEvent } from "react"; +import type { ComponentType, FormEvent } from "react"; import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; -import { IconPencil } from "@tabler/icons-react"; +import { IconArrowLeft, IconPencil } from "@tabler/icons-react"; import { z } from "zod/v4"; import { objectEntries } from "@homarr/common"; @@ -35,6 +35,16 @@ export interface WidgetEditModalState { advancedOptions: BoardItemAdvancedOptions; } +export interface EmbeddedIntegrationEditFormHandle { + submitIfDirty: () => Promise; +} + +export interface EmbeddedIntegrationEditFormProps { + integrationId: string; + handleRef: React.Ref; + onSuccess?: () => void; +} + export interface WidgetEditModalProps { kind: TSort; definition: WidgetDefinition; @@ -46,7 +56,7 @@ export interface WidgetEditModalProps { itemId?: string; boardId?: string; appId?: string; - onEditIntegration?: (integrationId: string) => void; + integrationEditForm?: ComponentType; onIntegrationSaved?: () => void; } @@ -61,6 +71,12 @@ export const WidgetEditModal = createModal>(({ const { data: session } = useSession(); const [advancedOptions, setAdvancedOptions] = useState(innerProps.value.advancedOptions); const appEditRef = useRef(null); + const integrationEditHandles = useRef(new Map()); + const integrationEditRefCallbacks = useRef( + new Map void>(), + ); + const [mountedIntegrationIds, setMountedIntegrationIds] = useState([]); + const [activeIntegrationId, setActiveIntegrationId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); z.config({ @@ -118,7 +134,24 @@ export const WidgetEditModal = createModal>(({ const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId); const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false; const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds); - const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0; + const editableIntegrationIds = selectedIntegrations + .filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true) + .map((integration) => integration.id); + let soleEditableIntegrationId: string | null = null; + if (selectedIntegrations.length === 1) { + soleEditableIntegrationId = editableIntegrationIds[0] ?? null; + } + + let displayedIntegrationId = soleEditableIntegrationId; + if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) { + displayedIntegrationId = activeIntegrationId; + } + const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id)); + if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) { + integrationIdsToRender.push(displayedIntegrationId); + } + const IntegrationEditForm = innerProps.integrationEditForm; + const showIntegrationTab = Boolean(IntegrationEditForm) && selectedIntegrations.length > 0; const showResourceTabs = showAppTab || showIntegrationTab; const [activeTab, setActiveTab] = useState("widget"); @@ -127,15 +160,31 @@ export const WidgetEditModal = createModal>(({ if (activeTab === "app" && !showAppTab) setActiveTab("widget"); }, [activeTab, showAppTab, showIntegrationTab]); - const handleTabChange = (value: string | null) => { - if (value === "integration" && selectedIntegrations.length === 1) { - const integration = selectedIntegrations[0]; - if (integration) { - innerProps.onEditIntegration?.(integration.id); - return; - } + const getIntegrationEditRef = (integrationId: string) => { + const existingCallback = integrationEditRefCallbacks.current.get(integrationId); + if (existingCallback) { + return existingCallback; } - setActiveTab(value); + + const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => { + if (handle) { + integrationEditHandles.current.set(integrationId, handle); + } else { + integrationEditHandles.current.delete(integrationId); + } + }; + integrationEditRefCallbacks.current.set(integrationId, callback); + return callback; + }; + + const beginEditingIntegration = (integrationId: string) => { + setMountedIntegrationIds((current) => { + if (current.includes(integrationId)) { + return current; + } + return [...current, integrationId]; + }); + setActiveIntegrationId(integrationId); }; const handleSubmit = form.onSubmit(async (values) => { @@ -147,6 +196,13 @@ export const WidgetEditModal = createModal>(({ return; } + for (const integrationEditHandle of integrationEditHandles.current.values()) { + const integrationSaved = await integrationEditHandle.submitIfDirty(); + if (!integrationSaved) { + return; + } + } + innerProps.onSuccessfulEdit({ ...values, advancedOptions, @@ -240,7 +296,7 @@ export const WidgetEditModal = createModal>(({ {showResourceTabs ? ( - + {t("item.edit.tab.widget")} {showAppTab && {t("item.edit.tab.app")}} @@ -257,42 +313,68 @@ export const WidgetEditModal = createModal>(({ {showIntegrationTab && ( - - {t("item.edit.integration.description")} - - {selectedIntegrations.map((integration) => { - const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true; - return ( - - - - - - {integration.name} - - - {integration.url} - - - - {canEdit ? ( - - ) : ( - - {t("item.edit.integration.fullAccessRequired")} - - )} - - ); - })} + {displayedIntegrationId === null && ( + <> + + {t("item.edit.integration.description")} + + {selectedIntegrations.map((integration) => { + const canEdit = editableIntegrationIds.includes(integration.id); + return ( + + + + + + {integration.name} + + + {integration.url} + + + + {canEdit ? ( + + ) : ( + + {t("item.edit.integration.fullAccessRequired")} + + )} + + ); + })} + + )} + {displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( + + )} + {integrationIdsToRender.map((integrationId) => ( + + ))} )}