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 { 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 { 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";
|
||||||
|
|
||||||
@@ -93,6 +94,11 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
|
<FirstDayOfWeek user={user} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{isCredentialsUser && (
|
{isCredentialsUser && (
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ describe("editProfile shoud update user", () => {
|
|||||||
homeBoardId: null,
|
homeBoardId: null,
|
||||||
provider: "credentials",
|
provider: "credentials",
|
||||||
colorScheme: "auto",
|
colorScheme: "auto",
|
||||||
|
firstDayOfWeek: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,6 +300,7 @@ describe("editProfile shoud update user", () => {
|
|||||||
homeBoardId: null,
|
homeBoardId: null,
|
||||||
provider: "credentials",
|
provider: "credentials",
|
||||||
colorScheme: "auto",
|
colorScheme: "auto",
|
||||||
|
firstDayOfWeek: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -323,6 +325,7 @@ describe("delete should delete user", () => {
|
|||||||
homeBoardId: null,
|
homeBoardId: null,
|
||||||
provider: "ldap" as const,
|
provider: "ldap" as const,
|
||||||
colorScheme: "auto" as const,
|
colorScheme: "auto" as const,
|
||||||
|
firstDayOfWeek: 1 as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: defaultOwnerId,
|
id: defaultOwnerId,
|
||||||
@@ -334,6 +337,7 @@ describe("delete should delete user", () => {
|
|||||||
salt: null,
|
salt: null,
|
||||||
homeBoardId: null,
|
homeBoardId: null,
|
||||||
colorScheme: "auto" as const,
|
colorScheme: "auto" as const,
|
||||||
|
firstDayOfWeek: 1 as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -346,6 +350,7 @@ describe("delete should delete user", () => {
|
|||||||
homeBoardId: null,
|
homeBoardId: null,
|
||||||
provider: "oidc" as const,
|
provider: "oidc" as const,
|
||||||
colorScheme: "auto" as const,
|
colorScheme: "auto" as const,
|
||||||
|
firstDayOfWeek: 1 as const,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
image: true,
|
image: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
homeBoardId: true,
|
homeBoardId: true,
|
||||||
|
firstDayOfWeek: true,
|
||||||
},
|
},
|
||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
@@ -375,6 +376,53 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, ctx.session.user.id));
|
.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>) => {
|
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,
|
"when": 1728074730696,
|
||||||
"tag": "0009_wakeful_tenebrous",
|
"tag": "0009_wakeful_tenebrous",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1728074724956,
|
||||||
"tag": "0009_stale_roulette",
|
"tag": "0009_stale_roulette",
|
||||||
"breakpoints": true
|
"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 { AdapterAccount } from "@auth/core/adapters";
|
||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
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 {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
@@ -43,6 +44,7 @@ export const users = mysqlTable("user", {
|
|||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
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
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = mysqlTable(
|
export const accounts = mysqlTable(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
@@ -44,6 +45,7 @@ export const users = sqliteTable("user", {
|
|||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
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
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = sqliteTable(
|
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: {
|
manageAvatar: {
|
||||||
changeImage: {
|
changeImage: {
|
||||||
label: "Change image",
|
label: "Change image",
|
||||||
@@ -1692,6 +1702,7 @@ export default {
|
|||||||
item: {
|
item: {
|
||||||
language: "Language & Region",
|
language: "Language & Region",
|
||||||
board: "Home board",
|
board: "Home board",
|
||||||
|
firstDayOfWeek: "First day of the week",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { colorSchemes } from "@homarr/definitions";
|
import { colorSchemes } from "@homarr/definitions";
|
||||||
@@ -103,6 +104,10 @@ const changeColorSchemeSchema = z.object({
|
|||||||
colorScheme: zodEnumFromArray(colorSchemes),
|
colorScheme: zodEnumFromArray(colorSchemes),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const firstDayOfWeekSchema = z.object({
|
||||||
|
firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
|
||||||
|
});
|
||||||
|
|
||||||
export const userSchemas = {
|
export const userSchemas = {
|
||||||
signIn: signInSchema,
|
signIn: signInSchema,
|
||||||
registration: registrationSchema,
|
registration: registrationSchema,
|
||||||
@@ -115,4 +120,5 @@ export const userSchemas = {
|
|||||||
changeHomeBoard: changeHomeBoardSchema,
|
changeHomeBoard: changeHomeBoardSchema,
|
||||||
changePasswordApi: changePasswordApiSchema,
|
changePasswordApi: changePasswordApiSchema,
|
||||||
changeColorScheme: changeColorSchemeSchema,
|
changeColorScheme: changeColorSchemeSchema,
|
||||||
|
firstDayOfWeek: firstDayOfWeekSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useParams } from "next/navigation";
|
|||||||
import { Calendar } from "@mantine/dates";
|
import { Calendar } from "@mantine/dates";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { CalendarDay } from "./calender-day";
|
import { CalendarDay } from "./calender-day";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
@@ -13,6 +15,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
|
|||||||
const [month, setMonth] = useState(new Date());
|
const [month, setMonth] = useState(new Date());
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const locale = params.locale as string;
|
const locale = params.locale as string;
|
||||||
|
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -23,6 +26,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
|
|||||||
hideWeekdays={false}
|
hideWeekdays={false}
|
||||||
date={month}
|
date={month}
|
||||||
maxLevel="month"
|
maxLevel="month"
|
||||||
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
static={isEditMode}
|
static={isEditMode}
|
||||||
|
|||||||
Reference in New Issue
Block a user