feat: add multi-organization dropdown for organization navigation (#40967)

This commit is contained in:
Jacques Ikot 2025-06-19 10:23:50 +01:00 committed by GitHub
parent 24ec7954a2
commit bb1c055126
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 474 additions and 4 deletions

View File

@ -19,3 +19,9 @@ export const updateOrganizationConfig = (
type: ReduxActionTypes.UPDATE_ORGANIZATION_CONFIG,
payload,
});
export const fetchMyOrganizations = () => {
return {
type: ReduxActionTypes.FETCH_MY_ORGANIZATIONS_INIT,
};
};

View File

@ -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<FetchCurrentOrganizationConfigResponse>
@ -41,6 +53,12 @@ export class OrganizationApi extends Api {
},
);
}
static async fetchMyOrganizations(): Promise<
AxiosPromise<FetchMyOrganizationsResponse>
> {
return Api.get(`${OrganizationApi.meUrl}/organizations`);
}
}
export default OrganizationApi;

View File

@ -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 = {

View File

@ -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";

View File

@ -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 {
isBannerVisible?: boolean;
isFetchingWorkspaces: boolean;
workspaces: Workspace[];
activeOrganizationId?: string;
activeWorkspaceId?: string;
isBannerVisible?: boolean;
isFetchingOrganizations: boolean;
isFetchingWorkspaces: boolean;
organizations: Organization[];
workspaces: Workspace[];
}
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 (
<LeftPaneWrapper isBannerVisible={isBannerVisible}>
{isCloudBillingEnabled &&
!isFetchingOrganizations &&
organizations.length > 0 && (
<OrganizationDropdown
organizations={organizations}
selectedOrganization={
organizations.find(
(organization) =>
organization.organizationId === activeOrganizationId,
) || organizations[0]
}
/>
)}
<LeftPaneSection
heading={createMessage(WORKSPACES_HEADING)}
isBannerVisible={isBannerVisible}
@ -992,6 +1019,9 @@ export const ApplictionsMainPage = (props: any) => {
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 (
<PageWrapper displayName="Applications">
<LeftPane
activeOrganizationId={currentOrganizationId}
activeWorkspaceId={activeWorkspaceId}
isBannerVisible={isBannerVisible}
isFetchingOrganizations={isFetchingOrganizations}
isFetchingWorkspaces={isFetchingWorkspaces}
organizations={organizations}
workspaces={workspaces}
/>
<MediaQuery maxWidth={MOBILE_MAX_WIDTH}>

View File

@ -10,6 +10,7 @@ import {
createBrandColorsFromPrimaryColor,
} from "utils/BrandingUtils";
import { createReducer } from "utils/ReducerUtils";
import type { Organization } from "ee/api/OrganizationApi";
export interface OrganizationReduxState<T> {
displayName?: string;
@ -21,6 +22,8 @@ export interface OrganizationReduxState<T> {
instanceId: string;
tenantId: string;
isWithinAnOrganization: boolean;
myOrganizations: Organization[];
isFetchingMyOrganizations: boolean;
}
export const defaultBrandingConfig = {
@ -43,6 +46,8 @@ export const initialState: OrganizationReduxState<any> = {
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<any>,
) => ({
...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<any>,
action: ReduxAction<Organization[]>,
) => ({
...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<any>,
) => ({
...state,
isFetchingMyOrganizations: false,
}),
};
export default createReducer(initialState, handlers);

View File

@ -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,
},
});
}
}

View File

@ -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;
};

View File

@ -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<OrganizationDropdownProps> = ({
"data-testid": testId,
organizations = [],
selectedOrganization,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<Avatar
firstLetter={generateInitials(orgName)}
label={generateInitials(orgName)}
size="sm"
/>
);
};
return (
<DropdownContainer data-testid={testId} ref={dropdownRef}>
<DropdownTrigger
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={`Current organization: ${displayText}`}
onClick={handleToggle}
onKeyDown={handleKeyDown}
ref={triggerRef}
type="button"
>
<TriggerContent>
{renderOrgAvatar(displayText)}
<TriggerText>{displayText}</TriggerText>
</TriggerContent>
<Icon name="dropdown" size="md" />
</DropdownTrigger>
<DropdownMenu aria-label="Organizations" isOpen={isOpen} role="listbox">
{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 (
<MenuItem
aria-selected={isSelected}
isSelected={isSelected}
key={org.organizationId}
onClick={!isSelected ? () => handleSelect(org) : undefined}
onKeyDown={
!isSelected
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect(org);
}
}
: undefined
}
role="option"
tabIndex={0}
>
{renderOrgAvatar(org.organizationName)}
<MenuItemText>
{org.organizationName}{" "}
{org.organizationId ===
selectedOrganization?.organizationId && "(current)"}
</MenuItemText>
{!isSelected && (
<MenuItemIcon
className="hover-icon color-fg-muted"
name="share-box-line"
size="md"
/>
)}
</MenuItem>
);
})}
{pendingInvitations.length > 0 && (
<>
<SectionDivider />
<SectionHeader>{createMessage(PENDING_INVITATIONS)}</SectionHeader>
{pendingInvitations.map((invitation) => (
<MenuItem
key={invitation.organizationId}
onClick={() => handleSelect(invitation)}
role="option"
>
{renderOrgAvatar(invitation.organizationName)}
<MenuItemText>{invitation.organizationName}</MenuItemText>
<MenuItemIcon
className="hover-icon color-fg-muted"
name="share-box-line"
size="md"
/>
</MenuItem>
))}
</>
)}
</DropdownMenu>
</DropdownContainer>
);
};
export default OrganizationDropdown;

View File

@ -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);
`;