feat: quick add app modal (#2248)

This commit is contained in:
Manuel
2025-02-18 22:53:44 +01:00
committed by GitHub
parent 63e96230e0
commit 6420feee72
24 changed files with 359 additions and 136 deletions

View File

@@ -111,16 +111,19 @@ export const appRouter = createTRPCRouter({
create: permissionRequiredProcedure
.requiresPermission("app-create")
.input(validation.app.manage)
.output(z.void())
.output(z.object({ appId: z.string() }))
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(apps).values({
id: createId(),
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
return { appId: id };
}),
createMany: permissionRequiredProcedure
.requiresPermission("app-create")

View File

@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,43 @@
{
"name": "@homarr/forms-collection",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"react": "19.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,3 @@
[data-combobox-selected="true"] .iconCard {
border-color: var(--mantine-primary-color-6);
}

View File

@@ -0,0 +1,190 @@
import type { FocusEventHandler } from "react";
import { startTransition } from "react";
import {
ActionIcon,
Box,
Card,
Combobox,
Flex,
Group,
Image,
Indicator,
InputBase,
Paper,
Skeleton,
Stack,
Text,
UnstyledButton,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media";
import classes from "./icon-picker.module.css";
interface IconPickerProps {
value?: string;
onChange: (iconUrl: string) => void;
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
}
export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedSearch] = useDebouncedValue(search, 100);
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
});
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
const groups = data?.icons.map((group) => {
const options = group.icons.map((item) => (
<Combobox.Option
key={item.id}
value={item.url}
p={0}
h="calc(2*var(--mantine-spacing-sm)+25px)"
w="calc(2*var(--mantine-spacing-sm)+25px)"
>
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setPreviewUrl(value);
setSearch(value);
setValue(value);
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
className={classes.iconCard}
withBorder
>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} radius="md" />
</Box>
</Card>
</Indicator>
</UnstyledButton>
</Combobox.Option>
));
return (
<Paper p="xs" key={group.slug} pt={2}>
<Text mb={8} size="sm" fw="bold">
{group.slug}
</Text>
<Flex gap={8} wrap={"wrap"}>
{options}
</Flex>
</Paper>
);
});
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
value={search}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={({ url }) => {
startTransition(() => {
setValue(url);
setPreviewUrl(url);
setSearch(url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};

View File

@@ -0,0 +1,6 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";

View File

@@ -0,0 +1,68 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import { AppForm } from "./_form";
export const AppNewForm = ({
showCreateAnother,
showBackToOverview,
}: {
showCreateAnother: boolean;
showBackToOverview: boolean;
}) => {
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
message: tScoped("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>, redirect: boolean, afterSuccess?: () => void) => {
mutate(values, {
onSuccess() {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
});
afterSuccess?.();
if (!redirect) {
return;
}
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
});
},
[mutate, router, tScoped],
);
return (
<AppForm
buttonLabels={{
submit: t("common.action.create"),
submitAndCreateAnother: showCreateAnother ? t("common.action.createAnother") : undefined,
}}
showBackToOverview={showBackToOverview}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};

View File

@@ -0,0 +1,89 @@
"use client";
import { useRef } from "react";
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import type { z } from "zod";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { IconPicker } from "../icon-picker/icon-picker";
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
showBackToOverview: boolean;
buttonLabels: {
submit: string;
submitAndCreateAnother?: string;
};
initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
isPending: boolean;
}
export const AppForm = ({
buttonLabels,
showBackToOverview,
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
}: AppFormProps) => {
const t = useI18n();
const form = useZodForm(validation.app.manage, {
initialValues: {
name: initialValues?.name ?? "",
description: initialValues?.description ?? "",
iconUrl: initialValues?.iconUrl ?? "",
href: initialValues?.href ?? "",
},
});
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
const afterSuccess = shouldCreateAnother.current
? () => {
form.reset();
shouldCreateAnother.current = false;
}
: undefined;
originalHandleSubmit(values, redirect, afterSuccess);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<Group justify="end">
{showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
</Button>
)}
{buttonLabels.submitAndCreateAnother && (
<Button
type="submit"
onClick={() => {
shouldCreateAnother.current = true;
}}
loading={isPending}
>
{buttonLabels.submitAndCreateAnother}
</Button>
)}
<Button type="submit" loading={isPending}>
{buttonLabels.submit}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,50 @@
import type { JSX } from "react";
import { FileButton } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
interface UploadMediaProps {
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
onSettled?: () => MaybePromise<void>;
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
}
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
const handleFileUploadAsync = async (file: File | null) => {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
async onSuccess(mediaId) {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
},
onError() {
showErrorNotification({
message: t("media.action.upload.notification.error.message"),
});
},
async onSettled() {
await onSettled?.();
},
});
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => children({ onClick, loading: isPending })}
</FileButton>
);
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src", "index.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -25,6 +25,7 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-import": "workspace:^0.1.0",

