diff --git a/client/packages/lowcoder/src/api/userApi.ts b/client/packages/lowcoder/src/api/userApi.ts index c80d4b19dd..cd06186cad 100644 --- a/client/packages/lowcoder/src/api/userApi.ts +++ b/client/packages/lowcoder/src/api/userApi.ts @@ -1,6 +1,6 @@ import Api from "api/api"; import { AxiosPromise } from "axios"; -import { OrgAndRole } from "constants/orgConstants"; +import { Org, OrgAndRole } from "constants/orgConstants"; import { BaseUserInfo, CurrentUser } from "constants/userConstants"; import { MarkUserStatusPayload, UpdateUserPayload } from "redux/reduxActions/userActions"; import { ApiResponse, GenericApiResponse } from "./apiResponses"; @@ -60,10 +60,23 @@ export interface FetchApiKeysResponse extends ApiResponse { export type GetCurrentUserResponse = GenericApiResponse; +export interface GetMyOrgsResponse extends ApiResponse { + data: { + data: Array<{ + orgId: string; + orgName: string; + }>; + pageNum: number; + pageSize: number; + total: number; + }; +} + class UserApi extends Api { static thirdPartyLoginURL = "/auth/tp/login"; static thirdPartyBindURL = "/auth/tp/bind"; static usersURL = "/users"; + static myOrgsURL = "/users/myorg"; static sendVerifyCodeURL = "/auth/otp/send"; static logoutURL = "/auth/logout"; static userURL = "/users/me"; @@ -127,6 +140,19 @@ class UserApi extends Api { static getCurrentUser(): AxiosPromise { return Api.get(UserApi.currentUserURL); } + static getMyOrgs( + pageNum: number = 1, + pageSize: number = 20, + orgName?: string + ): AxiosPromise { + const params = new URLSearchParams({ + pageNum: pageNum.toString(), + pageSize: pageSize.toString(), + ...(orgName && { orgName }) + }); + + return Api.get(`${UserApi.myOrgsURL}?${params}`); + } static getRawCurrentUser(): AxiosPromise { return Api.get(UserApi.rawCurrentUserURL); diff --git a/client/packages/lowcoder/src/constants/reduxActionConstants.ts b/client/packages/lowcoder/src/constants/reduxActionConstants.ts index aea840a5c6..f14f40c73d 100644 --- a/client/packages/lowcoder/src/constants/reduxActionConstants.ts +++ b/client/packages/lowcoder/src/constants/reduxActionConstants.ts @@ -11,6 +11,12 @@ export const ReduxActionTypes = { FETCH_API_KEYS_SUCCESS: "FETCH_API_KEYS_SUCCESS", MOVE_TO_FOLDER2_SUCCESS: "MOVE_TO_FOLDER2_SUCCESS", + /* workspace RELATED */ + FETCH_WORKSPACES_INIT: "FETCH_WORKSPACES_INIT", + FETCH_WORKSPACES_SUCCESS: "FETCH_WORKSPACES_SUCCESS", + + + /* plugin RELATED */ FETCH_DATA_SOURCE_TYPES: "FETCH_DATA_SOURCE_TYPES", FETCH_DATA_SOURCE_TYPES_SUCCESS: "FETCH_DATA_SOURCE_TYPES_SUCCESS", diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 79eb3619e3..fee16d1030 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -3639,6 +3639,7 @@ export const en = { "profile": { "orgSettings": "Workspace Settings", "switchOrg": "Switch Workspace", + "switchWorkspace": "Switch", "joinedOrg": "My Workspaces", "createOrg": "Create Workspace", "logout": "Log Out", diff --git a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx new file mode 100644 index 0000000000..8358ef4614 --- /dev/null +++ b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { Input, Pagination, Spin } from 'antd'; +import { User } from 'constants/userConstants'; +import { switchOrg, createOrgAction } from 'redux/reduxActions/orgActions'; +import { selectSystemConfig } from 'redux/selectors/configSelectors'; +import { showSwitchOrg } from '@lowcoder-ee/pages/common/customerService'; +import { useWorkspaceManager } from 'util/useWorkspaceManager'; +import { trans } from 'i18n'; +import { + AddIcon, + CheckoutIcon, + SearchIcon, +} from 'lowcoder-design'; +import { ORGANIZATION_SETTING } from 'constants/routesURL'; +import history from 'util/history'; +import { Org } from 'constants/orgConstants'; + +// Styled Components +const WorkspaceSection = styled.div` + padding: 8px 0; +`; + +const SectionHeader = styled.div` + padding: 8px 16px; + font-size: 12px; + font-weight: 500; + color: #8b8fa3; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const SearchContainer = styled.div` + padding: 8px 12px; + border-bottom: 1px solid #f0f0f0; +`; + +const StyledSearchInput = styled(Input)` + .ant-input { + border: 1px solid #e1e3eb; + border-radius: 6px; + font-size: 13px; + + &:focus { + border-color: #4965f2; + box-shadow: 0 0 0 2px rgba(73, 101, 242, 0.1); + } + } +`; + +const WorkspaceList = styled.div` + max-height: 200px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; + } +`; + +const WorkspaceItem = styled.div<{ isActive?: boolean }>` + display: flex; + align-items: center; + padding: 10px 16px; + cursor: pointer; + transition: background-color 0.2s; + background-color: ${props => props.isActive ? '#f0f5ff' : 'transparent'}; + + &:hover { + background-color: ${props => props.isActive ? '#f0f5ff' : '#f8f9fa'}; + } +`; + +const WorkspaceName = styled.div` + flex: 1; + font-size: 13px; + color: #222222; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ActiveIcon = styled(CheckoutIcon)` + width: 16px; + height: 16px; + color: #4965f2; + margin-left: 8px; +`; + +const CreateWorkspaceItem = styled.div` + display: flex; + align-items: center; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + font-size: 13px; + color: #4965f2; + font-weight: 500; + + &:hover { + background-color: #f0f5ff; + color: #3651d4; + } + + svg { + width: 16px; + height: 16px; + margin-right: 10px; + color: #4965f2; + } + + &:hover svg { + color: #3651d4; + } +`; + +const EmptyState = styled.div` + padding: 20px 16px; + text-align: center; + color: #8b8fa3; + font-size: 13px; +`; + +const PaginationContainer = styled.div` + padding: 12px 16px; + border-top: 1px solid #f0f0f0; + display: flex; + justify-content: center; + + .ant-pagination { + margin: 0; + + .ant-pagination-item { + min-width: 24px; + height: 24px; + line-height: 22px; + font-size: 12px; + margin-right: 4px; + } + + .ant-pagination-prev, + .ant-pagination-next { + min-width: 24px; + height: 24px; + line-height: 22px; + margin-right: 4px; + } + + .ant-pagination-item-link { + font-size: 11px; + } + } +`; + +const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; +`; + +// Component Props +interface WorkspaceSectionProps { + user: User; + isDropdownOpen: boolean; + onClose: () => void; +} + +// Main Component +export default function WorkspaceSectionComponent({ + user, + isDropdownOpen, + onClose +}: WorkspaceSectionProps) { + const dispatch = useDispatch(); + const sysConfig = useSelector(selectSystemConfig); + + // Use our custom hook + const { + searchTerm, + currentPage, + totalCount, + isLoading, + displayWorkspaces, + handleSearchChange, + handlePageChange, + pageSize, + } = useWorkspaceManager({}); + + // Early returns for better performance + if (!showSwitchOrg(user, sysConfig)) return null; + + // Event handlers + const handleOrgSwitch = (orgId: string) => { + if (user.currentOrgId !== orgId) { + dispatch(switchOrg(orgId)); + } + onClose(); + }; + + const handleCreateOrg = () => { + dispatch(createOrgAction(user.orgs)); + history.push(ORGANIZATION_SETTING); + onClose(); + }; + + return ( + + {trans("profile.switchOrg")} + + {/* Search Input - Only show if more than 3 workspaces */} + + handleSearchChange(e.target.value)} + prefix={} + size="small" + /> + + + {/* Workspace List */} + + {isLoading ? ( + + + + ) : displayWorkspaces.length > 0 ? ( + displayWorkspaces.map((org: Org) => ( + handleOrgSwitch(org.id)} + > + {org.name} + {user.currentOrgId === org.id && } + + )) + ) : ( + + {searchTerm.trim() + ? "No workspaces found" + : "No workspaces available" + } + + )} + + + {/* Pagination - Only show when needed */} + {totalCount > pageSize && !isLoading && ( + + + `${range[0]}-${range[1]} of ${total}` + } + onChange={handlePageChange} + simple={totalCount > 100} // Simple mode for large datasets + /> + + )} + + {/* Create Workspace Button */} + + + {trans("profile.createOrg")} + + + ); +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/pages/common/profileDropdown.tsx b/client/packages/lowcoder/src/pages/common/profileDropdown.tsx index 4f083cc186..ac86f42fe5 100644 --- a/client/packages/lowcoder/src/pages/common/profileDropdown.tsx +++ b/client/packages/lowcoder/src/pages/common/profileDropdown.tsx @@ -1,260 +1,200 @@ import { default as Dropdown } from "antd/es/dropdown"; -import { default as Menu, MenuItemProps } from "antd/es/menu"; import { Org, OrgRoleInfo } from "constants/orgConstants"; -import { ORGANIZATION_SETTING } from "constants/routesURL"; import { User } from "constants/userConstants"; +import { getCurrentOrg } from "redux/selectors/orgSelectors"; import { - AddIcon, - CheckoutIcon, - CommonGrayLabel, CommonTextLabel, - CommonTextLabel2, - DropdownMenu, - DropDownSubMenu, EditIcon, - PackUpIcon, } from "lowcoder-design"; import ProfileSettingModal from "pages/setting/profile"; -import React, { useMemo } from "react"; +import React, { useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { createOrgAction, switchOrg } from "redux/reduxActions/orgActions"; import styled from "styled-components"; -import history from "util/history"; import ProfileImage from "pages/common/profileImage"; import { isProfileSettingModalVisible } from "redux/selectors/usersSelectors"; import { logoutAction, profileSettingModalVisible } from "redux/reduxActions/userActions"; import { trans } from "i18n"; -import { showSwitchOrg } from "@lowcoder-ee/pages/common/customerService"; import { checkIsMobile } from "util/commonUtils"; -import { selectSystemConfig } from "redux/selectors/configSelectors"; -import type { ItemType } from "antd/es/menu/interface"; +import WorkspaceSectionComponent from "./WorkspaceSection"; -const { Item } = Menu; +// Keep existing styled components for profile and actions +const ProfileDropdownContainer = styled.div` + width: 280px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + border: 1px solid #e1e3eb; + overflow: hidden; +`; -const ProfileWrapper = styled.div` +const ProfileSection = styled.div` display: flex; align-items: center; - flex-direction: column; - gap: 10px; - padding: 4px 0 12px 0; - - p { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - word-break: keep-all; - } - - svg { - visibility: hidden; - } - - :hover svg { - visibility: visible; - - g g { - fill: #3377ff; - } + padding: 16px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #f8f9fa; } `; -const StyledDropdown = styled(Dropdown)` - display: flex; - flex-direction: column; +const ProfileInfo = styled.div` + margin-left: 12px; + flex: 1; min-width: 0; - align-items: end; `; -const StyledPackUpIcon = styled(PackUpIcon)` - width: 20px; - height: 20px; - transform: rotate(90deg); +const ProfileName = styled.div` + font-weight: 500; + font-size: 14px; + color: #222222; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; -const SelectDropMenuItem = styled((props: MenuItemProps) => )` - .ant-dropdown-menu-item-icon { - position: absolute; - right: 0; - width: 16px; - height: 16px; - margin-right: 8px; - } - - .ant-dropdown-menu-title-content { - color: #4965f2; - padding-right: 22px; - } +const ProfileOrg = styled.div` + font-size: 12px; + color: #8b8fa3; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; -const StyledDropdownSubMenu = styled(DropDownSubMenu)` - min-width: 192px; - - .ant-dropdown-menu-item { - height: 29px; - } +const ProfileRole = styled.div` + font-size: 11px; + color: #4965f2; + background: #f0f5ff; + border: 1px solid #d6e4ff; + border-radius: 4px; + padding: 2px 6px; + display: inline-block; + max-width: fit-content; +`; - .ant-dropdown-menu-item-divider, - .ant-dropdown-menu-submenu-title-divider { - background-color: #e1e3eb; - } +const ActionsSection = styled.div` + border-top: 1px solid #f0f0f0; `; -const StyledNameLabel = styled.div` - width: 160px; - text-align: center; - position: relative; - margin-top: -3px; +const ActionItem = styled.div` display: flex; - justify-content: center; - - p { - font-weight: 500; - font-size: 14px; - line-height: 16px; - color: #222222; - padding-left: 16px; + align-items: center; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + font-size: 13px; + color: #222222; + + &:hover { + background-color: #f8f9fa; + } + + svg { + width: 16px; + height: 16px; + margin-right: 10px; } `; -const OrgRoleLabel = styled.div` - font-size: 12px; - color: #4965f2; - line-height: 14px; - border: 1px solid #d6e4ff; - border-radius: 8px; - padding: 1px 5px; +const StyledDropdown = styled(Dropdown)` + display: flex; + flex-direction: column; + min-width: 0; + align-items: end; `; +// Component Props type DropDownProps = { onClick?: (text: string) => void; user: User; profileSide: number; fontSize?: number; }; + +// Simplified Main Component export default function ProfileDropdown(props: DropDownProps) { - const { avatarUrl, username, orgs, currentOrgId } = props.user; + const { avatarUrl, username, currentOrgId } = props.user; const currentOrgRoleId = props.user.orgRoleMap.get(currentOrgId); - const currentOrg = useMemo( - () => props.user.orgs.find((o) => o.id === currentOrgId), - [props.user, currentOrgId] - ); + const currentOrg = useSelector(getCurrentOrg); const settingModalVisible = useSelector(isProfileSettingModalVisible); - const sysConfig = useSelector(selectSystemConfig); const dispatch = useDispatch(); - const handleClick = (e: any) => { - if (e.key === "profile") { - // click the profile, while not close the dropdown - if (checkIsMobile(window.innerWidth)) { - return; - } - dispatch(profileSettingModalVisible(true)); - } else if (e.key === "logout") { - // logout - const organizationId = localStorage.getItem('lowcoder_login_orgId'); - if (organizationId) { - localStorage.removeItem('lowcoder_login_orgId'); - } - dispatch(logoutAction({ - organizationId: organizationId || undefined, - })); - } else if (e.keyPath.includes("switchOrg")) { - if (e.key === "newOrganization") { - // create new organization - dispatch(createOrgAction(orgs)); - history.push(ORGANIZATION_SETTING); - } else if (currentOrgId !== e.key) { - // switch org - dispatch(switchOrg(e.key)); - } + + // Simple state - only what we need + const [dropdownVisible, setDropdownVisible] = useState(false); + + // Event handlers + const handleProfileClick = () => { + if (checkIsMobile(window.innerWidth)) { + setDropdownVisible(false); + return; + } + dispatch(profileSettingModalVisible(true)); + setDropdownVisible(false); + }; + + const handleLogout = () => { + const organizationId = localStorage.getItem('lowcoder_login_orgId'); + if (organizationId) { + localStorage.removeItem('lowcoder_login_orgId'); } + dispatch(logoutAction({ + organizationId: organizationId || undefined, + })); + setDropdownVisible(false); }; - let profileDropdownMenuItems:ItemType[] = [ - { - key: 'profile', - label: ( - - - - {username} - {!checkIsMobile(window.innerWidth) && } - + const handleDropdownClose = () => { + setDropdownVisible(false); + }; + + // Dropdown content + const dropdownContent = ( + e.stopPropagation()}> + {/* Profile Section */} + + + + {username} {currentOrg && ( - - {currentOrg.name} - + {currentOrg.name} )} {currentOrgRoleId && OrgRoleInfo[currentOrgRoleId] && ( - {OrgRoleInfo[currentOrgRoleId].name} + {OrgRoleInfo[currentOrgRoleId].name} )} - - ), - }, - { - key: 'logout', - label: trans("profile.logout"), - } - ] - - if(orgs && orgs.length > 0 && showSwitchOrg(props.user, sysConfig)) { - const switchOrgSubMenu = orgs.map((org: Org) => ({ - key: org.id, - icon: currentOrgId === org.id && , - label: org.name - })) - - let addWorkSpace:ItemType[] = []; - if(!checkIsMobile(window.innerWidth)) { - addWorkSpace = [ - { type: 'divider'}, - { - key: 'newOrganization', - icon: , - label: trans("profile.createOrg") - } - ] - } - - const switchOrgMenu = { - key: 'switchOrg', - label: trans("profile.switchOrg"), - popupOffset: checkIsMobile(window.innerWidth) ? [-200, 36] : [4, -12], - children: [ - { - key: 'joinedOrg', - label: ( - - {trans("profile.joinedOrg")} - - ), - disabled: true, - }, - ...switchOrgSubMenu, - ...addWorkSpace, - ] - } - profileDropdownMenuItems.splice(1, 0, switchOrgMenu); - } - - const menu = ( - } - items={profileDropdownMenuItems} - /> + + {!checkIsMobile(window.innerWidth) && ( + + )} + + + {/* Workspaces Section - Now extracted and clean! */} + + + {/* Actions Section */} + + + {trans("profile.logout")} + + + ); + return ( <> menu} + open={dropdownVisible} + onOpenChange={setDropdownVisible} + dropdownRender={() => dropdownContent} trigger={["click"]} + placement="bottomRight" >
} ); -} +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx index ba99bc7df9..2f4dc160e3 100644 --- a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx +++ b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx @@ -1,7 +1,7 @@ import { ADMIN_ROLE, SUPER_ADMIN_ROLE } from "constants/orgConstants"; -import { AddIcon, CustomModal, DangerIcon, EditPopover } from "lowcoder-design"; +import { AddIcon, CustomModal, DangerIcon, EditPopover, SearchIcon, CheckoutIcon } from "lowcoder-design"; import { useDispatch, useSelector } from "react-redux"; -import { createOrgAction, deleteOrgAction } from "redux/reduxActions/orgActions"; +import { createOrgAction, deleteOrgAction, switchOrg } from "redux/reduxActions/orgActions"; import styled from "styled-components"; import { trans, transToNode } from "i18n"; import { buildOrgId } from "constants/routesURL"; @@ -15,13 +15,17 @@ import { Table } from "components/Table"; import history from "util/history"; import { StyledOrgLogo } from "./styledComponents"; import { Level1SettingPageContentWithList, Level1SettingPageTitleWithBtn } from "../styled"; -import { timestampToHumanReadable } from "util/dateTimeUtils"; import { isSaasMode } from "util/envUtils"; import { selectSystemConfig } from "redux/selectors/configSelectors"; import { default as Form } from "antd/es/form"; import { default as Input } from "antd/es/input"; +import { Pagination, Spin } from "antd"; import { getUser } from "redux/selectors/usersSelectors"; import { getOrgCreateStatus } from "redux/selectors/orgSelectors"; +import { useWorkspaceManager } from "util/useWorkspaceManager"; +import { Org } from "constants/orgConstants"; +import { useState } from "react"; +import { SwapOutlined } from "@ant-design/icons"; const OrgName = styled.div` display: flex; @@ -47,12 +51,56 @@ const OrgName = styled.div` } `; +// Icon to indicate the currently active organization +const ActiveOrgIcon = styled(CheckoutIcon)` + width: 16px; + height: 16px; + color: #4965f2; + margin-left: 6px; +`; + +// Button to switch to this organization +const SwitchBtn = styled(EditBtn)` + min-width: auto; + margin-right: 8px; +`; + const TableStyled = styled(Table)` .ant-table-tbody > tr > td { padding: 11px 12px; } `; +const SearchContainer = styled.div` + margin-bottom: 16px; + max-width: 320px; +`; + +const StyledSearchInput = styled(Input)` + .ant-input { + border: 1px solid #e1e3eb; + border-radius: 6px; + font-size: 13px; + + &:focus { + border-color: #4965f2; + box-shadow: 0 0 0 2px rgba(73, 101, 242, 0.1); + } + } +`; + +const PaginationContainer = styled.div` + margin-top: 16px; + display: flex; + justify-content: flex-end; +`; + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + padding: 40px 0; +`; + const Content = styled.div` &, .ant-form-item-label, @@ -120,29 +168,43 @@ const Tip = styled.div` type DataItemInfo = { id: string; del: boolean; - createTime: string; orgName: string; logoUrl: string; }; function OrganizationSetting() { const user = useSelector(getUser); - const orgs = user.orgs; - const adminOrgs = orgs.filter((org) => { - const role = user.orgRoleMap.get(org.id); - return role === ADMIN_ROLE || role === SUPER_ADMIN_ROLE; - }); const orgCreateStatus = useSelector(getOrgCreateStatus); const dispatch = useDispatch(); const sysConfig = useSelector(selectSystemConfig); const [form] = Form.useForm(); - const dataSource = adminOrgs.map((org) => ({ + // Use the workspace manager hook for search and pagination + const { + searchTerm, + currentPage, + totalCount, + isLoading, + displayWorkspaces, + handleSearchChange, + handlePageChange, + pageSize, + } = useWorkspaceManager({ + pageSize: 10 + }); + + + // Filter to only show orgs where user has admin permissions + const adminOrgs = displayWorkspaces.filter((org: Org) => { + const role = user.orgRoleMap.get(org.id); + return role === ADMIN_ROLE || role === SUPER_ADMIN_ROLE; + }); + + const dataSource = adminOrgs.map((org: Org) => ({ id: org.id, del: adminOrgs.length > 1, - createTime: org.createTime, orgName: org.name, - logoUrl: org.logoUrl, + logoUrl: org.logoUrl || "", })); return ( @@ -154,131 +216,175 @@ function OrganizationSetting() { loading={orgCreateStatus === "requesting"} buttonType={"primary"} icon={} - onClick={() => dispatch(createOrgAction(orgs))} + onClick={() => dispatch(createOrgAction(user.orgs))} > {trans("orgSettings.createOrg")} )} + + {/* Search Input */} + + handleSearchChange(e.target.value)} + prefix={} + size="middle" + /> + +
- ({ - onClick: () => history.push(buildOrgId((record as DataItemInfo).id)), - })} - columns={[ - { - title: trans("orgSettings.orgName"), - dataIndex: "orgName", - ellipsis: true, - render: (_, record: any) => { - return ( - - - {record.orgName} - - ); - }, - }, - { - title: trans("memberSettings.createTime"), - dataIndex: "createTime", - ellipsis: true, - render: (value) => ( - {timestampToHumanReadable(value)} - ), - }, - { title: " ", dataIndex: "operation", width: "208px" }, - ]} - dataSource={dataSource.map((item, i) => ({ - ...item, - key: i, - operation: ( - - history.push(buildOrgId(item.id))} - > - {trans("edit")} - - {item.del && ( - { - CustomModal.confirm({ - width: "384px", - title: trans("orgSettings.deleteModalTitle"), - bodyStyle: { marginTop: 0 }, - content: ( - - - - - {transToNode("orgSettings.deleteModalContent", { - permanentlyDelete: ( - {trans("orgSettings.permanentlyDelete")} - ), - notRestored: {trans("orgSettings.notRestored")}, - })} - - -
- - {item.orgName} - - ), - })} - rules={[ - { - required: true, - message: trans("orgSettings.deleteModalTip"), - }, - ]} - > - - -
-
- ), - onConfirm: () => { - form.submit(); - return form.validateFields().then(() => { - const name = form.getFieldValue("name"); - if (name === item.orgName) { - dispatch(deleteOrgAction(item.id)); + {isLoading ? ( + + + + ) : ( + <> + ({ + onClick: () => history.push(buildOrgId((record as DataItemInfo).id)), + })} + columns={[ + { + title: trans("orgSettings.orgName"), + dataIndex: "orgName", + ellipsis: true, + render: (_, record: any) => { + const isActiveOrg = record.id === user.currentOrgId; + return ( + + + {record.orgName} + {isActiveOrg && } + + ); + }, + }, + { title: " ", dataIndex: "operation", width: "208px" }, + ]} + dataSource={dataSource.map((item, i) => ({ + ...item, + key: i, + operation: ( + + {item.id !== user.currentOrgId && ( + } + onClick={(e) => { + e.stopPropagation(); + dispatch(switchOrg(item.id)); + }} + > + {trans("profile.switchWorkspace")} + + )} + history.push(buildOrgId(item.id))} + > + {trans("edit")} + + {item.del && ( + { + CustomModal.confirm({ + width: "384px", + title: trans("orgSettings.deleteModalTitle"), + bodyStyle: { marginTop: 0 }, + content: ( + + + + + {transToNode("orgSettings.deleteModalContent", { + permanentlyDelete: ( + {trans("orgSettings.permanentlyDelete")} + ), + notRestored: {trans("orgSettings.notRestored")}, + })} + + +
+ + {item.orgName} + + ), + })} + rules={[ + { + required: true, + message: trans("orgSettings.deleteModalTip"), + }, + ]} + > + + +
+
+ ), + onConfirm: () => { + form.submit(); + return form.validateFields().then(() => { + const name = form.getFieldValue("name"); + if (name === item.orgName) { + dispatch(deleteOrgAction(item.id)); + form.resetFields(); + } else { + form.setFields([ + { + name: "name", + errors: [trans("orgSettings.deleteModalErr")], + }, + ]); + throw new Error(); + } + }); + }, + onCancel: () => { form.resetFields(); - } else { - form.setFields([ - { - name: "name", - errors: [trans("orgSettings.deleteModalErr")], - }, - ]); - throw new Error(); - } + }, + confirmBtnType: "delete", + okText: trans("orgSettings.deleteModalBtn"), }); - }, - onCancel: () => { - form.resetFields(); - }, - confirmBtnType: "delete", - okText: trans("orgSettings.deleteModalBtn"), - }); - }} - > - -
- )} -
- ), - }))} - /> + }} + > + +
+ )} +
+ ), + }))} + /> + + {/* Pagination */} + {totalCount > pageSize && ( + + + `${range[0]}-${range[1]} of ${total} organizations` + } + onChange={handlePageChange} + /> + + )} + + )}
); diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts index be4b3a1dd0..4146dfd625 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts @@ -1,3 +1,4 @@ +import { Org } from "@lowcoder-ee/constants/orgConstants"; import { ReduxAction, ReduxActionErrorTypes, @@ -21,6 +22,10 @@ const initialState: UsersReduxState = { rawCurrentUser: defaultCurrentUser, profileSettingModalVisible: false, apiKeys: [], + workspaces: { + items: [], + totalCount: 0, + } }; const usersReducer = createReducer(initialState, { @@ -190,6 +195,21 @@ const usersReducer = createReducer(initialState, { ...state, apiKeys: action.payload, }), + + + [ReduxActionTypes.FETCH_WORKSPACES_SUCCESS]: ( + state: UsersReduxState, + action: ReduxAction<{ items: Org[], totalCount: number, isLoadMore?: boolean }> + ) => ({ + ...state, + workspaces: { + items: action.payload.isLoadMore + ? [...state.workspaces.items, ...action.payload.items] // Append for load more + : action.payload.items, // Replace for new search/initial load + totalCount: action.payload.totalCount + } + }), + }); export interface UsersReduxState { @@ -205,6 +225,13 @@ export interface UsersReduxState { error: string; profileSettingModalVisible: boolean; apiKeys: Array; + + // NEW state for workspaces + // NEW: Separate workspace state + workspaces: { + items: Org[]; // Current page of workspaces + totalCount: number; // Total workspaces available + }; } export default usersReducer; diff --git a/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts b/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts index 196d7c1554..7b94ee84d4 100644 --- a/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts +++ b/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts @@ -191,4 +191,14 @@ export const fetchLastMonthAPIUsageActionSuccess = (payload: OrgLastMonthAPIUsag type: ReduxActionTypes.FETCH_ORG_LAST_MONTH_API_USAGE_SUCCESS, payload: payload, }; -}; \ No newline at end of file +}; + +export const fetchWorkspacesAction = (page: number = 1,pageSize: number = 20, search?: string, isLoadMore?: boolean) => ({ + type: ReduxActionTypes.FETCH_WORKSPACES_INIT, + payload: { page, pageSize, search, isLoadMore } +}); + +export const loadMoreWorkspacesAction = (page: number, search?: string) => ({ + type: ReduxActionTypes.FETCH_WORKSPACES_INIT, + payload: { page, search, isLoadMore: true } +}); \ No newline at end of file diff --git a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts index f4b2d3d3f2..54e4eee1e7 100644 --- a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts @@ -25,11 +25,14 @@ import { fetchLastMonthAPIUsageActionSuccess, UpdateUserGroupRolePayload, UpdateUserOrgRolePayload, + fetchWorkspacesAction, } from "redux/reduxActions/orgActions"; import { getUser } from "redux/selectors/usersSelectors"; import { validateResponse } from "api/apiUtils"; import { User } from "constants/userConstants"; import { getUserSaga } from "redux/sagas/userSagas"; +import { GetMyOrgsResponse } from "@lowcoder-ee/api/userApi"; +import UserApi from "@lowcoder-ee/api/userApi"; export function* updateGroupSaga(action: ReduxAction) { try { @@ -268,6 +271,8 @@ export function* deleteOrgSaga(action: ReduxAction<{ orgId: string }>) { orgId: action.payload.orgId, }, }); + // Refetch workspaces to update the profile dropdown + yield put(fetchWorkspacesAction(1, 10)); } } catch (error: any) { messageInstance.error(error.message); @@ -281,6 +286,8 @@ export function* updateOrgSaga(action: ReduxAction) { const isValidResponse: boolean = validateResponse(response); if (isValidResponse) { yield put(updateOrgSuccess(action.payload)); + // Refetch workspaces to update the profile dropdown + yield put(fetchWorkspacesAction(1, 10)); } } catch (error: any) { messageInstance.error(error.message); @@ -324,6 +331,43 @@ export function* fetchLastMonthAPIUsageSaga(action: ReduxAction<{ } } +// fetch my orgs +// In userSagas.ts +export function* fetchWorkspacesSaga(action: ReduxAction<{page: number, pageSize: number, search?: string, isLoadMore?: boolean}>) { + try { + const { page, pageSize, search, isLoadMore } = action.payload; + + const response: AxiosResponse = yield call( + UserApi.getMyOrgs, + page, // pageNum + pageSize, // pageSize (changed to 5 for testing) + search // orgName + ); + + if (validateResponse(response)) { + const apiData = response.data.data; + console.log("apiData", apiData); + + // Transform orgId/orgName to match Org interface + const transformedItems = apiData.data.map(item => ({ + id: item.orgId, + name: item.orgName, + })); + + yield put({ + type: ReduxActionTypes.FETCH_WORKSPACES_SUCCESS, + payload: { + items: transformedItems, + totalCount: apiData.total, + isLoadMore: isLoadMore || false + } + }); + } + } catch (error: any) { + console.error('Error fetching workspaces:', error); + } +} + export default function* orgSagas() { yield all([ takeLatest(ReduxActionTypes.UPDATE_GROUP_INFO, updateGroupSaga), @@ -343,5 +387,8 @@ export default function* orgSagas() { takeLatest(ReduxActionTypes.UPDATE_ORG, updateOrgSaga), takeLatest(ReduxActionTypes.FETCH_ORG_API_USAGE, fetchAPIUsageSaga), takeLatest(ReduxActionTypes.FETCH_ORG_LAST_MONTH_API_USAGE, fetchLastMonthAPIUsageSaga), + takeLatest(ReduxActionTypes.FETCH_WORKSPACES_INIT, fetchWorkspacesSaga), + + ]); } diff --git a/client/packages/lowcoder/src/redux/sagas/userSagas.ts b/client/packages/lowcoder/src/redux/sagas/userSagas.ts index 5b980953fd..d0dfdba068 100644 --- a/client/packages/lowcoder/src/redux/sagas/userSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/userSagas.ts @@ -25,6 +25,7 @@ import { messageInstance } from "lowcoder-design/src/components/GlobalInstances" import { AuthSearchParams } from "constants/authConstants"; import { saveAuthSearchParams } from "pages/userAuth/authUtils"; import { initTranslator } from "i18n"; +import { fetchWorkspacesAction } from "../reduxActions/orgActions"; function validResponseData(response: AxiosResponse) { return response && response.data && response.data.data; @@ -71,10 +72,13 @@ export function* getUserSaga() { orgs: orgs, orgRoleMap: orgRoleMap, }; + yield put({ type: ReduxActionTypes.FETCH_USER_DETAILS_SUCCESS, payload: user, }); + // fetch all workspaces and store in redux + yield put(fetchWorkspacesAction(1, 10)); } } catch (error: any) { yield put({ diff --git a/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts b/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts index 2115f1499b..322f414f7b 100644 --- a/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts +++ b/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts @@ -1,4 +1,7 @@ +import { Org } from "@lowcoder-ee/constants/orgConstants"; +import { getUser } from "./usersSelectors"; import { AppState } from "redux/reducers"; +import { getHomeOrg } from "./applicationSelector"; export const getOrgUsers = (state: AppState) => { return state.ui.org.orgUsers; @@ -27,3 +30,18 @@ export const getOrgApiUsage = (state: AppState) => { export const getOrgLastMonthApiUsage = (state: AppState) => { return state.ui.org.lastMonthApiUsage; } + +// Add to usersSelectors.ts +export const getWorkspaces = (state: AppState) => state.ui.users.workspaces; + +export const getCurrentOrg = (state: AppState): Pick | undefined => { + const homeOrg = getHomeOrg(state); + if (!homeOrg) { + return undefined; + } + + return { + id: homeOrg.id, + name: homeOrg.name, + }; +}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/util/useWorkspaceManager.ts b/client/packages/lowcoder/src/util/useWorkspaceManager.ts new file mode 100644 index 0000000000..501fe77586 --- /dev/null +++ b/client/packages/lowcoder/src/util/useWorkspaceManager.ts @@ -0,0 +1,188 @@ +import { useReducer, useEffect, useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { debounce } from 'lodash'; +import { Org } from 'constants/orgConstants'; +import { getWorkspaces } from 'redux/selectors/orgSelectors'; +import UserApi from 'api/userApi'; + +// State interface for the workspace manager +interface WorkspaceState { + searchTerm: string; + currentPage: number; + currentPageWorkspaces: Org[]; + totalCount: number; + isLoading: boolean; +} + +// Action types for the reducer +type WorkspaceAction = + | { type: 'SET_SEARCH_TERM'; payload: string } + | { type: 'SET_PAGE'; payload: number } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'SET_WORKSPACES'; payload: { workspaces: Org[]; total: number } } + | { type: 'RESET'; payload: { totalCount: number } }; + +// Initial state +const initialState: WorkspaceState = { + searchTerm: '', + currentPage: 1, + currentPageWorkspaces: [], + totalCount: 0, + isLoading: false, +}; + +// Reducer function - handles state transitions +function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState { + switch (action.type) { + case 'SET_SEARCH_TERM': + return { + ...state, + searchTerm: action.payload, + currentPage: 1 , + isLoading: Boolean(action.payload.trim()) + }; + case 'SET_PAGE': + return { ...state, currentPage: action.payload }; + case 'SET_LOADING': + return { ...state, isLoading: action.payload }; + case 'SET_WORKSPACES': + return { + ...state, + currentPageWorkspaces: action.payload.workspaces, + totalCount: action.payload.total, + isLoading: false, + }; + case 'RESET': + return { + ...initialState, + totalCount: action.payload.totalCount, + }; + default: + return state; + } +} + +// Hook interface +interface UseWorkspaceManagerOptions { + pageSize?: number; +} + +// Main hook +export function useWorkspaceManager({ + pageSize = 10 +}: UseWorkspaceManagerOptions) { + // Get workspaces from Redux + const workspaces = useSelector(getWorkspaces); + + // Initialize reducer with Redux total count + const [state, dispatch] = useReducer(workspaceReducer, { + ...initialState, + totalCount: workspaces.totalCount, + }); + + + // API call to fetch workspaces (memoized for stable reference) + const fetchWorkspacesPage = useCallback( + async (page: number, search?: string) => { + dispatch({ type: 'SET_LOADING', payload: true }); + + try { + const response = await UserApi.getMyOrgs(page, pageSize, search); + if (response.data.success) { + const apiData = response.data.data; + const transformedItems = apiData.data.map(item => ({ + id: item.orgId, + name: item.orgName, + })); + + dispatch({ + type: 'SET_WORKSPACES', + payload: { + workspaces: transformedItems as Org[], + total: apiData.total, + }, + }); + } + } catch (error) { + console.error('Error fetching workspaces:', error); + dispatch({ type: 'SET_WORKSPACES', payload: { workspaces: [], total: 0 } }); + } + }, + [dispatch, pageSize] + ); + + // Debounced search function (memoized to keep a single instance across renders) + const debouncedSearch = useMemo(() => + debounce(async (term: string) => { + if (!term.trim()) { + // Clear search - reset to Redux data + dispatch({ + type: 'SET_WORKSPACES', + payload: { workspaces: [], total: workspaces.totalCount }, + }); + return; + } + + // Perform search + await fetchWorkspacesPage(1, term); + }, 500) + , [dispatch, fetchWorkspacesPage, workspaces.totalCount]); + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + // Handle search input change + const handleSearchChange = (value: string) => { + dispatch({ type: 'SET_SEARCH_TERM', payload: value }); + debouncedSearch(value); + }; + + // Handle page change + const handlePageChange = (page: number) => { + dispatch({ type: 'SET_PAGE', payload: page }); + + if (page === 1 && !state.searchTerm.trim()) { + // Page 1 + no search = use Redux data + dispatch({ + type: 'SET_WORKSPACES', + payload: { workspaces: [], total: workspaces.totalCount } + }); + } else { + // Other pages or search = fetch from API + fetchWorkspacesPage(page, state.searchTerm.trim() || undefined); + } + }; + + // Determine which workspaces to display + const displayWorkspaces = (() => { + if (state.searchTerm.trim() || state.currentPage > 1) { + return state.currentPageWorkspaces; // API results + } + return workspaces.items; // Redux data for page 1 + })(); + + // Determine current total count + const currentTotalCount = state.searchTerm.trim() + ? state.totalCount + : workspaces.totalCount; + + return { + // State + searchTerm: state.searchTerm, + currentPage: state.currentPage, + isLoading: state.isLoading, + displayWorkspaces, + totalCount: currentTotalCount, + + // Actions + handleSearchChange, + handlePageChange, + + // Config + pageSize, + }; +} \ No newline at end of file