mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-12 07:42:34 +10:00
space updates
* space UI * space management * space permissions * other fixes
This commit is contained in:
@ -11,6 +11,7 @@ import SettingsLayout from "@/components/layouts/settings/settings-layout.tsx";
|
||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
||||
import Groups from "@/pages/settings/group/groups";
|
||||
import GroupInfo from "./pages/settings/group/group-info";
|
||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -31,8 +32,7 @@ export default function App() {
|
||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||
<Route path={"groups"} element={<Groups />} />
|
||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||
<Route path={"spaces"} element={<Home />} />
|
||||
<Route path={"security"} element={<Home />} />
|
||||
<Route path={"spaces"} element={<Spaces />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
15
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
15
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { ActionIcon, rem } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
interface IconPeopleCircleProps extends React.ComponentPropsWithoutRef<"svg"> {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function IconGroupCircle() {
|
||||
return (
|
||||
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@ -42,11 +42,6 @@ const groupedData: DataGroup[] = [
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{
|
||||
label: "Security",
|
||||
icon: IconFingerprint,
|
||||
path: "/settings/security",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -31,7 +31,8 @@
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@ -5,24 +5,23 @@ import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { spotlight } from '@mantine/spotlight';
|
||||
} from "@mantine/core";
|
||||
import { spotlight } from "@mantine/spotlight";
|
||||
import {
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconFilePlus,
|
||||
IconHome,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import classes from './navbar.module.css';
|
||||
import { UserButton } from './user-button';
|
||||
import React from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { SearchSpotlight } from '@/features/search/search-spotlight';
|
||||
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom';
|
||||
import PageTree from '@/features/page/tree/page-tree';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import classes from "./navbar.module.css";
|
||||
import { UserButton } from "./user-button";
|
||||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { SearchSpotlight } from "@/features/search/search-spotlight";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
|
||||
import PageTree from "@/features/page/tree/page-tree";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface PrimaryMenuItem {
|
||||
icon: React.ElementType;
|
||||
@ -31,9 +30,9 @@ interface PrimaryMenuItem {
|
||||
}
|
||||
|
||||
const primaryMenu: PrimaryMenuItem[] = [
|
||||
{ icon: IconHome, label: 'Home' },
|
||||
{ icon: IconSearch, label: 'Search' },
|
||||
{ icon: IconSettings, label: 'Settings' },
|
||||
{ icon: IconHome, label: "Home" },
|
||||
{ icon: IconSearch, label: "Search" },
|
||||
{ icon: IconSettings, label: "Settings" },
|
||||
// { icon: IconFilePlus, label: 'New Page' },
|
||||
];
|
||||
|
||||
@ -42,21 +41,21 @@ export function Navbar() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleMenuItemClick = (label: string) => {
|
||||
if (label === 'Home') {
|
||||
navigate('/home');
|
||||
if (label === "Home") {
|
||||
navigate("/home");
|
||||
}
|
||||
|
||||
if (label === 'Search') {
|
||||
if (label === "Search") {
|
||||
spotlight.open();
|
||||
}
|
||||
|
||||
if (label === 'Settings') {
|
||||
navigate('/settings/workspace');
|
||||
if (label === "Settings") {
|
||||
navigate("/settings/workspace");
|
||||
}
|
||||
};
|
||||
|
||||
function handleCreatePage() {
|
||||
tree?.create({ parentId: null, type: 'internal', index: 0 });
|
||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||
}
|
||||
|
||||
const primaryMenuItems = primaryMenu.map((menuItem) => (
|
||||
|
||||
63
apps/client/src/components/ui/role-select-menu.tsx
Normal file
63
apps/client/src/components/ui/role-select-menu.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
import { Group, Text, Menu, Button } from "@mantine/core";
|
||||
import { IRoleData } from "@/lib/types.ts";
|
||||
|
||||
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
|
||||
({ name, ...others }: RoleButtonProps, ref) => (
|
||||
<Button
|
||||
variant="default"
|
||||
ref={ref}
|
||||
style={{
|
||||
border: "none",
|
||||
}}
|
||||
rightSection={<IconChevronDown size="1rem" />}
|
||||
{...others}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
|
||||
interface SpaceRoleMenuProps {
|
||||
roles: IRoleData[];
|
||||
roleName: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function RoleSelectMenu({
|
||||
roles,
|
||||
roleName,
|
||||
onChange,
|
||||
}: SpaceRoleMenuProps) {
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
<RoleButton name={roleName} />
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{roles?.map((item) => (
|
||||
<Menu.Item
|
||||
onClick={() => onChange && onChange(item.value)}
|
||||
key={item.value}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{item.label}</Text>
|
||||
<Text size="xs" opacity={0.65}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</div>
|
||||
{item.label === roleName && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
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";
|
||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
|
||||
export default function GroupList() {
|
||||
const { data, isLoading } = useGetGroupsQuery();
|
||||
@ -33,7 +33,7 @@ export default function GroupList() {
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{group.name}
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { searchPage } from '@/features/search/services/search-service';
|
||||
import { IPageSearch } from '@/features/search/types/search.types';
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
searchPage,
|
||||
searchSuggestions,
|
||||
} from "@/features/search/services/search-service";
|
||||
import {
|
||||
IPageSearch,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
|
||||
export function usePageSearchQuery(query: string): UseQueryResult<IPageSearch[], Error> {
|
||||
export function usePageSearchQuery(
|
||||
query: string,
|
||||
): UseQueryResult<IPageSearch[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ['page-history', query],
|
||||
queryKey: ["page-search", query],
|
||||
queryFn: () => searchPage(query),
|
||||
enabled: !!query,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchSuggestionsQuery(
|
||||
params: SearchSuggestionParams,
|
||||
): UseQueryResult<ISuggestionResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["search-suggestion", params],
|
||||
queryFn: () => searchSuggestions(params),
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { IPageSearch } from '@/features/search/types/search.types';
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IPageSearch,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
|
||||
export async function searchPage(query: string): Promise<IPageSearch[]> {
|
||||
const req = await api.post<IPageSearch[]>('/search', { query });
|
||||
return req.data as any;
|
||||
const req = await api.post<IPageSearch[]>("/search", { query });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function searchSuggestions(
|
||||
params: SearchSuggestionParams,
|
||||
): Promise<ISuggestionResult> {
|
||||
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
|
||||
export interface IPageSearch {
|
||||
id: string;
|
||||
@ -10,3 +12,14 @@ export interface IPageSearch {
|
||||
rank: string;
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
export interface SearchSuggestionParams {
|
||||
query: string;
|
||||
includeUsers?: boolean;
|
||||
includeGroups?: boolean;
|
||||
}
|
||||
|
||||
export interface ISuggestionResult {
|
||||
users?: Partial<IUser[]>;
|
||||
groups?: Partial<IGroup[]>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
82
apps/client/src/features/space/components/settings-modal.tsx
Normal file
82
apps/client/src/features/space/components/settings-modal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
apps/client/src/features/space/components/space-details.tsx
Normal file
24
apps/client/src/features/space/components/space-details.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
apps/client/src/features/space/components/space-list.tsx
Normal file
67
apps/client/src/features/space/components/space-list.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
176
apps/client/src/features/space/components/space-members.tsx
Normal file
176
apps/client/src/features/space/components/space-members.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,137 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { getUserSpaces } from "@/features/space/services/space-service";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
IAddSpaceMember,
|
||||
IChangeSpaceMemberRole,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
ISpaceMember,
|
||||
} from "@/features/space/types/space.types";
|
||||
import {
|
||||
addSpaceMember,
|
||||
changeMemberRole,
|
||||
getSpaceById,
|
||||
getSpaceMembers,
|
||||
getSpaces,
|
||||
removeSpaceMember,
|
||||
updateSpace,
|
||||
} from "@/features/space/services/space-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export function useUserSpacesQuery(): UseQueryResult<ISpace[], Error> {
|
||||
export function useGetSpacesQuery(): UseQueryResult<
|
||||
IPagination<ISpace>,
|
||||
Error
|
||||
> {
|
||||
return useQuery({
|
||||
queryKey: ["user-spaces"],
|
||||
queryFn: () => getUserSpaces(),
|
||||
queryKey: ["spaces"],
|
||||
queryFn: () => getSpaces(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSpaceMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ISpace, Error, Partial<ISpace>>({
|
||||
mutationFn: (data) => updateSpace(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Space updated successfully" });
|
||||
|
||||
const space = queryClient.getQueryData([
|
||||
"space",
|
||||
variables.spaceId,
|
||||
]) as ISpace;
|
||||
if (space) {
|
||||
const updatedSpace = { ...space, ...data };
|
||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["spaces"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpaceMembersQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<IPagination<ISpaceMember>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["spaceMembers", spaceId],
|
||||
queryFn: () => getSpaceMembers(spaceId),
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddSpaceMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, IAddSpaceMember>({
|
||||
mutationFn: (data) => addSpaceMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Members added successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: "Failed to add space members",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveSpaceMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, IRemoveSpaceMember>({
|
||||
mutationFn: (data) => removeSpaceMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeSpaceMemberRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, IChangeSpaceMemberRole>({
|
||||
mutationFn: (data) => changeMemberRole(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Member role updated successfully" });
|
||||
// due to pagination levels, change in cache instead
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,47 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { ISpace } from '@/features/space/types/space.types';
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IAddSpaceMember,
|
||||
IChangeSpaceMemberRole,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
} from "@/features/space/types/space.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
|
||||
export async function getUserSpaces(): Promise<ISpace[]> {
|
||||
const req = await api.get<ISpace[]>('/spaces');
|
||||
return req.data as ISpace[];
|
||||
export async function getSpaces(): Promise<IPagination<ISpace>> {
|
||||
const req = await api.post("/spaces");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSpaceById(spaceId: string): Promise<ISpace> {
|
||||
const req = await api.post<ISpace>("/spaces/info", { spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
|
||||
const req = await api.post<ISpace>("/spaces/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSpaceMembers(
|
||||
spaceId: string,
|
||||
): Promise<IPagination<IUser>> {
|
||||
const req = await api.post<any>("/spaces/members", { spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
|
||||
await api.post("/spaces/members/add", data);
|
||||
}
|
||||
|
||||
export async function removeSpaceMember(
|
||||
data: IRemoveSpaceMember,
|
||||
): Promise<void> {
|
||||
await api.post("/spaces/members/remove", data);
|
||||
}
|
||||
|
||||
export async function changeMemberRole(
|
||||
data: IChangeSpaceMemberRole,
|
||||
): Promise<void> {
|
||||
await api.post("/spaces/members/role", data);
|
||||
}
|
||||
|
||||
24
apps/client/src/features/space/types/space-role-data.ts
Normal file
24
apps/client/src/features/space/types/space-role-data.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IRoleData, SpaceRole } from "@/lib/types.ts";
|
||||
|
||||
export const spaceRoleData: IRoleData[] = [
|
||||
{
|
||||
label: "Full access",
|
||||
value: SpaceRole.ADMIN,
|
||||
description: "Has full access to space settings and pages",
|
||||
},
|
||||
{
|
||||
label: "Can edit",
|
||||
value: SpaceRole.WRITER,
|
||||
description: "Can create and edit pages in space.",
|
||||
},
|
||||
{
|
||||
label: "Can view",
|
||||
value: SpaceRole.READER,
|
||||
description: "Can view pages in space but not edit",
|
||||
},
|
||||
];
|
||||
|
||||
export function getSpaceRoleLabel(value: string) {
|
||||
const role = spaceRoleData.find((item) => item.value === value);
|
||||
return role ? role.label : undefined;
|
||||
}
|
||||
@ -7,5 +7,43 @@ export interface ISpace {
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
memberCount?: number;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export interface IAddSpaceMember {
|
||||
spaceId: string;
|
||||
userIds?: string[];
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export interface IRemoveSpaceMember {
|
||||
spaceId: string;
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export interface IChangeSpaceMemberRole {
|
||||
spaceId: string;
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
// space member
|
||||
export interface SpaceUserInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
type: "user";
|
||||
}
|
||||
|
||||
export interface SpaceGroupInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
memberCount: number;
|
||||
type: "group";
|
||||
}
|
||||
|
||||
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
|
||||
|
||||
@ -1,10 +1,36 @@
|
||||
import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import {
|
||||
useChangeMemberRoleMutation,
|
||||
useWorkspaceMembersQuery,
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
|
||||
import React from "react";
|
||||
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
|
||||
import {
|
||||
getUserRoleLabel,
|
||||
userRoleData,
|
||||
} from "@/features/workspace/types/user-role-data.ts";
|
||||
|
||||
export default function WorkspaceMembersTable() {
|
||||
const { data, isLoading } = useWorkspaceMembersQuery();
|
||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||
|
||||
const handleRoleChange = async (
|
||||
userId: string,
|
||||
currentRole: string,
|
||||
newRole: string,
|
||||
) => {
|
||||
if (newRole === currentRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberRoleUpdate = {
|
||||
userId: userId,
|
||||
role: newRole,
|
||||
};
|
||||
|
||||
await changeMemberRoleMutation.mutateAsync(memberRoleUpdate);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -39,7 +65,15 @@ export default function WorkspaceMembersTable() {
|
||||
<Badge variant="light">Active</Badge>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>{user.role}</Table.Td>
|
||||
<Table.Td>
|
||||
<RoleSelectMenu
|
||||
roles={userRoleData}
|
||||
roleName={getUserRoleLabel(user.role)}
|
||||
onChange={(newRole) =>
|
||||
handleRoleChange(user.id, user.role, newRole)
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
changeMemberRole,
|
||||
getWorkspaceMembers,
|
||||
} from "@/features/workspace/services/workspace-service";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export function useWorkspaceMembersQuery(params?: QueryParams) {
|
||||
return useQuery({
|
||||
@ -8,3 +12,21 @@ export function useWorkspaceMembersQuery(params?: QueryParams) {
|
||||
queryFn: () => getWorkspaceMembers(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeMemberRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<any, Error, any>({
|
||||
mutationFn: (data) => changeMemberRole(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Member role updated successfully" });
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["workspaceMembers", variables.spaceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
import { IWorkspace } from "../types/workspace.types";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export async function getWorkspace(): Promise<IWorkspace> {
|
||||
const req = await api.post<IWorkspace>("/workspace/info");
|
||||
@ -9,8 +9,10 @@ export async function getWorkspace(): Promise<IWorkspace> {
|
||||
}
|
||||
|
||||
// Todo: fix all paginated types
|
||||
export async function getWorkspaceMembers(params?: QueryParams): Promise<any> {
|
||||
const req = await api.post<any>("/workspace/members", params);
|
||||
export async function getWorkspaceMembers(
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IUser>> {
|
||||
const req = await api.post("/workspace/members", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@ -19,3 +21,10 @@ export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
|
||||
return req.data as IWorkspace;
|
||||
}
|
||||
|
||||
export async function changeMemberRole(data: {
|
||||
userId: string;
|
||||
role: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/workspace/members/role", data);
|
||||
}
|
||||
|
||||
24
apps/client/src/features/workspace/types/user-role-data.ts
Normal file
24
apps/client/src/features/workspace/types/user-role-data.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IRoleData, UserRole } from "@/lib/types.ts";
|
||||
|
||||
export const userRoleData: IRoleData[] = [
|
||||
{
|
||||
label: "Owner",
|
||||
value: UserRole.OWNER,
|
||||
description: "Can manage workspace",
|
||||
},
|
||||
{
|
||||
label: "Admin",
|
||||
value: UserRole.ADMIN,
|
||||
description: "Can manage workspace but cannot delete it",
|
||||
},
|
||||
{
|
||||
label: "Member",
|
||||
value: UserRole.MEMBER,
|
||||
description: "Can become members of groups and spaces in workspace.",
|
||||
},
|
||||
];
|
||||
|
||||
export function getUserRoleLabel(value: string) {
|
||||
const role = userRoleData.find((item) => item.value === value);
|
||||
return role ? role.label : undefined;
|
||||
}
|
||||
@ -3,3 +3,32 @@ export interface QueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
OWNER = "owner",
|
||||
ADMIN = "admin",
|
||||
MEMBER = "member",
|
||||
}
|
||||
|
||||
export enum SpaceRole {
|
||||
ADMIN = "admin",
|
||||
WRITER = "writer",
|
||||
READER = "reader",
|
||||
}
|
||||
|
||||
export interface IRoleData {
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type IPaginationMeta = {
|
||||
limit: number;
|
||||
page: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
};
|
||||
export type IPagination<T> = {
|
||||
items: T[];
|
||||
meta: IPaginationMeta;
|
||||
};
|
||||
|
||||
11
apps/client/src/pages/settings/space/spaces.tsx
Normal file
11
apps/client/src/pages/settings/space/spaces.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
|
||||
import SpaceList from "@/features/space/components/space-list.tsx";
|
||||
|
||||
export default function Spaces() {
|
||||
return (
|
||||
<>
|
||||
<SettingsTitle title="Spaces" />
|
||||
<SpaceList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user