feat: add home board for users (#505)
* feat: add home board for users * fix: format issues * fix: deepsource issue * chore: address pull request feedback * fix: typecheck issue
This commit is contained in:
@@ -4,6 +4,6 @@ import { createBoardContentPage } from "../_creator";
|
|||||||
|
|
||||||
export default createBoardContentPage<{ locale: string }>({
|
export default createBoardContentPage<{ locale: string }>({
|
||||||
async getInitialBoardAsync() {
|
async getInitialBoardAsync() {
|
||||||
return await api.board.getDefaultBoard();
|
return await api.board.getHomeBoard();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -19,8 +19,8 @@ export const updateBoardName = (name: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCallback = (
|
type UpdateCallback = (
|
||||||
prev: RouterOutputs["board"]["getDefaultBoard"],
|
prev: RouterOutputs["board"]["getHomeBoard"],
|
||||||
) => RouterOutputs["board"]["getDefaultBoard"];
|
) => RouterOutputs["board"]["getHomeBoard"];
|
||||||
|
|
||||||
export const useUpdateBoard = () => {
|
export const useUpdateBoard = () => {
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { updateBoardName } from "./_client";
|
import { updateBoardName } from "./_client";
|
||||||
|
|
||||||
const BoardContext = createContext<{
|
const BoardContext = createContext<{
|
||||||
board: RouterOutputs["board"]["getDefaultBoard"];
|
board: RouterOutputs["board"]["getHomeBoard"];
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
markAsReady: (id: string) => void;
|
markAsReady: (id: string) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { capitalize } from "@homarr/common";
|
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
import type { Board } from "../_types";
|
import type { Board } from "../_types";
|
||||||
import { ClientBoard } from "./_client";
|
import { ClientBoard } from "./_client";
|
||||||
@@ -38,9 +39,14 @@ export const createBoardContentPage = <
|
|||||||
}): Promise<Metadata> => {
|
}): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
const board = await getInitialBoard(params);
|
const board = await getInitialBoard(params);
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
|
title:
|
||||||
|
board.metaTitle ??
|
||||||
|
createMetaTitle(
|
||||||
|
t("board.content.metaTitle", { boardName: board.name }),
|
||||||
|
),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
{
|
{
|
||||||
onSettled() {
|
onSettled() {
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void utils.board.getDefaultBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -64,7 +64,7 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
changeVisibility,
|
changeVisibility,
|
||||||
t,
|
t,
|
||||||
utils.board.getBoardByName,
|
utils.board.getBoardByName,
|
||||||
utils.board.getDefaultBoard,
|
utils.board.getHomeBoard,
|
||||||
visibility,
|
visibility,
|
||||||
openConfirmModal,
|
openConfirmModal,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useZodForm } from "@homarr/form";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { useUpdateBoard } from "../../(content)/_client";
|
import { useUpdateBoard } from "../../(content)/_client";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
import { useSavePartialSettingsMutation } from "./_shared";
|
||||||
@@ -105,7 +106,9 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
|||||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("board.field.metaTitle.label")}
|
label={t("board.field.metaTitle.label")}
|
||||||
placeholder="Default Board | Homarr"
|
placeholder={createMetaTitle(
|
||||||
|
t("board.content.metaTitle", { boardName: board.name }),
|
||||||
|
)}
|
||||||
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
|
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
|
||||||
{...form.getInputProps("metaTitle")}
|
{...form.getInputProps("metaTitle")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const useSavePartialSettingsMutation = (board: Board) => {
|
|||||||
return clientApi.board.savePartialBoardSettings.useMutation({
|
return clientApi.board.savePartialBoardSettings.useMutation({
|
||||||
onSettled() {
|
onSettled() {
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
void utils.board.getDefaultBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
|
||||||
export type Board = RouterOutputs["board"]["getDefaultBoard"];
|
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||||
export type Section = Board["sections"][number];
|
export type Section = Board["sections"][number];
|
||||||
export type Item = Section["items"][number];
|
export type Item = Section["items"][number];
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { setStaticParamsLocale } from "next-international/server";
|
|||||||
|
|
||||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||||
import contributorsData from "../../../../../../../static-data/contributors.json";
|
import contributorsData from "../../../../../../../static-data/contributors.json";
|
||||||
import translatorsData from "../../../../../../../static-data/translators.json";
|
import translatorsData from "../../../../../../../static-data/translators.json";
|
||||||
@@ -29,10 +30,9 @@ import classes from "./about.module.css";
|
|||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu } from "@mantine/core";
|
import { Menu } from "@mantine/core";
|
||||||
import { IconSettings, IconTrash } from "@tabler/icons-react";
|
import { IconHome, IconSettings, IconTrash } 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";
|
||||||
@@ -40,7 +40,13 @@ export const BoardCardMenuDropdown = ({
|
|||||||
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
|
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||||
|
await revalidatePathActionAsync("/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await revalidatePathActionAsync("/manage/boards");
|
await revalidatePathActionAsync("/manage/boards");
|
||||||
},
|
},
|
||||||
@@ -54,23 +60,36 @@ export const BoardCardMenuDropdown = ({
|
|||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await mutateAsync({
|
await deleteBoardMutation.mutateAsync({
|
||||||
id: board.id,
|
id: board.id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [board.id, board.name, mutateAsync, openConfirmModal, t]);
|
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
|
||||||
|
|
||||||
|
const handleSetHomeBoard = useCallback(async () => {
|
||||||
|
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||||
|
}, [board.id, setHomeBoardMutation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={handleSetHomeBoard}
|
||||||
|
leftSection={<IconHome {...iconProps} />}
|
||||||
|
>
|
||||||
|
{t("setHomeBoard.label")}
|
||||||
|
</Menu.Item>
|
||||||
{hasChangeAccess && (
|
{hasChangeAccess && (
|
||||||
<Menu.Item
|
<>
|
||||||
component={Link}
|
<Menu.Divider />
|
||||||
href={`/boards/${board.name}/settings`}
|
<Menu.Item
|
||||||
leftSection={<IconSettings {...iconProps} />}
|
component={Link}
|
||||||
>
|
href={`/boards/${board.name}/settings`}
|
||||||
{t("settings.label")}
|
leftSection={<IconSettings {...iconProps} />}
|
||||||
</Menu.Item>
|
>
|
||||||
|
{t("settings.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
@@ -80,7 +99,7 @@ export const BoardCardMenuDropdown = ({
|
|||||||
c="red.7"
|
c="red.7"
|
||||||
leftSection={<IconTrash {...iconProps} />}
|
leftSection={<IconTrash {...iconProps} />}
|
||||||
onClick={handleDeletion}
|
onClick={handleDeletion}
|
||||||
disabled={isPending}
|
disabled={deleteBoardMutation.isPending}
|
||||||
>
|
>
|
||||||
{t("delete.label")}
|
{t("delete.label")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardSection,
|
CardSection,
|
||||||
@@ -13,7 +14,12 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react";
|
import {
|
||||||
|
IconDotsVertical,
|
||||||
|
IconHomeFilled,
|
||||||
|
IconLock,
|
||||||
|
IconWorld,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -71,12 +77,27 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{board.creator && (
|
<Group>
|
||||||
<Group gap="xs">
|
{board.isHome && (
|
||||||
<UserAvatar user={board.creator} size="sm" />
|
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
|
||||||
<Text>{board.creator?.name}</Text>
|
<Badge
|
||||||
</Group>
|
tt="none"
|
||||||
)}
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconHomeFilled size=".7rem" />}
|
||||||
|
>
|
||||||
|
{t("action.setHomeBoard.badge.label")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{board.creator && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<UserAvatar user={board.creator} size="sm" />
|
||||||
|
<Text>{board.creator?.name}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IconArrowRight } from "@tabler/icons-react";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { HeroBanner } from "./_components/hero-banner";
|
import { HeroBanner } from "./_components/hero-banner";
|
||||||
|
|
||||||
interface LinkProps {
|
interface LinkProps {
|
||||||
@@ -16,10 +17,9 @@ interface LinkProps {
|
|||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DangerZoneRoot,
|
DangerZoneRoot,
|
||||||
} from "~/components/manage/danger-zone";
|
} from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
@@ -35,10 +36,9 @@ export async function generateMetadata({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.user.edit");
|
const t = await getScopedI18n("management.page.user.edit");
|
||||||
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: createMetaTitle(t("metaTitle", { username: user?.name })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management.page.user.create");
|
const t = await getScopedI18n("management.page.user.create");
|
||||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { UserListComponent } from "./_components/user-list.component";
|
import { UserListComponent } from "./_components/user-list.component";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management.page.user.list");
|
const t = await getScopedI18n("management.page.user.list");
|
||||||
const metaTitle = `${t("metaTitle")} • Homarr`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: metaTitle,
|
title: createMetaTitle(t("metaTitle")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const BoardRenameModal = createModal<InnerProps>(
|
|||||||
void utils.board.getBoardByName.invalidate({
|
void utils.board.getBoardByName.invalidate({
|
||||||
name: innerProps.previousName,
|
name: innerProps.previousName,
|
||||||
});
|
});
|
||||||
void utils.board.getDefaultBoard.invalidate();
|
void utils.board.getHomeBoard.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const form = useZodForm(validation.board.rename.omit({ id: true }), {
|
const form = useZodForm(validation.board.rename.omit({ id: true }), {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useTimeout } from "@mantine/hooks";
|
import { useTimeout } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconDashboard,
|
IconHome,
|
||||||
IconLogin,
|
IconLogin,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
@@ -72,9 +72,9 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
href="/boards"
|
href="/boards"
|
||||||
leftSection={<IconDashboard size="1rem" />}
|
leftSection={<IconHome size="1rem" />}
|
||||||
>
|
>
|
||||||
{t("navigateDefaultBoard")}
|
{t("homeBoard")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
|
|||||||
1
apps/nextjs/src/metadata.ts
Normal file
1
apps/nextjs/src/metadata.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const createMetaTitle = (name: string) => `${name} • Homarr`;
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
integrationItems,
|
integrationItems,
|
||||||
items,
|
items,
|
||||||
sections,
|
sections,
|
||||||
|
users,
|
||||||
} from "@homarr/db/schema/sqlite";
|
} from "@homarr/db/schema/sqlite";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||||
@@ -33,14 +34,15 @@ import { throwIfActionForbiddenAsync } from "./board/board-access";
|
|||||||
|
|
||||||
export const boardRouter = createTRPCRouter({
|
export const boardRouter = createTRPCRouter({
|
||||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.session?.user.id;
|
||||||
const permissionsOfCurrentUserWhenPresent =
|
const permissionsOfCurrentUserWhenPresent =
|
||||||
await ctx.db.query.boardUserPermissions.findMany({
|
await ctx.db.query.boardUserPermissions.findMany({
|
||||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const permissionsOfCurrentUserGroupsWhenPresent =
|
const permissionsOfCurrentUserGroupsWhenPresent =
|
||||||
await ctx.db.query.groupMembers.findMany({
|
await ctx.db.query.groupMembers.findMany({
|
||||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
where: eq(groupMembers.userId, userId ?? ""),
|
||||||
with: {
|
with: {
|
||||||
group: {
|
group: {
|
||||||
with: {
|
with: {
|
||||||
@@ -60,6 +62,11 @@ export const boardRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.flat(),
|
.flat(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
const dbBoards = await ctx.db.query.boards.findMany({
|
const dbBoards = await ctx.db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -98,7 +105,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return dbBoards;
|
return dbBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||||
|
}));
|
||||||
}),
|
}),
|
||||||
createBoard: permissionRequiredProcedure
|
createBoard: permissionRequiredProcedure
|
||||||
.requiresPermission("board-create")
|
.requiresPermission("board-create")
|
||||||
@@ -160,8 +170,31 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||||
}),
|
}),
|
||||||
getDefaultBoard: publicProcedure.query(async ({ ctx }) => {
|
setHomeBoard: protectedProcedure
|
||||||
const boardWhere = eq(boards.name, "default");
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await throwIfActionForbiddenAsync(
|
||||||
|
ctx,
|
||||||
|
eq(boards.id, input.id),
|
||||||
|
"board-view",
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({ homeBoardId: input.id })
|
||||||
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
}),
|
||||||
|
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.session?.user.id;
|
||||||
|
const user = userId
|
||||||
|
? await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const boardWhere = user?.homeBoardId
|
||||||
|
? eq(boards.id, user.homeBoardId)
|
||||||
|
: eq(boards.name, "home");
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(
|
return await getFullBoardWithWhereAsync(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const createRandomUserAsync = async (db: Database) => {
|
|||||||
const userId = createId();
|
const userId = createId();
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
homeBoardId: null,
|
||||||
});
|
});
|
||||||
return userId;
|
return userId;
|
||||||
};
|
};
|
||||||
@@ -493,21 +494,21 @@ describe("deleteBoard should delete board", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getDefaultBoard should return default board", () => {
|
describe("getHomeBoard should return home board", () => {
|
||||||
it("should return default board", async () => {
|
it("should return home board", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||||
|
|
||||||
const fullBoardProps = await createFullBoardAsync(db, "default");
|
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await caller.getDefaultBoard();
|
const result = await caller.getHomeBoard();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expectInputToBeFullBoardWithName(result, {
|
expectInputToBeFullBoardWithName(result, {
|
||||||
name: "default",
|
name: "home",
|
||||||
...fullBoardProps,
|
...fullBoardProps,
|
||||||
});
|
});
|
||||||
expect(spy).toHaveBeenCalledWith(
|
expect(spy).toHaveBeenCalledWith(
|
||||||
@@ -1339,7 +1340,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const expectInputToBeFullBoardWithName = (
|
const expectInputToBeFullBoardWithName = (
|
||||||
input: RouterOutputs["board"]["getDefaultBoard"],
|
input: RouterOutputs["board"]["getHomeBoard"],
|
||||||
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
||||||
) => {
|
) => {
|
||||||
expect(input.id).toBe(props.boardId);
|
expect(input.id).toBe(props.boardId);
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ describe("editProfile shoud update user", () => {
|
|||||||
salt: null,
|
salt: null,
|
||||||
password: null,
|
password: null,
|
||||||
image: null,
|
image: null,
|
||||||
|
homeBoardId: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,6 +275,7 @@ describe("editProfile shoud update user", () => {
|
|||||||
salt: null,
|
salt: null,
|
||||||
password: null,
|
password: null,
|
||||||
image: null,
|
image: null,
|
||||||
|
homeBoardId: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -297,6 +299,7 @@ describe("delete should delete user", () => {
|
|||||||
image: null,
|
image: null,
|
||||||
password: null,
|
password: null,
|
||||||
salt: null,
|
salt: null,
|
||||||
|
homeBoardId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: userToDelete,
|
id: userToDelete,
|
||||||
@@ -306,6 +309,7 @@ describe("delete should delete user", () => {
|
|||||||
image: null,
|
image: null,
|
||||||
password: null,
|
password: null,
|
||||||
salt: null,
|
salt: null,
|
||||||
|
homeBoardId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -315,6 +319,7 @@ describe("delete should delete user", () => {
|
|||||||
image: null,
|
image: null,
|
||||||
password: null,
|
password: null,
|
||||||
salt: null,
|
salt: null,
|
||||||
|
homeBoardId: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
2
packages/db/migrations/mysql/0001_wild_alex_wilder.sql
Normal file
2
packages/db/migrations/mysql/0001_wild_alex_wilder.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user` ADD `homeBoardId` varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD CONSTRAINT `user_homeBoardId_board_id_fk` FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
1166
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
1166
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1715334452118,
|
"when": 1715334452118,
|
||||||
"tag": "0000_harsh_photon",
|
"tag": "0000_harsh_photon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1715885855801,
|
||||||
|
"tag": "0001_wild_alex_wilder",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
33
packages/db/migrations/sqlite/0001_mixed_titanium_man.sql
Normal file
33
packages/db/migrations/sqlite/0001_mixed_titanium_man.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
COMMIT TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
--> statement-breakpoint
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` RENAME TO `__user_old`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text,
|
||||||
|
`email` text,
|
||||||
|
`emailVerified` integer,
|
||||||
|
`image` text,
|
||||||
|
`password` text,
|
||||||
|
`salt` text,
|
||||||
|
`homeBoardId` text,
|
||||||
|
FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `user` SELECT `id`, `name`, `email`, `emailVerified`, `image`, `password`, `salt`, null FROM `__user_old`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `__user_old`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` RENAME TO `__user_old`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `__user_old` RENAME TO `user`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
--> statement-breakpoint
|
||||||
|
BEGIN TRANSACTION;
|
||||||
1114
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
1114
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1715334238443,
|
"when": 1715334238443,
|
||||||
"tag": "0000_talented_ben_parker",
|
"tag": "0000_talented_ben_parker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1715871797713,
|
||||||
|
"tag": "0001_mixed_titanium_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
|
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
@@ -36,6 +37,12 @@ export const users = mysqlTable("user", {
|
|||||||
image: text("image"),
|
image: text("image"),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
salt: text("salt"),
|
salt: text("salt"),
|
||||||
|
homeBoardId: varchar("homeBoardId", { length: 64 }).references(
|
||||||
|
(): AnyMySqlColumn => boards.id,
|
||||||
|
{
|
||||||
|
onDelete: "set null",
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = mysqlTable(
|
export const accounts = mysqlTable(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
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 {
|
import {
|
||||||
index,
|
index,
|
||||||
int,
|
int,
|
||||||
@@ -35,6 +36,12 @@ export const users = sqliteTable("user", {
|
|||||||
image: text("image"),
|
image: text("image"),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
salt: text("salt"),
|
salt: text("salt"),
|
||||||
|
homeBoardId: text("homeBoardId").references(
|
||||||
|
(): AnySQLiteColumn => boards.id,
|
||||||
|
{
|
||||||
|
onDelete: "set null",
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = sqliteTable(
|
export const accounts = sqliteTable(
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|||||||
|
|
||||||
import { schema } from "..";
|
import { schema } from "..";
|
||||||
|
|
||||||
export const createDb = () => {
|
export const createDb = (debug?: boolean) => {
|
||||||
const sqlite = new Database(":memory:");
|
const sqlite = new Database(":memory:");
|
||||||
const db = drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema, logger: debug });
|
||||||
migrate(db, {
|
migrate(db, {
|
||||||
migrationsFolder: "./packages/db/migrations/sqlite",
|
migrationsFolder: "./packages/db/migrations/sqlite",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log("Database created");
|
||||||
|
}
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ export default {
|
|||||||
preferences: "Your preferences",
|
preferences: "Your preferences",
|
||||||
logout: "Logout",
|
logout: "Logout",
|
||||||
login: "Login",
|
login: "Login",
|
||||||
navigateDefaultBoard: "Navigate to default board",
|
homeBoard: "Your home board",
|
||||||
loggedOut: "Logged out",
|
loggedOut: "Logged out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -970,6 +970,9 @@ export default {
|
|||||||
label: "Name",
|
label: "Name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
content: {
|
||||||
|
metaTitle: "{boardName} board",
|
||||||
|
},
|
||||||
setting: {
|
setting: {
|
||||||
title: "Settings for {boardName} board",
|
title: "Settings for {boardName} board",
|
||||||
section: {
|
section: {
|
||||||
@@ -1152,6 +1155,13 @@ export default {
|
|||||||
settings: {
|
settings: {
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
},
|
},
|
||||||
|
setHomeBoard: {
|
||||||
|
label: "Set as your home board",
|
||||||
|
badge: {
|
||||||
|
label: "Home",
|
||||||
|
tooltip: "This board will show as your home board",
|
||||||
|
},
|
||||||
|
},
|
||||||
delete: {
|
delete: {
|
||||||
label: "Delete permanently",
|
label: "Delete permanently",
|
||||||
confirm: {
|
confirm: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
|
|||||||
import { ClientServerDataInitalizer } from "./client";
|
import { ClientServerDataInitalizer } from "./client";
|
||||||
import { GlobalItemServerDataProvider } from "./provider";
|
import { GlobalItemServerDataProvider } from "./provider";
|
||||||
|
|
||||||
type Board = RouterOutputs["board"]["getDefaultBoard"];
|
type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
type Props = PropsWithChildren<{
|
||||||
shouldRun: boolean;
|
shouldRun: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user