diff --git a/app/client/src/ce/actions/organizationActions.ts b/app/client/src/ce/actions/organizationActions.ts index e65b80af230f..3e7d65c141e5 100644 --- a/app/client/src/ce/actions/organizationActions.ts +++ b/app/client/src/ce/actions/organizationActions.ts @@ -19,3 +19,9 @@ export const updateOrganizationConfig = ( type: ReduxActionTypes.UPDATE_ORGANIZATION_CONFIG, payload, }); + +export const fetchMyOrganizations = () => { + return { + type: ReduxActionTypes.FETCH_MY_ORGANIZATIONS_INIT, + }; +}; diff --git a/app/client/src/ce/api/OrganizationApi.ts b/app/client/src/ce/api/OrganizationApi.ts index 4476731652af..473784fcc4c4 100644 --- a/app/client/src/ce/api/OrganizationApi.ts +++ b/app/client/src/ce/api/OrganizationApi.ts @@ -20,8 +20,20 @@ export interface UpdateOrganizationConfigRequest { apiConfig?: AxiosRequestConfig; } +export type FetchMyOrganizationsResponse = ApiResponse<{ + organizations: Organization[]; +}>; + +export interface Organization { + organizationId: string; + organizationName: string; + organizationUrl: string; + state: string; +} + export class OrganizationApi extends Api { static tenantsUrl = "v1/tenants"; + static meUrl = "v1/users/me"; static async fetchCurrentOrganizationConfig(): Promise< AxiosPromise @@ -41,6 +53,12 @@ export class OrganizationApi extends Api { }, ); } + + static async fetchMyOrganizations(): Promise< + AxiosPromise + > { + return Api.get(`${OrganizationApi.meUrl}/organizations`); + } } export default OrganizationApi; diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index d589f39f1024..80d94e61bd3a 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -1229,6 +1229,8 @@ const OrganizationActionTypes = { FETCH_PRODUCT_ALERT_INIT: "FETCH_PRODUCT_ALERT_INIT", FETCH_PRODUCT_ALERT_SUCCESS: "FETCH_PRODUCT_ALERT_SUCCESS", UPDATE_PRODUCT_ALERT_CONFIG: "UPDATE_PRODUCT_ALERT_CONFIG", + FETCH_MY_ORGANIZATIONS_INIT: "FETCH_MY_ORGANIZATIONS_INIT", + FETCH_MY_ORGANIZATIONS_SUCCESS: "FETCH_MY_ORGANIZATIONS_SUCCESS", }; const OrganizationActionErrorTypes = { @@ -1236,6 +1238,7 @@ const OrganizationActionErrorTypes = { "FETCH_CURRENT_ORGANIZATION_CONFIG_ERROR", UPDATE_ORGANIZATION_CONFIG_ERROR: "UPDATE_ORGANIZATION_CONFIG_ERROR", FETCH_PRODUCT_ALERT_FAILED: "FETCH_PRODUCT_ALERT_FAILED", + FETCH_MY_ORGANIZATIONS_ERROR: "FETCH_MY_ORGANIZATIONS_ERROR", }; const AnalyticsActionTypes = { diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 1830b12cdd72..bc4d16b1b8eb 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -2717,3 +2717,5 @@ export const MULTI_ORG_FOOTER_CREATE_ORG_LEFT_TEXT = () => "Looking to create one?"; export const MULTI_ORG_FOOTER_CREATE_ORG_RIGHT_TEXT = () => "Create an organization"; + +export const PENDING_INVITATIONS = () => "Pending invitations"; diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index e25b2a03012f..fd201a609fe4 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -99,8 +99,11 @@ import { getIsFetchingApplications, } from "ee/selectors/selectedWorkspaceSelectors"; import { + getIsFetchingMyOrganizations, + getMyOrganizations, getOrganizationPermissions, shouldShowLicenseBanner, + activeOrganizationId, } from "ee/selectors/organizationSelectors"; import { getWorkflowsList } from "ee/selectors/workflowSelectors"; import { @@ -141,6 +144,10 @@ import { } from "git"; import OldRepoLimitExceededErrorModal from "pages/Editor/gitSync/RepoLimitExceededErrorModal"; import { trackCurrentDomain } from "utils/multiOrgDomains"; +import OrganizationDropdown from "components/OrganizationDropdown"; +import { fetchMyOrganizations } from "ee/actions/organizationActions"; +import type { Organization } from "ee/api/OrganizationApi"; +import { useIsCloudBillingEnabled } from "hooks"; function GitModals() { const isGitModEnabled = useGitModEnabled(); @@ -434,25 +441,45 @@ export const submitCreateWorkspaceForm = async (data: any, dispatch: any) => { }; export interface LeftPaneProps { + activeOrganizationId?: string; + activeWorkspaceId?: string; isBannerVisible?: boolean; + isFetchingOrganizations: boolean; isFetchingWorkspaces: boolean; + organizations: Organization[]; workspaces: Workspace[]; - activeWorkspaceId?: string; } export function LeftPane(props: LeftPaneProps) { const { + activeOrganizationId, activeWorkspaceId, isBannerVisible = false, + isFetchingOrganizations, isFetchingWorkspaces, + organizations = [], workspaces = [], } = props; const isMobile = useIsMobileDevice(); + const isCloudBillingEnabled = useIsCloudBillingEnabled(); if (isMobile) return null; return ( + {isCloudBillingEnabled && + !isFetchingOrganizations && + organizations.length > 0 && ( + + organization.organizationId === activeOrganizationId, + ) || organizations[0] + } + /> + )} { const isHomePage = useRouteMatch("/applications")?.isExact; const isLicensePage = useRouteMatch("/license")?.isExact; const isBannerVisible = showBanner && (isHomePage || isLicensePage); + const organizations = useSelector(getMyOrganizations); + const isFetchingOrganizations = useSelector(getIsFetchingMyOrganizations); + const currentOrganizationId = useSelector(activeOrganizationId); // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1013,6 +1043,10 @@ export const ApplictionsMainPage = (props: any) => { workspaceIdFromQueryParams ? workspaceIdFromQueryParams : workspaces[0]?.id, ); + useEffect(() => { + dispatch(fetchMyOrganizations()); + }, []); + useEffect(() => { setActiveWorkspaceId( workspaceIdFromQueryParams @@ -1056,9 +1090,12 @@ export const ApplictionsMainPage = (props: any) => { return ( diff --git a/app/client/src/ce/reducers/organizationReducer.ts b/app/client/src/ce/reducers/organizationReducer.ts index 4d52873023d6..536722dccca2 100644 --- a/app/client/src/ce/reducers/organizationReducer.ts +++ b/app/client/src/ce/reducers/organizationReducer.ts @@ -10,6 +10,7 @@ import { createBrandColorsFromPrimaryColor, } from "utils/BrandingUtils"; import { createReducer } from "utils/ReducerUtils"; +import type { Organization } from "ee/api/OrganizationApi"; export interface OrganizationReduxState { displayName?: string; @@ -21,6 +22,8 @@ export interface OrganizationReduxState { instanceId: string; tenantId: string; isWithinAnOrganization: boolean; + myOrganizations: Organization[]; + isFetchingMyOrganizations: boolean; } export const defaultBrandingConfig = { @@ -43,6 +46,8 @@ export const initialState: OrganizationReduxState = { instanceId: "", tenantId: "", isWithinAnOrganization: false, + myOrganizations: [], + isFetchingMyOrganizations: false, }; export const handlers = { @@ -113,6 +118,32 @@ export const handlers = { ...state, isLoading: false, }), + [ReduxActionTypes.FETCH_MY_ORGANIZATIONS_INIT]: ( + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: OrganizationReduxState, + ) => ({ + ...state, + isFetchingMyOrganizations: true, + }), + [ReduxActionTypes.FETCH_MY_ORGANIZATIONS_SUCCESS]: ( + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: OrganizationReduxState, + action: ReduxAction, + ) => ({ + ...state, + myOrganizations: action.payload, + isFetchingMyOrganizations: false, + }), + [ReduxActionErrorTypes.FETCH_MY_ORGANIZATIONS_ERROR]: ( + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: OrganizationReduxState, + ) => ({ + ...state, + isFetchingMyOrganizations: false, + }), }; export default createReducer(initialState, handlers); diff --git a/app/client/src/ce/sagas/organizationSagas.tsx b/app/client/src/ce/sagas/organizationSagas.tsx index 81a3e00d9070..d97c04d24c30 100644 --- a/app/client/src/ce/sagas/organizationSagas.tsx +++ b/app/client/src/ce/sagas/organizationSagas.tsx @@ -5,7 +5,10 @@ import { } from "ee/constants/ReduxActionConstants"; import { call, put } from "redux-saga/effects"; import type { APIResponseError, ApiResponse } from "api/ApiResponses"; -import type { UpdateOrganizationConfigRequest } from "ee/api/OrganizationApi"; +import type { + FetchMyOrganizationsResponse, + UpdateOrganizationConfigRequest, +} from "ee/api/OrganizationApi"; import { OrganizationApi } from "ee/api/OrganizationApi"; import { validateResponse } from "sagas/ErrorSagas"; import { safeCrashAppRequest } from "actions/errorActions"; @@ -158,3 +161,26 @@ export function* updateOrganizationConfigSaga( }); } } + +export function* fetchMyOrganizationsSaga() { + try { + const response: FetchMyOrganizationsResponse = yield call( + OrganizationApi.fetchMyOrganizations, + ); + const isValidResponse: boolean = yield validateResponse(response); + + if (isValidResponse) { + yield put({ + type: ReduxActionTypes.FETCH_MY_ORGANIZATIONS_SUCCESS, + payload: response.data, + }); + } + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.FETCH_MY_ORGANIZATIONS_ERROR, + payload: { + error, + }, + }); + } +} diff --git a/app/client/src/ce/selectors/organizationSelectors.tsx b/app/client/src/ce/selectors/organizationSelectors.tsx index b49f923f5e78..93a6d4112e09 100644 --- a/app/client/src/ce/selectors/organizationSelectors.tsx +++ b/app/client/src/ce/selectors/organizationSelectors.tsx @@ -67,3 +67,15 @@ export const isFreePlan = (state: DefaultRootState) => true; // eslint-disable-next-line @typescript-eslint/no-unused-vars export const isWithinAnOrganization = (state: DefaultRootState) => true; + +export const getMyOrganizations = (state: DefaultRootState) => { + return state.organization?.myOrganizations || []; +}; + +export const getIsFetchingMyOrganizations = (state: DefaultRootState) => { + return state.organization?.isFetchingMyOrganizations || false; +}; + +export const activeOrganizationId = (state: DefaultRootState) => { + return state.organization?.tenantId; +}; diff --git a/app/client/src/components/OrganizationDropdown/index.tsx b/app/client/src/components/OrganizationDropdown/index.tsx new file mode 100644 index 000000000000..51efa6e0fd04 --- /dev/null +++ b/app/client/src/components/OrganizationDropdown/index.tsx @@ -0,0 +1,205 @@ +import { Avatar, Icon } from "@appsmith/ads"; +import type { Organization } from "ee/api/OrganizationApi"; +import { createMessage, PENDING_INVITATIONS } from "ee/constants/messages"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + DropdownContainer, + DropdownMenu, + DropdownTrigger, + MenuItem, + MenuItemIcon, + MenuItemText, + SectionDivider, + SectionHeader, + TriggerContent, + TriggerText, +} from "./styles"; + +export interface PendingInvitation { + id: string; + organizationName: string; +} + +export interface OrganizationDropdownProps { + "data-testid"?: string; + organizations: Organization[]; + selectedOrganization: Organization; +} + +const OrganizationDropdown: React.FC = ({ + "data-testid": testId, + organizations = [], + selectedOrganization, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const triggerRef = useRef(null); + const safeOrganizations = organizations || []; + const activeOrganizations = safeOrganizations.filter( + (org) => org.state === "ACTIVE", + ); + const pendingInvitations = safeOrganizations.filter( + (org) => org.state === "INVITED", + ); + + const generateInitials = (name: string): string => { + if (!name) return ""; + + return name.charAt(0).toUpperCase(); + }; + + const handleToggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleSelect = useCallback((organization: Organization) => { + if (organization.organizationUrl) { + const url = `https://${organization.organizationUrl}`; + + window.open(url, "_blank", "noopener,noreferrer"); + } + + setIsOpen(false); + }, []); + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + triggerRef.current?.focus(); + } + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [isOpen]); + + const displayText = selectedOrganization?.organizationName; + + const renderOrgAvatar = (orgName: string) => { + return ( + + ); + }; + + return ( + + + + {renderOrgAvatar(displayText)} + {displayText} + + + + + + {activeOrganizations + .slice() + .sort((a, b) => { + const aIsSelected = + a.organizationId === selectedOrganization?.organizationId; + + const bIsSelected = + b.organizationId === selectedOrganization?.organizationId; + + if (aIsSelected && !bIsSelected) return -1; + + if (!aIsSelected && bIsSelected) return 1; + + return 0; + }) + .map((org) => { + const isSelected = + org.organizationId === selectedOrganization?.organizationId; + + return ( + handleSelect(org) : undefined} + onKeyDown={ + !isSelected + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(org); + } + } + : undefined + } + role="option" + tabIndex={0} + > + {renderOrgAvatar(org.organizationName)} + + {org.organizationName}{" "} + {org.organizationId === + selectedOrganization?.organizationId && "(current)"} + + + {!isSelected && ( + + )} + + ); + })} + + {pendingInvitations.length > 0 && ( + <> + + {createMessage(PENDING_INVITATIONS)} + {pendingInvitations.map((invitation) => ( + handleSelect(invitation)} + role="option" + > + {renderOrgAvatar(invitation.organizationName)} + {invitation.organizationName} + + + + ))} + + )} + + + ); +}; + +export default OrganizationDropdown; diff --git a/app/client/src/components/OrganizationDropdown/styles.ts b/app/client/src/components/OrganizationDropdown/styles.ts new file mode 100644 index 000000000000..f5d54398c35d --- /dev/null +++ b/app/client/src/components/OrganizationDropdown/styles.ts @@ -0,0 +1,130 @@ +import { Icon } from "@appsmith/ads"; +import styled from "styled-components"; + +export const DropdownContainer = styled.div` + position: relative; + width: 100%; + margin: var(--ads-v2-spaces-3) 0; +`; + +export const DropdownTrigger = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--ads-v2-spaces-3) var(--ads-v2-spaces-4); + background: var(--ads-v2-color-bg); + border: 1px solid var(--ads-v2-color-border); + border-radius: var(--ads-v2-border-radius); + cursor: pointer; + font-size: 14px; + font-weight: 400; + color: var(--ads-v2-color-fg); + transition: all 0.2s ease; + min-height: 40px; +`; + +export const TriggerContent = styled.div` + display: flex; + align-items: center; + gap: var(--ads-v2-spaces-3); + min-width: 0; + flex: 1; +`; + +export const TriggerText = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 400; +`; + +export const DropdownMenu = styled.div<{ isOpen: boolean }>` + position: absolute; + top: calc(100% + 12px); + left: 0; + right: 0; + background: var(--ads-v2-color-bg); + padding: var(--ads-v2-spaces-2); + border: 1px solid var(--ads-v2-color-border); + border-radius: var(--ads-v2-border-radius); + box-shadow: var(--ads-v2-shadow-popovers); + z-index: var(--ads-v2-z-index-7); + max-height: 320px; + overflow-y: auto; + opacity: ${({ isOpen }) => (isOpen ? 1 : 0)}; + visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")}; + transform: ${({ isOpen }) => (isOpen ? "translateY(0)" : "translateY(-8px)")}; + transition: all 0.2s ease; +`; + +export const MenuItem = styled.div<{ + isSelected?: boolean; +}>` + display: flex; + align-items: center; + gap: var(--ads-v2-spaces-3); + padding: var(--ads-v2-spaces-3) var(--ads-v2-spaces-4); + margin-bottom: var(--ads-v2-spaces-2); + border-radius: var(--ads-v2-border-radius); + cursor: ${({ isSelected }) => (isSelected ? "default" : "pointer")}; + font-size: 14px; + color: var(--ads-v2-color-fg); + background: ${({ isSelected }) => + isSelected ? "var(--ads-v2-color-bg-muted)" : "transparent"}; + + .hover-icon { + opacity: 0; + transition: opacity 0.2s ease; + } + + &:hover { + background: ${({ isSelected }) => + isSelected + ? "var(--ads-v2-color-bg-muted)" + : "var(--ads-v2-color-bg-subtle)"}; + + .hover-icon { + opacity: ${({ isSelected }) => (isSelected ? 0 : 1)}; + } + } + + &:focus { + outline: none; + background: ${({ isSelected }) => + isSelected + ? "var(--ads-v2-color-bg-muted)" + : "var(--ads-v2-color-bg-subtle)"}; + + .hover-icon { + opacity: ${({ isSelected }) => (isSelected ? 0 : 1)}; + } + } +`; + +export const MenuItemIcon = styled(Icon)` + color: var(--ads-v2-color-fg-muted); + flex-shrink: 0; +`; + +export const MenuItemText = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + min-width: 0; +`; + +export const SectionDivider = styled.div` + height: 1px; + background: var(--ads-v2-color-border); + margin: var(--ads-v2-spaces-2) 0; +`; + +export const SectionHeader = styled.div` + padding: var(--ads-v2-spaces-2) var(--ads-v2-spaces-4); + font-size: 14px; + font-weight: 500; + color: var(--ads-v2-color-fg-muted); +`;