diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index f1185430c..e160430ba 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -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 ( @@ -127,6 +134,10 @@ const AddMenu = () => { {t("app.action.add")} + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + } onClick={handleAddCategory}> diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 016b9336c..f0577bd76 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -45,9 +45,11 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; interface NewIntegrationFormProps { - searchParams: Partial> & { - 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 (
void handleSubmitAsync(value))}> @@ -184,7 +189,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => @@ -193,9 +198,15 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => - + {onCancel ? ( + + ) : ( + + )} diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 1938a569e..803504329 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -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> & { - 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 ( + <> + + + + {t("action.create")} + + + + + ); } const tCreate = await getScopedI18n("integration.page.create"); @@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps {tCreate("title", { name: getIntegrationName(currentKind) })} - + diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 5066f5539..ee8b05b58 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -70,7 +70,7 @@ export const ItemSelectModal = createModal(({ actions }) => { ); }).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: "xl", + size: 1200, }); const WidgetItem = ({ diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index e488eb3a6..a4f37fcb7 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -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(({ 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(({ 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) => { + mutate(values, { + onSuccess(app) { + showSuccessNotification({ + title: tScoped("success.title"), + message: tScoped("success.message"), + }); + innerProps.onSelect?.(app); actions.closeModal(); }, }); }; + if (step === "form") { + return ( + + setStep("select")}> + + {t("app.action.create.title")} + + + + ); + } + return ( (({ actions, inner {t("app.action.create.description")} - @@ -108,7 +139,7 @@ export const AppSelectModal = createModal(({ actions, inner ))} - {filteredApps.length === 0 && !isPending && ( + {filteredApps.length === 0 && !isLoadingApps && (
{t("app.action.select.noResults")} @@ -120,5 +151,5 @@ export const AppSelectModal = createModal(({ actions, inner ); }).withOptions({ defaultTitle: (t) => t("app.action.select.title"), - size: "xl", + size: 1200, });