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 { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button"; import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week"; import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form"; import { UserProfileForm } from "./_components/_profile-form";
@@ -99,6 +100,11 @@ export default async function EditUserPage({ params }: Props) {
<FirstDayOfWeek user={user} /> <FirstDayOfWeek user={user} />
</Stack> </Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{isCredentialsUser && ( {isCredentialsUser && (
<DangerZoneRoot> <DangerZoneRoot>
<DangerZoneItem <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)); const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1); expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({ expect(user[0]).containSubset({
id: defaultOwnerId, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "abc@gmail.com", email: "abc@gmail.com",
emailVerified, 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)); const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1); expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({ expect(user[0]).containSubset({
id: defaultOwnerId, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "myNewEmail@gmail.com", email: "myNewEmail@gmail.com",
emailVerified: null, 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(), id: createId(),
name: "User 1", 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, id: defaultOwnerId,
name: "User 2", 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(), id: createId(),
name: "User 3", 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); await caller.delete(defaultOwnerId);
const usersInDb = await db.select().from(schema.users); 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, provider: true,
homeBoardId: true, homeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true,
}, },
where: eq(users.id, input.userId), where: eq(users.id, input.userId),
}); });
@@ -376,6 +377,39 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .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 }) => { getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
return 1 as const; return 1 as const;
@@ -394,7 +428,7 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId)) .input(validation.user.firstDayOfWeek.and(validation.common.byId))
.mutation(async ({ input, ctx }) => { .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) { if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", 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, "when": 1728142597094,
"tag": "0010_melted_pestilence", "tag": "0010_melted_pestilence",
"breakpoints": true "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, "when": 1728142590232,
"tag": "0010_gorgeous_stingray", "tag": "0010_gorgeous_stingray",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1728490026154,
"tag": "0011_classy_angel",
"breakpoints": true
} }
] ]
} }

View File

@@ -22,8 +22,10 @@
"lint": "eslint", "lint": "eslint",
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts", "migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "drizzle-kit migrate --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:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "drizzle-kit migrate --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:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts", "push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
"studio": "drizzle-kit studio --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(), colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
}); });
export const accounts = mysqlTable( export const accounts = mysqlTable(

View File

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

View File

@@ -48,6 +48,9 @@ export default {
homeBoard: { homeBoard: {
label: "Home board", label: "Home board",
}, },
pingIconsEnabled: {
label: "Use icons for pings",
},
}, },
error: { error: {
usernameTaken: "Username already taken", 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: { manageAvatar: {
changeImage: { changeImage: {
label: "Change image", label: "Change image",
@@ -1703,6 +1716,7 @@ export default {
language: "Language & Region", language: "Language & Region",
board: "Home board", board: "Home board",
firstDayOfWeek: "First day of the week", firstDayOfWeek: "First day of the week",
accessibility: "Accessibility",
}, },
}, },
security: { 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), firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
}); });
const pingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(),
});
export const userSchemas = { export const userSchemas = {
signIn: signInSchema, signIn: signInSchema,
registration: registrationSchema, registration: registrationSchema,
@@ -121,4 +125,5 @@ export const userSchemas = {
changePasswordApi: changePasswordApiSchema, changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema, changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema, firstDayOfWeek: firstDayOfWeekSchema,
pingIconsEnabled: pingIconsEnabledSchema,
}; };

View File

@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -59,7 +60,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
</Flex> </Flex>
</Tooltip.Floating> </Tooltip.Floating>
{options.pingEnabled && app.href ? ( {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} /> <PingIndicator href={app.href} />
</Suspense> </Suspense>
) : null} ) : null}

View File

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

View File

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