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:
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
1
packages/db/migrations/mysql/0010_melted_pestilence.sql
Normal file
1
packages/db/migrations/mysql/0010_melted_pestilence.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `firstDayOfWeek` tinyint DEFAULT 1 NOT NULL;
|
||||
1489
packages/db/migrations/mysql/meta/0010_snapshot.json
Normal file
1489
packages/db/migrations/mysql/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1728074730696,
|
||||
"tag": "0009_wakeful_tenebrous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1728142597094,
|
||||
"tag": "0010_melted_pestilence",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
packages/db/migrations/sqlite/0010_gorgeous_stingray.sql
Normal file
1
packages/db/migrations/sqlite/0010_gorgeous_stingray.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `firstDayOfWeek` integer DEFAULT 1 NOT NULL;
|
||||
1422
packages/db/migrations/sqlite/meta/0010_snapshot.json
Normal file
1422
packages/db/migrations/sqlite/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1728074724956,
|
||||
"tag": "0009_stale_roulette",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1728142590232,
|
||||
"tag": "0010_gorgeous_stingray",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user