From 395cc6603ebaafbdbcfefd8c6457cedf0ebc247f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Wed, 10 Sep 2025 15:38:06 -0300 Subject: [PATCH 1/8] feature/BA-2737-profiles-multiples-members --- .../common/graphql/fragments/UserItem.ts | 10 + .../common/graphql/fragments/UsersList.ts | 32 ++ .../mutations/ProfileUserRoleCreate.ts | 48 +++ ...moveMember.ts => ProfileUserRoleDelete.ts} | 16 +- ...geUserRole.ts => ProfileUserRoleUpdate.ts} | 18 +- .../common/graphql/queries/UsersList.ts | 7 + .../modules/profiles/common/index.ts | 9 +- .../web/ProfileMembers/MemberItem/index.tsx | 33 +-- .../web/ProfileMembers/MemberItem/styled.tsx | 12 - .../web/ProfileMembers/MemberItem/types.ts | 2 +- .../ProfileMembers/MemberListItem/index.tsx | 6 +- .../components/AddMembersDialog.tsx | 275 ++++++++++++++++++ .../MembersList/components/UserCard.tsx | 52 ++++ .../components/VirtuosoListBox.tsx | 42 +++ .../MembersList/components/types.ts | 26 ++ .../web/ProfileMembers/MembersList/index.tsx | 90 +++--- .../components/MemberPersonalInfo.tsx | 44 +++ .../profiles/web/ProfileMembers/index.tsx | 45 ++- .../profiles/web/ProfileMembers/styled.tsx | 36 ++- .../profiles/web/ProfileMembers/types.ts | 18 +- packages/components/schema.graphql | 82 ++++-- .../__storybook__/AutoCompleteField.mdx | 67 +++++ .../__storybook__/stories.tsx | 64 ++++ .../web/inputs/AutoCompleteField/index.tsx | 13 + .../web/inputs/AutoCompleteField/types.ts | 6 + .../components/web/inputs/index.ts | 2 + .../functions/form/withController/index.tsx | 36 ++- .../functions/form/withController/types.ts | 1 + 28 files changed, 923 insertions(+), 169 deletions(-) create mode 100644 packages/components/modules/profiles/common/graphql/fragments/UserItem.ts create mode 100644 packages/components/modules/profiles/common/graphql/fragments/UsersList.ts create mode 100644 packages/components/modules/profiles/common/graphql/mutations/ProfileUserRoleCreate.ts rename packages/components/modules/profiles/common/graphql/mutations/{RemoveMember.ts => ProfileUserRoleDelete.ts} (53%) rename packages/components/modules/profiles/common/graphql/mutations/{ChangeUserRole.ts => ProfileUserRoleUpdate.ts} (53%) create mode 100644 packages/components/modules/profiles/common/graphql/queries/UsersList.ts create mode 100644 packages/components/modules/profiles/web/ProfileMembers/MembersList/components/AddMembersDialog.tsx create mode 100644 packages/components/modules/profiles/web/ProfileMembers/MembersList/components/UserCard.tsx create mode 100644 packages/components/modules/profiles/web/ProfileMembers/MembersList/components/VirtuosoListBox.tsx create mode 100644 packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts create mode 100644 packages/components/modules/profiles/web/ProfileMembers/components/MemberPersonalInfo.tsx create mode 100644 packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx create mode 100644 packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/stories.tsx create mode 100644 packages/design-system/components/web/inputs/AutoCompleteField/index.tsx create mode 100644 packages/design-system/components/web/inputs/AutoCompleteField/types.ts 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..d7bb60ec 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 '../components/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 ( = ({ + 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 }] + } + return filtered + }, [filteredUsers, searchQuery]) + + 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 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 renderItem = (index: number, option: User | NewEmail) => { + const isNewEmail = 'isNewEmail' in option + return ( + { + handleItemSelection(option) + }} + > + {isNewEmail ? ( + + + + {option.email} + + + + + + + ) : ( + + )} + + ) + } + + const CustomVirtuosoListBox = (props: any) => + VirtuosoListBox( + props, + autocompleteOptions, + renderItem, + renderLoadingState, + hasNext, + isLoadingNext, + loadNext, + ) + + if (!isOpen) return null + + return ( + + + Add Members 1 + + + + + + + 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/components/UserCard.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/UserCard.tsx new file mode 100644 index 00000000..7148e2a2 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/UserCard.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 '../../components/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/components/VirtuosoListBox.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/VirtuosoListBox.tsx new file mode 100644 index 00000000..5253aa97 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/VirtuosoListBox.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +import { Virtuoso } from 'react-virtuoso' + +const VirtuosoListBox = ( + props: any, + autocompleteOptions: any[], + renderItem: (index: number, option: any) => React.ReactNode, + renderLoadingState: () => React.ReactNode, + hasNext: boolean, + isLoadingNext: boolean, + loadNext: (count: number) => void, +) => { + 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) // Remove any undefined values + if (options.length === 0) { + options = autocompleteOptions + } + const height = options.length * 56 > 300 ? 300 : options.length * 56 + return ( +
+ renderItem(index, option)} + components={{ + Footer: renderLoadingState, + }} + endReached={() => { + if (hasNext && !isLoadingNext) { + loadNext(10) + } + }} + /> +
+ ) +} + +export default VirtuosoListBox diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts new file mode 100644 index 00000000..ae23c9fa --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts @@ -0,0 +1,26 @@ +import { LoadingStateProps } from '@baseapp-frontend/design-system/components/web/displays' + +import { AvatarProps } from '@mui/material' +import { RefetchFnDynamic } from 'react-relay' + +import { UserMembersListFragment$key } from '../../../../../../__generated__/UserMembersListFragment.graphql' +import { UsersListFragment$data } from '../../../../../../__generated__/UsersListFragment.graphql' + +export type User = NonNullable< + NonNullable['edges'][number]>['node'] +> +export type NewEmail = { email: string; isNewEmail: boolean } + +export interface AddMembersDialogProps { + isOpen: boolean + onClose: () => void + profileId?: string + refetchMembers: RefetchFnDynamic + LoadingStateProps?: LoadingStateProps +} + +export interface UserCardProps { + user: User | NewEmail + onRemove: () => void + avatarProps?: AvatarProps +} diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx index f7999c23..6b07e632 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 './components/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/components/MemberPersonalInfo.tsx b/packages/components/modules/profiles/web/ProfileMembers/components/MemberPersonalInfo.tsx new file mode 100644 index 00000000..b633db52 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/components/MemberPersonalInfo.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react' + +import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' + +import { Box, Typography } from '@mui/material' +import { useFragment } from 'react-relay' + +import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileItemFragment.graphql' +import { ProfileItemFragment } from '../../../common' +import { MEMBER_STATUSES } from '../constants' +import { MemberPersonalInformation } from '../styled' +import { MemberPersonalInfoProps } from '../types' + +const MemberPersonalInfo: FC = ({ + 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/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..c1044709 --- /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..bba57b3b --- /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..91ae96c6 --- /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..8a333a48 --- /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..e36e54ab 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 From 2e1ccd78a5547ae16a452487dd9ae85fd5ff146e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Thu, 11 Sep 2025 15:40:46 -0300 Subject: [PATCH 2/8] Review --- .../index.tsx} | 26 +++++++------------ .../UserCard.tsx => UserCard/index.tsx} | 2 +- .../index.tsx} | 0 .../MembersList/components/types.ts | 26 ------------------- .../web/ProfileMembers/MembersList/index.tsx | 2 +- .../web/ProfileMembers/MembersList/styled.tsx | 9 +++++++ .../web/ProfileMembers/MembersList/types.ts | 23 ++++++++++++++++ 7 files changed, 43 insertions(+), 45 deletions(-) rename packages/components/modules/profiles/web/ProfileMembers/MembersList/{components/AddMembersDialog.tsx => AddMembersDialog/index.tsx} (93%) rename packages/components/modules/profiles/web/ProfileMembers/MembersList/{components/UserCard.tsx => UserCard/index.tsx} (97%) rename packages/components/modules/profiles/web/ProfileMembers/MembersList/{components/VirtuosoListBox.tsx => VirtuosoListBox/index.tsx} (100%) delete mode 100644 packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts create mode 100644 packages/components/modules/profiles/web/ProfileMembers/MembersList/styled.tsx diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/AddMembersDialog.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx similarity index 93% rename from packages/components/modules/profiles/web/ProfileMembers/MembersList/components/AddMembersDialog.tsx rename to packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx index 6cd0c03b..84a8c51f 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/AddMembersDialog.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -26,9 +26,10 @@ import { UsersListPaginationQuery } from '../../../../common/graphql/queries/Use import MemberPersonalInfo from '../../components/MemberPersonalInfo' import { MEMBER_STATUSES } from '../../constants' import { UserListItemContainer } from '../../styled' -import UserCard from './UserCard' -import VirtuosoListBox from './VirtuosoListBox' -import { AddMembersDialogProps, NewEmail, User } from './types' +import UserCard from '../UserCard' +import VirtuosoListBox from '../VirtuosoListBox' +import { EmailListItemContainer } from '../styled' +import { AddMembersDialogProps, NewEmail, User } from '../types' const AddMembersDialog: FC = ({ isOpen, @@ -161,23 +162,14 @@ const AddMembersDialog: FC = ({ }} > {isNewEmail ? ( - - - - {option.email} - - + + + {option.email} + - + ) : ( = ({ user, onRemove, avatarProps = {} }) => { if (!user) return null diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/VirtuosoListBox.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx similarity index 100% rename from packages/components/modules/profiles/web/ProfileMembers/MembersList/components/VirtuosoListBox.tsx rename to packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts deleted file mode 100644 index ae23c9fa..00000000 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/components/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { LoadingStateProps } from '@baseapp-frontend/design-system/components/web/displays' - -import { AvatarProps } from '@mui/material' -import { RefetchFnDynamic } from 'react-relay' - -import { UserMembersListFragment$key } from '../../../../../../__generated__/UserMembersListFragment.graphql' -import { UsersListFragment$data } from '../../../../../../__generated__/UsersListFragment.graphql' - -export type User = NonNullable< - NonNullable['edges'][number]>['node'] -> -export type NewEmail = { email: string; isNewEmail: boolean } - -export interface AddMembersDialogProps { - isOpen: boolean - onClose: () => void - profileId?: string - refetchMembers: RefetchFnDynamic - LoadingStateProps?: LoadingStateProps -} - -export interface UserCardProps { - user: User | NewEmail - onRemove: () => void - avatarProps?: AvatarProps -} diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx index 6b07e632..c5378699 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/index.tsx @@ -14,7 +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 './components/AddMembersDialog' +import AddMembersDialog from './AddMembersDialog' import { MembersListProps } from './types' const MembersList: FC = ({ 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..c72c4e4d --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/styled.tsx @@ -0,0 +1,9 @@ +import { Box, styled } from '@mui/material' + +export const EmailListItemContainer = styled(Box)(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + width: '100%', +})) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts index 6443e722..b6855606 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 } 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,17 @@ 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 +} From 2c401cdeffca1c52b41c6955f02f92e2f22de560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Mon, 15 Sep 2025 14:57:14 -0300 Subject: [PATCH 3/8] Review --- .../MembersList/AddMembersDialog/index.tsx | 67 ++++++------------- .../MembersList/VirtuosoListBox/index.tsx | 55 +++++++++++++-- .../web/ProfileMembers/MembersList/styled.tsx | 24 ++++++- .../web/ProfileMembers/MembersList/types.ts | 6 +- .../web/inputs/AutoCompleteField/index.tsx | 2 +- 5 files changed, 100 insertions(+), 54 deletions(-) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx index 84a8c51f..2191aeae 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -1,7 +1,7 @@ import React, { FC, useMemo, useRef, useState, useTransition } from 'react' import { LoadingState } from '@baseapp-frontend/design-system/components/web/displays' -import { AddIcon, CloseIcon } from '@baseapp-frontend/design-system/components/web/icons' +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' @@ -23,12 +23,9 @@ import { UsersListPaginationQuery as UsersListPaginationQueryType } from '../../ import { UsersListFragment } from '../../../../common' import { useProfileUserRoleCreateMutation } from '../../../../common/graphql/mutations/ProfileUserRoleCreate' import { UsersListPaginationQuery } from '../../../../common/graphql/queries/UsersList' -import MemberPersonalInfo from '../../components/MemberPersonalInfo' -import { MEMBER_STATUSES } from '../../constants' -import { UserListItemContainer } from '../../styled' import UserCard from '../UserCard' -import VirtuosoListBox from '../VirtuosoListBox' -import { EmailListItemContainer } from '../styled' +import VirtuosoListbox from '../VirtuosoListbox' +import { AddMembersDialogHeader } from '../styled' import { AddMembersDialogProps, NewEmail, User } from '../types' const AddMembersDialog: FC = ({ @@ -94,6 +91,9 @@ const AddMembersDialog: FC = ({ if (inputValue.includes('@') && filtered.length === 0) { return [{ email: inputValue, isNewEmail: true }] } + if (filtered.length === 0) { + return [{ empty: true }] + } return filtered }, [filteredUsers, searchQuery]) @@ -152,39 +152,11 @@ const AddMembersDialog: FC = ({ } } - const renderItem = (index: number, option: User | NewEmail) => { - const isNewEmail = 'isNewEmail' in option - return ( - { - handleItemSelection(option) - }} - > - {isNewEmail ? ( - - - {option.email} - - - - - - ) : ( - - )} - - ) - } - - const CustomVirtuosoListBox = (props: any) => - VirtuosoListBox( + const CustomVirtuosoListbox = (props: any) => + VirtuosoListbox( props, autocompleteOptions, - renderItem, + handleItemSelection, renderLoadingState, hasNext, isLoadingNext, @@ -193,16 +165,16 @@ const AddMembersDialog: FC = ({ if (!isOpen) return null + console.log('autocompleteOptions', autocompleteOptions) + return ( - - - Add Members 1 + + + Add Members 6 - + Add users to your organization or send an invitation email. @@ -213,7 +185,7 @@ const AddMembersDialog: FC = ({ options={autocompleteOptions} control={control} isPending={isPending} - ListboxComponent={CustomVirtuosoListBox} + ListboxComponent={CustomVirtuosoListbox} value={null} inputValue={searchQuery} onInputChange={(event: any, newInputValue: string) => { @@ -222,7 +194,7 @@ const AddMembersDialog: FC = ({ } }} renderInput={(params: any) => ( - + )} filterOptions={(options: any) => options} /> @@ -245,8 +217,8 @@ const AddMembersDialog: FC = ({ ))} - - diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx index 5253aa97..b94bf158 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx @@ -1,11 +1,19 @@ import React from 'react' +import { AddIcon } from '@baseapp-frontend/design-system/components/web/icons' + +import { IconButton, Typography } from '@mui/material' import { Virtuoso } from 'react-virtuoso' -const VirtuosoListBox = ( +import MemberPersonalInfo from '../../components/MemberPersonalInfo' +import { MEMBER_STATUSES } from '../../constants' +import { EmailListItemContainer, UserListItemContainer } from '../styled' +import { NewEmail, User } from '../types' + +const VirtuosoListbox = ( props: any, autocompleteOptions: any[], - renderItem: (index: number, option: any) => React.ReactNode, + handleItemSelection: (option: User | NewEmail) => void, renderLoadingState: () => React.ReactNode, hasNext: boolean, isLoadingNext: boolean, @@ -15,11 +23,50 @@ const VirtuosoListBox = ( let options = React.Children.toArray(children) .filter((child: any) => child && typeof child === 'object' && child.props) .map((child: any) => child.props.value) - .filter(Boolean) // Remove any undefined values + .filter(Boolean) if (options.length === 0) { options = autocompleteOptions } const height = options.length * 56 > 300 ? 300 : options.length * 56 + + 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 (
( + ({ 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', @@ -7,3 +22,10 @@ export const EmailListItemContainer = styled(Box)(() => ({ cursor: 'pointer', width: '100%', })) + +export const AddMembersDialogHeader = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: 3, +})) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts index b6855606..c70ebf24 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts @@ -2,7 +2,7 @@ import { FC } from 'react' import { LoadingStateProps } from '@baseapp-frontend/design-system/components/web/displays' -import { AvatarProps } from '@mui/material' +import { AvatarProps, ListItemProps } from '@mui/material' import { RefetchFnDynamic } from 'react-relay' import { UserMembersListFragment$key } from '../../../../../__generated__/UserMembersListFragment.graphql' @@ -36,3 +36,7 @@ export interface UserCardProps { onRemove: () => void avatarProps?: AvatarProps } + +export interface UserListItemContainerProps extends ListItemProps { + isEmpty?: boolean +} diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx b/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx index 91ae96c6..f3b7b192 100644 --- a/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx +++ b/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx @@ -7,7 +7,7 @@ import { Autocomplete } from '@mui/material' import { AutoCompleteFieldProps } from './types' const AutoCompleteField: FC = ({ ...props }) => ( - + ) export default withController(AutoCompleteField, { shouldDebounce: true }) From 6e655b4abc861fae15f1b9b10163f7378604f9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Mon, 15 Sep 2025 16:23:12 -0300 Subject: [PATCH 4/8] Renaming --- .../MembersList/AddMembersDialog/index.tsx | 6 ++---- .../profiles/web/ProfileMembers/MembersList/styled.tsx | 4 ++-- .../__storybook__/AutoCompleteField.mdx | 8 ++++---- .../inputs/AutoCompleteField/__storybook__/stories.tsx | 10 +++++----- .../components/web/inputs/AutoCompleteField/index.tsx | 6 +++--- .../components/web/inputs/AutoCompleteField/types.ts | 2 +- packages/design-system/components/web/inputs/index.ts | 4 ++-- 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx index 2191aeae..d2ad0a31 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -2,7 +2,7 @@ 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 { AutocompleteField } from '@baseapp-frontend/design-system/components/web/inputs' import { useNotification } from '@baseapp-frontend/utils' import { @@ -165,8 +165,6 @@ const AddMembersDialog: FC = ({ if (!isOpen) return null - console.log('autocompleteOptions', autocompleteOptions) - return ( @@ -179,7 +177,7 @@ const AddMembersDialog: FC = ({ Add users to your organization or send an invitation email. - ({ width: '100%', })) -export const AddMembersDialogHeader = styled(Box)(() => ({ +export const AddMembersDialogHeader = styled(Box)(({ theme }) => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - padding: 3, + padding: theme.spacing(3), })) diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx b/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx index c1044709..fe2fe222 100644 --- a/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx +++ b/packages/design-system/components/web/inputs/AutoCompleteField/__storybook__/AutoCompleteField.mdx @@ -1,10 +1,10 @@ import { Meta } from '@storybook/addon-docs' - + # Component Documentation -## AutoCompleteField +## 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. @@ -37,7 +37,7 @@ import { Meta } from '@storybook/addon-docs' ## Example Usage ```javascript -import { AutoCompleteField } from '@baseapp-frontend/design-system/web' +import { AutocompleteField } from '@baseapp-frontend/design-system/web' import { TextField } from '@mui/material' const MyComponent = () => { @@ -48,7 +48,7 @@ const MyComponent = () => { ] return ( - = { +const meta: Meta = { title: '@baseapp-frontend | designSystem/Inputs/AutoCompleteField', - component: AutoCompleteField, + component: AutocompleteField, } export default meta -type Story = StoryObj +type Story = StoryObj const sampleOptions = [ { label: 'Apple', value: 'apple' }, diff --git a/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx b/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx index f3b7b192..4ebeaa09 100644 --- a/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx +++ b/packages/design-system/components/web/inputs/AutoCompleteField/index.tsx @@ -4,10 +4,10 @@ import { withController } from '@baseapp-frontend/utils' import { Autocomplete } from '@mui/material' -import { AutoCompleteFieldProps } from './types' +import { AutocompleteFieldProps } from './types' -const AutoCompleteField: FC = ({ ...props }) => ( +const AutocompleteField: FC = ({ ...props }) => ( ) -export default withController(AutoCompleteField, { shouldDebounce: true }) +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 index 8a333a48..10d26f66 100644 --- a/packages/design-system/components/web/inputs/AutoCompleteField/types.ts +++ b/packages/design-system/components/web/inputs/AutoCompleteField/types.ts @@ -1,6 +1,6 @@ import { AutocompleteProps } from '@mui/material' -export type AutoCompleteFieldProps = AutocompleteProps & { +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 e36e54ab..aa9162c8 100644 --- a/packages/design-system/components/web/inputs/index.ts +++ b/packages/design-system/components/web/inputs/index.ts @@ -1,7 +1,7 @@ 'use client' -export { default as AutoCompleteField } from './AutoCompleteField' -export type * from './AutoCompleteField/types' +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' From 110f99983d8c537463c5ef69a68e804449767123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Mon, 15 Sep 2025 16:35:07 -0300 Subject: [PATCH 5/8] Small improvements --- .../MembersList/AddMembersDialog/index.tsx | 14 ++++++++++---- .../MembersList/VirtuosoListBox/index.tsx | 19 ++++++++++--------- .../web/ProfileMembers/MembersList/types.ts | 10 ++++++++++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx index d2ad0a31..bf071fe5 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -130,6 +130,12 @@ const AddMembersDialog: FC = ({ }) } + const handleClose = () => { + onClose() + setSelectedUsers([]) + setSelectedEmails([]) + } + const renderLoadingState = () => { if (!isLoadingNext) return null @@ -155,7 +161,7 @@ const AddMembersDialog: FC = ({ const CustomVirtuosoListbox = (props: any) => VirtuosoListbox( props, - autocompleteOptions, + autocompleteOptions as (User | NewEmail)[], handleItemSelection, renderLoadingState, hasNext, @@ -166,10 +172,10 @@ const AddMembersDialog: FC = ({ if (!isOpen) return null return ( - + - Add Members 6 - + Add Members + diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx index b94bf158..3a354f0c 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx @@ -8,16 +8,16 @@ import { Virtuoso } from 'react-virtuoso' import MemberPersonalInfo from '../../components/MemberPersonalInfo' import { MEMBER_STATUSES } from '../../constants' import { EmailListItemContainer, UserListItemContainer } from '../styled' -import { NewEmail, User } from '../types' +import { NewEmail, User, VirtuosoListboxFunction } from '../types' -const VirtuosoListbox = ( - props: any, - autocompleteOptions: any[], - handleItemSelection: (option: User | NewEmail) => void, - renderLoadingState: () => React.ReactNode, - hasNext: boolean, - isLoadingNext: boolean, - loadNext: (count: number) => void, +const VirtuosoListbox: VirtuosoListboxFunction = ( + props, + autocompleteOptions, + handleItemSelection, + renderLoadingState, + hasNext, + isLoadingNext, + loadNext, ) => { const { children, ...other } = props let options = React.Children.toArray(children) @@ -81,6 +81,7 @@ const VirtuosoListbox = ( loadNext(10) } }} + useWindowScroll={false} />
) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts index c70ebf24..eb5d1184 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts @@ -40,3 +40,13 @@ export interface UserCardProps { export interface UserListItemContainerProps extends ListItemProps { isEmpty?: boolean } + +export type VirtuosoListboxFunction = ( + props: any, + autocompleteOptions: (User | NewEmail)[], + handleItemSelection: (option: User | NewEmail) => void, + renderLoadingState: () => React.ReactNode, + hasNext: boolean, + isLoadingNext: boolean, + loadNext: (count: number) => void, +) => React.ReactElement From 523c06d520630fed9d0dcf85930260302da18d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Mon, 15 Sep 2025 18:10:25 -0300 Subject: [PATCH 6/8] Scroll attempt --- .../MembersList/AddMembersDialog/index.tsx | 4 +-- .../MembersList/VirtuosoListBox/index.tsx | 31 +++++++++++++++++-- .../web/ProfileMembers/MembersList/types.ts | 2 +- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx index bf071fe5..65929806 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -95,7 +95,7 @@ const AddMembersDialog: FC = ({ return [{ empty: true }] } return filtered - }, [filteredUsers, searchQuery]) + }, [filteredUsers]) const isEmailAlreadySelected = (currentEmail: NewEmail) => selectedEmails.some((selectedEmail) => selectedEmail?.email === currentEmail?.email) @@ -174,7 +174,7 @@ const AddMembersDialog: FC = ({ return ( - Add Members + Add Members 9 diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx index 3a354f0c..241e9c02 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' import { AddIcon } from '@baseapp-frontend/design-system/components/web/icons' @@ -8,9 +8,14 @@ import { Virtuoso } from 'react-virtuoso' import MemberPersonalInfo from '../../components/MemberPersonalInfo' import { MEMBER_STATUSES } from '../../constants' import { EmailListItemContainer, UserListItemContainer } from '../styled' -import { NewEmail, User, VirtuosoListboxFunction } from '../types' +import { NewEmail, User, VirtuosoListboxProps } from '../types' -const VirtuosoListbox: VirtuosoListboxFunction = ( +// 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, @@ -19,6 +24,7 @@ const VirtuosoListbox: VirtuosoListboxFunction = ( 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) @@ -27,8 +33,26 @@ const VirtuosoListbox: VirtuosoListboxFunction = ( 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 @@ -70,6 +94,7 @@ const VirtuosoListbox: VirtuosoListboxFunction = ( return (
renderItem(index, option)} diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts index eb5d1184..c6734688 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/types.ts @@ -41,7 +41,7 @@ export interface UserListItemContainerProps extends ListItemProps { isEmpty?: boolean } -export type VirtuosoListboxFunction = ( +export type VirtuosoListboxProps = ( props: any, autocompleteOptions: (User | NewEmail)[], handleItemSelection: (option: User | NewEmail) => void, From f21c621606cc02607d9a4c18567afc8458684c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Tue, 16 Sep 2025 11:00:57 -0300 Subject: [PATCH 7/8] Remove test features --- .../MembersList/AddMembersDialog/index.tsx | 2 +- .../MembersList/VirtuosoListBox/index.tsx | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx index 65929806..be9e9019 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/AddMembersDialog/index.tsx @@ -174,7 +174,7 @@ const AddMembersDialog: FC = ({ return ( - Add Members 9 + Add Members diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx index 241e9c02..e7cf3ade 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import React, { useRef } from 'react' import { AddIcon } from '@baseapp-frontend/design-system/components/web/icons' @@ -36,22 +36,22 @@ const VirtuosoListbox: VirtuosoListboxProps = ( 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]) + // 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 From 214fc1508ef40920db6993846b30ff604e86774b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20BJ?= Date: Tue, 16 Sep 2025 14:07:38 -0300 Subject: [PATCH 8/8] Convention --- .../modules/profiles/web/ProfileMembers/MemberItem/index.tsx | 2 +- .../MemberPersonalInfo.tsx => MemberPersonalInfo/index.tsx} | 0 .../profiles/web/ProfileMembers/MembersList/UserCard/index.tsx | 2 +- .../web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/components/modules/profiles/web/ProfileMembers/{components/MemberPersonalInfo.tsx => MemberPersonalInfo/index.tsx} (100%) diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx index d7bb60ec..e48ee981 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx @@ -10,7 +10,7 @@ import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileIte import { ProfileRoles } from '../../../../../__generated__/ProfileUserRoleUpdateMutation.graphql' import { ProfileItemFragment, useProfileUserRoleUpdateMutation } from '../../../common' import { useRemoveMemberMutation } from '../../../common/graphql/mutations/ProfileUserRoleDelete' -import MemberPersonalInfo from '../components/MemberPersonalInfo' +import MemberPersonalInfo from '../MemberPersonalInfo' import { MEMBER_ACTIONS, MEMBER_ROLES, MEMBER_STATUSES, roleOptions } from '../constants' import { capitalizeFirstLetter } from '../utils' import { MemberItemContainer, Select } from './styled' diff --git a/packages/components/modules/profiles/web/ProfileMembers/components/MemberPersonalInfo.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberPersonalInfo/index.tsx similarity index 100% rename from packages/components/modules/profiles/web/ProfileMembers/components/MemberPersonalInfo.tsx rename to packages/components/modules/profiles/web/ProfileMembers/MemberPersonalInfo/index.tsx diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx index 351eac6e..a00d7184 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/UserCard/index.tsx @@ -6,7 +6,7 @@ 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 '../../components/MemberPersonalInfo' +import MemberPersonalInfo from '../../MemberPersonalInfo' import { MEMBER_STATUSES } from '../../constants' import { MemberCardContainer, MemberPersonalInformation } from '../../styled' import { UserCardProps } from '../types' diff --git a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx index e7cf3ade..f1aab0dc 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MembersList/VirtuosoListBox/index.tsx @@ -5,7 +5,7 @@ import { AddIcon } from '@baseapp-frontend/design-system/components/web/icons' import { IconButton, Typography } from '@mui/material' import { Virtuoso } from 'react-virtuoso' -import MemberPersonalInfo from '../../components/MemberPersonalInfo' +import MemberPersonalInfo from '../../MemberPersonalInfo' import { MEMBER_STATUSES } from '../../constants' import { EmailListItemContainer, UserListItemContainer } from '../styled' import { NewEmail, User, VirtuosoListboxProps } from '../types'