feat: quick add app modal (#2248)
This commit is contained in:
@@ -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")
|
||||
|
||||
4
packages/forms-collection/eslint.config.js
Normal file
4
packages/forms-collection/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
1
packages/forms-collection/index.ts
Normal file
1
packages/forms-collection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
43
packages/forms-collection/package.json
Normal file
43
packages/forms-collection/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[data-combobox-selected="true"] .iconCard {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
}
|
||||
190
packages/forms-collection/src/icon-picker/icon-picker.tsx
Normal file
190
packages/forms-collection/src/icon-picker/icon-picker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
packages/forms-collection/src/index.tsx
Normal file
6
packages/forms-collection/src/index.tsx
Normal 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";
|
||||
68
packages/forms-collection/src/new-app/_app-new-form.tsx
Normal file
68
packages/forms-collection/src/new-app/_app-new-form.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
89
packages/forms-collection/src/new-app/_form.tsx
Normal file
89
packages/forms-collection/src/new-app/_form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
packages/forms-collection/src/upload-media/upload-media.tsx
Normal file
50
packages/forms-collection/src/upload-media/upload-media.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
packages/forms-collection/tsconfig.json
Normal file
8
packages/forms-collection/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
1
packages/modals-collection/src/apps/index.ts
Normal file
1
packages/modals-collection/src/apps/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -3,3 +3,4 @@ export * from "./invites";
|
||||
export * from "./groups";
|
||||
export * from "./search-engines";
|
||||
export * from "./docker";
|
||||
export * from "./apps";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user