feat: add first day of week user setting (#1249)

* feat: add first day of week user setting

* fix: add missing migrations

* fix: format and test issues

* fix: deepsource issue

* refactor: rename first-day-of-week procedure
This commit is contained in:
Meier Lukas
2024-10-07 21:13:38 +02:00
committed by GitHub
parent eb21628ee4
commit ab1744ce20
15 changed files with 3094 additions and 1 deletions

View File

@@ -0,0 +1,82 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
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";
dayjs.extend(localeData);
interface FirstDayOfWeekProps {
user: RouterOutputs["user"]["getById"];
}
const weekDays = dayjs.weekdays(false);
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.firstDayOfWeek, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
const inputProps = form.getInputProps("firstDayOfWeek");
const onChange = inputProps.onChange as (value: number) => void;
const value = (inputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
<Group mt="xs">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<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.firstDayOfWeek>;

View File

@@ -13,6 +13,7 @@ import { createMetaTitle } from "~/metadata";
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 { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
@@ -93,6 +94,11 @@ export default async function EditUserPage({ params }: Props) {
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>
<DangerZoneItem

View File

@@ -259,6 +259,7 @@ describe("editProfile shoud update user", () => {
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
@@ -299,6 +300,7 @@ describe("editProfile shoud update user", () => {
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
});
@@ -323,6 +325,7 @@ describe("delete should delete user", () => {
homeBoardId: null,
provider: "ldap" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: defaultOwnerId,
@@ -334,6 +337,7 @@ describe("delete should delete user", () => {
salt: null,
homeBoardId: null,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: createId(),
@@ -346,6 +350,7 @@ describe("delete should delete user", () => {
homeBoardId: null,
provider: "oidc" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
];

View File

@@ -208,6 +208,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
firstDayOfWeek: true,
},
where: eq(users.id, input.userId),
});
@@ -375,6 +376,53 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return 1 as const;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
firstDayOfWeek: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.firstDayOfWeek ?? (1 as const);
}),
changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users' passwords
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
.where(eq(users.id, ctx.session.user.id));
}),
});
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `firstDayOfWeek` tinyint DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1728074730696,
"tag": "0009_wakeful_tenebrous",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1728142597094,
"tag": "0010_melted_pestilence",
"breakpoints": true
}
]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1728074724956,
"tag": "0009_stale_roulette",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1728142590232,
"tag": "0010_gorgeous_stingray",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,8 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { DayOfWeek } from "@mantine/dates";
import { relations } from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, tinyint, varchar } from "drizzle-orm/mysql-core";
import type {
BackgroundImageAttachment,
@@ -43,6 +44,7 @@ export const users = mysqlTable("user", {
onDelete: "set null",
}),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
});
export const accounts = mysqlTable(

View File

@@ -1,4 +1,5 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { DayOfWeek } from "@mantine/dates";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
@@ -44,6 +45,7 @@ export const users = sqliteTable("user", {
onDelete: "set null",
}),
colorScheme: text("colorScheme").$type<ColorScheme>().default("auto").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
});
export const accounts = sqliteTable(

View File

@@ -106,6 +106,16 @@ export default {
},
},
},
changeFirstDayOfWeek: {
notification: {
success: {
message: "First day of week changed successfully",
},
error: {
message: "Unable to change first day of week",
},
},
},
manageAvatar: {
changeImage: {
label: "Change image",
@@ -1692,6 +1702,7 @@ export default {
item: {
language: "Language & Region",
board: "Home board",
firstDayOfWeek: "First day of the week",
},
},
security: {

View File

@@ -1,3 +1,4 @@
import type { DayOfWeek } from "@mantine/dates";
import { z } from "zod";
import { colorSchemes } from "@homarr/definitions";
@@ -103,6 +104,10 @@ const changeColorSchemeSchema = z.object({
colorScheme: zodEnumFromArray(colorSchemes),
});
const firstDayOfWeekSchema = z.object({
firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
});
export const userSchemas = {
signIn: signInSchema,
registration: registrationSchema,
@@ -115,4 +120,5 @@ export const userSchemas = {
changeHomeBoard: changeHomeBoardSchema,
changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema,
};

View File

@@ -5,6 +5,8 @@ import { useParams } from "next/navigation";
import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
@@ -13,6 +15,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
const [month, setMonth] = useState(new Date());
const params = useParams();
const locale = params.locale as string;
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery();
return (
<Calendar
@@ -23,6 +26,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
w="100%"
h="100%"
static={isEditMode}