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:
@@ -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";
|
||||
|
||||
59
packages/ui/src/components/search-input.tsx
Normal file
59
packages/ui/src/components/search-input.tsx
Normal 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} />;
|
||||
};
|
||||
80
packages/ui/src/components/table-pagination.tsx
Normal file
80
packages/ui/src/components/table-pagination.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
48
packages/ui/src/components/user-avatar-group.tsx
Normal file
48
packages/ui/src/components/user-avatar-group.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/ui/src/components/user-avatar.tsx
Normal file
28
packages/ui/src/components/user-avatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user