feat: add user groups (#376)

* feat: add user groups

* wip: add unit tests

* wip: add more tests and normalized name for creation and update

* test: add unit tests for group router

* fix: type issues, missing mysql schema, rename column creator_id to owner_id

* fix: lint and format issues

* fix: deepsource issues

* fix: forgot to add log message

* fix: build not working

* chore: address pull request feedback

* feat: add mysql migration and fix merge conflicts

* fix: format issue and test issue
This commit is contained in:
Meier Lukas
2024-04-29 21:46:30 +02:00
committed by GitHub
parent 621f6c81ae
commit 036925bf78
50 changed files with 3333 additions and 132 deletions

View File

@@ -1,3 +1,7 @@
export * from "./count-badge";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";
export { UserAvatar } from "./user-avatar";
export { UserAvatarGroup } from "./user-avatar-group";
export { TablePagination } from "./table-pagination";
export { SearchInput } from "./search-input";

View File

@@ -0,0 +1,59 @@
"use client";
import type { ChangeEvent } from "react";
import { useCallback, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Loader, TextInput } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
interface SearchInputProps {
defaultValue?: string;
placeholder: string;
}
export const SearchInput = ({
placeholder,
defaultValue,
}: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const handleSearchDebounced = useDebouncedCallback((value: string) => {
const params = new URLSearchParams(searchParams);
params.set("search", value.toString());
if (params.has("page")) params.set("page", "1"); // Reset page to 1
replace(`${pathName}?${params.toString()}`);
setLoading(false);
}, 250);
const handleSearch = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setLoading(true);
handleSearchDebounced(event.currentTarget.value);
},
[setLoading, handleSearchDebounced],
);
return (
<TextInput
leftSection={<LeftSection loading={loading} />}
defaultValue={defaultValue}
onChange={handleSearch}
placeholder={placeholder}
/>
);
};
interface LeftSectionProps {
loading: boolean;
}
const LeftSection = ({ loading }: LeftSectionProps) => {
if (loading) {
return <Loader size="xs" />;
}
return <IconSearch size={20} stroke={1.5} />;
};

View File

@@ -0,0 +1,80 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { PaginationProps } from "@mantine/core";
import { Pagination } from "@mantine/core";
interface TablePaginationProps {
total: number;
}
export const TablePagination = ({ total }: TablePaginationProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const current = Number(searchParams.get("page")) || 1;
const getItemProps = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
return {
component: Link,
href: `?${params.toString()}`,
};
},
[searchParams],
);
const getControlProps = useCallback(
(control: ControlType) => {
return getItemProps(calculatePageFor(control, current, total));
},
[current],
);
const handleChange = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
replace(`${pathName}?${params.toString()}`);
},
[pathName, searchParams],
);
return (
<Pagination
total={total}
getItemProps={getItemProps}
getControlProps={getControlProps}
onChange={handleChange}
/>
);
};
type ControlType = Parameters<
Exclude<PaginationProps["getControlProps"], undefined>
>[0];
const calculatePageFor = (
type: ControlType,
current: number,
total: number,
) => {
switch (type) {
case "first":
return 1;
case "previous":
return Math.max(current - 1, 1);
case "next":
return current + 1;
case "last":
return total;
default:
console.error(`Unknown pagination control type: ${type as string}`);
return 1;
}
};

View File

@@ -0,0 +1,48 @@
import type { MantineSize } from "@mantine/core";
import { Avatar, AvatarGroup, Tooltip, TooltipGroup } from "@mantine/core";
import type { UserProps } from "./user-avatar";
import { UserAvatar } from "./user-avatar";
interface UserAvatarGroupProps {
size: MantineSize;
limit: number;
users: UserProps[];
}
export const UserAvatarGroup = ({
size,
limit,
users,
}: UserAvatarGroupProps) => {
return (
<TooltipGroup openDelay={300} closeDelay={300}>
<AvatarGroup>
{users.slice(0, limit).map((user) => (
<Tooltip key={user.name} label={user.name} withArrow>
<UserAvatar user={user} size={size} />
</Tooltip>
))}
<MoreUsers size={size} users={users} offset={limit} />
</AvatarGroup>
</TooltipGroup>
);
};
interface MoreUsersProps {
size: MantineSize;
users: unknown[];
offset: number;
}
const MoreUsers = ({ size, users, offset }: MoreUsersProps) => {
if (users.length <= offset) return null;
const moreAmount = users.length - offset;
return (
<Avatar size={size} radius="xl">
+{moreAmount}
</Avatar>
);
};

View File

@@ -0,0 +1,28 @@
import { Avatar } from "@mantine/core";
import type { AvatarProps, MantineSize } from "@mantine/core";
export interface UserProps {
name: string | null;
image: string | null;
}
interface UserAvatarProps {
user: UserProps | null;
size: MantineSize;
}
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
const commonProps = {
size,
color: "primaryColor",
} satisfies Partial<AvatarProps>;
if (!user?.name) return <Avatar {...commonProps} />;
if (user.image) {
return <Avatar {...commonProps} src={user.image} alt={user.name} />;
}
return (
<Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>
);
};