feat: Add apps crud (#174)

* wip: add apps crud

* wip: add edit for apps

* feat: add apps crud

* fix: color of icon for no app results wrong

* ci: fix lint issues

* test: add unit tests for app crud

* ci: fix format issue

* fix: missing rename in edit form

* fix: missing callback deepsource issues
This commit is contained in:
Meier Lukas
2024-03-04 22:13:40 +01:00
committed by GitHub
parent 70f34efd53
commit 8d5984c58a
17 changed files with 1501 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { ActionIcon, IconTrash } from "@homarr/ui";
import { revalidatePathAction } from "../../../revalidatePathAction";
import { modalEvents } from "../../modals";
interface AppDeleteButtonProps {
app: RouterOutputs["app"]["all"][number];
}
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const { mutate, isPending } = clientApi.app.delete.useMutation();
const onClick = useCallback(() => {
modalEvents.openConfirmModal({
title: t("title"),
children: t("message", app),
onConfirm: () => {
mutate(
{ id: app.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathAction("/apps");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
});
}, [app, mutate, t]);
return (
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};

View File

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useForm, zodResolver } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack, Textarea, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
// TODO: add icon picker
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
}
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
props;
const t = useI18n();
const form = useForm({
initialValues: initialValues ?? {
name: "",
description: "",
iconUrl: "",
href: "",
},
validate: zodResolver(validation.app.manage),
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<TextInput
{...form.getInputProps("iconUrl")}
withAsterisk
label="Icon URL"
/>
<Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" />
<Group justify="end">
<Button variant="default" component={Link} href="/apps">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,68 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../../_form";
interface AppEditFormProps {
app: RouterOutputs["app"]["byId"];
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const t = useScopedI18n("app.page.edit.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathAction("/apps").then(() => {
router.push("/apps");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => {
mutate({
id: app.id,
...values,
});
},
[mutate, app.id],
);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.save"),
[],
);
return (
<AppForm
submitButtonTranslation={submitButtonTranslation}
initialValues={app}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};

View File

@@ -0,0 +1,23 @@
import { getI18n } from "@homarr/translation/server";
import { Container, Stack, Title } from "@homarr/ui";
import { api } from "~/trpc/server";
import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps {
params: { id: string };
}
export default async function AppEditPage({ params }: AppEditPageProps) {
const app = await api.app.byId({ id: params.id });
const t = await getI18n();
return (
<Container>
<Stack>
<Title>{t("app.page.edit.title")}</Title>
<AppEditForm app={app} />
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
import { AppForm } from "../_form";
export const AppNewForm = () => {
const t = useScopedI18n("app.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathAction("/apps").then(() => {
router.push("/apps");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => {
mutate(values);
},
[mutate],
);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.create"),
[],
);
return (
<AppForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};

View File

@@ -0,0 +1,14 @@
import { Container, Stack, Title } from "@homarr/ui";
import { AppNewForm } from "./_app-new-form";
export default function AppNewPage() {
return (
<Container>
<Stack>
<Title>New app</Title>
<AppNewForm />
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,117 @@
import Link from "next/link";
import type { RouterOutputs } from "@homarr/api";
import { getI18n } from "@homarr/translation/server";
import {
ActionIcon,
ActionIconGroup,
Anchor,
Avatar,
Button,
Card,
Container,
Group,
IconApps,
IconPencil,
Stack,
Text,
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
const apps = await api.app.all();
return (
<Container>
<Stack>
<Group justify="space-between" align="center">
<Title>Apps</Title>
<Button component={Link} href="/apps/new">
New app
</Button>
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
)}
</Stack>
</Container>
);
}
interface AppCardProps {
app: RouterOutputs["app"]["all"][number];
}
const AppCard = ({ app }: AppCardProps) => {
return (
<Card>
<Group justify="space-between">
<Group align="top" justify="start" wrap="nowrap">
<Avatar
size="sm"
src={app.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0}>
<Text fw={500}>{app.name}</Text>
{app.description && (
<Text size="sm" c="gray.6">
{app.description}
</Text>
)}
{app.href && (
<Anchor href={app.href} size="sm" w="min-content">
{app.href}
</Anchor>
)}
</Stack>
</Group>
<Group>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label="Edit app"
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<AppDeleteButton app={app} />
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const AppNoResults = async () => {
const t = await getI18n();
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconApps size="2rem" />
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/apps/new">
{t("app.page.list.noResults.description")}
</Anchor>
</Stack>
</Card>
);
};