diff --git a/packages/components/modules/profiles/common/graphql/fragments/UserItem.ts b/packages/components/modules/profiles/common/graphql/fragments/UserItem.ts new file mode 100644 index 00000000..120935c5 --- /dev/null +++ b/packages/components/modules/profiles/common/graphql/fragments/UserItem.ts @@ -0,0 +1,10 @@ +import { graphql } from 'react-relay' + +export const UserItemFragment = graphql` + fragment UserItemFragment on User { + id + email + isActive + fullName + } +` diff --git a/packages/components/modules/profiles/common/graphql/fragments/UsersList.ts b/packages/components/modules/profiles/common/graphql/fragments/UsersList.ts new file mode 100644 index 00000000..6afeb4f2 --- /dev/null +++ b/packages/components/modules/profiles/common/graphql/fragments/UsersList.ts @@ -0,0 +1,32 @@ +import { graphql } from 'react-relay' + +export const UsersListFragment = graphql` + fragment UsersListFragment on Query + @refetchable(queryName: "usersListPaginationRefetchable") + @argumentDefinitions( + count: { type: "Int", defaultValue: 10 } + cursor: { type: "String" } + orderBy: { type: "String" } + q: { type: "String" } + ) { + users(q: $q, first: $count, after: $cursor, orderBy: $orderBy) + @connection(key: "UsersList_users") { + totalCount + edges { + cursor + node { + id + ...UserItemFragment @relay(mask: false) + profile { + id + ...ProfileItemFragment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` diff --git a/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleCreate.ts b/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleCreate.ts new file mode 100644 index 00000000..2b318374 --- /dev/null +++ b/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleCreate.ts @@ -0,0 +1,48 @@ +import { useNotification } from '@baseapp-frontend/utils' + +import { UseMutationConfig, graphql, useMutation } from 'react-relay' + +import { ProfileUserRoleCreateMutation } from '../../../../../__generated__/ProfileUserRoleCreateMutation.graphql' + +export const ProfileUserRoleCreateMutationQuery = graphql` + mutation ProfileUserRoleCreateMutation($input: ProfileUserRoleCreateInput!) { + profileUserRoleCreate(input: $input) { + profileUserRoles { + id + role + } + } + } +` + +export const useProfileUserRoleCreateMutation = (): [ + (config: UseMutationConfig) => void, + boolean, +] => { + const [commitMutation, isMutationInFlight] = useMutation( + ProfileUserRoleCreateMutationQuery, + ) + + const { sendToast } = useNotification() + const commit = (config: UseMutationConfig) => { + commitMutation({ + ...config, + onCompleted: (response, errors) => { + errors?.forEach((error) => { + sendToast(error.message, { type: 'error' }) + }) + config?.onCompleted?.(response, errors) + }, + onError: (error) => { + if (error.message.includes('duplicate key value violates unique constraint')) { + sendToast('You have already invited this user to this profile.', { type: 'error' }) + } else { + sendToast(error.message, { type: 'error' }) + } + config?.onError?.(error) + }, + }) + } + + return [commit, isMutationInFlight] +} diff --git a/packages/components/modules/profiles/common/graphql/mutations/RemoveMember.ts b/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleDelete.ts similarity index 53% rename from packages/components/modules/profiles/common/graphql/mutations/RemoveMember.ts rename to packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleDelete.ts index da0948fa..6c71179e 100644 --- a/packages/components/modules/profiles/common/graphql/mutations/RemoveMember.ts +++ b/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleDelete.ts @@ -2,26 +2,26 @@ import { useNotification } from '@baseapp-frontend/utils' import { Disposable, UseMutationConfig, graphql, useMutation } from 'react-relay' -import { RemoveMemberMutation } from '../../../../../__generated__/RemoveMemberMutation.graphql' +import { ProfileUserRoleDeleteMutation } from '../../../../../__generated__/ProfileUserRoleDeleteMutation.graphql' -export const ProfileRemoveMemberMutationQuery = graphql` - mutation RemoveMemberMutation($input: ProfileRemoveMemberInput!) { - profileRemoveMember(input: $input) { +export const ProfileUserRoleDeleteMutationQuery = graphql` + mutation ProfileUserRoleDeleteMutation($input: ProfileUserRoleDeleteInput!) { + profileUserRoleDelete(input: $input) { deletedId @deleteRecord } } ` export const useRemoveMemberMutation = (): [ - (config: UseMutationConfig) => Disposable, + (config: UseMutationConfig) => Disposable, boolean, ] => { const { sendToast } = useNotification() - const [commitMutation, isMutationInFlight] = useMutation( - ProfileRemoveMemberMutationQuery, + const [commitMutation, isMutationInFlight] = useMutation( + ProfileUserRoleDeleteMutationQuery, ) - const commit = (config: UseMutationConfig) => + const commit = (config: UseMutationConfig) => commitMutation({ ...config, onCompleted: (response, errors) => { diff --git a/packages/components/modules/profiles/common/graphql/mutations/ChangeUserRole.ts b/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleUpdate.ts similarity index 53% rename from packages/components/modules/profiles/common/graphql/mutations/ChangeUserRole.ts rename to packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleUpdate.ts index 264d3904..145391e9 100644 --- a/packages/components/modules/profiles/common/graphql/mutations/ChangeUserRole.ts +++ b/packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleUpdate.ts @@ -2,11 +2,11 @@ import { useNotification } from '@baseapp-frontend/utils' import { Disposable, UseMutationConfig, graphql, useMutation } from 'react-relay' -import { ChangeUserRoleMutation } from '../../../../../__generated__/ChangeUserRoleMutation.graphql' +import { ProfileUserRoleUpdateMutation } from '../../../../../__generated__/ProfileUserRoleUpdateMutation.graphql' -export const ChangeUserRoleMutationQuery = graphql` - mutation ChangeUserRoleMutation($input: RoleUpdateInput!) { - profileRoleUpdate(input: $input) { +export const ProfileUserRoleUpdateMutationQuery = graphql` + mutation ProfileUserRoleUpdateMutation($input: ProfileUserRoleUpdateInput!) { + profileUserRoleUpdate(input: $input) { profileUserRole { id role @@ -19,16 +19,16 @@ export const ChangeUserRoleMutationQuery = graphql` } ` -export const useChangeUserRoleMutation = (): [ - (config: UseMutationConfig) => Disposable, +export const useProfileUserRoleUpdateMutation = (): [ + (config: UseMutationConfig) => Disposable, boolean, ] => { const { sendToast } = useNotification() - const [commitMutation, isMutationInFlight] = useMutation( - ChangeUserRoleMutationQuery, + const [commitMutation, isMutationInFlight] = useMutation( + ProfileUserRoleUpdateMutationQuery, ) - const commit = (config: UseMutationConfig) => + const commit = (config: UseMutationConfig) => commitMutation({ ...config, onCompleted: (response, errors) => { diff --git a/packages/components/modules/profiles/common/graphql/queries/UsersList.ts b/packages/components/modules/profiles/common/graphql/queries/UsersList.ts new file mode 100644 index 00000000..dd6f04cd --- /dev/null +++ b/packages/components/modules/profiles/common/graphql/queries/UsersList.ts @@ -0,0 +1,7 @@ +import { graphql } from 'react-relay' + +export const UsersListPaginationQuery = graphql` + query UsersListPaginationQuery($count: Int = 10, $cursor: String, $orderBy: String, $q: String) { + ...UsersListFragment @arguments(count: $count, cursor: $cursor, orderBy: $orderBy, q: $q) + } +` diff --git a/packages/components/modules/profiles/common/index.ts b/packages/components/modules/profiles/common/index.ts index 60165f73..3852da73 100644 --- a/packages/components/modules/profiles/common/index.ts +++ b/packages/components/modules/profiles/common/index.ts @@ -7,18 +7,25 @@ export * from './graphql/fragments/MemberItem' export * from './graphql/fragments/ProfileComponent' export * from './graphql/fragments/ProfileItem' export * from './graphql/fragments/ProfilesList' +export * from './graphql/fragments/UserItem' export * from './graphql/fragments/UserMembersList' +export * from './graphql/fragments/UsersList' export * from './graphql/mutations/BlockToggle' -export * from './graphql/mutations/ChangeUserRole' export * from './graphql/mutations/FollowToggle' export * from './graphql/mutations/OrganizationCreate' export * from './graphql/mutations/ProfileUpdate' +export * from './graphql/mutations/ProfileUserRoleUpdate' +export * from './graphql/mutations/ProfileUserRoleCreate' +export * from './graphql/mutations/ProfileUserRoleDelete' +export * from './graphql/mutations/ReportCreate' export * from './graphql/queries/AddProfilePopover' +export * from './graphql/queries/ProfileSettingsRelayTest' export * from './graphql/queries/ProfilesList' export * from './graphql/queries/UserMembersList' export * from './graphql/queries/UserProfile' +export * from './graphql/queries/UsersList' export * from './constants' export type * from './types' diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx index 72596ac8..e48ee981 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx @@ -1,19 +1,19 @@ import { FC, useState } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' -import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' import { ConfirmDialog } from '@baseapp-frontend/design-system/components/web/dialogs' import { Box, Button, MenuItem, SelectChangeEvent, Typography, useTheme } from '@mui/material' import { useFragment } from 'react-relay' -import { ProfileRoles } from '../../../../../__generated__/ChangeUserRoleMutation.graphql' import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileItemFragment.graphql' -import { ProfileItemFragment, useChangeUserRoleMutation } from '../../../common' -import { useRemoveMemberMutation } from '../../../common/graphql/mutations/RemoveMember' +import { ProfileRoles } from '../../../../../__generated__/ProfileUserRoleUpdateMutation.graphql' +import { ProfileItemFragment, useProfileUserRoleUpdateMutation } from '../../../common' +import { useRemoveMemberMutation } from '../../../common/graphql/mutations/ProfileUserRoleDelete' +import MemberPersonalInfo from '../MemberPersonalInfo' import { MEMBER_ACTIONS, MEMBER_ROLES, MEMBER_STATUSES, roleOptions } from '../constants' import { capitalizeFirstLetter } from '../utils' -import { MemberItemContainer, MemberPersonalInformation, Select } from './styled' +import { MemberItemContainer, Select } from './styled' import { MemberItemProps } from './types' const MemberItem: FC = ({ @@ -33,7 +33,7 @@ const MemberItem: FC = ({ const { currentProfile } = useCurrentProfile() - const [changeUserRole, isChangingUserRole] = useChangeUserRoleMutation() + const [changeUserRole, isChangingUserRole] = useProfileUserRoleUpdateMutation() const [removeMember, isRemovingMember] = useRemoveMemberMutation() const [openConfirmChangeMember, setOpenConfirmChangeMember] = useState(false) const [openConfirmRemoveMember, setOpenConfirmRemoveMember] = useState(false) @@ -183,20 +183,13 @@ const MemberItem: FC = ({ } /> - - - - {memberProfile.name} - {memberProfile?.urlPath?.path} - - + {renderRoleButton()} ) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/styled.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/styled.tsx index 15ef3e6d..dbfb5d77 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/styled.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/styled.tsx @@ -1,7 +1,5 @@ import { Box, Select as MUISelect, alpha, styled } from '@mui/material' -import { MemberPersonalInformationProps } from './types' - export const MemberItemContainer = styled(Box)(({ theme }) => ({ display: 'flex', gap: theme.spacing(1.5), @@ -10,16 +8,6 @@ export const MemberItemContainer = styled(Box)(({ theme }) => ({ padding: theme.spacing(1.5, 0), })) -export const MemberPersonalInformation = styled(Box, { - shouldForwardProp: (prop) => prop !== 'isActive', -})(({ isActive, theme }) => ({ - opacity: isActive ? 1 : 0.6, - display: 'flex', - gap: theme.spacing(1.5), - alignItems: 'center', - justifyContent: 'space-between', -})) - export const Select = styled(MUISelect)(({ theme }) => ({ backgroundColor: alpha(theme.palette.grey[500], 0.08), borderRadius: 8, diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/types.ts index f4e2e3e7..330f0838 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/types.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/types.ts @@ -8,7 +8,7 @@ export interface MemberPersonalInformationProps extends BoxProps { } export interface MemberItemProps { - member: ProfileItemFragment$key | null | undefined + member: ProfileItemFragment$key | undefined memberRole: MemberItemFragment$data['role'] | 'owner' status: MemberItemFragment$data['status'] avatarProps?: AvatarProps diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberListItem/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberListItem/index.tsx index 63d91aae..5132ae0a 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberListItem/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberListItem/index.tsx @@ -42,7 +42,7 @@ const MemberListItem: FC = ({ {...memberItemComponentProps} /> = ({ return ( <> = ({ return ( = ({ + avatarProps = {}, + avatarWidth = 40, + avatarHeight = 40, + member, + status, + children, +}) => { + const memberProfile = useFragment(ProfileItemFragment, member) + return ( + + + {memberProfile?.image?.url ? '' : memberProfile?.name?.slice(0, 2)?.toUpperCase()} + + + {memberProfile?.name} + {memberProfile?.urlPath?.path} + + {children} + + ) +} + +export default MemberPersonalInfo diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx new file mode 100644 index 00000000..be9e9019 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -0,0 +1,244 @@ +import React, { FC, useMemo, useRef, useState, useTransition } from 'react' + +import { LoadingState } from '@baseapp-frontend/design-system/components/web/displays' +import { CloseIcon } from '@baseapp-frontend/design-system/components/web/icons' +import { AutocompleteField } from '@baseapp-frontend/design-system/components/web/inputs' +import { useNotification } from '@baseapp-frontend/utils' + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + IconButton, + TextField, + Typography, +} from '@mui/material' +import { useForm } from 'react-hook-form' +import { useLazyLoadQuery, usePaginationFragment } from 'react-relay' + +import { UsersListFragment$key } from '../../../../../../__generated__/UsersListFragment.graphql' +import { UsersListPaginationQuery as UsersListPaginationQueryType } from '../../../../../../__generated__/UsersListPaginationQuery.graphql' +import { UsersListFragment } from '../../../../common' +import { useProfileUserRoleCreateMutation } from '../../../../common/graphql/mutations/ProfileUserRoleCreate' +import { UsersListPaginationQuery } from '../../../../common/graphql/queries/UsersList' +import UserCard from '../UserCard' +import VirtuosoListbox from '../VirtuosoListbox' +import { AddMembersDialogHeader } from '../styled' +import { AddMembersDialogProps, NewEmail, User } from '../types' + +const AddMembersDialog: FC = ({ + isOpen, + onClose, + profileId, + refetchMembers, + LoadingStateProps, +}) => { + const [selectedUsers, setSelectedUsers] = useState([]) + const [selectedEmails, setSelectedEmails] = useState([]) + const autocompleteRef = useRef(null) + + const [commitMutation, isMutationInFlight] = useProfileUserRoleCreateMutation() + const { sendToast } = useNotification() + const [isPending, startTransition] = useTransition() + const { control, reset, watch } = useForm({ defaultValues: { search: '' } }) + const searchQuery = watch('search') + + const usersQueryData = useLazyLoadQuery(UsersListPaginationQuery, { + q: '', + }) + const { data, refetch, isLoadingNext, hasNext, loadNext } = usePaginationFragment< + UsersListPaginationQueryType, + UsersListFragment$key + >(UsersListFragment, usersQueryData) + + const handleSearch = (value: string) => { + startTransition(() => { + refetch({ q: value }) + }) + } + + const handleSearchClear = () => { + startTransition(() => { + reset() + handleSearch('') + }) + } + + const users = useMemo( + () => data?.users?.edges?.map((edge) => edge?.node) || [], + [data?.users?.edges], + ) + const filteredUsers = useMemo( + () => + users + .filter((user) => !selectedUsers.some((selectedUser) => selectedUser?.id === user?.id)) + .filter((user) => user?.profile?.id !== profileId), + [users, selectedUsers, profileId], + ) + const autocompleteOptions = useMemo(() => { + const baseOptions = filteredUsers + const inputValue = searchQuery?.trim() + if (!inputValue) { + return baseOptions + } + const filtered = baseOptions.filter( + (option) => + option?.fullName?.toLowerCase().includes(inputValue.toLowerCase()) || + option?.email?.toLowerCase().includes(inputValue.toLowerCase()), + ) + if (inputValue.includes('@') && filtered.length === 0) { + return [{ email: inputValue, isNewEmail: true }] + } + if (filtered.length === 0) { + return [{ empty: true }] + } + return filtered + }, [filteredUsers]) + + const isEmailAlreadySelected = (currentEmail: NewEmail) => + selectedEmails.some((selectedEmail) => selectedEmail?.email === currentEmail?.email) + + const onSelectUser = (event: any, newValue: User | NewEmail) => { + handleSearchClear() + if ('isNewEmail' in newValue) { + if (isEmailAlreadySelected(newValue)) { + sendToast('Email already added', { type: 'warning' }) + return + } + setSelectedEmails([...selectedEmails, newValue]) + } else { + setSelectedUsers([...selectedUsers, newValue as User]) + } + } + + const handleInvite = () => { + const usersIds = selectedUsers.map((user: User) => user?.id) + const emailsToInvite = selectedEmails.map((email: NewEmail) => email?.email) + commitMutation({ + variables: { input: { profileId: profileId ?? '', usersIds, emailsToInvite } }, + onCompleted: (response, errors) => { + if (!errors) { + sendToast('Members invited successfully', { type: 'success' }) + refetchMembers?.({ q: '' }) + setSelectedUsers([]) + handleSearchClear() + onClose() + } + }, + }) + } + + const handleClose = () => { + onClose() + setSelectedUsers([]) + setSelectedEmails([]) + } + + const renderLoadingState = () => { + if (!isLoadingNext) return null + + return ( + + ) + } + + const handleItemSelection = (option: User | NewEmail) => { + onSelectUser(null, option) + if (autocompleteRef.current) { + const inputElement = autocompleteRef.current.querySelector('input') + if (inputElement) { + inputElement.blur() + } + } + } + + const CustomVirtuosoListbox = (props: any) => + VirtuosoListbox( + props, + autocompleteOptions as (User | NewEmail)[], + handleItemSelection, + renderLoadingState, + hasNext, + isLoadingNext, + loadNext, + ) + + if (!isOpen) return null + + return ( + + + Add Members + + + + + + + Add users to your organization or send an invitation email. + + { + if (event?.type === 'change') { + handleSearch(newInputValue) + } + }} + renderInput={(params: any) => ( + + )} + filterOptions={(options: any) => options} + /> + + {selectedUsers.map((user: User) => ( + setSelectedUsers(selectedUsers.filter((u) => u?.id !== user?.id))} + /> + ))} + {selectedEmails.map((email: NewEmail) => ( + + setSelectedEmails(selectedEmails.filter((e) => e?.email !== email?.email)) + } + /> + ))} + + + + + + + + ) +} + +export default AddMembersDialog diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx new file mode 100644 index 00000000..a00d7184 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx @@ -0,0 +1,52 @@ +import { FC } from 'react' + +import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' +import { CloseIcon } from '@baseapp-frontend/design-system/components/web/icons' + +import { IconButton, Typography } from '@mui/material' + +import { ProfileItemFragment$key } from '../../../../../../__generated__/ProfileItemFragment.graphql' +import MemberPersonalInfo from '../../MemberPersonalInfo' +import { MEMBER_STATUSES } from '../../constants' +import { MemberCardContainer, MemberPersonalInformation } from '../../styled' +import { UserCardProps } from '../types' + +const UserCard: FC = ({ user, onRemove, avatarProps = {} }) => { + if (!user) return null + if ('isActive' in user) + return ( + + + + + + + ) + + const initials = (user as { email: string })?.email?.split('@')[0]?.slice(0, 2) + return ( + + + + {initials?.toUpperCase()} + + {(user as { email: string })?.email} + + + + + + ) +} + +export default UserCard diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx new file mode 100644 index 00000000..f1aab0dc --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx @@ -0,0 +1,115 @@ +import React, { useRef } from 'react' + +import { AddIcon } from '@baseapp-frontend/design-system/components/web/icons' + +import { IconButton, Typography } from '@mui/material' +import { Virtuoso } from 'react-virtuoso' + +import MemberPersonalInfo from '../../MemberPersonalInfo' +import { MEMBER_STATUSES } from '../../constants' +import { EmailListItemContainer, UserListItemContainer } from '../styled' +import { NewEmail, User, VirtuosoListboxProps } from '../types' + +// It needs to : +// 1. Know that it's the first render, so it doesn't scroll to the last item +// 2. Record current options length, so it doesn't infinitely load more items +// 3. Scroll to the last item of previous options when the options change + +const VirtuosoListbox: VirtuosoListboxProps = ( + props, + autocompleteOptions, + handleItemSelection, + renderLoadingState, + hasNext, + isLoadingNext, + loadNext, +) => { + const virtuosoRef = useRef(null) + const { children, ...other } = props + let options = React.Children.toArray(children) + .filter((child: any) => child && typeof child === 'object' && child.props) + .map((child: any) => child.props.value) + .filter(Boolean) + if (options.length === 0) { + options = autocompleteOptions + } + + const height = options.length * 56 > 300 ? 300 : options.length * 56 + + // useEffect(() => { + // console.log('useEffect') + // console.log('isLoadingNext', isLoadingNext) + // if (isLoadingNext) return + // console.log('virtuosoRef.current', virtuosoRef.current) + // if (virtuosoRef.current && options.length > 0) { + // console.log('Attempting to scroll to index:', options.length - 1) + // setTimeout(() => { + // virtuosoRef.current.scrollToIndex({ + // index: options.length - 1, + // align: 'end', + // behavior: 'instant', + // }) + // }, 100) + // } + // }, [isLoadingNext, options.length]) + + const renderItem = (index: number, option: User | NewEmail) => { + const isNewEmail = 'isNewEmail' in option + const isEmpty = 'empty' in option + if (isEmpty) { + return ( + + + No users found + + + ) + } + return ( + { + handleItemSelection(option) + }} + > + {isNewEmail ? ( + + + {option.email} + + + + + + ) : ( + + )} + + ) + } + + return ( +
+ renderItem(index, option)} + components={{ + Footer: renderLoadingState, + }} + endReached={() => { + if (hasNext && !isLoadingNext) { + loadNext(10) + } + }} + useWindowScroll={false} + /> +
+ ) +} + +export default VirtuosoListbox diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx index f7999c23..c5378699 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx @@ -1,9 +1,9 @@ -import { FC, useMemo, useTransition } from 'react' +import { FC, useMemo, useState, useTransition } from 'react' import { LoadingState as DefaultLoadingState } from '@baseapp-frontend/design-system/components/web/displays' import { Searchbar } from '@baseapp-frontend/design-system/components/web/inputs' -import { Box, Typography } from '@mui/material' +import { Box, Button, Typography } from '@mui/material' import { useForm } from 'react-hook-form' import { useFragment, usePaginationFragment } from 'react-relay' import { Virtuoso } from 'react-virtuoso' @@ -14,6 +14,7 @@ import { ProfileItemFragment, UserMembersListFragment } from '../../../common' import DefaultMemberItem from '../MemberItem' import MemberListItem from '../MemberListItem' import { MEMBER_STATUSES, NUMBER_OF_MEMBERS_TO_LOAD_NEXT } from '../constants' +import AddMembersDialog from './AddMembersDialog' import { MembersListProps } from './types' const MembersList: FC = ({ @@ -24,6 +25,8 @@ const MembersList: FC = ({ LoadingStateProps = {}, membersContainerHeight = 400, }) => { + const [isAddMembersModalOpen, setIsAddMembersModalOpen] = useState(false) + const [isPending, startTransition] = useTransition() const { control, reset, watch } = useForm({ defaultValues: { search: '' } }) const { data, loadNext, hasNext, isLoadingNext, refetch } = usePaginationFragment( @@ -78,32 +81,6 @@ const MembersList: FC = ({ /> ) - if (members.length === 0) { - return ( - <> - handleSearch(e.target.value)} - onClear={() => handleSearchClear()} - name="search" - control={control} - sx={{ mb: 4 }} - /> - - {resultsCount === 1 ? `${resultsCount} member` : `${resultsCount} members`} - - - - ) - } - return ( <> = ({ control={control} sx={{ mb: 4 }} /> - - {resultsCount === 1 ? `${resultsCount} member` : `${resultsCount} members`} - - member && renderMemberItem(member, _index)} - components={{ - Footer: renderLoadingState, - }} - endReached={() => { - if (hasNext) { - loadNext(NUMBER_OF_MEMBERS_TO_LOAD_NEXT) - } - }} + + + {resultsCount === 1 ? `${resultsCount} member` : `${resultsCount} members`} + + + + {resultsCount === 1 && isOwnerVisible ? ( + + ) : ( + member && renderMemberItem(member, _index)} + components={{ + Footer: renderLoadingState, + }} + endReached={() => { + if (hasNext) { + loadNext(NUMBER_OF_MEMBERS_TO_LOAD_NEXT) + } + }} + /> + )} + setIsAddMembersModalOpen(false)} + profileId={ownerProfile?.id} + refetchMembers={refetch} + LoadingStateProps={LoadingStateProps} /> ) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/styled.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/styled.tsx new file mode 100644 index 00000000..81667540 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/styled.tsx @@ -0,0 +1,31 @@ +import { Box, ListItem, styled } from '@mui/material' + +import { UserListItemContainerProps } from './types' + +export const UserListItemContainer = styled(ListItem)( + ({ theme, isEmpty = false }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1), + cursor: isEmpty ? 'default' : 'pointer', + '&:hover': { + backgroundColor: isEmpty ? 'transparent' : theme.palette.grey[300], + }, + }), +) + +export const EmailListItemContainer = styled(Box)(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + width: '100%', +})) + +export const AddMembersDialogHeader = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(3), +})) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts index 6443e722..c6734688 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts @@ -2,9 +2,18 @@ import { FC } from 'react' import { LoadingStateProps } from '@baseapp-frontend/design-system/components/web/displays' +import { AvatarProps, ListItemProps } from '@mui/material' +import { RefetchFnDynamic } from 'react-relay' + import { UserMembersListFragment$key } from '../../../../../__generated__/UserMembersListFragment.graphql' +import { UsersListFragment$data } from '../../../../../__generated__/UsersListFragment.graphql' import { MemberItemProps } from '../MemberItem/types' +export type User = NonNullable< + NonNullable['edges'][number]>['node'] +> +export type NewEmail = { email: string; isNewEmail: boolean } + export interface MembersListProps { MemberItem?: FC MemberItemProps?: Partial @@ -13,3 +22,31 @@ export interface MembersListProps { LoadingStateProps?: LoadingStateProps membersContainerHeight?: number } + +export interface AddMembersDialogProps { + isOpen: boolean + onClose: () => void + profileId?: string + refetchMembers: RefetchFnDynamic + LoadingStateProps?: LoadingStateProps +} + +export interface UserCardProps { + user: User | NewEmail + onRemove: () => void + avatarProps?: AvatarProps +} + +export interface UserListItemContainerProps extends ListItemProps { + isEmpty?: boolean +} + +export type VirtuosoListboxProps = ( + props: any, + autocompleteOptions: (User | NewEmail)[], + handleItemSelection: (option: User | NewEmail) => void, + renderLoadingState: () => React.ReactNode, + hasNext: boolean, + isLoadingNext: boolean, + loadNext: (count: number) => void, +) => React.ReactElement diff --git a/packages/components/modules/profiles/web/ProfileMembers/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/index.tsx index e64f54bf..c91be99e 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/index.tsx @@ -1,7 +1,6 @@ -import { FC, Suspense } from 'react' +import { FC } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' -import { LoadingState as DefaultLoadingState } from '@baseapp-frontend/design-system/components/web/displays' import { Typography } from '@mui/material' import { useLazyLoadQuery } from 'react-relay' @@ -10,9 +9,13 @@ import { UserMembersListPaginationQuery as UserMembersListPaginationQueryType } import { UserMembersListPaginationQuery } from '../../common' import MembersList from './MembersList' import { NUMBER_OF_MEMBERS_ON_FIRST_LOAD } from './constants' -import type { ProfileMembersProps, ProfileMembersSuspendedProps } from './types' +import type { ProfileMembersProps } from './types' -const ProfileMembers: FC = ({ MembersListProps = {} }) => { +const ProfileMembers: FC = ({ + title = 'Members', + subtitle, + MembersListProps = {}, +}) => { const { currentProfile } = useCurrentProfile() const data = useLazyLoadQuery( @@ -25,26 +28,18 @@ const ProfileMembers: FC = ({ MembersListProps = {} }) => { ) if (!data.profile) return null - return + + return ( + <> + + {title} + + + {subtitle} + + + + ) } -const ProfileMembersSuspended: FC = ({ - title = 'Members', - subtitle, - InitialLoadingState = DefaultLoadingState, - ...props -}) => ( - <> - - {title} - - - {subtitle} - - }> - - - -) - -export default ProfileMembersSuspended +export default ProfileMembers diff --git a/packages/components/modules/profiles/web/ProfileMembers/styled.tsx b/packages/components/modules/profiles/web/ProfileMembers/styled.tsx index eddb6b63..9ca7db85 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/styled.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/styled.tsx @@ -1,7 +1,41 @@ -import { Skeleton, styled } from '@mui/material' +import { Box, Skeleton, styled } from '@mui/material' + +import { MemberPersonalInfoProps } from './types' export const MemberItemSkeleton = styled(Skeleton)(({ theme }) => ({ width: '100%', height: 52, borderRadius: theme.spacing(0.75), })) + +export const MemberPersonalInformation = styled(Box, { + shouldForwardProp: (prop) => prop !== 'isActive', +})(({ isActive, theme }) => ({ + opacity: isActive ? 1 : 0.6, + display: 'flex', + gap: theme.spacing(1.5), + alignItems: 'center', + justifyContent: 'space-between', +})) + +export const MemberCardContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1), + cursor: 'pointer', + backgroundColor: theme.palette.grey[300], + borderRadius: '50px', + width: 'fit-content', +})) + +export const UserListItemContainer = styled('li')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1), + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.palette.grey[300], + }, +})) diff --git a/packages/components/modules/profiles/web/ProfileMembers/types.ts b/packages/components/modules/profiles/web/ProfileMembers/types.ts index 196649a1..9ac811f9 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/types.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/types.ts @@ -1,13 +1,21 @@ -import type { FC } from 'react' +import { AvatarProps } from '@mui/material' +import { ProfileRoleStatus } from '../../../../__generated__/MemberItemFragment.graphql' +import { ProfileItemFragment$key } from '../../../../__generated__/ProfileItemFragment.graphql' import { MembersListProps } from './MembersList/types' -export interface ProfileMembersSuspendedProps { +export interface ProfileMembersProps { MembersListProps?: Partial title?: string subtitle?: string - InitialLoadingState?: FC } -export interface ProfileMembersProps - extends Omit {} +export interface MemberPersonalInfoProps { + avatarProps?: AvatarProps + avatarWidth?: number + avatarHeight?: number + member?: ProfileItemFragment$key + status?: ProfileRoleStatus | null + children?: React.ReactNode + isActive?: boolean +} diff --git a/packages/components/schema.graphql b/packages/components/schema.graphql index 538ae11c..3a50e453 100644 --- a/packages/components/schema.graphql +++ b/packages/components/schema.graphql @@ -829,8 +829,9 @@ type Mutation { profileCreate(input: ProfileCreateInput!): ProfileCreatePayload profileUpdate(input: ProfileUpdateInput!): ProfileUpdatePayload profileDelete(input: ProfileDeleteInput!): ProfileDeletePayload - profileRoleUpdate(input: RoleUpdateInput!): RoleUpdatePayload - profileRemoveMember(input: ProfileRemoveMemberInput!): ProfileRemoveMemberPayload + profileUserRoleCreate(input: ProfileUserRoleCreateInput!): ProfileUserRoleCreatePayload + profileUserRoleUpdate(input: ProfileUserRoleUpdateInput!): ProfileUserRoleUpdatePayload + profileUserRoleDelete(input: ProfileUserRoleDeleteInput!): ProfileUserRoleDeletePayload } """An object with an ID""" @@ -1377,20 +1378,6 @@ interface ProfileInterface { profile: Profile } -input ProfileRemoveMemberInput { - profileId: ID! - userId: ID! - clientMutationId: String -} - -type ProfileRemoveMemberPayload { - """May contain more than one error for same field.""" - errors: [ErrorType] - _debug: DjangoDebug - deletedId: ID - clientMutationId: String -} - """An enumeration.""" enum ProfileRoles { """admin""" @@ -1476,6 +1463,37 @@ type ProfileUserRoleConnection { edgeCount: Int } +input ProfileUserRoleCreateInput { + profileId: ID! + usersIds: [ID]! + roleType: ProfileRoles = null + emailsToInvite: [String] + exclude: [String] + clientMutationId: String +} + +type ProfileUserRoleCreatePayload { + """May contain more than one error for same field.""" + errors: [ErrorType] + _debug: DjangoDebug + profileUserRoles: [ProfileUserRole] + clientMutationId: String +} + +input ProfileUserRoleDeleteInput { + profileId: ID! + userId: ID! + clientMutationId: String +} + +type ProfileUserRoleDeletePayload { + """May contain more than one error for same field.""" + errors: [ErrorType] + _debug: DjangoDebug + deletedId: ID + clientMutationId: String +} + """A Relay edge containing a `ProfileUserRole` and its cursor.""" type ProfileUserRoleEdge { """The item at the end of the edge""" @@ -1485,6 +1503,22 @@ type ProfileUserRoleEdge { cursor: String! } +input ProfileUserRoleUpdateInput { + profileId: ID! + userId: ID! + roleType: ProfileRoles = null + clientMutationId: String + exclude: [String] +} + +type ProfileUserRoleUpdatePayload { + """May contain more than one error for same field.""" + errors: [ErrorType] + _debug: DjangoDebug + profileUserRole: ProfileUserRole + clientMutationId: String +} + type Query { activityLogs(visibility: VisibilityTypes, first: Int = 10, offset: Int, before: String, after: String, last: Int, createdFrom: Date, createdTo: Date, userPk: Decimal, profilePk: Decimal, userName: String): ActivityLogConnection organization( @@ -1794,21 +1828,6 @@ type ReportTypeEdge { cursor: String! } -input RoleUpdateInput { - profileId: ID! - userId: ID! - roleType: ProfileRoles = null - clientMutationId: String -} - -type RoleUpdatePayload { - """May contain more than one error for same field.""" - errors: [ErrorType] - _debug: DjangoDebug - profileUserRole: ProfileUserRole - clientMutationId: String -} - type Subscription { chatRoomOnMessage(profileId: ID!, roomId: ID!): ChatRoomOnMessage chatRoomOnRoomUpdate(profileId: ID!): ChatRoomOnRoomUpdate @@ -1963,4 +1982,3 @@ enum VisibilityTypes { """internal""" INTERNAL } - diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx b/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx new file mode 100644 index 00000000..fe2fe222 --- /dev/null +++ b/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx @@ -0,0 +1,67 @@ +import { Meta } from '@storybook/addon-docs' + + + +# Component Documentation + +## AutocompleteField + +- **Purpose**: An autocomplete input component that provides suggestions as the user types. +- **Expected Behavior**: Renders a text input field that shows a dropdown list of options based on user input, with support for single/multiple selection and free text entry. + +## Use Cases + +- **Current Usage**: + - Search with suggestions + - Form field with predefined options + - Tag selection + - User selection with search + +## Props + +- **options** (Array): Array of options to display in the dropdown +- **isPending** (boolean): Shows loading state when fetching options +- **onClear** (function): Callback fired when the clear button is clicked +- **multiple** (boolean): Allow multiple selection +- **freeSolo** (boolean): Allow free text input not limited to options +- **renderInput** (function): Custom render function for the input field +- **...AutocompleteProps**: All other props are passed to the underlying MUI Autocomplete component + +## Notes + +- **Related Components**: + - TextField: Used as the base input component via renderInput + - withController: HOC that provides form integration with debouncing + - MUI Autocomplete: The underlying autocomplete component + +## Example Usage + +```javascript +import { AutocompleteField } from '@baseapp-frontend/design-system/web' +import { TextField } from '@mui/material' + +const MyComponent = () => { + const [value, setValue] = useState(null) + const options = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + ] + + return ( + setValue(newValue)} + renderInput={(params) => ( + + )} + /> + ) +} +export default MyComponent +``` \ No newline at end of file diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/stories.tsx b/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/stories.tsx new file mode 100644 index 00000000..a381e9fa --- /dev/null +++ b/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/stories.tsx @@ -0,0 +1,64 @@ +import { TextField } from '@mui/material' +import { Meta, StoryObj } from '@storybook/react' + +import AutocompleteField from '..' +import { AutocompleteFieldProps } from '../types' + +const meta: Meta = { + title: '@baseapp-frontend | designSystem/Inputs/AutoCompleteField', + component: AutocompleteField, +} + +export default meta + +type Story = StoryObj + +const sampleOptions = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Date', value: 'date' }, + { label: 'Elderberry', value: 'elderberry' }, +] + +export const Default: Story = { + args: { + options: sampleOptions, + isPending: false, + renderInput: (params: any) => ( + + ), + }, +} + +export const WithLoading: Story = { + args: { + options: sampleOptions, + isPending: true, + renderInput: (params: any) => ( + + ), + }, +} + +export const Disabled: Story = { + args: { + options: sampleOptions, + disabled: true, + isPending: false, + renderInput: (params: any) => ( + + ), + }, +} + +export const FreeSolo: Story = { + args: { + options: sampleOptions, + freeSolo: true, + isPending: false, + renderInput: (params: any) => ( + + ), + }, +} diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx b/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx new file mode 100644 index 00000000..4ebeaa09 --- /dev/null +++ b/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react' + +import { withController } from '@baseapp-frontend/utils' + +import { Autocomplete } from '@mui/material' + +import { AutocompleteFieldProps } from './types' + +const AutocompleteField: FC = ({ ...props }) => ( + +) + +export default withController(AutocompleteField, { shouldDebounce: true }) diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/types.ts b/packages/design-system/components/web/inputs/AutoCompleteField/types.ts new file mode 100644 index 00000000..10d26f66 --- /dev/null +++ b/packages/design-system/components/web/inputs/AutoCompleteField/types.ts @@ -0,0 +1,6 @@ +import { AutocompleteProps } from '@mui/material' + +export type AutocompleteFieldProps = AutocompleteProps & { + isPending: boolean + onClear?: () => void +} diff --git a/packages/design-system/components/web/inputs/index.ts b/packages/design-system/components/web/inputs/index.ts index d7cfc9d7..aa9162c8 100644 --- a/packages/design-system/components/web/inputs/index.ts +++ b/packages/design-system/components/web/inputs/index.ts @@ -1,5 +1,7 @@ 'use client' +export { default as AutocompleteField } from './AutocompleteField' +export type * from './AutocompleteField/types' export { default as PhoneNumberField } from './PhoneNumberField' export type * from './PhoneNumberField/types' export { default as Searchbar } from './Searchbar' diff --git a/packages/utils/functions/form/withController/index.tsx b/packages/utils/functions/form/withController/index.tsx index 18a03372..fbaa84f5 100644 --- a/packages/utils/functions/form/withController/index.tsx +++ b/packages/utils/functions/form/withController/index.tsx @@ -5,19 +5,28 @@ import { ChangeEventHandler, FC, FocusEventHandler } from 'react' import { Controller } from 'react-hook-form' import useDebounce from '../../../hooks/useDebounce' -import type { DebouncedFunction, WithControllerProps } from './types' +import type { WithControllerProps } from './types' function withController(Component: FC, { shouldDebounce = false, debounceTime = 500 } = {}) { return ({ name, control, helperText, ...props }: WithControllerProps) => { if (control) { - const { onChange, onBlur, ...restOfTheProps } = props + const { onChange, onBlur, onInputChange, ...restOfTheProps } = props const onChangeWithFallback = onChange ?? (() => {}) - const { debouncedFunction: debouncedOnChange } = useDebounce( - onChangeWithFallback, - { - debounceTime, - }, - ) + const onInputChangeWithFallback = onInputChange ?? (() => {}) + const onChangeWrapper = (params: any) => { + const [event] = params + onChangeWithFallback(event) + } + const { debouncedFunction: debouncedOnChange } = useDebounce(onChangeWrapper, { + debounceTime, + }) + const onInputChangeWrapper = (params: any) => { + const [event, newInputValue] = params + onInputChangeWithFallback(event, newInputValue) + } + const { debouncedFunction: debouncedOnInputChange } = useDebounce(onInputChangeWrapper, { + debounceTime, + }) return ( (Component: FC, { shouldDebounce = false, debounceT ) => { field.onChange(event) if (onChange && shouldDebounce) { - debouncedOnChange(event) + debouncedOnChange([event]) } else { onChange?.(event) } } + const handleOnInputChange = (event: any, newInputValue: any) => { + field.onChange({ target: { value: newInputValue } }) + if (onInputChange && shouldDebounce) { + debouncedOnInputChange([event, newInputValue]) + } else { + onInputChange?.(event, newInputValue) + } + } const handleOnBlur: FocusEventHandler = ( event, ) => { @@ -47,6 +64,7 @@ function withController(Component: FC, { shouldDebounce = false, debounceT error={!!fieldState.error} name={name} onChange={handleOnChange} + onInputChange={handleOnInputChange} onBlur={handleOnBlur} helperText={helperText || (!!fieldState.error && fieldState.error?.message)} {...(restOfTheProps as any)} diff --git a/packages/utils/functions/form/withController/types.ts b/packages/utils/functions/form/withController/types.ts index befe0b25..034b88ba 100644 --- a/packages/utils/functions/form/withController/types.ts +++ b/packages/utils/functions/form/withController/types.ts @@ -7,6 +7,7 @@ import { FormControl } from '../../../types/form' type OptionalActions = { onChange?: (value: any) => void | ChangeEventHandler onBlur?: (value?: any) => void | FocusEventHandler + onInputChange?: (event: any, newInputValue: any) => void } export type DebouncedFunction = NonUndefined