Compare commits

...
Author SHA1 Message Date
ajnart 37ef86f32e Embed integration editing in widget tab 2026-08-19 09:46:58 +02:00
6 changed files with 412 additions and 304 deletions

No files matched your search

@@ -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 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. 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 Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
changes apply to every app and widget that uses the integration. 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 When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one. create one.
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
@@ -27,8 +27,14 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
interface EditIntegrationFormProps { interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
embedded?: boolean; hideButtons?: boolean;
onSuccess?: () => void; onSuccess?: () => void;
formRef?: React.Ref<EditIntegrationFormHandle>;
}
export interface EditIntegrationFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
} }
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and( 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 t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind); 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 secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => { const handleSubmitAsync = useCallback(
setError(null); async ({ app, ...values }: FormType) => {
let url: string; setError(null);
try { let url: string;
url = hasUrlSecret try {
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin url = hasUrlSecret
: values.url; ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
} catch { : values.url;
showErrorNotification({ } catch {
title: t("integration.page.edit.notification.error.title"), showErrorNotification({
message: t("integration.page.edit.notification.error.message"), 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({ // We do it this way as we are unable to send a typesafe error through onError
id: integration.id, if (data?.error) {
...values, setError(data.error);
url, showErrorNotification({
secrets: values.secrets.map((secret) => ({ title: t("integration.page.edit.notification.error.title"),
kind: secret.kind, message: t("integration.page.edit.notification.error.message"),
value: secret.value === "" ? null : secret.value, });
})), requestAnimationFrame(() => errorRef.current?.focus());
appId: app?.id ?? null, return false;
}); }
// We do it this way as we are unable to send a typesafe error through onError showSuccessNotification({
if (data?.error) { title: t("integration.page.edit.notification.success.title"),
setError(data.error); message: t("integration.page.edit.notification.success.message"),
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
}); });
requestAnimationFrame(() => errorRef.current?.focus()); void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
return; 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({ useImperativeHandle(
title: t("integration.page.edit.notification.success.title"), formRef,
message: t("integration.page.edit.notification.success.message"), () => ({
}); submit: () =>
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]); new Promise<boolean>((resolve) => {
onSuccess?.(); form.onSubmit(
if (!embedded) { async (values) => {
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); try {
} resolve(await handleSubmitAsync(values));
}; } catch {
resolve(false);
}
},
() => resolve(false),
)();
}),
isDirty: () => form.isDirty(),
}),
[form, handleSubmitAsync],
);
const isInitialSecretKinds = const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) && initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length; form.values.secrets.length === initialSecretsKinds.length;
return ( const formFields = (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}> <Stack>
<Stack> <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)} )}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{allSecretKinds.length > 1 && ( {allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl <SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
defaultKinds={initialSecretsKinds} )}
secretKinds={allSecretKinds} {!isInitialSecretKinds
form={form} ? null
/> : form.values.secrets.map((secret, index) => (
)} <SecretCard
{!isInitialSecretKinds key={secret.kind}
? null secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
: form.values.secrets.map((secret, index) => ( onCancel={() =>
<SecretCard new Promise((resolve) => {
key={secret.kind} // When nothing changed, just close the secret card
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }} if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
onCancel={() => return resolve(true);
new Promise((resolve) => { }
// When nothing changed, just close the secret card openConfirmModal({
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) { title: t("integration.secrets.reset.title"),
return resolve(true); children: t("integration.secrets.reset.message"),
} onCancel: () => resolve(false),
openConfirmModal({ onConfirm: () => {
title: t("integration.secrets.reset.title"), form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
children: t("integration.secrets.reset.message"), resolve(true);
onCancel: () => resolve(false), },
onConfirm: () => { });
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? ""); })
resolve(true); }
}, >
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={kind} key={secret.kind}
kind={kind} kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
/> />
))} </SecretCard>
{form.values.secrets.length === 0 && ( ))}
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}> {isInitialSecretKinds
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> ? null
</Alert> : form.values.secrets.map(({ kind }, index) => (
)} <IntegrationSecretInput
</Stack> withAsterisk
</Fieldset> key={kind}
kind={kind}
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} /> {...form.getInputProps(`secrets.${index}.value`)}
/>
{error !== null && ( ))}
<div ref={errorRef} role="alert" tabIndex={-1}> {form.values.secrets.length === 0 && (
<IntegrationTestConnectionError error={error} url={form.values.url} /> <Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
</div> <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
)} </Alert>
<Group justify="end" align="center">
{!embedded && (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
)} )}
</Stack>
</Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} />
</div>
)}
{!hideButtons && (
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")} {t("integration.testConnection.action.edit")}
</Button> </Button>
</Group> </Group>
</Stack> )}
</form> </Stack>
); );
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
}; };
type FormType = z.infer<typeof formSchema>; type FormType = z.infer<typeof formSchema>;
@@ -3,16 +3,16 @@
import { Suspense, use } from "react"; import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals"; import { createModal, modalSizeForm } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals"; import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals"; import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal"; import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal"; import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined; let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
@@ -29,42 +29,38 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined; let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
const loadIntegrationEditModal = () => { const loadIntegrationEditForm = () => {
if (integrationEditModalPromise) return integrationEditModalPromise; if (integrationEditFormPromise) return integrationEditFormPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-modal"); const currentPromise = import("~/components/integration/embedded-integration-edit-form");
integrationEditModalPromise = currentPromise; integrationEditFormPromise = currentPromise;
void currentPromise.catch(() => { void currentPromise.catch(() => {
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined; if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
}); });
return currentPromise; return currentPromise;
}; };
const LazyIntegrationEditModalContent = (props: { const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
actions: { closeModal: () => void }; const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
innerProps: EmbeddedIntegrationEditModalProps; return <EmbeddedIntegrationEditForm {...props} />;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
}; };
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => ( const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<ErrorBoundary <Center py="xl">
fallbackRender={({ error, resetErrorBoundary }) => ( <WidgetError
<Center py="xl"> error={error}
<WidgetError resetErrorBoundary={() => {
error={error} integrationEditFormPromise = undefined;
resetErrorBoundary={() => { resetErrorBoundary();
integrationEditModalPromise = undefined; }}
resetErrorBoundary(); />
}} </Center>
/> );
</Center>
)} const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
> <ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
<Suspense <Suspense
fallback={ fallback={
<Center py="xl"> <Center py="xl">
@@ -72,15 +68,10 @@ const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
</Center> </Center>
} }
> >
<LazyIntegrationEditModalContent {...props} /> <LazyIntegrationEditFormContent {...props} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)).withOptions({ );
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
interface LazyWidgetEditModalContentProps { interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void }; actions: { closeModal: () => void };
@@ -90,17 +81,12 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal()); const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component; const Component = WidgetEditModal.component;
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
return ( return (
<Component <Component
{...props} {...props}
innerProps={{ innerProps={{
...props.innerProps, ...props.innerProps,
onEditIntegration: (integrationId) => integrationEditForm: LazyIntegrationEditForm,
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
}} }}
/> />
); );
@@ -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<EditIntegrationFormHandle>(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 (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
</Stack>
);
};
@@ -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<EmbeddedIntegrationEditModalProps>(
({ actions, innerProps }) => {
const t = useI18n();
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: innerProps.integrationId,
});
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm
integration={integration}
embedded
onSuccess={() => {
innerProps.onSuccess?.();
actions.closeModal();
}}
/>
</Stack>
);
},
).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
+131 -49
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; 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 { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
import { IconPencil } from "@tabler/icons-react"; import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
@@ -35,6 +35,16 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
} }
export interface EmbeddedIntegrationEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
export interface EmbeddedIntegrationEditFormProps {
integrationId: string;
handleRef: React.Ref<EmbeddedIntegrationEditFormHandle>;
onSuccess?: () => void;
}
export interface WidgetEditModalProps<TSort extends WidgetKind> { export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort; kind: TSort;
definition: WidgetDefinition; definition: WidgetDefinition;
@@ -46,7 +56,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
appId?: string; appId?: string;
onEditIntegration?: (integrationId: string) => void; integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
} }
@@ -61,6 +71,12 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const { data: session } = useSession(); const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions); const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null); const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
const integrationEditRefCallbacks = useRef(
new Map<string, (handle: EmbeddedIntegrationEditFormHandle | null) => void>(),
);
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
z.config({ z.config({
@@ -118,7 +134,24 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId); const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false; const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds); 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 showResourceTabs = showAppTab || showIntegrationTab;
const [activeTab, setActiveTab] = useState<string | null>("widget"); const [activeTab, setActiveTab] = useState<string | null>("widget");
@@ -127,15 +160,31 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (activeTab === "app" && !showAppTab) setActiveTab("widget"); if (activeTab === "app" && !showAppTab) setActiveTab("widget");
}, [activeTab, showAppTab, showIntegrationTab]); }, [activeTab, showAppTab, showIntegrationTab]);
const handleTabChange = (value: string | null) => { const getIntegrationEditRef = (integrationId: string) => {
if (value === "integration" && selectedIntegrations.length === 1) { const existingCallback = integrationEditRefCallbacks.current.get(integrationId);
const integration = selectedIntegrations[0]; if (existingCallback) {
if (integration) { return existingCallback;
innerProps.onEditIntegration?.(integration.id);
return;
}
} }
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) => { const handleSubmit = form.onSubmit(async (values) => {
@@ -147,6 +196,13 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return; return;
} }
for (const integrationEditHandle of integrationEditHandles.current.values()) {
const integrationSaved = await integrationEditHandle.submitIfDirty();
if (!integrationSaved) {
return;
}
}
innerProps.onSuccessfulEdit({ innerProps.onSuccessfulEdit({
...values, ...values,
advancedOptions, advancedOptions,
@@ -240,7 +296,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<FormProvider form={form}> <FormProvider form={form}>
{showResourceTabs ? ( {showResourceTabs ? (
<Stack> <Stack>
<Tabs value={activeTab} onChange={handleTabChange}> <Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab> <Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>} {showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
@@ -257,42 +313,68 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
{showIntegrationTab && ( {showIntegrationTab && (
<Tabs.Panel value="integration" pt="md"> <Tabs.Panel value="integration" pt="md">
<Stack> <Stack>
<Text size="sm" c="dimmed"> {displayedIntegrationId === null && (
{t("item.edit.integration.description")} <>
</Text> <Text size="sm" c="dimmed">
{selectedIntegrations.map((integration) => { {t("item.edit.integration.description")}
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true; </Text>
return ( {selectedIntegrations.map((integration) => {
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm"> const canEdit = editableIntegrationIds.includes(integration.id);
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> return (
<IntegrationAvatar kind={integration.kind} size="sm" /> <Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate> <IntegrationAvatar kind={integration.kind} size="sm" />
{integration.name} <Stack gap={0} style={{ minWidth: 0 }}>
</Text> <Text fw={600} size="sm" truncate>
<Text size="xs" c="dimmed" truncate> {integration.name}
{integration.url} </Text>
</Text> <Text size="xs" c="dimmed" truncate>
</Stack> {integration.url}
</Group> </Text>
{canEdit ? ( </Stack>
<Button </Group>
type="button" {canEdit ? (
variant="light" <Button
leftSection={<IconPencil size={16} />} type="button"
onClick={() => innerProps.onEditIntegration?.(integration.id)} variant="light"
aria-label={t("item.edit.integration.editLabel", { name: integration.name })} leftSection={<IconPencil size={16} />}
> onClick={() => beginEditingIntegration(integration.id)}
{t("item.edit.integration.action")} aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
</Button> >
) : ( {t("item.edit.integration.action")}
<Text size="xs" c="dimmed" ta="end"> </Button>
{t("item.edit.integration.fullAccessRequired")} ) : (
</Text> <Text size="xs" c="dimmed" ta="end">
)} {t("item.edit.integration.fullAccessRequired")}
</Group> </Text>
); )}
})} </Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{t("common.action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
)} )}