space updates

* space UI
* space management
* space permissions
* other fixes
This commit is contained in:
Philipinho
2024-04-12 19:38:58 +01:00
parent b02cfd02f0
commit 90ae750d48
54 changed files with 1966 additions and 365 deletions

View File

@ -0,0 +1,72 @@
import { Button, Divider, Group, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import React, { useState } from "react";
import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.ts";
import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
import { SpaceRole } from "@/lib/types.ts";
interface AddSpaceMemberModalProps {
spaceId: string;
}
export default function AddSpaceMembersModal({
spaceId,
}: AddSpaceMemberModalProps) {
const [opened, { open, close }] = useDisclosure(false);
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(SpaceRole.WRITER);
const addSpaceMemberMutation = useAddSpaceMemberMutation();
const handleMultiSelectChange = (value: string[]) => {
setMemberIds(value);
};
const handleRoleSelection = (role: string) => {
setRole(role);
};
const handleSubmit = async () => {
// member can be a users or groups
const userIds = memberIds
.map((id) => (id.startsWith("user-") ? id.split("user-")[1] : null))
.filter((id) => id !== null);
const groupIds = memberIds
.map((id) => (id.startsWith("group-") ? id.split("group-")[1] : null))
.filter((id) => id !== null);
const addSpaceMember = {
spaceId: spaceId,
userIds: userIds,
groupIds: groupIds,
role: role,
};
await addSpaceMemberMutation.mutateAsync(addSpaceMember);
close();
};
return (
<>
<Button onClick={open}>Add space members</Button>
<Modal opened={opened} onClose={close} title="Add space members">
<Divider size="xs" mb="xs" />
<Stack>
<MultiMemberSelect onChange={handleMultiSelectChange} />
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
label="Select role"
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
</Button>
</Group>
</Modal>
</>
);
}

View File

@ -0,0 +1,78 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React from "react";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(250),
});
type FormValues = z.infer<typeof formSchema>;
interface EditSpaceFormProps {
space: ISpace;
}
export function EditSpaceForm({ space }: EditSpaceFormProps) {
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: space?.name,
description: space?.description || "",
},
});
const handleSubmit = async (values: {
name?: string;
description?: string;
}) => {
const spaceData: Partial<ISpace> = {
spaceId: space.id,
};
if (form.isDirty("name")) {
spaceData.name = values.name;
}
if (form.isDirty("description")) {
spaceData.description = values.description;
}
await updateSpaceMutation.mutateAsync(spaceData);
form.resetDirty();
};
return (
<>
<Box>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
id="name"
label="Name"
placeholder="e.g Developers"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Description"
placeholder="e.g Space for developers to collaborate"
autosize
minRows={1}
maxRows={3}
{...form.getInputProps("description")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={!form.isDirty()}>
Save
</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
import { IGroup } from "@/features/group/types/group.types.ts";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
interface MultiMemberSelectProps {
onChange: (value: string[]) => void;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option,
}) => (
<Group gap="sm">
{option["type"] === "user" && (
<UserAvatar
avatarUrl={option["avatarUrl"]}
size={20}
name={option.label}
/>
)}
{option["type"] === "group" && <IconGroupCircle />}
<div>
<Text size="sm">{option.label}</Text>
</div>
</Group>
);
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: true,
});
const [data, setData] = useState([]);
useEffect(() => {
if (suggestion) {
// Extract user and group items
const userItems = suggestion?.users.map((user: IUser) => ({
value: `user-${user.id}`,
label: user.name,
avatarUrl: user.avatarUrl,
type: "user",
}));
const groupItems = suggestion?.groups.map((group: IGroup) => ({
value: `group-${group.id}`,
label: group.name,
type: "group",
}));
// Function to merge items into groups without duplicates
const mergeItemsIntoGroups = (existingGroups, newItems, groupName) => {
const existingValues = new Set(
existingGroups.flatMap((group) =>
group.items.map((item) => item.value),
),
);
const newItemsFiltered = newItems.filter(
(item) => !existingValues.has(item.value),
);
const updatedGroups = existingGroups.map((group) => {
if (group.group === groupName) {
return { ...group, items: [...group.items, ...newItemsFiltered] };
}
return group;
});
// Use spread syntax to avoid mutation
return updatedGroups.some((group) => group.group === groupName)
? updatedGroups
: [...updatedGroups, { group: groupName, items: newItemsFiltered }];
};
// Merge user items into groups
const updatedUserGroups = mergeItemsIntoGroups(
data,
userItems,
"Select a user",
);
// Merge group items into groups
const finalData = mergeItemsIntoGroups(
updatedUserGroups,
groupItems,
"Select a group",
);
setData(finalData);
}
}, [suggestion, data]);
return (
<MultiSelect
data={data}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label="Add members"
placeholder="Search for users and groups"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
maxValues={50}
/>
);
}

View File

