client: updates

* work on groups ui
* move settings to its own page
* other fixes and refactoring
This commit is contained in:
Philipinho
2024-04-04 22:19:15 +01:00
parent cab5e67055
commit 1412f1d982
64 changed files with 1770 additions and 474 deletions

View File

@ -0,0 +1,45 @@
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import React, { useState } from "react";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useParams } from "react-router-dom";
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
export default function AddGroupMemberModal() {
const { groupId } = useParams();
const [opened, { open, close }] = useDisclosure(false);
const [userIds, setUserIds] = useState<string[]>([]);
const addGroupMemberMutation = useAddGroupMemberMutation();
const handleMultiSelectChange = (value: string[]) => {
setUserIds(value);
};
const handleSubmit = async () => {
const addGroupMember = {
groupId: groupId,
userIds: userIds,
};
await addGroupMemberMutation.mutateAsync(addGroupMember);
close();
};
return (
<>
<Button onClick={open}>Add group members</Button>
<Modal opened={opened} onClose={close} title="Add group members">
<Divider size="xs" mb="xs" />
<MultiUserSelect onChange={handleMultiSelectChange} />
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
</Button>
</Group>
</Modal>
</>
);
}

View File

@ -0,0 +1,82 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateGroupForm() {
const createGroupMutation = useCreateGroupMutation();
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
description: "",
},
});
const handleMultiSelectChange = (value: string[]) => {
setUserIds(value);
};
const handleSubmit = async (data: {
name?: string;
description?: string;
}) => {
const groupData = {
name: data.name,
description: data.description,
userIds: userIds,
};
const createdGroup = await createGroupMutation.mutateAsync(groupData);
navigate(`/settings/groups/${createdGroup.id}`);
};
return (
<>
<Box maw="500" mx="auto">
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
variant="filled"
autosize
minRows={2}
maxRows={8}
{...form.getInputProps("description")}
/>
<MultiUserSelect onChange={handleMultiSelectChange} />
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,18 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
export default function CreateGroupModal() {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Create group</Button>
<Modal opened={opened} onClose={close} title="Create group">
<Divider size="xs" mb="xs" />
<CreateGroupForm />
</Modal>
</>
);
}

View File

