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:
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
packages/db/migrations/mysql/0011_freezing_banshee.sql
Normal file
1
packages/db/migrations/mysql/0011_freezing_banshee.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `pingIconsEnabled` boolean DEFAULT false NOT NULL;
|
||||
1497
packages/db/migrations/mysql/meta/0011_snapshot.json
Normal file
1497
packages/db/migrations/mysql/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1728142597094,
|
||||
"tag": "0010_melted_pestilence",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1728490046896,
|
||||
"tag": "0011_freezing_banshee",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
packages/db/migrations/sqlite/0011_classy_angel.sql
Normal file
1
packages/db/migrations/sqlite/0011_classy_angel.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `pingIconsEnabled` integer DEFAULT false NOT NULL;
|
||||
1430
packages/db/migrations/sqlite/meta/0011_snapshot.json
Normal file
1430
packages/db/migrations/sqlite/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1728142590232,
|
||||
"tag": "0010_gorgeous_stingray",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1728490026154,
|
||||
"tag": "0011_classy_angel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user