feat(integrations): add grid picker page and dashboard integration action
Refactor integration creation page to show a grid picker before the form. Add "Add integration" action to dashboard header menu. Convert app select modal to a two-step stepper. Standardize modal widths to 1200 for item, integration, and app select modals.
This commit is contained in:
5 files changed
+112
-47
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
@@ -36,6 +37,7 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
@@ -67,6 +69,7 @@ const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
@@ -108,6 +111,10 @@ const AddMenu = () => {
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
@@ -127,6 +134,10 @@ const AddMenu = () => {
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
|
||||
@@ -45,9 +45,11 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
|
||||
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
kind: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
@@ -58,29 +60,28 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||
export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
// Placeholder Url, replaced with origin of the secret Url on submit
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||
name: initialName ?? getIntegrationName(kind),
|
||||
url,
|
||||
secrets: secretKinds[0].map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: false,
|
||||
hasApp: true,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
@@ -106,7 +107,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
: {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(searchParams.kind),
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
|
||||
await createIntegrationAsync(
|
||||
{
|
||||
kind: searchParams.kind,
|
||||
kind,
|
||||
...values,
|
||||
url,
|
||||
app,
|
||||
@@ -136,7 +137,11 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
router.push("/manage/integrations");
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push("/manage/integrations");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
@@ -148,7 +153,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
const supportsSearchEngine = integrationDefs[kind].category.flat().includes("search");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
@@ -184,7 +189,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
kind: getIntegrationName(searchParams.kind),
|
||||
kind: getIntegrationName(kind),
|
||||
})}
|
||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||
/>
|
||||
@@ -193,9 +198,15 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
{onCancel ? (
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
|
||||
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import type { integrationCreateSchema } from "@homarr/validation/integration";
|
||||
|
||||
import { IntegrationPickerPage } from "~/components/integration/integration-picker-page";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { env } from "~/env";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
searchParams: Promise<
|
||||
Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
>;
|
||||
searchParams: Promise<{
|
||||
kind?: string;
|
||||
url?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||
@@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
||||
}
|
||||
|
||||
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
||||
|
||||
if (!result.success) {
|
||||
notFound();
|
||||
const t = await getScopedI18n("integration");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container size="xl">
|
||||
<Stack>
|
||||
<Title>{t("action.create")}</Title>
|
||||
<IntegrationPickerPage enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const tCreate = await getScopedI18n("integration.page.create");
|
||||
@@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
<NewIntegrationForm kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: "xl",
|
||||
size: 1200,
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { Button, Card, Center, Grid, Group, Input, ScrollArea, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconArrowLeft, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface AppSelectModalProps {
|
||||
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
|
||||
@@ -15,10 +17,20 @@ interface AppSelectModalProps {
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [step, setStep] = useState<"select" | "form">("select");
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
|
||||
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
|
||||
const tScoped = useScopedI18n("app.page.create.notification");
|
||||
const { data: apps = [], isPending: isLoadingApps } = clientApi.app.selectable.useQuery();
|
||||
|
||||
const { mutate, isPending: isCreating } = clientApi.app.create.useMutation({
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tScoped("error.title"),
|
||||
message: tScoped("error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
@@ -29,23 +41,42 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
);
|
||||
|
||||
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAddNewApp = () => {
|
||||
openQuickAddAppModal({
|
||||
onClose(app) {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
const handleCreateSubmit = (values: z.infer<typeof appManageSchema>) => {
|
||||
mutate(values, {
|
||||
onSuccess(app) {
|
||||
showSuccessNotification({
|
||||
title: tScoped("success.title"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (step === "form") {
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={() => setStep("select")}>
|
||||
<IconArrowLeft size={18} />
|
||||
<Title order={4}>{t("app.action.create.title")}</Title>
|
||||
</Group>
|
||||
<AppForm
|
||||
buttonLabels={{
|
||||
submit: t("board.action.quickCreateApp.modal.createAndUse"),
|
||||
}}
|
||||
showBackToOverview={false}
|
||||
handleSubmit={handleCreateSubmit}
|
||||
isPending={isCreating}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Input
|
||||
@@ -77,7 +108,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
<Button onClick={() => setStep("form")} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -108,7 +139,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
</Grid.Col>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
{filteredApps.length === 0 && !isLoadingApps && (
|
||||
<Grid.Col span={12}>
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
@@ -120,5 +151,5 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: "xl",
|
||||
size: 1200,
|
||||
});
|
||||
Reference in new issue
Block a user