feat: user setting ping icons (#1277)

* feat: user setting ping icons

* fix: format issues

* test: adjust test to match expectations
This commit is contained in:
Meier Lukas
2024-10-12 00:20:47 +02:00
committed by GitHub
parent 0f8d9edb3e
commit 348687670d
18 changed files with 3104 additions and 55 deletions

View File

@@ -0,0 +1,66 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.pingIconsEnabled, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.pingIconsEnabled>;

View File

@@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
@@ -99,6 +100,11 @@ export default async function EditUserPage({ params }: Props) {
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>
<DangerZoneItem

View File

@@ -248,18 +248,11 @@ describe("editProfile shoud update user", () => {
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "ABC",
email: "abc@gmail.com",
emailVerified,
salt: null,
password: null,
image: null,
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
@@ -289,18 +282,11 @@ describe("editProfile shoud update user", () => {
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "ABC",
email: "myNewEmail@gmail.com",
emailVerified: null,
salt: null,
password: null,
image: null,
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
});
@@ -317,40 +303,14 @@ describe("delete should delete user", () => {
{
id: createId(),
name: "User 1",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
provider: "ldap" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: defaultOwnerId,
name: "User 2",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: createId(),
name: "User 3",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
provider: "oidc" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
];
@@ -359,6 +319,8 @@ describe("delete should delete user", () => {
await caller.delete(defaultOwnerId);
const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);
expect(usersInDb).toHaveLength(2);
expect(usersInDb[0]).containSubset(initialUsers[0]);
expect(usersInDb[1]).containSubset(initialUsers[2]);
});
});

View File

@@ -209,6 +209,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
});
@@ -376,6 +377,39 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return false;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
pingIconsEnabled: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.pingIconsEnabled ?? false;
}),
changePingIconsEnabled: protectedProcedure
.input(validation.user.pingIconsEnabled.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users ping icons enabled
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, ctx.session.user.id));
}),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return 1 as const;
@@ -394,7 +428,7 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users' passwords
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `pingIconsEnabled` boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1728142597094,
"tag": "0010_melted_pestilence",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1728490046896,
"tag": "0011_freezing_banshee",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `pingIconsEnabled` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1728142590232,
"tag": "0010_gorgeous_stingray",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1728490026154,
"tag": "0011_classy_angel",
"breakpoints": true
}
]
}

View File

@@ -22,8 +22,10 @@
"lint": "eslint",
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
"migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts",
"migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",

View File

@@ -45,6 +45,7 @@ export const users = mysqlTable("user", {
}),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
});
export const accounts = mysqlTable(

View File

@@ -46,6 +46,7 @@ export const users = sqliteTable("user", {
}),
colorScheme: text("colorScheme").$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
});
export const accounts = sqliteTable(

View File

@@ -48,6 +48,9 @@ export default {
homeBoard: {
label: "Home board",
},
pingIconsEnabled: {
label: "Use icons for pings",
},
},
error: {
usernameTaken: "Username already taken",
@@ -116,6 +119,16 @@ export default {
},
},
},
changePingIconsEnabled: {
notification: {
success: {
message: "Ping icons toggled successfully",
},
error: {
message: "Unable to toggle ping icons",
},
},
},
manageAvatar: {
changeImage: {
label: "Change image",
@@ -1703,6 +1716,7 @@ export default {
language: "Language & Region",
board: "Home board",
firstDayOfWeek: "First day of the week",
accessibility: "Accessibility",
},
},
security: {

View File

@@ -108,6 +108,10 @@ const firstDayOfWeekSchema = z.object({
firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
});
const pingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(),
});
export const userSchemas = {
signIn: signInSchema,
registration: registrationSchema,
@@ -121,4 +125,5 @@ export const userSchemas = {
changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema,
pingIconsEnabled: pingIconsEnabledSchema,
};

View File

@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react";
import { Suspense } from "react";
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
@@ -59,7 +60,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
</Flex>
</Tooltip.Floating>
{options.pingEnabled && app.href ? (
<Suspense fallback={<PingDot color="blue" tooltip={`${t("common.action.loading")}`} />}>
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}>
<PingIndicator href={app.href} />
</Suspense>
) : null}

View File

@@ -1,23 +1,33 @@
import type { MantineColor } from "@mantine/core";
import { Box, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { TablerIcon } from "@homarr/ui";
interface PingDotProps {
icon: TablerIcon;
color: MantineColor;
tooltip: string;
}
export const PingDot = ({ color, tooltip }: PingDotProps) => {
export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => {
const [pingIconsEnabled] = clientApi.user.getPingIconsEnabledOrDefault.useSuspenseQuery();
return (
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">
<Tooltip label={tooltip}>
<Box
bg={color}
style={{
borderRadius: "100%",
}}
w="10cqmin"
h="10cqmin"
></Box>
{pingIconsEnabled ? (
<props.icon style={{ width: "10cqmin", height: "10cqmin" }} color={color} />
) : (
<Box
bg={color}
style={{
borderRadius: "100%",
}}
w="10cqmin"
h="10cqmin"
></Box>
)}
</Tooltip>
</Box>
);

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -32,9 +33,12 @@ export const PingIndicator = ({ href }: PingIndicatorProps) => {
},
);
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return (
<PingDot
color={"error" in pingResult || pingResult.statusCode >= 500 ? "red" : "green"}
icon={isError ? IconX : IconCheck}
color={isError ? "red" : "green"}
tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error}
/>
);