Skip to content

Commit

Permalink
feat: workspace member manager
Browse files Browse the repository at this point in the history
  • Loading branch information
nikkothari22 committed Dec 14, 2024
1 parent 3e22766 commit 45d07d3
Show file tree
Hide file tree
Showing 8 changed files with 370 additions and 87 deletions.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"react-idle-timer": "^5.7.2",
"react-intersection-observer": "^9.10.3",
"react-router-dom": "^6.26.1",
"react-virtuoso": "^4.12.3",
"react-zoom-pan-pinch": "^3.4.4",
"sonner": "^1.7.0",
"tailwindcss": "^3.4.10",
Expand Down
131 changes: 131 additions & 0 deletions frontend/src/components/common/MemberManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { HStack, Stack } from "../layout/Stack"
import { Checkbox, Table, Text, TextField } from "@radix-ui/themes"
import { UserFields, UserListContext } from "@/utils/users/UserListProvider"
import { UserAvatar } from "./UserAvatar"
import { useContext, useEffect, useMemo, useState } from "react"
import { BiSearch } from "react-icons/bi"
import { TableVirtuoso } from "react-virtuoso"

export type MemberObject = { user: string, is_admin?: 0 | 1, is_member?: 0 | 1 }
type Props = {
currentMembers: MemberObject[]
onChange: (members: MemberObject[]) => void
}

/**
* Common component to manage members of a workspace/channel
*/
const MemberManager = ({ currentMembers, onChange }: Props) => {

const { users } = useContext(UserListContext)

const [search, setSearch] = useState('')

const onMemberChange = (user: string, is_member?: boolean, is_admin?: boolean) => {
// Add the member after removing the existing member
const newMembers = currentMembers.filter((member) => member.user !== user)
newMembers.push({ user, is_admin: is_admin && is_member ? 1 : 0, is_member: is_member ? 1 : 0 })
onChange(newMembers)
}

const filteredUsers = useMemo(() => {
return users.filter((user) => user.full_name.toLowerCase().includes(search.toLowerCase()))
}, [users, search])

const userMembershipMap: Record<string, { is_admin: boolean, is_member: boolean }> = currentMembers.reduce((acc, member) => {
acc[member.user] = {
is_admin: member.is_admin ? true : false,
is_member: member.is_member ? true : false
}
return acc
}, {} as Record<string, { is_admin: boolean, is_member: boolean }>)

return (
<Stack>
<SearchBar onSearch={setSearch} />
<Stack className="max-h-[600px] overflow-y-auto transition-height duration-300">
<TableVirtuoso
style={{ height: '540px' }}
data={filteredUsers}
components={{
Table: (props) => <Table.Root size='1' {...props} />,
TableHead: Table.Header,
TableBody: Table.Body,
TableRow: (props) => <Table.Row className="hover:bg-gray-2" {...props} />,
}}
fixedHeaderContent={() => (
<Table.Row>
<Table.ColumnHeaderCell className="w-[50%]">User</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="w-[25%]">Member</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="w-[25%]">Admin</Table.ColumnHeaderCell>
</Table.Row>
)}
itemContent={(index, user) => {
return <MemberRow member={user} membership={userMembershipMap[user.name]} key={user.name} onMemberChange={onMemberChange} />
}}
/>
</Stack>
</Stack>
)
}

const SearchBar = ({ onSearch }: { onSearch: (search: string) => void }) => {

const [search, setSearch] = useState('')

useEffect(() => {
// Debounced search
const timeout = setTimeout(() => {
onSearch(search)
}, 250)
return () => clearTimeout(timeout)
}, [search])

return (
<TextField.Root placeholder="Search" value={search} onChange={(e) => setSearch(e.target.value)}>
<TextField.Slot>
<BiSearch />
</TextField.Slot>
</TextField.Root>
)
}

interface MemberRowProps {
member: UserFields
membership?: { is_admin: boolean, is_member: boolean }
onMemberChange: (user: string, is_member?: boolean, is_admin?: boolean) => void
}

