feat(groups): add home board settings (#2321)
This commit is contained in:
@@ -15,6 +15,10 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Group, Switch, Text } from "@mantine/core";
|
import { Switch, Text } from "@mantine/core";
|
||||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { SelectWithCustomItems } from "@homarr/ui";
|
|
||||||
|
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
import { CommonSettingsForm } from "./common-form";
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||||
@@ -18,42 +17,19 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
|||||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||||
{(form) => (
|
{(form) => (
|
||||||
<>
|
<>
|
||||||
<SelectWithCustomItems
|
<BoardSelect
|
||||||
label={tBoard("homeBoard.label")}
|
label={tBoard("homeBoard.label")}
|
||||||
description={tBoard("homeBoard.description")}
|
description={tBoard("homeBoard.description")}
|
||||||
data={selectableBoards.map((board) => ({
|
clearable
|
||||||
value: board.id,
|
boards={selectableBoards}
|
||||||
label: board.name,
|
|
||||||
image: board.logoImageUrl,
|
|
||||||
}))}
|
|
||||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
|
||||||
<Group>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
{...form.getInputProps("homeBoardId")}
|
||||||
/>
|
/>
|
||||||
<SelectWithCustomItems
|
|
||||||
|
<BoardSelect
|
||||||
label={tBoard("homeBoard.mobileLabel")}
|
label={tBoard("homeBoard.mobileLabel")}
|
||||||
description={tBoard("homeBoard.description")}
|
description={tBoard("homeBoard.description")}
|
||||||
data={selectableBoards.map((board) => ({
|
clearable
|
||||||
value: board.id,
|
boards={selectableBoards}
|
||||||
label: board.name,
|
|
||||||
image: board.logoImageUrl,
|
|
||||||
}))}
|
|
||||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
|
||||||
<Group>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
|
|
||||||
interface ChangeHomeBoardFormProps {
|
interface ChangeHomeBoardFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
boardsData: { value: string; label: string }[];
|
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||||
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Select
|
<BoardSelect
|
||||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
w="100%"
|
w="100%"
|
||||||
data={boardsData}
|
|
||||||
{...form.getInputProps("homeBoardId")}
|
{...form.getInputProps("homeBoardId")}
|
||||||
/>
|
/>
|
||||||
<Select
|
<BoardSelect
|
||||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||||
|
clearable
|
||||||
|
boards={boardsData}
|
||||||
w="100%"
|
w="100%"
|
||||||
data={boardsData}
|
|
||||||
{...form.getInputProps("mobileHomeBoardId")}
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
|
|||||||
<ChangeHomeBoardForm
|
<ChangeHomeBoardForm
|
||||||
user={user}
|
user={user}
|
||||||
boardsData={boards.map((board) => ({
|
boardsData={boards.map((board) => ({
|
||||||
value: board.id,
|
id: board.id,
|
||||||
label: board.name,
|
name: board.name,
|
||||||
|
logoImageUrl: board.logoImageUrl,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
|||||||
<NavigationLink
|
<NavigationLink
|
||||||
href={`/manage/users/groups/${params.id}`}
|
href={`/manage/users/groups/${params.id}`}
|
||||||
label={tGroup("setting.general.title")}
|
label={tGroup("setting.general.title")}
|
||||||
|
icon={<IconId size="1rem" stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
<NavigationLink
|
||||||
|
href={`/manage/users/groups/${params.id}/settings`}
|
||||||
|
label={tGroup("setting.setting.title")}
|
||||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
<NavigationLink
|
<NavigationLink
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { BoardSelect } from "~/components/board/board-select";
|
||||||
|
|
||||||
|
interface GroupHomeBoardsProps {
|
||||||
|
homeBoardId: string | null;
|
||||||
|
mobileHomeBoardId: string | null;
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
|
||||||
|
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
|
||||||
|
initialValues: {
|
||||||
|
homeBoardId,
|
||||||
|
mobileHomeBoardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
id: groupId,
|
||||||
|
settings: values,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
form.setInitialValues(values);
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("group.action.settings.board.notification.success.title"),
|
||||||
|
message: t("group.action.settings.board.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("group.action.settings.board.notification.error.title"),
|
||||||
|
message: t("group.action.settings.board.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<BoardSelect
|
||||||
|
label={t("group.field.homeBoard.label")}
|
||||||
|
description={t("group.field.homeBoard.description")}
|
||||||
|
clearable
|
||||||
|
boards={availableBoards}
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BoardSelect
|
||||||
|
label={t("group.field.mobileBoard.label")}
|
||||||
|
description={t("group.field.mobileBoard.description")}
|
||||||
|
clearable
|
||||||
|
boards={availableBoards}
|
||||||
|
{...form.getInputProps("mobileHomeBoardId")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Alert, Stack, Title } from "@mantine/core";
|
||||||
|
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { GroupHomeBoards } from "./_group-home-boards";
|
||||||
|
|
||||||
|
interface GroupSettingsPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await api.group.getById({ id: params.id });
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("management.page.group.setting.setting.title")}</Title>
|
||||||
|
|
||||||
|
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
|
{t("management.page.group.setting.setting.alert")}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
|
||||||
|
|
||||||
|
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { AddGroupModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
|
|
||||||
export const AddGroup = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
const { openModal } = useModalAction(AddGroupModal);
|
|
||||||
|
|
||||||
const handleAddGroup = useCallback(() => {
|
|
||||||
openModal();
|
|
||||||
}, [openModal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
|
||||||
{t("group.action.create.label")}
|
|
||||||
</MobileAffixButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
65
apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx
Normal file
65
apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Group, TextInput } from "@mantine/core";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddGroupModal } from "@homarr/modals-collection";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { GroupsTable } from "./_groups-table";
|
||||||
|
|
||||||
|
interface GroupsListProps {
|
||||||
|
groups: RouterOutputs["group"]["getAll"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const initialGroupIds = useMemo(
|
||||||
|
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||||
|
[groups],
|
||||||
|
);
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
groups
|
||||||
|
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.sort((groupA, groupB) => groupA.position - groupB.position),
|
||||||
|
[groups, search],
|
||||||
|
);
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
placeholder={`${t("group.search")}...`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<AddGroup />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddGroup = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { openModal } = useModalAction(AddGroupModal);
|
||||||
|
|
||||||
|
const handleAddGroup = useCallback(() => {
|
||||||
|
openModal();
|
||||||
|
}, [openModal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||||
|
{t("group.action.create.label")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
|
||||||
|
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||||
|
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Table,
|
||||||
|
TableTbody,
|
||||||
|
TableTd,
|
||||||
|
TableTh,
|
||||||
|
TableThead,
|
||||||
|
TableTr,
|
||||||
|
Text,
|
||||||
|
Transition,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconGripVertical } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatarGroup } from "@homarr/ui";
|
||||||
|
|
||||||
|
interface GroupsTableProps {
|
||||||
|
initialGroupIds: string[];
|
||||||
|
groups: RouterOutputs["group"]["getAll"];
|
||||||
|
hasFilter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
|
||||||
|
[groupIds, initialGroupIds],
|
||||||
|
);
|
||||||
|
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
|
||||||
|
const handleSavePositionsAsync = async () => {
|
||||||
|
await mutateAsync(
|
||||||
|
{ positions: groupIds },
|
||||||
|
{
|
||||||
|
async onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("group.action.changePosition.notification.success.message"),
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/users/groups");
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("group.action.changePosition.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
setActiveId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroupIds((groupIds) => {
|
||||||
|
const oldIndex = groupIds.indexOf(active.id as string);
|
||||||
|
const newIndex = groupIds.indexOf(over.id as string);
|
||||||
|
return arrayMove(groupIds, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragCancel() {
|
||||||
|
setActiveId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRow = useMemo(() => {
|
||||||
|
if (!activeId) return null;
|
||||||
|
|
||||||
|
const current = groups.find((group) => group.id === activeId);
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
|
||||||
|
}, [activeId, groups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
id="groups-table"
|
||||||
|
>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh>{t("group.field.name")}</TableTh>
|
||||||
|
<TableTh>{t("group.field.members")}</TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{groupIds.map((groupId) => {
|
||||||
|
const group = groups.find(({ id }) => id === groupId);
|
||||||
|
if (!group) return null;
|
||||||
|
|
||||||
|
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeId && (
|
||||||
|
<Table w="100%">
|
||||||
|
<TableTbody>{selectedRow}</TableTbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
<SaveAffix
|
||||||
|
visible={isDirty}
|
||||||
|
onDiscard={() => setGroupIds(initialGroupIds)}
|
||||||
|
isPending={isPending}
|
||||||
|
onSave={handleSavePositionsAsync}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DraggableRowProps {
|
||||||
|
group: RouterOutputs["group"]["getAll"][number];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
|
||||||
|
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||||
|
id: group.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<TableTr ref={setNodeRef} style={style}>
|
||||||
|
<TableTd colSpan={2}> </TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
group={group}
|
||||||
|
setNodeRef={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
group: RouterOutputs["group"]["getAll"][number];
|
||||||
|
handle?: ReactNode;
|
||||||
|
setNodeRef?: (node: HTMLElement | null) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
|
||||||
|
return (
|
||||||
|
<TableTr ref={setNodeRef} style={style}>
|
||||||
|
<TableTd>
|
||||||
|
<Group>
|
||||||
|
{handle}
|
||||||
|
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||||
|
{group.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DragHandleProps {
|
||||||
|
attributes: DraggableAttributes | undefined;
|
||||||
|
listeners: SyntheticListenerMap | undefined;
|
||||||
|
active: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
|
||||||
|
if (disabled) {
|
||||||
|
return <Box w={40} h="100%" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
h="100%"
|
||||||
|
w={40}
|
||||||
|
style={{ cursor: active ? "grabbing" : "grab" }}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<IconGripVertical size={18} stroke={1.5} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SaveAffixProps {
|
||||||
|
visible: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "sticky", bottom: 20 }}>
|
||||||
|
<Transition transition="slide-up" mounted={visible}>
|
||||||
|
{(transitionStyles) => (
|
||||||
|
<Card style={transitionStyles} withBorder>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button disabled={isPending} onClick={onDiscard}>
|
||||||
|
{t("common.action.discard")}
|
||||||
|
</Button>
|
||||||
|
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||||
|
{t("common.action.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.everyoneGroup {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.everyoneGroup:hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||||
import { z } from "zod";
|
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AddGroup } from "./_add-group";
|
import { GroupsList } from "./_client";
|
||||||
|
import classes from "./groups.module.css";
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
export default async function GroupsListPage() {
|
||||||
search: z.string().optional(),
|
|
||||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
|
||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface GroupsListPageProps {
|
|
||||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const groups = await api.group.getAll();
|
||||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
|
||||||
|
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManageContainer size="xl">
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("group.title")}</Title>
|
<Title>{t("group.title")}</Title>
|
||||||
<Group justify="space-between">
|
|
||||||
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
|
|
||||||
<AddGroup />
|
|
||||||
</Group>
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<TableThead>
|
|
||||||
<TableTr>
|
|
||||||
<TableTh>{t("group.field.name")}</TableTh>
|
|
||||||
<TableTh>{t("group.field.members")}</TableTh>
|
|
||||||
</TableTr>
|
|
||||||
</TableThead>
|
|
||||||
<TableTbody>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<Row key={group.id} group={group} />
|
|
||||||
))}
|
|
||||||
</TableTbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Group justify="end">
|
{dbEveryoneGroup && (
|
||||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||||
</Group>
|
<Card className={classes.everyoneGroup}>
|
||||||
|
<Group align="center">
|
||||||
|
<ThemeIcon radius="xl" variant="light">
|
||||||
|
<IconUsersGroup size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Stack gap={0} flex={1}>
|
||||||
|
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
|
||||||
|
<Text size="sm" c="gray.6">
|
||||||
|
{t("group.defaultGroup.description", { name: everyoneGroup })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconChevronRight size={20} />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GroupsList groups={groupsWithoutEveryone} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</ManageContainer>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RowProps {
|
|
||||||
group: RouterOutputs["group"]["getPaginated"]["items"][number];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Row = ({ group }: RowProps) => {
|
|
||||||
return (
|
|
||||||
<TableTr>
|
|
||||||
<TableTd>
|
|
||||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
|
||||||
{group.name}
|
|
||||||
</Anchor>
|
|
||||||
</TableTd>
|
|
||||||
<TableTd>
|
|
||||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
|
||||||
</TableTd>
|
|
||||||
</TableTr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
33
apps/nextjs/src/components/board/board-select.tsx
Normal file
33
apps/nextjs/src/components/board/board-select.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { SelectWithCustomItemsProps } from "@homarr/ui";
|
||||||
|
import { SelectWithCustomItems } from "@homarr/ui";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
|
||||||
|
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
|
||||||
|
return (
|
||||||
|
<SelectWithCustomItems
|
||||||
|
{...props}
|
||||||
|
data={boards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.name,
|
||||||
|
image: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||||
|
<Group>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ export class OnboardingActions {
|
|||||||
await this.db.insert(sqliteSchema.groups).values({
|
await this.db.insert(sqliteSchema.groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: input.group,
|
name: input.group,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import type { DeviceType } from "@homarr/common/server";
|
import type { DeviceType } from "@homarr/common/server";
|
||||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||||
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
boardGroupPermissions,
|
boardGroupPermissions,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
boardUserPermissions,
|
boardUserPermissions,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
groupPermissions,
|
groupPermissions,
|
||||||
|
groups,
|
||||||
integrationGroupPermissions,
|
integrationGroupPermissions,
|
||||||
integrationItems,
|
integrationItems,
|
||||||
integrationUserPermissions,
|
integrationUserPermissions,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from "@homarr/db/schema";
|
} from "@homarr/db/schema";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||||
import { importOldmarrAsync } from "@homarr/old-import";
|
import { importOldmarrAsync } from "@homarr/old-import";
|
||||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||||
@@ -57,6 +58,37 @@ export const boardRouter = createTRPCRouter({
|
|||||||
where: eq(boards.isPublic, true),
|
where: eq(boards.isPublic, true),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
getBoardsForGroup: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.input(z.object({ groupId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
|
||||||
|
where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
|
||||||
|
with: {
|
||||||
|
boardPermissions: true,
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const distinctPermissions = new Set(
|
||||||
|
dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
|
||||||
|
);
|
||||||
|
const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
|
||||||
|
|
||||||
|
const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
|
||||||
|
group.boardPermissions.map(({ boardId }) => boardId),
|
||||||
|
);
|
||||||
|
const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
|
||||||
|
|
||||||
|
return await ctx.db.query.boards.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
logoImageUrl: true,
|
||||||
|
},
|
||||||
|
where: boardWhere,
|
||||||
|
});
|
||||||
|
}),
|
||||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||||
@@ -89,6 +121,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
logoImageUrl: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
@@ -975,9 +1008,13 @@ export const boardRouter = createTRPCRouter({
|
|||||||
* For an example of a user with deviceType = 'mobile' it would go through the following order:
|
* For an example of a user with deviceType = 'mobile' it would go through the following order:
|
||||||
* 1. user.mobileHomeBoardId
|
* 1. user.mobileHomeBoardId
|
||||||
* 2. user.homeBoardId
|
* 2. user.homeBoardId
|
||||||
* 3. serverSettings.mobileHomeBoardId
|
* 3. group.mobileHomeBoardId of the lowest positions group
|
||||||
* 4. serverSettings.homeBoardId
|
* 4. group.homeBoardId of the lowest positions group
|
||||||
* 5. show NOT_FOUND error
|
* 5. everyoneGroup.mobileHomeBoardId
|
||||||
|
* 6. everyoneGroup.homeBoardId
|
||||||
|
* 7. serverSettings.mobileHomeBoardId
|
||||||
|
* 8. serverSettings.homeBoardId
|
||||||
|
* 9. show NOT_FOUND error
|
||||||
*/
|
*/
|
||||||
const getHomeIdBoardAsync = async (
|
const getHomeIdBoardAsync = async (
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -985,12 +1022,46 @@ const getHomeIdBoardAsync = async (
|
|||||||
deviceType: DeviceType,
|
deviceType: DeviceType,
|
||||||
) => {
|
) => {
|
||||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||||
if (user?.[settingKey] || user?.homeBoardId) {
|
|
||||||
return user[settingKey] ?? user.homeBoardId;
|
if (!user) {
|
||||||
} else {
|
|
||||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user[settingKey]) return user[settingKey];
|
||||||
|
if (user.homeBoardId) return user.homeBoardId;
|
||||||
|
|
||||||
|
const lowestGroupExceptEveryone = await db
|
||||||
|
.select({
|
||||||
|
homeBoardId: groups.homeBoardId,
|
||||||
|
mobileHomeBoardId: groups.mobileHomeBoardId,
|
||||||
|
})
|
||||||
|
.from(groups)
|
||||||
|
.leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(groupMembers.userId, user.id),
|
||||||
|
not(eq(groups.name, everyoneGroup)),
|
||||||
|
not(isNull(groups[settingKey])),
|
||||||
|
not(isNull(groups.homeBoardId)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(groups.position))
|
||||||
|
.limit(1)
|
||||||
|
.then((result) => result[0]);
|
||||||
|
|
||||||
|
if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
|
||||||
|
if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
|
||||||
|
|
||||||
|
const dbEveryoneGroup = await db.query.groups.findFirst({
|
||||||
|
where: eq(groups.name, everyoneGroup),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
|
||||||
|
if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
|
||||||
|
|
||||||
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
|
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq, like, not, sql } from "@homarr/db";
|
import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
|
||||||
|
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
@@ -12,6 +13,30 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
|
|||||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
export const groupRouter = createTRPCRouter({
|
||||||
|
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||||
|
const dbGroups = await ctx.db.query.groups.findMany({
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbGroups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
members: group.members.map((member) => member.user),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
getPaginated: permissionRequiredProcedure
|
getPaginated: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(validation.common.paginated)
|
.input(validation.common.paginated)
|
||||||
@@ -153,10 +178,13 @@ export const groupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||||
|
|
||||||
|
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||||
|
|
||||||
const groupId = createId();
|
const groupId = createId();
|
||||||
await ctx.db.insert(groups).values({
|
await ctx.db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
position: maxPosition + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.db.insert(groupPermissions).values({
|
await ctx.db.insert(groupPermissions).values({
|
||||||
@@ -172,10 +200,13 @@ export const groupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||||
|
|
||||||
|
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||||
|
|
||||||
const id = createId();
|
const id = createId();
|
||||||
await ctx.db.insert(groups).values({
|
await ctx.db.insert(groups).values({
|
||||||
id,
|
id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
position: maxPosition + 1,
|
||||||
ownerId: ctx.session.user.id,
|
ownerId: ctx.session.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,6 +228,43 @@ export const groupRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(groups.id, input.id));
|
.where(eq(groups.id, input.id));
|
||||||
}),
|
}),
|
||||||
|
savePartialSettings: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.input(validation.group.savePartialSettings)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(groups)
|
||||||
|
.set({
|
||||||
|
homeBoardId: input.settings.homeBoardId,
|
||||||
|
mobileHomeBoardId: input.settings.mobileHomeBoardId,
|
||||||
|
})
|
||||||
|
.where(eq(groups.id, input.id));
|
||||||
|
}),
|
||||||
|
savePositions: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.input(validation.group.savePositions)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
|
||||||
|
|
||||||
|
await handleTransactionsAsync(ctx.db, {
|
||||||
|
handleAsync: async (db, schema) => {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (const { id, position } of positions) {
|
||||||
|
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSync: (db) => {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
for (const { id, position } of positions) {
|
||||||
|
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
savePermissions: permissionRequiredProcedure
|
savePermissions: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(validation.group.savePermissions)
|
.input(validation.group.savePermissions)
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "group1",
|
name: "group1",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "group1",
|
name: "group1",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(boardGroupPermissions).values({
|
await db.insert(boardGroupPermissions).values({
|
||||||
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "group1",
|
name: "group1",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
[1, 2, 3, 4, 5].map((number) => ({
|
[1, 2, 3, 4, 5].map((number) => ({
|
||||||
id: number.toString(),
|
id: number.toString(),
|
||||||
name: `Group ${number}`,
|
name: `Group ${number}`,
|
||||||
|
position: number,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
[1, 2, 3, 4, 5].map((number) => ({
|
[1, 2, 3, 4, 5].map((number) => ({
|
||||||
id: number.toString(),
|
id: number.toString(),
|
||||||
name: `Group ${number}`,
|
name: `Group ${number}`,
|
||||||
|
position: number,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
groupId,
|
groupId,
|
||||||
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
|||||||
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
||||||
id: index.toString(),
|
id: index.toString(),
|
||||||
name: key,
|
name: key,
|
||||||
|
position: index + 1,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Another group",
|
name: "Another group",
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: "2",
|
id: "2",
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: similarName,
|
name: similarName,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: initialValue,
|
name: initialValue,
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Third",
|
name: "Third",
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Something",
|
name: "Something",
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: initialDuplicate,
|
name: initialDuplicate,
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "something",
|
name: "something",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupPermissions).values({
|
await db.insert(groupPermissions).values({
|
||||||
groupId,
|
groupId,
|
||||||
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
|
|||||||
{
|
{
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Another group",
|
name: "Another group",
|
||||||
|
position: 2,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "Group",
|
name: "Group",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
groupId,
|
groupId,
|
||||||
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
|
|||||||
id: groupId,
|
id: groupId,
|
||||||
name: "Group",
|
name: "Group",
|
||||||
ownerId: defaultOwnerId,
|
ownerId: defaultOwnerId,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
groupId,
|
groupId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq, like } from "@homarr/db";
|
import { and, createId, eq, like } from "@homarr/db";
|
||||||
|
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||||
@@ -31,12 +32,14 @@ export const userRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
throwIfCredentialsDisabled();
|
throwIfCredentialsDisabled();
|
||||||
|
|
||||||
|
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||||
const userId = await createUserAsync(ctx.db, input);
|
const userId = await createUserAsync(ctx.db, input);
|
||||||
const groupId = createId();
|
const groupId = createId();
|
||||||
await ctx.db.insert(groups).values({
|
await ctx.db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: credentialsAdminGroup,
|
name: credentialsAdminGroup,
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
|
position: maxPosition + 1,
|
||||||
});
|
});
|
||||||
await ctx.db.insert(groupPermissions).values({
|
await ctx.db.insert(groupPermissions).values({
|
||||||
groupId,
|
groupId,
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||||
await db.insert(groups).values({ id: "1", name: "" });
|
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||||
await db.insert(groups).values({ id: "1", name: "" });
|
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
];
|
];
|
||||||
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
await db.insert(boards).values(createMockBoard({ id: "1" }));
|
||||||
await db.insert(boards).values(createMockBoard({ id: "2" }));
|
await db.insert(boards).values(createMockBoard({ id: "2" }));
|
||||||
await db.insert(groups).values({ id: "1", name: "" });
|
await db.insert(groups).values({ id: "1", name: "", position: 1 });
|
||||||
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
|
||||||
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
|
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
|
||||||
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
|
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ describe("authorizeWithLdapCredentials", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
name: "homarr_example",
|
name: "homarr_example",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("getCurrentUserPermissions", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: "2",
|
id: "2",
|
||||||
name: "test",
|
name: "test",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupPermissions).values({
|
await db.insert(groupPermissions).values({
|
||||||
groupId: "2",
|
groupId: "2",
|
||||||
@@ -51,6 +52,7 @@ describe("getCurrentUserPermissions", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: "2",
|
id: "2",
|
||||||
name: "test",
|
name: "test",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupPermissions).values({
|
await db.insert(groupPermissions).values({
|
||||||
groupId: "2",
|
groupId: "2",
|
||||||
@@ -81,6 +83,7 @@ describe("getCurrentUserPermissions", () => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: mockId,
|
id: mockId,
|
||||||
name: "test",
|
name: "test",
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
await db.insert(groupMembers).values({
|
await db.insert(groupMembers).values({
|
||||||
userId: mockId,
|
userId: mockId,
|
||||||
|
|||||||
@@ -259,4 +259,5 @@ const createGroupAsync = async (db: Database, name = "test") =>
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: "1",
|
id: "1",
|
||||||
name,
|
name,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
ALTER TABLE `group` ADD `home_board_id` varchar(64);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD `mobile_home_board_id` varchar(64);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD `position` smallint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `temp_group` (
|
||||||
|
`id` varchar(64) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`position` smallint NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `temp_group`(`id`, `name`, `position`) SELECT `id`, `name`, ROW_NUMBER() OVER(ORDER BY `name`) FROM `group` WHERE `name` != 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `group` SET `position`=(SELECT `position` FROM `temp_group` WHERE `temp_group`.`id`=`group`.`id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `temp_group`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `group` SET `position` = -1 WHERE `name` = 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` MODIFY `position` smallint NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD CONSTRAINT `group_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `group` ADD CONSTRAINT `group_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
1811
packages/db/migrations/mysql/meta/0025_snapshot.json
Normal file
1811
packages/db/migrations/mysql/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,13 @@
|
|||||||
"when": 1738961147412,
|
"when": 1738961147412,
|
||||||
"tag": "0024_mean_vin_gonzales",
|
"tag": "0024_mean_vin_gonzales",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1739469710187,
|
||||||
|
"tag": "0025_add-group-home-board-settings",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
|||||||
await db.insert(groups).values({
|
await db.insert(groups).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: everyoneGroup,
|
name: everyoneGroup,
|
||||||
|
position: -1,
|
||||||
});
|
});
|
||||||
console.log("Created group 'everyone' through seed");
|
console.log("Created group 'everyone' through seed");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
COMMIT TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
--> statement-breakpoint
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_group` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`owner_id` text,
|
||||||
|
`home_board_id` text,
|
||||||
|
`mobile_home_board_id` text,
|
||||||
|
`position` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", -1 FROM `group` WHERE "name" = 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", ROW_NUMBER() OVER(ORDER BY "name") FROM `group` WHERE "name" != 'everyone';
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `group`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_group` RENAME TO `group`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
--> statement-breakpoint
|
||||||
|
BEGIN TRANSACTION;
|
||||||
1736
packages/db/migrations/sqlite/meta/0025_snapshot.json
Normal file
1736
packages/db/migrations/sqlite/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,13 @@
|
|||||||
"when": 1738961178990,
|
"when": 1738961178990,
|
||||||
"tag": "0024_bitter_scrambler",
|
"tag": "0024_bitter_scrambler",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1739468826756,
|
||||||
|
"tag": "0025_add-group-home-board-settings",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/db/queries/group.ts
Normal file
11
packages/db/queries/group.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { max } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { HomarrDatabase } from "../driver";
|
||||||
|
import { groups } from "../schema";
|
||||||
|
|
||||||
|
export const getMaxGroupPositionAsync = async (db: HomarrDatabase) => {
|
||||||
|
return await db
|
||||||
|
.select({ value: max(groups.position) })
|
||||||
|
.from(groups)
|
||||||
|
.then((result) => result[0]?.value ?? 1);
|
||||||
|
};
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./item";
|
export * from "./item";
|
||||||
export * from "./server-setting";
|
export * from "./server-setting";
|
||||||
|
export * from "./group";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
int,
|
int,
|
||||||
mysqlTable,
|
mysqlTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
smallint,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
tinyint,
|
tinyint,
|
||||||
@@ -150,6 +151,13 @@ export const groups = mysqlTable("group", {
|
|||||||
ownerId: varchar({ length: 64 }).references(() => users.id, {
|
ownerId: varchar({ length: 64 }).references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
homeBoardId: varchar({ length: 64 }).references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
mobileHomeBoardId: varchar({ length: 64 }).references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
position: smallint().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const groupPermissions = mysqlTable("groupPermission", {
|
export const groupPermissions = mysqlTable("groupPermission", {
|
||||||
@@ -499,6 +507,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
|||||||
fields: [groups.ownerId],
|
fields: [groups.ownerId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
|
homeBoard: one(boards, {
|
||||||
|
fields: [groups.homeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: one(boards, {
|
||||||
|
fields: [groups.mobileHomeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||||
@@ -574,6 +592,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
}),
|
}),
|
||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
|
groupHomes: many(groups, {
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: many(groups, {
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ export const groups = sqliteTable("group", {
|
|||||||
ownerId: text().references(() => users.id, {
|
ownerId: text().references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
homeBoardId: text().references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
mobileHomeBoardId: text().references(() => boards.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
position: int().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const groupPermissions = sqliteTable("groupPermission", {
|
export const groupPermissions = sqliteTable("groupPermission", {
|
||||||
@@ -486,6 +493,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
|||||||
fields: [groups.ownerId],
|
fields: [groups.ownerId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
|
homeBoard: one(boards, {
|
||||||
|
fields: [groups.homeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: one(boards, {
|
||||||
|
fields: [groups.mobileHomeBoardId],
|
||||||
|
references: [boards.id],
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||||
@@ -561,6 +578,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
}),
|
}),
|
||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
|
groupHomes: many(groups, {
|
||||||
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
|
}),
|
||||||
|
mobileHomeBoard: many(groups, {
|
||||||
|
relationName: "groupRelations__board__mobileHomeBoardId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const createUserInsertCollection = (
|
|||||||
insertCollection.groups.push({
|
insertCollection.groups.push({
|
||||||
id: adminGroupId,
|
id: adminGroupId,
|
||||||
name: credentialsAdminGroup,
|
name: credentialsAdminGroup,
|
||||||
|
position: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
insertCollection.groupPermissions.push({
|
insertCollection.groupPermissions.push({
|
||||||
|
|||||||
@@ -305,7 +305,15 @@
|
|||||||
"search": "Find a group",
|
"search": "Find a group",
|
||||||
"field": {
|
"field": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"members": "Members"
|
"members": "Members",
|
||||||
|
"homeBoard": {
|
||||||
|
"label": "Home board",
|
||||||
|
"description": "Only boards accessible to the group can be selected"
|
||||||
|
},
|
||||||
|
"mobileBoard": {
|
||||||
|
"label": "Mobile board",
|
||||||
|
"description": "Only boards accessible to the group can be selected"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"admin": {
|
"admin": {
|
||||||
@@ -501,7 +509,35 @@
|
|||||||
"select": {
|
"select": {
|
||||||
"label": "Select group",
|
"label": "Select group",
|
||||||
"notFound": "No group found"
|
"notFound": "No group found"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"board": {
|
||||||
|
"notification": {
|
||||||
|
"success": {
|
||||||
|
"title": "Settings saved",
|
||||||
|
"message": "Board settings saved successfully"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Failed to save settings",
|
||||||
|
"message": "Unable to save board settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"changePosition": {
|
||||||
|
"notification": {
|
||||||
|
"success": {
|
||||||
|
"message": "Position changed successfully"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"message": "Unable to change position"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"defaultGroup": {
|
||||||
|
"name": "Default group",
|
||||||
|
"description": "{name} - All signed in users"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -888,6 +924,7 @@
|
|||||||
},
|
},
|
||||||
"dangerZone": "Danger zone",
|
"dangerZone": "Danger zone",
|
||||||
"noResults": "No results found",
|
"noResults": "No results found",
|
||||||
|
"unsavedChanges": "You have unsaved changes!",
|
||||||
"preview": {
|
"preview": {
|
||||||
"show": "Show preview",
|
"show": "Show preview",
|
||||||
"hide": "Hide preview"
|
"hide": "Hide preview"
|
||||||
@@ -2414,6 +2451,13 @@
|
|||||||
"ownerOfGroup": "Owner of this group",
|
"ownerOfGroup": "Owner of this group",
|
||||||
"ownerOfGroupDeleted": "The owner of this group was deleted. It currently has no owner."
|
"ownerOfGroupDeleted": "The owner of this group was deleted. It currently has no owner."
|
||||||
},
|
},
|
||||||
|
"setting": {
|
||||||
|
"title": "Settings",
|
||||||
|
"alert": "Group settings are prioritized by the order of groups in the list. The top settings overwrite the bottom settings.",
|
||||||
|
"board": {
|
||||||
|
"title": "Boards"
|
||||||
|
}
|
||||||
|
},
|
||||||
"members": {
|
"members": {
|
||||||
"title": "Members",
|
"title": "Members",
|
||||||
"search": "Find a member",
|
"search": "Find a member",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { SearchInput } from "./search-input";
|
|||||||
export * from "./select-with-description";
|
export * from "./select-with-description";
|
||||||
export * from "./select-with-description-and-badge";
|
export * from "./select-with-description-and-badge";
|
||||||
export { SelectWithCustomItems } from "./select-with-custom-items";
|
export { SelectWithCustomItems } from "./select-with-custom-items";
|
||||||
|
export type { SelectWithCustomItemsProps } from "./select-with-custom-items";
|
||||||
export { TablePagination } from "./table-pagination";
|
export { TablePagination } from "./table-pagination";
|
||||||
export { TextMultiSelect } from "./text-multi-select";
|
export { TextMultiSelect } from "./text-multi-select";
|
||||||
export { UserAvatar } from "./user-avatar";
|
export { UserAvatar } from "./user-avatar";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import type { SelectProps } from "@mantine/core";
|
import type { SelectProps } from "@mantine/core";
|
||||||
import { Combobox, Input, InputBase, useCombobox } from "@mantine/core";
|
import { Combobox, ComboboxClearButton, Input, InputBase, useCombobox } from "@mantine/core";
|
||||||
import { useUncontrolled } from "@mantine/hooks";
|
import { useUncontrolled } from "@mantine/hooks";
|
||||||
|
|
||||||
interface BaseSelectItem {
|
interface BaseSelectItem {
|
||||||
@@ -11,7 +11,7 @@ interface BaseSelectItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
|
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
|
||||||
extends Pick<SelectProps, "label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"> {
|
extends Pick<SelectProps, "label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder" | "clearable"> {
|
||||||
data: TSelectItem[];
|
data: TSelectItem[];
|
||||||
description?: string;
|
description?: string;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -32,6 +32,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
|||||||
placeholder,
|
placeholder,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
w,
|
w,
|
||||||
|
clearable,
|
||||||
...props
|
...props
|
||||||
}: Props<TSelectItem>) => {
|
}: Props<TSelectItem>) => {
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
@@ -65,6 +66,8 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
|||||||
[setValue, data, combobox],
|
[setValue, data, combobox],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const _clearable = clearable && Boolean(_value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox store={combobox} withinPortal={false} onOptionSubmit={onOptionSubmit}>
|
<Combobox store={combobox} withinPortal={false} onOptionSubmit={onOptionSubmit}>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
@@ -73,9 +76,11 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
|||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
pointer
|
pointer
|
||||||
rightSection={<Combobox.Chevron />}
|
__clearSection={<ComboboxClearButton onClear={() => setValue(null, null)} />}
|
||||||
|
__clearable={_clearable}
|
||||||
|
__defaultRightSection={<Combobox.Chevron />}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents={_clearable ? "all" : "none"}
|
||||||
multiline
|
multiline
|
||||||
w={w}
|
w={w}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,11 +18,25 @@ const createSchema = z.object({
|
|||||||
|
|
||||||
const updateSchema = createSchema.merge(byIdSchema);
|
const updateSchema = createSchema.merge(byIdSchema);
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
homeBoardId: z.string().nullable(),
|
||||||
|
mobileHomeBoardId: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const savePartialSettingsSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
settings: settingsSchema.partial(),
|
||||||
|
});
|
||||||
|
|
||||||
const savePermissionsSchema = z.object({
|
const savePermissionsSchema = z.object({
|
||||||
groupId: z.string(),
|
groupId: z.string(),
|
||||||
permissions: z.array(zodEnumFromArray(groupPermissionKeys)),
|
permissions: z.array(zodEnumFromArray(groupPermissionKeys)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savePositionsSchema = z.object({
|
||||||
|
positions: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
|
const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
|
||||||
|
|
||||||
export const groupSchemas = {
|
export const groupSchemas = {
|
||||||
@@ -30,4 +44,7 @@ export const groupSchemas = {
|
|||||||
update: updateSchema,
|
update: updateSchema,
|
||||||
savePermissions: savePermissionsSchema,
|
savePermissions: savePermissionsSchema,
|
||||||
groupUser: groupUserSchema,
|
groupUser: groupUserSchema,
|
||||||
|
savePartialSettings: savePartialSettingsSchema,
|
||||||
|
settings: settingsSchema,
|
||||||
|
savePositions: savePositionsSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -82,6 +82,18 @@ importers:
|
|||||||
|
|
||||||
apps/nextjs:
|
apps/nextjs:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@dnd-kit/modifiers':
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.0.0)
|
||||||
'@homarr/analytics':
|
'@homarr/analytics':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../packages/analytics
|
version: link:../../packages/analytics
|
||||||
@@ -2351,6 +2363,12 @@ packages:
|
|||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
react-dom: '>=16.8.0'
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/modifiers@9.0.0':
|
||||||
|
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@dnd-kit/sortable@10.0.0':
|
'@dnd-kit/sortable@10.0.0':
|
||||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10324,7 +10342,7 @@ snapshots:
|
|||||||
'@dnd-kit/accessibility@3.1.1(react@19.0.0)':
|
'@dnd-kit/accessibility@3.1.1(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
'@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10332,19 +10350,26 @@ snapshots:
|
|||||||
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
|
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
|
||||||
|
react: 19.0.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dnd-kit/core': 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
'@dnd-kit/core': 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
|
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@dnd-kit/utilities@3.2.2(react@19.0.0)':
|
'@dnd-kit/utilities@3.2.2(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.2': {}
|
'@drizzle-team/brocli@0.10.2': {}
|
||||||
|
|
||||||
@@ -10815,7 +10840,7 @@ snapshots:
|
|||||||
|
|
||||||
'@formatjs/intl-localematcher@0.5.5':
|
'@formatjs/intl-localematcher@0.5.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@grpc/grpc-js@1.12.5':
|
'@grpc/grpc-js@1.12.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user