From fdc06cf61c2bc7feb61c209a29dd3ae32c5dd56d Mon Sep 17 00:00:00 2001 From: Aleksandr Lushkin Date: Wed, 24 Jul 2024 13:14:34 +0200 Subject: [PATCH 1/7] Client: InfoPanel Groups: Fix getting all members in one request. Add infinite loader --- .../CreateEditGroupDialog/EditGroupDialog.tsx | 2 +- .../Body/views/Groups/GroupMember.js | 2 +- .../GroupMembersList/GroupMembersList.tsx | 155 ++++++++++++++++++ .../Home/InfoPanel/Body/views/Groups/index.js | 95 +++++++++-- .../Body/views/Groups/useFetchGroup.js | 2 +- packages/shared/api/groups/index.ts | 10 +- packages/shared/api/groups/types.ts | 1 + 7 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMembersList/GroupMembersList.tsx diff --git a/packages/client/src/components/dialogs/CreateEditGroupDialog/EditGroupDialog.tsx b/packages/client/src/components/dialogs/CreateEditGroupDialog/EditGroupDialog.tsx index c0027e0286..9f706cbc83 100644 --- a/packages/client/src/components/dialogs/CreateEditGroupDialog/EditGroupDialog.tsx +++ b/packages/client/src/components/dialogs/CreateEditGroupDialog/EditGroupDialog.tsx @@ -143,7 +143,7 @@ const EditGroupDialog = ({ if (groupParams.groupMembers) return; setFetchMembersIsLoading(true); - getGroupById(group.id)! + getGroupById(group.id, true)! .then((data: any) => { prevGroupParams.current.groupMembers = data.members; setInitialMembersIds(data.members.map((gm) => gm.id)); diff --git a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMember.js b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMember.js index 4b2487f9a2..f0d4ad9672 100644 --- a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMember.js +++ b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMember.js @@ -84,7 +84,7 @@ const GroupMember = ({ className="avatar" role={groupMember.role || "user"} size={"min"} - source={groupMember.avatarSmall} + source={groupMember.avatarSmall || groupMember.avatar} />
diff --git a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMembersList/GroupMembersList.tsx b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMembersList/GroupMembersList.tsx new file mode 100644 index 0000000000..08800bb675 --- /dev/null +++ b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/GroupMembersList/GroupMembersList.tsx @@ -0,0 +1,155 @@ +// (c) Copyright Ascensio System SIA 2009-2024 +// +// This program is a free software product. +// You can redistribute it and/or modify it under the terms +// of the GNU Affero General Public License (AGPL) version 3 as published by the Free Software +// Foundation. In accordance with Section 7(a) of the GNU AGPL its Section 15 shall be amended +// to the effect that Ascensio System SIA expressly excludes the warranty of non-infringement of +// any third-party rights. +// +// This program is distributed WITHOUT ANY WARRANTY, without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For details, see +// the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html +// +// You can contact Ascensio System SIA at Lubanas st. 125a-25, Riga, Latvia, EU, LV-1021. +// +// The interactive user interfaces in modified source and object code versions of the Program must +// display Appropriate Legal Notices, as required under Section 5 of the GNU AGPL version 3. +// +// Pursuant to Section 7(b) of the License you must retain the original Product logo when +// distributing the program. Pursuant to Section 7(e) we decline to grant you any rights under +// trademark law for use of our trademarks. +// +// All the Product's GUI elements, including illustrations and icon sets, as well as technical writing +// content are licensed under the terms of the Creative Commons Attribution-ShareAlike 4.0 +// International. See the License terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode + +import React, { useCallback, useEffect, useState } from "react"; +import { + ListRowProps, + Index, + IndexRange, + InfiniteLoader, + List, + WindowScroller, +} from "react-virtualized"; +import styled from "styled-components"; + +import { TUser } from "@docspace/shared/api/people/types"; +import { RowLoader } from "@docspace/shared/skeletons/selector"; + +import GroupMember from "../GroupMember"; + +const ROW_HEIGHT = 50; + +export const StyledList = styled(List)` + width: ${({ width }) => `${width - 40}px`} !important; + + .group-member-row-loader { + padding: 0; + } +`; + +interface GroupMembersListProps { + members: TUser[]; + loadNextPage: (startIndex: number) => Promise; + hasNextPage: boolean; + total: number; +} + +export const GroupMembersList = ({ + members, + loadNextPage, + hasNextPage, + total, +}: GroupMembersListProps) => { + const [scrollElement, setScrollElement] = useState( + null, + ); + const [isNextPageLoading, setIsNextPageLoading] = useState(false); + + const itemsCount = hasNextPage ? members.length + 1 : members.length; + + const isItemLoaded = useCallback( + ({ index }: Index) => { + return !hasNextPage || index < itemsCount; + }, + [hasNextPage, itemsCount], + ); + + const loadMoreItems = useCallback( + async ({ startIndex }: IndexRange) => { + setIsNextPageLoading(true); + if (!isNextPageLoading) { + await loadNextPage(startIndex - 1); + } + setIsNextPageLoading(false); + }, + [isNextPageLoading, loadNextPage], + ); + + const renderRow = ({ key, index, style }: ListRowProps) => { + const item = members[index]; + + return ( +
+ {item ? ( + + ) : ( + + )} +
+ ); + }; + + useEffect(() => { + const scrollEl = document.querySelector(".info-panel-scroll"); + + if (scrollEl) { + setScrollElement(scrollEl as HTMLDivElement); + } + }, []); + + if (!scrollElement) { + return null; + } + + return ( + + {({ onRowsRendered, registerChild }) => ( + + {({ height, isScrolling, scrollTop }) => { + const scrollRect = scrollElement.getBoundingClientRect(); + + return ( + + ); + }} + + )} + + ); +}; diff --git a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/index.js b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/index.js index ac9bebf5b7..f250e63b2b 100644 --- a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/index.js +++ b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/index.js @@ -26,14 +26,20 @@ import { inject, observer } from "mobx-react"; import { withTranslation } from "react-i18next"; -import * as Styled from "../../styles/groups.styled"; -import withLoader from "@docspace/client/src/HOCs/withLoader"; -import InfoPanelViewLoader from "@docspace/shared/skeletons/info-panel/body"; -import GroupMember from "./GroupMember"; -import useFetchGroup from "./useFetchGroup"; import { useParams } from "react-router-dom"; import { useState, useEffect } from "react"; +import withLoader from "@docspace/client/src/HOCs/withLoader"; +import InfoPanelViewLoader from "@docspace/shared/skeletons/info-panel/body"; +import api from "@docspace/shared/api"; +import AccountsFilter from "@docspace/shared/api/people/filter"; +import { MIN_LOADER_TIMER } from "@docspace/shared/selectors/Files/FilesSelector.constants"; + +import GroupMember from "./GroupMember"; +import * as Styled from "../../styles/groups.styled"; +import useFetchGroup from "./useFetchGroup"; +import { GroupMembersList } from "./GroupMembersList/GroupMembersList"; + const Groups = ({ infoPanelSelection, currentGroup, @@ -42,6 +48,9 @@ const Groups = ({ setInfoPanelSelectedGroup, }) => { const [isShowLoader, setIsShowLoader] = useState(false); + const [areMembersLoading, setAreMembersLoading] = useState(false); + const [groupMembers, setGroupMembers] = useState(null); + const [total, setTotal] = useState(0); const { groupId: paramsGroupId } = useParams(); const isInsideGroup = !!paramsGroupId; @@ -51,32 +60,88 @@ const Groups = ({ const groupId = isInsideGroup ? paramsGroupId : infoPanelSelection?.id; const setGroup = isInsideGroup ? setCurrentGroup : setInfoPanelSelectedGroup; + const groupManager = group?.manager; + + const loadNextPage = async (startIndex) => { + const startLoadingTime = new Date(); + + try { + if (startIndex === 0) { + setAreMembersLoading(true); + } + + const pageCount = 100; + const filter = AccountsFilter.getDefault(); + filter.group = groupId; + filter.page = startIndex / pageCount; + filter.pageCount = pageCount; + + const res = await api.people.getUserList(filter); + + const membersWithoutManager = groupManager + ? res.items.filter((item) => item.id !== groupManager.id) + : res.items; + + setTotal(res.total); + if (startIndex === 0 || !groupMembers) { + setGroupMembers(membersWithoutManager); + } else { + setGroupMembers([...groupMembers, ...membersWithoutManager]); + } + } catch (e) { + console.log(e); + } finally { + const nowDate = new Date(); + const diff = Math.abs(nowDate.getTime() - startLoadingTime.getTime()); + + if (diff < MIN_LOADER_TIMER) { + setTimeout(() => { + setAreMembersLoading(false); + }, MIN_LOADER_TIMER - diff); + } else { + setAreMembersLoading(false); + } + } + }; + useFetchGroup(groupId, group?.id, setGroup); + useEffect(() => { + if (group) { + loadNextPage(0); + } + }, [group]); + useEffect(() => { const showLoaderTimer = setTimeout(() => setIsShowLoader(true), 500); return () => clearTimeout(showLoaderTimer); }, []); - const groupManager = group?.manager; - const groupMembers = group?.members?.filter( - (user) => user.id !== groupManager?.id, - ); - if (!group) { - if (isShowLoader) return ; + if (isShowLoader) + return ( + + + + ); return null; } + const totalWithoutManager = groupManager ? total - 1 : total; + return ( {groupManager && } - {!groupMembers ? ( + {!groupMembers || areMembersLoading ? ( ) : ( - groupMembers?.map((groupMember) => ( - - )) + )} ); diff --git a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/useFetchGroup.js b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/useFetchGroup.js index c65aff8872..cb2b08f351 100644 --- a/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/useFetchGroup.js +++ b/packages/client/src/pages/Home/InfoPanel/Body/views/Groups/useFetchGroup.js @@ -45,7 +45,7 @@ const useFetchGroup = (groupId, fetchedGroupId, setGroup) => { setIsLoading(true); } - getGroupById(groupId, abortControllerRef.current?.signal) + getGroupById(groupId, false, abortControllerRef.current?.signal) .then((data) => { if (isMount.current) startTransition(() => setGroup(data)); }) diff --git a/packages/shared/api/groups/index.ts b/packages/shared/api/groups/index.ts index e9f642f9cf..25f72dc909 100644 --- a/packages/shared/api/groups/index.ts +++ b/packages/shared/api/groups/index.ts @@ -69,12 +69,16 @@ export const getGroups = (filter = Filter.getDefault()) => { }); }; -export const getGroupById = (groupId: string, signal: AbortSignal) => { +export const getGroupById = ( + groupId: string, + includeMembers: boolean = false, + signal?: AbortSignal, +) => { return request({ method: "get", - url: `/group/${groupId}`, + url: `/group/${groupId}?includeMembers=${includeMembers}`, signal, - }); + }) as Promise; }; export const getGroupsByName = async ( diff --git a/packages/shared/api/groups/types.ts b/packages/shared/api/groups/types.ts index 1669911d06..e8d103736e 100644 --- a/packages/shared/api/groups/types.ts +++ b/packages/shared/api/groups/types.ts @@ -34,6 +34,7 @@ export type TGroup = { name: string; parent: string; isGroup?: boolean; + members?: TUser[]; membersCount: number; shared?: boolean; isLDAP: boolean; From a8e5a244f72aa631092eab4ea2585c287bbc0a4c Mon Sep 17 00:00:00 2001 From: Aleksandr Lushkin Date: Mon, 29 Jul 2024 14:48:36 +0200 Subject: [PATCH 2/7] Client: EditGroupDialog: Rewrite dialog to work with infinite loader --- .../CreateEditGroupDialog.styled.ts | 32 ++ .../CreateGroupDialog.tsx | 4 +- .../CreateEditGroupDialog/EditGroupDialog.tsx | 287 ++++++++++-------- .../sub-components/BodyLoader/BodyLoader.tsx | 43 +++ .../sub-components/GroupMemberRow/index.tsx | 19 +- .../GroupMembersList/GroupMembersList.tsx | 152 ++++++++++ .../SelectGroupManagerPanel.tsx | 7 +- .../sub-components/HeadOfGroupParam/index.tsx | 27 +- .../sub-components/MembersParam/index.tsx | 78 +++-- .../MembersSelector.tsx} | 63 ++-- .../create-components/SelectMembersPanel.tsx} | 66 ++-- .../edit-components/SelectMembersPanel.tsx | 62 ++++ .../components/panels/AddUsersPanel/index.tsx | 19 +- packages/client/src/store/EditGroupStore.ts | 248 +++++++++++++++ packages/client/src/store/GroupsStore.ts | 2 +- packages/client/src/store/index.js | 4 + packages/shared/api/groups/index.ts | 2 +- packages/shared/api/groups/types.ts | 2 +- .../components/selector/Selector.types.ts | 3 + 19 files changed, 849 insertions(+), 271 deletions(-) create mode 100644 packages/client/src/components/dialogs/CreateEditGroupDialog/sub-components/BodyLoader/BodyLoader.tsx create mode 100644 packages/client/src/components/dialogs/CreateEditGroupDialog/sub-components/GroupMembersList/GroupMembersList.tsx rename packages/client/src/components/dialogs/CreateEditGroupDialog/sub-components/{MembersParam/SelectGroupMembersPanel.tsx => MembersSelector/MembersSelector.tsx} (67%) rename packages/client/src/components/dialogs/CreateEditGroupDialog/{utils/index.ts => sub-components/create-components/SelectMembersPanel.tsx} (54%) create mode 100644 packages/client/src/components/dialogs/CreateEditGroupDialog/sub-components/edit-components/SelectMembersPanel.tsx create mode 100644 packages/client/src/store/EditGroupStore.ts diff --git a/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateEditGroupDialog.styled.ts b/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateEditGroupDialog.styled.ts index 795b0eaac0..e0d9b7ae7c 100644 --- a/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateEditGroupDialog.styled.ts +++ b/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateEditGroupDialog.styled.ts @@ -34,3 +34,35 @@ export const StyledModal = styled(ModalDialog)` padding-top: 20px; } `; + +export const StyledBodyLoader = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + + .title-section { + margin-bottom: 14px; + } + + .group-title { + margin-bottom: 4px; + height: 16px; + padding-top: 4px; + } + + .manager-title, + .members-title { + padding-block: 8px; + } + + .add-member-container { + display: flex; + align-items: center; + gap: 8px; + margin-block: 8px; + } + + .member-row { + padding-inline: 0; + } +`; diff --git a/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateGroupDialog.tsx b/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateGroupDialog.tsx index 71cf29c8ba..3c0d2bfdaf 100644 --- a/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateGroupDialog.tsx +++ b/packages/client/src/components/dialogs/CreateEditGroupDialog/CreateGroupDialog.tsx @@ -42,7 +42,7 @@ import GroupNameParam from "./sub-components/GroupNameParam"; import HeadOfGroup from "./sub-components/HeadOfGroupParam"; import MembersParam from "./sub-components/MembersParam"; import SelectGroupManagerPanel from "./sub-components/HeadOfGroupParam/SelectGroupManagerPanel"; -import SelectGroupMembersPanel from "./sub-components/MembersParam/SelectGroupMembersPanel"; +import { SelectMembersPanel } from "./sub-components/create-components/SelectMembersPanel"; interface CreateGroupDialogProps { visible: boolean; @@ -177,7 +177,7 @@ const CreateGroupDialog = ({ )} {selectMembersPanelIsVisible && ( - ; + +type EditGroupDialogProps = { + group: TGroup; visible: boolean; onClose: () => void; - updateGroup: ( - groupId: string, - groupName: string, - groupManager: string, - membersToAdd: string[], - membersToRemove: string[], - ) => Promise; -} + injectedProps?: InjectedProps; +}; const EditGroupDialog = ({ group, visible, onClose, - updateGroup, - setInfoPanelSelectedGroup, + + injectedProps, }: EditGroupDialogProps) => { + const { + initGroupData, + resetGroupData, + isInit, + loadMembers, + manager, + addManager, + removeManager, + members, + addMembers, + removeMember, + currentTotal, + submitChanges, + title, + setTitle, + hasChanges, + } = injectedProps!; + const { t } = useTranslation(["PeopleTranslations", "Common"]); - - const [initialMembersIds, setInitialMembersIds] = useState([]); - - const [isCreateGroupLoading, setCreateGroupIsLoading] = - useState(false); - - const [isFetchMembersLoading, setFetchMembersIsLoading] = - useState(false); - - const [groupParams, setGroupParams] = useState({ - groupName: group.name, - groupManager: group.manager, - groupMembers: null, - }); - - const prevGroupParams = useRef({ ...groupParams }); - - const onChangeGroupName = (e: ChangeEvent) => - setGroupParams((prev) => ({ ...prev, groupName: e.target.value })); - - const setGroupManager = (groupManager: object | null) => - setGroupParams((prev) => ({ ...prev, groupManager })); - - const setGroupMembers = (groupMembers: object[]) => - setGroupParams((prev) => ({ ...prev, groupMembers })); - + const [isSubmitting, setIsSubmitting] = useState(false); const [selectGroupMangerPanelIsVisible, setSelectGroupMangerPanelIsVisible] = useState(false); + const [selectMembersPanelIsVisible, setSelectMembersPanelIsVisible] = + useState(false); + + const onChangeGroupName = (e: ChangeEvent) => { + setTitle(e.target.value); + }; + + const closeModal = () => { + resetGroupData(); + onClose(); + }; const onShowSelectGroupManagerPanel = () => setSelectGroupMangerPanelIsVisible(true); const onHideSelectGroupManagerPanel = () => setSelectGroupMangerPanelIsVisible(false); - const [selectMembersPanelIsVisible, setSelectMembersPanelIsVisible] = - useState(false); - const onShowSelectMembersPanel = () => setSelectMembersPanelIsVisible(true); const onHideSelectMembersPanel = () => setSelectMembersPanelIsVisible(false); const onEditGroup = async () => { - setCreateGroupIsLoading(true); + setIsSubmitting(true); - const groupManagerId = groupParams.groupManager?.id || undefined; + await submitChanges(); - const newMembersIds = - groupParams.groupMembers?.map((gm: any) => gm.id) || []; - const membersToAdd = newMembersIds.filter( - (gm) => !initialMembersIds.includes(gm), - ); - const membersToDelete = initialMembersIds.filter( - (gm) => !newMembersIds.includes(gm), - ); - - await updateGroup( - group.id, - groupParams.groupName, - groupManagerId, - membersToAdd, - membersToDelete, - ); - - setCreateGroupIsLoading(false); - onClose(); + setIsSubmitting(false); + closeModal(); }; - const notEnoughGroupParamsToEdit = - !groupParams.groupName || - (!groupParams.groupManager && !groupParams.groupMembers?.length); - - const groupParamsNotChanged = compareGroupParams( - groupParams, - prevGroupParams.current, - ); - useEffect(() => { - if (groupParams.groupMembers) return; - setFetchMembersIsLoading(true); + initGroupData(group); - getGroupById(group.id, true)! - .then((data: any) => { - prevGroupParams.current.groupMembers = data.members; - setInitialMembersIds(data.members.map((gm) => gm.id)); - setGroupMembers(data.members); - }) - .then((data) => { - setInfoPanelSelectedGroup(data); - }) - .catch((err) => console.error(err)) - .finally(() => setFetchMembersIsLoading(false)); - }, [group.id]); + return () => { + resetGroupData(); + }; + }, []); + + const notEnoughParamsToEdit = !title || (!manager && !members?.length); return ( <> {t("PeopleTranslations:EditGroup")} - - - {!isFetchMembersLoading && ( - + {isInit ? ( + <> + + + + + + ) : ( + )} @@ -198,21 +186,21 @@ const EditGroupDialog = ({ id="edit-group-modal_submit" tabIndex={5} label={t("Common:SaveButton")} - size="normal" + size={ButtonSize.normal} primary scale onClick={onEditGroup} - isDisabled={notEnoughGroupParamsToEdit || groupParamsNotChanged} - isLoading={isCreateGroupLoading} + isDisabled={!hasChanges || notEnoughParamsToEdit} + isLoading={isSubmitting} />