const MemberRow = ({ member, onMemberChange, membership }: MemberRowProps) => {

return (
<>
<Table.Cell className="w-[50%]" onClick={() => onMemberChange(member.name, !membership?.is_member, membership?.is_admin)}>
<HStack justify='between'>
<HStack align='center'>
<UserAvatar size='2' loading="eager" src={member.user_image} alt={member.full_name ?? member.name} />
<Text as='span' weight='medium'>{member.full_name ?? member.name}</Text>
</HStack>
</HStack>
</Table.Cell>
<Table.Cell className="w-[25%]">
<div className="flex h-full justify-start items-center px-2">
<Checkbox
name={`member-${member.name}`}
checked={membership?.is_member}
onCheckedChange={(checked) => onMemberChange(member.name, checked ? true : false, membership?.is_admin)} />
</div>
</Table.Cell>
<Table.Cell className="w-[25%]">
<div className="flex h-full justify-start items-center px-2">
<Checkbox
checked={membership?.is_admin}
disabled={!membership?.is_member}
onCheckedChange={(checked) => onMemberChange(member.name, membership?.is_member, checked ? true : false)} />
</div>
</Table.Cell>
</>
)
}

export default MemberManager
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const WorkspaceEditForm = () => {
{errors.description && <ErrorText>{errors.description?.message}</ErrorText>}
</Stack>

<Stack>
<Stack className='max-w-md'>
<Label htmlFor='channel_type'>Workspace Type</Label>
<Controller
name='type'
Expand Down
166 changes: 155 additions & 11 deletions frontend/src/components/feature/workspaces/WorkspaceMemberManagement.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { ErrorCallout } from "@/components/common/Callouts/ErrorCallouts"
import { Loader } from "@/components/common/Loader"
import MemberManager, { MemberObject } from "@/components/common/MemberManager"
import { UserAvatar } from "@/components/common/UserAvatar"
import { ErrorBanner, getErrorMessage } from "@/components/layout/AlertBanner/ErrorBanner"
import { TableLoader } from "@/components/layout/Loaders/TableLoader"
import { HStack, Stack } from "@/components/layout/Stack"
import { useGetUser } from "@/hooks/useGetUser"
import { useBoolean } from "@/hooks/useBoolean"
import { useGetUserRecords } from "@/hooks/useGetUserRecords"
import { RavenWorkspaceMember } from "@/types/Raven/RavenWorkspaceMember"
import { UserContext } from "@/utils/auth/UserProvider"
import { getDateObject } from "@/utils/dateConversions/utils"
import { Button, DropdownMenu, IconButton, Table, Text, Tooltip } from "@radix-ui/themes"
import { useFrappeDeleteDoc, useFrappeGetCall, useFrappeUpdateDoc, useSWRConfig } from "frappe-react-sdk"
import { useContext, useMemo } from "react"
import { DIALOG_CONTENT_CLASS } from "@/utils/layout/dialog"
import { __ } from "@/utils/translations"
import { UserFields } from "@/utils/users/UserListProvider"
import { Button, Dialog, DropdownMenu, HoverCard, IconButton, ScrollArea, Separator, Table, Text, Tooltip } from "@radix-ui/themes"
import clsx from "clsx"
import { useFrappeDeleteDoc, useFrappeGetCall, useFrappePostCall, useFrappeUpdateDoc, useSWRConfig } from "frappe-react-sdk"
import { useContext, useMemo, useState } from "react"
import { BiCrown, BiDotsVerticalRounded, BiSolidCrown } from "react-icons/bi"
import { FiUserMinus } from "react-icons/fi"
import { toast } from "sonner"
Expand All @@ -19,12 +27,16 @@ type Props = {

type WorkspaceMemberFields = Pick<RavenWorkspaceMember, 'user' | 'is_admin' | 'creation' | 'name'>

const WorkspaceMemberManagement = ({ workspaceID }: Props) => {

const { data, isLoading, error } = useFrappeGetCall<{ message: WorkspaceMemberFields[] }>('raven.api.workspaces.fetch_workspace_members', { workspace: workspaceID }, `fetch_workspace_members_${workspaceID}`, {
const useFetchWorkspaceMembers = (workspaceID: string) => {
return useFrappeGetCall<{ message: WorkspaceMemberFields[] }>('raven.api.workspaces.fetch_workspace_members', { workspace: workspaceID }, `fetch_workspace_members_${workspaceID}`, {
revalidateOnFocus: false,
errorRetryCount: 2
})
}

const WorkspaceMemberManagement = ({ workspaceID }: Props) => {

const { data, isLoading, error } = useFetchWorkspaceMembers(workspaceID)

const { currentUser } = useContext(UserContext)

Expand All @@ -35,7 +47,7 @@ const WorkspaceMemberManagement = ({ workspaceID }: Props) => {
return (
<Stack>
<HStack justify='end'>
<Button className="not-cal" size='2' variant="soft" type='button'>Manage Members</Button>
<ManageMembersDialog workspaceID={workspaceID} />
</HStack>
{isLoading && !error && <TableLoader columns={2} />}
<ErrorBanner error={error} />
Expand All @@ -46,6 +58,8 @@ const WorkspaceMemberManagement = ({ workspaceID }: Props) => {

const MembersTable = ({ members, isAdmin, workspaceID }: { members: WorkspaceMemberFields[], isAdmin: boolean, workspaceID: string }) => {

const users = useGetUserRecords()

return <Table.Root variant="surface" className='rounded-sm'>
<Table.Header>
<Table.Row>
Expand All @@ -55,14 +69,14 @@ const MembersTable = ({ members, isAdmin, workspaceID }: { members: WorkspaceMem
</Table.Row>
</Table.Header>
<Table.Body>
{members.map((member) => <MemberRow key={member.name} member={member} isAdmin={isAdmin} workspaceID={workspaceID} />)}
{members.map((member) => <MemberRow key={member.name} users={users} member={member} isAdmin={isAdmin} workspaceID={workspaceID} />)}
</Table.Body>
</Table.Root>
}

const MemberRow = ({ member, isAdmin, workspaceID }: { member: WorkspaceMemberFields, isAdmin: boolean, workspaceID: string }) => {
const MemberRow = ({ users, member, isAdmin, workspaceID }: { users: Record<string, UserFields>, member: WorkspaceMemberFields, isAdmin: boolean, workspaceID: string }) => {

const user = useGetUser(member.user)
const user = users[member.user]
return <Table.Row>
<Table.Cell>
<HStack align='center'>
Expand Down Expand Up @@ -178,4 +192,134 @@ const RemoveAdminButton = ({ memberID, onUpdate }: { memberID: string, onUpdate:
</DropdownMenu.Item>
}


const ManageMembersDialog = ({ workspaceID }: { workspaceID: string }) => {

const [isOpen, { off }, setOpen] = useBoolean()
const { data, isLoading, error } = useFetchWorkspaceMembers(workspaceID)

return <Dialog.Root open={isOpen} onOpenChange={setOpen}>
<Dialog.Trigger>
<Button className="not-cal" size='2' variant="soft" type='button'>Manage Members</Button>
</Dialog.Trigger>
<Dialog.Content className={clsx(DIALOG_CONTENT_CLASS, 'w-full max-w-2xl')}>
<Dialog.Title>Manage Members</Dialog.Title>
<Dialog.Description>Add or remove members from your workspace.</Dialog.Description>
{isLoading && <Loader />}
<ErrorBanner error={error} />
{data && <ManageMembersDialogContent workspaceID={workspaceID} onClose={off} members={data.message} />}
</Dialog.Content>
</Dialog.Root>

}

const ManageMembersDialogContent = ({ workspaceID, onClose, members }: { workspaceID: string, onClose: () => void, members: WorkspaceMemberFields[] }) => {

const [newMembers, setNewMembers] = useState<MemberObject[]>(members.map((member) => ({
user: member.user,
is_admin: member?.is_admin ? 1 : 0,
is_member: 1
})))
const [hasChanged, setHasChanged] = useState(false)

const onChange = (members: MemberObject[]) => {
setHasChanged(true)
setNewMembers(members)
}

const { mutate } = useSWRConfig()

const [errors, setErrors] = useState<string[]>([])

const { call, loading, error } = useFrappePostCall('raven.api.workspaces.update_workspace_members')

const saveMembers = () => {
call({
workspace: workspaceID,
members: newMembers
}).then((response) => {
toast.success('Members updated')
mutate(`fetch_workspace_members_${workspaceID}`)
if (response.errors) {
setErrors(response.errors)
} else {
onClose()
}
})
}

return <Stack className="min-h-48 py-4">
<ErrorBanner error={error} />
{errors.length > 0 && <ErrorCallout>
{errors.map((error) => <Text key={error}>{error}</Text>)}
</ErrorCallout>}
<MemberManager currentMembers={newMembers} onChange={onChange} />
<HStack justify='between' pt='4' className="h-full">
<MemberStats members={newMembers} />
<HStack>
<Dialog.Close>
<Button size='2' variant='soft' type='button' color='gray' disabled={loading}>Cancel</Button>
</Dialog.Close>
<Button size='2' type='button' onClick={saveMembers} disabled={!hasChanged || loading}>
{loading ? <Loader className="text-white" /> : null}
{loading ? __("Saving") : __("Save")}
</Button>
</HStack>
</HStack>
</Stack>
}

const MemberStats = ({ members }: { members: MemberObject[] }) => {

const { admins, memberIDs } = useMemo(() => {
const memberIDs = members.filter((member) => member.is_member).sort((a, b) => a.user.localeCompare(b.user))
const admins = memberIDs.filter((member) => member.is_admin)
return { admins, memberIDs }
}, [members])

return <HStack align='center'>
<HoverCard.Root>
<HoverCard.Trigger>
<Text as='span' size='2' className="text-gray-11 h-full flex items-center underline underline-offset-4 decoration-gray-8">
{memberIDs.length} members
</Text>
</HoverCard.Trigger>
<HoverCard.Content className="w-72">
<Text size='2' weight='medium'>Members</Text>
<Separator className="my-2" size='4' />
<ScrollArea className="max-h-64 w-full">
<ul>
{memberIDs.map((member) => <li key={member.user}>
<Text as='span' size='2' color='gray' className="h-full flex items-center">
{member.user}
</Text>
</li>)}
</ul>
</ScrollArea>
</HoverCard.Content>
</HoverCard.Root>
<Separator orientation="vertical" />
<HoverCard.Root>
<HoverCard.Trigger>
<Text as='span' size='2' className="text-gray-11 h-full flex items-center underline underline-offset-4 decoration-gray-8">
{admins.length} admins
</Text>
</HoverCard.Trigger>
<HoverCard.Content className="w-72">
<Text size='2' weight='medium'>Admins</Text>
<Separator className="my-2" size='4' />
<ScrollArea className="max-h-64 w-full">
<ul>
{admins.map((admin) => <li key={admin.user}>
<Text as='span' size='2' color='gray' className="h-full flex items-center">
{admin.user}
</Text>
</li>)}
</ul>
</ScrollArea>
</HoverCard.Content>
</HoverCard.Root>
</HStack>
}

export default WorkspaceMemberManagement
10 changes: 7 additions & 3 deletions frontend/src/pages/settings/Workspaces/WorkspaceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Badge, Button, Dialog, Table, Text } from '@radix-ui/themes'
import { useBoolean } from '@/hooks/useBoolean'
import { DIALOG_CONTENT_CLASS } from '@/utils/layout/dialog'
import AddWorkspaceForm from '@/components/feature/workspaces/AddWorkspaceForm'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { ChannelIcon } from '@/utils/layout/channelIcon'

const WorkspaceList = () => {
Expand Down Expand Up @@ -51,12 +51,16 @@ const MyWorkspacesTable = ({ workspaces }: { workspaces: WorkspaceFields[] }) =>
{workspaces?.map((workspace) => (
<Table.Row key={workspace.name}>
<Table.Cell maxWidth={"150px"}>
<HStack align='center'>
{workspace.is_admin ? <Link to={`${workspace.name}`} className='hover:underline underline-offset-4'>
<HStack align='center'>
<UserAvatar src={workspace.logo} alt={workspace.workspace_name} />
<Text weight='medium'>{workspace.workspace_name}</Text>
</HStack>
</HStack>
</Link> :
<HStack align='center'>
<UserAvatar src={workspace.logo} alt={workspace.workspace_name} />
<Text weight='medium'>{workspace.workspace_name}</Text>
</HStack>}
</Table.Cell>
<Table.Cell>

Expand Down
Loading

0 comments on commit 45d07d3

Please sign in to comment.