View File

@@ -0,0 +1 @@
export { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";

View File

@@ -0,0 +1,56 @@
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
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 { validation } from "@homarr/validation";
interface QuickAddAppModalProps {
onClose: (createdAppId: string) => Promise<void>;
}
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const { mutate, isPending } = clientApi.app.create.useMutation({
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
message: tScoped("error.message"),
});
},
});
const handleSubmit = (values: z.infer<typeof validation.app.manage>) => {
mutate(values, {
async onSuccess({ appId }) {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
});
await innerProps.onClose(appId);
actions.closeModal();
},
});
};
return (
<AppForm
buttonLabels={{
submit: t("board.action.quickCreateApp.modal.createAndUse"),
submitAndCreateAnother: undefined,
}}
showBackToOverview={false}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
}).withOptions({
defaultTitle(t) {
return t("board.action.quickCreateApp.modal.title");
},
});

View File

@@ -3,3 +3,4 @@ export * from "./invites";
export * from "./groups";
export * from "./search-engines";
export * from "./docker";
export * from "./apps";

View File

@@ -1608,7 +1608,8 @@
},
"app": {
"noData": "No app found",
"description": "Click <here></here> to create a new app"
"description": "Click <here></here> to create a new app",
"quickCreate": "Create app on the fly"
},
"error": {
"noIntegration": "No integration selected",
@@ -2007,6 +2008,12 @@
}
}
}
},
"quickCreateApp": {
"modal": {
"title": "Create app on the fly",
"createAndUse": "Create and use"
}
}
},
"field": {

View File

@@ -35,6 +35,7 @@
"@homarr/form": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",

View File

@@ -3,11 +3,13 @@
import { memo, useMemo } from "react";
import Link from "next/link";
import type { SelectProps } from "@mantine/core";
import { Anchor, Group, Loader, Select, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { Anchor, Button, Group, Loader, Select, SimpleGrid, Text } from "@mantine/core";
import { IconCheck, IconRocket } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { QuickAddAppModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
@@ -18,7 +20,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
const { openModal } = useModalAction(QuickAddAppModal);
const currentApp = useMemo(
() => apps?.find((app) => app.id === form.values.options.appId),
@@ -26,34 +30,53 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
);
return (
<Select
label={tInput("label")}
searchable
limit={10}
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
})}
</Text>
}
{...form.getInputProps(`options.${property}`)}
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={{ base: "md" }} style={{ alignItems: "center" }}>
<Select
label={tInput("label")}
searchable
limit={10}
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
})}
</Text>
}
styles={{ root: { flex: "1" } }}
{...form.getInputProps(`options.${property}`)}
/>
<Button
mt={3}
rightSection={<IconRocket size="1.5rem" />}
variant="default"
onClick={() =>
openModal({
// eslint-disable-next-line no-restricted-syntax
async onClose(createdAppId) {
await refetch();
form.setFieldValue(`options.${property}`, createdAppId);
},
})
}
>
{t("widget.common.app.quickCreate")}
</Button>
</SimpleGrid>
);
};