@ -0,0 +1,88 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import {
useGroupQuery,
useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
interface EditGroupFormProps {
onClose?: () => void;
}
export function EditGroupForm({ onClose }: EditGroupFormProps) {
const updateGroupMutation = useUpdateGroupMutation();
const { isSuccess } = updateGroupMutation;
const { groupId } = useParams();
const { data: group } = useGroupQuery(groupId);
useEffect(() => {
if (isSuccess) {
if (onClose) {
onClose();
}
}
}, [isSuccess]);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: group?.name,
description: group?.description,
},
});
const handleSubmit = async (data: {
name?: string;
description?: string;
}) => {
const groupData = {
groupId: groupId,
name: data.name,
description: data.description,
};
await updateGroupMutation.mutateAsync(groupData);
};
return (
<>
<Box maw="500" mx="auto">
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
variant="filled"
autosize
minRows={2}
maxRows={8}
{...form.getInputProps("description")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Edit</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,21 @@
import { Divider, Modal } from "@mantine/core";
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
interface EditGroupModalProps {
opened: boolean;
onClose: () => void;
}
export default function EditGroupModal({
opened,
onClose,
}: EditGroupModalProps) {
return (
<>
<Modal opened={opened} onClose={onClose} title="Edit group">
<Divider size="xs" mb="xs" />
<EditGroupForm onClose={onClose} />
</Modal>
</>
);
}

View File

@ -0,0 +1,79 @@
import {
useDeleteGroupMutation,
useGroupQuery,
} from "@/features/group/queries/group-query";
import { useNavigate, useParams } from "react-router-dom";
import { Menu, ActionIcon, Text } from "@mantine/core";
import React from "react";
import { IconDots, IconTrash } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import { modals } from "@mantine/modals";
export default function GroupActionMenu() {
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const deleteGroupMutation = useDeleteGroupMutation();
const navigate = useNavigate();
const [opened, { open, close }] = useDisclosure(false);
const onDelete = async () => {
await deleteGroupMutation.mutateAsync(groupId);
navigate("/settings/groups");
};
const openDeleteModal = () =>
modals.openConfirmModal({
title: "Delete group",
children: (
<Text size="sm">
Are you sure you want to delete this group? Members will lose access
to resources this group has access to.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: onDelete,
});
return (
<>
{group && (
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="light">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={open} disabled={group.isDefault}>
Edit group
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
onClick={openDeleteModal}
disabled={group.isDefault}
leftSection={<IconTrash size={16} stroke={2} />}
>
Delete group
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
)}
<EditGroupModal opened={opened} onClose={close} />
</>
);
}

View File

@ -0,0 +1,33 @@
import { useGroupQuery } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import { Group, Title, Text } from "@mantine/core";
import AddGroupMemberModal from "@/features/group/components/add-group-member-modal";
import React from "react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
export default function GroupDetails() {
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const [opened, { open, close }] = useDisclosure(false);
return (
<>
{group && (
<div>
{/* Todo: back navigation */}
<Title order={3}>{group.name}</Title>
<Text c="dimmed">{group.description}</Text>
<Group my="md" justify="flex-end">
<AddGroupMemberModal />
<GroupActionMenu />
</Group>
</div>
)}
<EditGroupModal opened={opened} onClose={close} />
</>
);
}

View File

@ -0,0 +1,70 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { IconUsersGroup } from "@tabler/icons-react";
import React from "react";
import { Link } from "react-router-dom";
export default function GroupList() {
const { data, isLoading } = useGetGroupsQuery();
return (
<>
{data && (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Group</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm">
<IconUsersGroup stroke={1.5} />
<div>
<Text fz="sm" fw={500}>
{group.name}
</Text>
<Text fz="xs" c="dimmed">
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
}

View File

@ -0,0 +1,102 @@
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
import {
useGroupMembersQuery,
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
export default function GroupMembersList() {
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
const onRemove = async (userId: string) => {
const memberToRemove = {
groupId: groupId,
userId: userId,
};
await removeGroupMember.mutateAsync(memberToRemove);
};
const openRemoveModal = (userId: string) =>
modals.openConfirmModal({
title: "Remove group member",
children: (
<Text size="sm">
Are you sure you want to remove this user from the group? The user
will lose access to resources this group has access to.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});
return (
<>
{data && (
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<UserAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
}

View File

@ -0,0 +1,73 @@
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import {
Avatar,
Group,
MultiSelect,
MultiSelectProps,
Text,
} from "@mantine/core";
interface MultiUserSelectProps {
onChange: (value: string[]) => void;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option,
}) => (
<Group gap="sm">
<Avatar src={option?.["avatarUrl"]} size={36} radius="xl" />
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" opacity={0.5}>
{option?.["email"]}
</Text>
</div>
</Group>
);
export function MultiUserSelect({ onChange }: MultiUserSelectProps) {
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({
query: debouncedQuery,
limit: 50,
});
const [data, setData] = useState([]);
useEffect(() => {
if (users) {
const usersData = users?.items.map((user: IUser) => {
return {
value: user.id,
label: user.name,
avatarUrl: user.avatarUrl,
email: user.email,
};
});
if (usersData.length > 0) {
setData(usersData);
}
}
}, [users]);
return (
<MultiSelect
data={data}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label="Add group members"
placeholder="Search for users"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
variant="filled"
onChange={onChange}
nothingFoundMessage="Nothing found..."
maxValues={50}
/>
);
}

View File

@ -0,0 +1,134 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { IGroup } from "@/features/group/types/group.types";
import {
addGroupMember,
createGroup,
deleteGroup,
getGroupById,
getGroupMembers,
getGroups,
removeGroupMember,
updateGroup,
} from "@/features/group/services/group-service";
import { notifications } from "@mantine/notifications";
export function useGetGroupsQuery(): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["groups"],
queryFn: () => getGroups(),
});
}
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({
queryKey: ["group", groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,
});
}
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ["groupMembers", groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useCreateGroupMutation() {
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => createGroup(data),
onSuccess: () => {
notifications.show({ message: "Group created successfully" });
},
onError: () => {
notifications.show({ message: "Failed to create group", color: "red" });
},
});
}
export function useUpdateGroupMutation() {
const queryClient = useQueryClient();
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Group updated successfully" });
queryClient.invalidateQueries({
queryKey: ["group", variables.groupId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useDeleteGroupMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
notifications.show({ message: "Group deleted successfully" });
queryClient.refetchQueries({
queryKey: ["groups"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
},
onError: () => {
notifications.show({
message: "Failed to add group members",
color: "red",
});
},
});
}
export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
groupId: string;
userId: string;
}
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import { IGroup } from "@/features/group/types/group.types";
export async function getGroups(): Promise<any> {
// TODO: returns paginated. Fix type
const req = await api.post<any>("/groups");
return req.data;
}
export async function getGroupById(groupId: string): Promise<IGroup> {
const req = await api.post<IGroup>("/groups/info", { groupId });
return req.data as IGroup;
}
export async function getGroupMembers(groupId: string) {
const req = await api.post("/groups/members", { groupId });
return req.data;
}
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
const req = await api.post<IGroup>("/groups/create", data);
return req.data;
}
export async function updateGroup(data: Partial<IGroup>): Promise<IGroup> {
const req = await api.post<IGroup>("/groups/update", data);
return req.data;
}
export async function deleteGroup(data: { groupId: string }): Promise<void> {
await api.post("/groups/delete", data);
}
export async function addGroupMember(data: {
groupId: string;
userIds: string[];
}): Promise<void> {
await api.post<IGroup>("/groups/members/add", data);
}
export async function removeGroupMember(data: {
groupId: string;
userId: string;
}): Promise<void> {
await api.post<IGroup>("/groups/members/remove", data);
}

View File

@ -0,0 +1,12 @@
export interface IGroup {
groupId: string;
id: string;
name: string;
description: string | null;
isDefault: boolean;
creatorId: string | null;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
memberCount: number;
}