@ -0,0 +1,82 @@
import {
Modal,
Tabs,
rem,
Group,
Divider,
Text,
ScrollArea,
} from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpaceDetails from "@/features/space/components/space-details.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
interface SpaceSettingsModalProps {
spaceId: string;
opened: boolean;
onClose: () => void;
}
export default function SpaceSettingsModal({
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
return (
<>
<Modal.Root
opened={opened}
onClose={onClose}
size={600}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{space?.name} space </Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<div style={{ height: rem("600px") }}>
<Tabs color="gray" defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
Settings
</Tabs.Tab>
<Tabs.Tab fw={500} value="members">
Members
</Tabs.Tab>
</Tabs.List>
<ScrollArea h="600" w="100%" scrollbarSize={5}>
<Tabs.Panel value="general">
<SpaceDetails spaceId={space?.id} />
<Divider my="sm" />
</Tabs.Panel>
<Tabs.Panel value="members">
<Group my="md" justify="flex-end">
<AddSpaceMembersModal spaceId={space?.id} />
<GroupActionMenu />
</Group>
<SpaceMembersList spaceId={space?.id} />
</Tabs.Panel>
</ScrollArea>
</Tabs>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
);
}

View File

@ -0,0 +1,24 @@
import React from "react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
import { Text } from "@mantine/core";
interface SpaceDetailsProps {
spaceId: string;
}
export default function SpaceDetails({ spaceId }: SpaceDetailsProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
return (
<>
{space && (
<div>
<Text my="md" fw={600}>
Details
</Text>
<EditSpaceForm space={space} />
</div>
)}
</>
);
}

View File

@ -0,0 +1,67 @@
import { Table, Group, Text, Avatar } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks";
export default function SpaceList() {
const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => {
setSelectedSpaceId(spaceId);
open();
};
return (
<>
{data && (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Space</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{ cursor: "pointer" }}
onClick={() => handleClick(space.id)}
>
<Table.Td>
<Group gap="sm">
<Avatar color="gray" radius="xl">
{space.name.charAt(0).toUpperCase()}
</Avatar>
<div>
<Text fz="sm" fw={500}>
{space.name}
</Text>
<Text fz="xs" c="dimmed">
{space.description}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{space.memberCount} members</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{selectedSpaceId && (
<SpaceSettingsModal
opened={opened}
onClose={close}
spaceId={selectedSpaceId}
/>
)}
</>
);
}

View File

@ -0,0 +1,52 @@
import { IconCheck } from "@tabler/icons-react";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import React from "react";
import { spaceRoleData } from "@/features/space/types/space-role-data.ts";
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({
option,
checked,
}) => (
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" opacity={0.65}>
{option["description"]}
</Text>
</div>{" "}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
interface SpaceMemberRoleProps {
onSelect: (value: string) => void;
defaultRole: string;
label?: string;
}
export function SpaceMemberRole({
onSelect,
defaultRole,
label,
}: SpaceMemberRoleProps) {
return (
<Select
data={spaceRoleData}
defaultValue={defaultRole}
label={label}
onChange={onSelect}
renderOption={renderSelectOption}
allowDeselect={false}
variant="filled"
/>
);
}

View File

@ -0,0 +1,176 @@
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
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";
import {
useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation,
useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
getSpaceRoleLabel,
spaceRoleData,
} from "@/features/space/types/space-role-data.ts";
type MemberType = "user" | "group";
interface SpaceMembersProps {
spaceId: string;
}
export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
const handleRoleChange = async (
memberId: string,
type: MemberType,
newRole: string,
currentRole: string,
) => {
if (newRole === currentRole) {
return;
}
const memberRoleUpdate: {
spaceId: string;
role: string;
userId?: string;
groupId?: string;
} = {
spaceId: spaceId,
role: newRole,
};
if (type === "user") {
memberRoleUpdate.userId = memberId;
}
if (type === "group") {
memberRoleUpdate.groupId = memberId;
}
await changeSpaceMemberRoleMutation.mutateAsync(memberRoleUpdate);
};
const onRemove = async (memberId: string, type: MemberType) => {
console.log("remove", spaceId);
const memberToRemove: IRemoveSpaceMember = {
spaceId: spaceId,
};
if (type === "user") {
memberToRemove.userId = memberId;
}
if (type === "group") {
memberToRemove.groupId = memberId;
}
await removeSpaceMember.mutateAsync(memberToRemove);
};
const openRemoveModal = (memberId: string, type: MemberType) =>
modals.openConfirmModal({
title: "Remove space member",
children: (
<Text size="sm">
Are you sure you want to remove this user from the space? The user
will lose all access to this space.
</Text>
),
centered: true,
labels: { confirm: "Remove", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: () => onRemove(memberId, type),
});
return (
<>
{data && (
<Table verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>Member</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
{member.type === "user" && (
<UserAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${member?.memberCount === 1 ? "1 member" : `${member?.memberCount} members`}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
/>
</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(member.id, member.type)}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
}