diff --git a/components/dashboard/src/components/ContextMenu.tsx b/components/dashboard/src/components/ContextMenu.tsx index 57f81e633773e3..b0d09da84ec8da 100644 --- a/components/dashboard/src/components/ContextMenu.tsx +++ b/components/dashboard/src/components/ContextMenu.tsx @@ -4,7 +4,7 @@ * See License.AGPL.txt in the project root for license information. */ -import React, { HTMLAttributeAnchorTarget } from "react"; +import React, { FunctionComponent, HTMLAttributeAnchorTarget } from "react"; import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import cn from "classnames"; @@ -82,10 +82,6 @@ function ContextMenu(props: ContextMenuProps) { }; }, []); // Empty array ensures that effect is only run on mount and unmount - const font = "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"; - - const menuId = String(Math.random()); - // Default 'children' is the three dots hamburger button. const children = props.children || ( {expanded ? (
No actions available

) : ( props.menuEntries.map((e, index) => { - const clickable = e.href || e.onClick || e.link; const entry = ( -
- {e.customContent || ( - <> -
{e.title}
-
- {e.active ?
: null} - - )} -
+ ); - const key = `entry-${menuId}-${index}-${e.title}`; + const key = `entry-${index}-${e.title}`; if (e.link) { return ( @@ -185,3 +165,47 @@ function ContextMenu(props: ContextMenuProps) { } export default ContextMenu; + +type MenuEntryProps = ContextMenuEntry & { + isFirst: boolean; + isLast: boolean; +}; +export const MenuEntry: FunctionComponent = ({ + title, + href, + link, + active = false, + separator = false, + customContent, + customFontStyle, + isFirst, + isLast, + onClick, +}) => { + const clickable = href || link || onClick; + + return ( +
+ {customContent || ( + <> +
{title}
+
+ {active ?
: null} + + )} +
+ ); +}; diff --git a/components/dashboard/src/components/org-icon/OrgIcon.tsx b/components/dashboard/src/components/org-icon/OrgIcon.tsx new file mode 100644 index 00000000000000..8f6c27a190c556 --- /dev/null +++ b/components/dashboard/src/components/org-icon/OrgIcon.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import classNames from "classnames"; +import { FunctionComponent } from "react"; +import { consistentClassname } from "./consistent-classname"; +import "./styles.css"; + +const SIZE_CLASSES = { + small: "w-6 h-6", + medium: "w-10 h-10", +}; + +const TEXT_SIZE_CLASSES = { + small: "text-sm", + medium: "text-xl", +}; + +type Props = { + id: string; + name: string; + size?: keyof typeof SIZE_CLASSES; + className?: string; +}; +export const OrgIcon: FunctionComponent = ({ id, name, size = "medium", className }) => { + const logoBGClass = consistentClassname(id); + const initials = getOrgInitials(name); + const sizeClasses = SIZE_CLASSES[size]; + const textClass = TEXT_SIZE_CLASSES[size]; + + return ( +
+ {initials} +
+ ); +}; + +function getOrgInitials(name: string) { + // If for some reason there is no name, default to G for Gitpod + return (name || "G").charAt(0).toLocaleUpperCase(); +} diff --git a/components/dashboard/src/components/org-icon/consistent-classname.test.ts b/components/dashboard/src/components/org-icon/consistent-classname.test.ts new file mode 100644 index 00000000000000..2fbedea2a3944d --- /dev/null +++ b/components/dashboard/src/components/org-icon/consistent-classname.test.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { BG_CLASSES, consistentClassname } from "./consistent-classname"; + +describe("consistentClassname()", () => { + test("empty string", () => { + const id = ""; + const cn = consistentClassname(id); + + expect(cn).toEqual(BG_CLASSES[0]); + }); + + test("max value", () => { + const id = "ffffffffffffffffffffffffffffffff"; + const cn = consistentClassname(id); + + expect(cn).toEqual(BG_CLASSES[BG_CLASSES.length - 1]); + }); + + test("with an id value", () => { + const id = "c5895528-23ac-4ebd-9d8b-464228d5755f"; + const cn = consistentClassname(id); + + expect(BG_CLASSES).toContain(cn); + }); + + test("with an id value without hyphens", () => { + const id = "c589552823ac4ebd9d8b464228d5755f"; + const cn = consistentClassname(id); + + expect(BG_CLASSES).toContain(cn); + }); + + test("with a shorter id value", () => { + const id = "c5895528"; + const cn = consistentClassname(id); + + expect(BG_CLASSES).toContain(cn); + }); + + test("returns the same classname for the same value", () => { + const id = "c5895528-23ac-4ebd-9d8b-464228d5755f"; + const cn1 = consistentClassname(id); + const cn2 = consistentClassname(id); + + expect(cn1).toEqual(cn2); + expect(BG_CLASSES).toContain(cn1); + expect(BG_CLASSES).toContain(cn2); + }); +}); diff --git a/components/dashboard/src/components/org-icon/consistent-classname.ts b/components/dashboard/src/components/org-icon/consistent-classname.ts new file mode 100644 index 00000000000000..80f01f091d131b --- /dev/null +++ b/components/dashboard/src/components/org-icon/consistent-classname.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +// Represents the max value our base16 guid can be, 32 "f"s +const GUID_MAX = "".padEnd(32, "f"); + +export const BG_CLASSES = [ + "bg-gradient-1", + "bg-gradient-2", + "bg-gradient-3", + "bg-gradient-4", + "bg-gradient-5", + "bg-gradient-6", + "bg-gradient-7", + "bg-gradient-8", + "bg-gradient-9", +]; + +export const consistentClassname = (id: string) => { + // Turn id into a 32 char. guid, pad with "0" if it's not 32 chars already + const guid = id.replaceAll("-", "").substring(0, 32).padEnd(32, "0"); + + // Map guid into a 0,1 range by dividing by the max guid + var quotient = parseInt(guid, 16) / parseInt(GUID_MAX, 16); + var idx = Math.floor(quotient * (BG_CLASSES.length - 1)); + + return BG_CLASSES[idx]; +}; diff --git a/components/dashboard/src/components/org-icon/styles.css b/components/dashboard/src/components/org-icon/styles.css new file mode 100644 index 00000000000000..8ca2471044365d --- /dev/null +++ b/components/dashboard/src/components/org-icon/styles.css @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + + +@layer components { + .bg-gradient-1 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.orange.300') 75%); + } + .bg-gradient-2 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.pink.800') 75%); + } + .bg-gradient-3 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.red.500') 75%); + } + .bg-gradient-4 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.green.700') 75%); + } + .bg-gradient-5 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.yellow.300') 75%); + } + .bg-gradient-6 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.indigo.700') 75%); + } + .bg-gradient-7 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.teal.500') 75%); + } + .bg-gradient-8 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.sky.900') 75%); + } + .bg-gradient-9 { + background: linear-gradient(45deg, theme('colors.orange.500') 25%, theme('colors.rose.300') 75%); + } +} diff --git a/components/dashboard/src/data/billing-mode/user-billing-mode-query.ts b/components/dashboard/src/data/billing-mode/user-billing-mode-query.ts new file mode 100644 index 00000000000000..4501f4d47e9bd5 --- /dev/null +++ b/components/dashboard/src/data/billing-mode/user-billing-mode-query.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import { useQuery } from "@tanstack/react-query"; +import { getGitpodService } from "../../service/service"; +import { useCurrentUser } from "../../user-context"; + +type UserBillingModeQueryResult = BillingMode; + +export const useUserBillingMode = () => { + const user = useCurrentUser(); + + return useQuery({ + queryKey: getUserBillingModeQueryKey(user?.id ?? ""), + queryFn: async () => { + if (!user) { + throw new Error("No current user, cannot load billing mode"); + } + return await getGitpodService().server.getBillingModeForUser(); + }, + enabled: !!user, + }); +}; + +export const getUserBillingModeQueryKey = (userId: string) => ["billing-mode", { userId }]; diff --git a/components/dashboard/src/menu/Menu.tsx b/components/dashboard/src/menu/Menu.tsx index 862e08e653defd..9b0940542c7cbe 100644 --- a/components/dashboard/src/menu/Menu.tsx +++ b/components/dashboard/src/menu/Menu.tsx @@ -5,7 +5,7 @@ */ import { User } from "@gitpod/gitpod-protocol"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useLocation } from "react-router"; import { Location } from "history"; @@ -13,18 +13,13 @@ import { countries } from "countries-list"; import gitpodIcon from "../icons/gitpod.svg"; import { getGitpodService, gitpodHostUrl } from "../service/service"; import { useCurrentUser } from "../user-context"; -import { useCurrentTeam, useTeamMemberInfos } from "../teams/teams-context"; import ContextMenu from "../components/ContextMenu"; import Separator from "../components/Separator"; import PillMenuItem from "../components/PillMenuItem"; -import { getTeamSettingsMenu } from "../teams/TeamSettings"; import { PaymentContext } from "../payment-context"; import FeedbackFormModal from "../feedback-form/FeedbackModal"; import { isGitpodIo } from "../utils"; -import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; -import { useFeatureFlags } from "../contexts/FeatureFlagContext"; import OrganizationSelector from "./OrganizationSelector"; -import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query"; import { getAdminTabs } from "../admin/admin.routes"; interface Entry { @@ -35,18 +30,9 @@ interface Entry { export default function Menu() { const user = useCurrentUser(); - const team = useCurrentTeam(); const location = useLocation(); - const { data: teamBillingMode } = useOrgBillingMode(); - const { showUsageView, oidcServiceEnabled, orgGitAuthProviders } = useFeatureFlags(); const { setCurrency, setIsStudent, setIsChargebeeCustomer } = useContext(PaymentContext); - const [userBillingMode, setUserBillingMode] = useState(undefined); const [isFeedbackFormVisible, setFeedbackFormVisible] = useState(false); - const teamMembers = useTeamMemberInfos(); - - useEffect(() => { - getGitpodService().server.getBillingModeForUser().then(setUserBillingMode); - }, []); function isSelected(entry: Entry, location: Location) { const all = [entry.link, ...(entry.alternatives || [])].map((l) => l.toLowerCase()); @@ -66,70 +52,18 @@ export default function Menu() { ]).then((setters) => setters.forEach((s) => s())); }, [setCurrency, setIsChargebeeCustomer, setIsStudent]); - const leftMenu = useMemo(() => { - const leftMenu: Entry[] = [ - { - title: "Workspaces", - link: "/workspaces", - alternatives: ["/"], - }, - { - title: "Projects", - link: `/projects`, - alternatives: [] as string[], - }, - ]; - - if ( - !team && - BillingMode.showUsageBasedBilling(userBillingMode) && - !user?.additionalData?.isMigratedToTeamOnlyAttribution - ) { - leftMenu.push({ - title: "Usage", - link: "/usage", - }); - } - if (team) { - leftMenu.push({ - title: "Members", - link: `/members`, - }); - const currentUserInTeam = (teamMembers[team.id] || []).find((m) => m.userId === user?.id); - if ( - currentUserInTeam?.role === "owner" && - (showUsageView || (teamBillingMode && teamBillingMode.mode === "usage-based")) - ) { - leftMenu.push({ - title: "Usage", - link: `/usage`, - }); - } - if (currentUserInTeam?.role === "owner") { - leftMenu.push({ - title: "Settings", - link: `/settings`, - alternatives: getTeamSettingsMenu({ - team, - billingMode: teamBillingMode, - ssoEnabled: oidcServiceEnabled, - orgGitAuthProviders, - }).flatMap((e) => e.link), - }); - } - } - return leftMenu; - }, [ - oidcServiceEnabled, - orgGitAuthProviders, - showUsageView, - team, - teamBillingMode, - teamMembers, - user?.additionalData?.isMigratedToTeamOnlyAttribution, - user?.id, - userBillingMode, - ]); + const leftMenu: Entry[] = [ + { + title: "Workspaces", + link: "/workspaces", + alternatives: ["/"], + }, + { + title: "Projects", + link: `/projects`, + alternatives: [] as string[], + }, + ]; const adminMenu: Entry = { title: "Admin", diff --git a/components/dashboard/src/menu/OrganizationSelector.tsx b/components/dashboard/src/menu/OrganizationSelector.tsx index e062e24bf9c78a..a32dfe89a027b8 100644 --- a/components/dashboard/src/menu/OrganizationSelector.tsx +++ b/components/dashboard/src/menu/OrganizationSelector.tsx @@ -4,65 +4,158 @@ * See License.AGPL.txt in the project root for license information. */ -import { useMemo } from "react"; -import { useLocation } from "react-router-dom"; +import { FunctionComponent, useMemo } from "react"; import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu"; +import { OrgIcon } from "../components/org-icon/OrgIcon"; import { useCurrentTeam, useTeamMemberInfos, useTeams } from "../teams/teams-context"; +import { useCurrentOrgMember } from "../data/organizations/org-members-query"; import { useCurrentUser } from "../user-context"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import { useUserBillingMode } from "../data/billing-mode/user-billing-mode-query"; +import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query"; +import { useFeatureFlags } from "../contexts/FeatureFlagContext"; export interface OrganizationSelectorProps {} export default function OrganizationSelector(p: OrganizationSelectorProps) { const user = useCurrentUser(); const teams = useTeams(); - const team = useCurrentTeam(); + const currentOrg = useCurrentTeam(); const teamMembers = useTeamMemberInfos(); - const location = useLocation(); + const { member: currentOrgMember } = useCurrentOrgMember(); + const { data: userBillingMode } = useUserBillingMode(); + const { data: orgBillingMode } = useOrgBillingMode(); + const { showUsageView } = useFeatureFlags(); const userFullName = user?.fullName || user?.name || "..."; - const entries: ContextMenuEntry[] = useMemo( - () => [ - ...(!user?.additionalData?.isMigratedToTeamOnlyAttribution + + const entries: ContextMenuEntry[] = useMemo(() => { + let activeOrgEntry = !currentOrg + ? { + title: userFullName, + customContent: , + active: false, + separator: false, + tight: true, + } + : { + title: currentOrg.name, + customContent: ( + + ), + active: false, + separator: false, + tight: true, + }; + + const linkEntries: ContextMenuEntry[] = []; + + // Show members if we have an org selected + if (currentOrg) { + linkEntries.push({ + title: "Members", + customContent: Members, + active: false, + separator: true, + link: "/members", + }); + } + + // Show usage for personal account if usage based billing enabled for user + const showUsageForPersonalAccount = + !currentOrg && + BillingMode.showUsageBasedBilling(userBillingMode) && + !user?.additionalData?.isMigratedToTeamOnlyAttribution; + + const showUsageForOrg = + currentOrg && + currentOrgMember?.role === "owner" && + (orgBillingMode?.mode === "usage-based" || showUsageView); + + if (showUsageForPersonalAccount || showUsageForOrg) { + linkEntries.push({ + title: "Usage", + customContent: Usage, + active: false, + separator: false, + link: "/usage", + }); + } + + // Show settings if user is an owner of current org + if (currentOrg && currentOrgMember?.role === "owner") { + linkEntries.push({ + title: "Settings", + customContent: Settings, + active: false, + separator: false, + link: "/settings", + }); + } + + // Ensure only last link entry has a separator + linkEntries.forEach((e, idx) => { + e.separator = idx === linkEntries.length - 1; + }); + + const otherOrgEntries = (teams || []) + .filter((t) => t.id !== currentOrg?.id) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((t) => ({ + title: t.name, + customContent: ( + + ), + // marking as active for styles + active: true, + separator: true, + link: `/?org=${t.id}`, + })); + + const userMigrated = user?.additionalData?.isMigratedToTeamOnlyAttribution ?? false; + const showPersonalEntry = !userMigrated && !!currentOrg; + + return [ + activeOrgEntry, + ...linkEntries, + // If user has not been migrated, and isn't currently selected, show personal account + ...(showPersonalEntry ? [ { title: userFullName, customContent: ( -
- - {userFullName} - - Personal Account -
+ ), - active: team === undefined, + // marking as active for styles + active: true, separator: true, link: `/?org=0`, }, ] : []), - ...(teams || []) - .map((t) => ({ - title: t.name, - customContent: ( -
- {t.name} - - {!!teamMembers[t.id] - ? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? "" : "s"}` - : "..."} - -
- ), - active: team?.id === t.id, - separator: true, - link: `/?org=${t.id}`, - })) - .sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1)), + ...otherOrgEntries, { title: "Create a new organization", customContent: ( -
- New Organization +
+ New Organization ), link: "/orgs/new", + // marking as active for styles + active: true, }, - ], - [ - user?.additionalData?.isMigratedToTeamOnlyAttribution, - userFullName, - team, - location.pathname, - teams, - teamMembers, - ], - ); - const selectedEntry = entries.find((e) => e.active) || entries[0]; + ]; + }, [ + currentOrg, + userFullName, + user?.id, + user?.additionalData?.isMigratedToTeamOnlyAttribution, + teamMembers, + userBillingMode, + showUsageView, + currentOrgMember?.role, + orgBillingMode?.mode, + teams, + ]); + + const selectedTitle = currentOrg ? currentOrg.name : userFullName; const classes = "flex h-full text-base py-0 text-gray-500 bg-gray-50 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-700"; return ( -
-
- {selectedEntry.title!} +
+
+ + {selectedTitle}
@@ -109,3 +214,41 @@ export default function OrganizationSelector(p: OrganizationSelectorProps) { ); } + +const LinkEntry: FunctionComponent = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +type OrgEntryProps = { + id: string; + title: string; + subtitle: string; +}; +const OrgEntry: FunctionComponent = ({ id, title, subtitle }) => { + return ( +
+ +
+ {title} + {subtitle} +
+
+ ); +}; + +type CurrentOrgEntryProps = { + title: string; + subtitle: string; +}; +const CurrentOrgEntry: FunctionComponent = ({ title, subtitle }) => { + return ( +
+ {title} + {subtitle} +
+ ); +}; diff --git a/components/dashboard/tailwind.config.js b/components/dashboard/tailwind.config.js index 35ceb1e4e0bf5e..0a0544e505b482 100644 --- a/components/dashboard/tailwind.config.js +++ b/components/dashboard/tailwind.config.js @@ -23,6 +23,10 @@ module.exports = { DEFAULT: "#5C8DD6", dark: "#265583", }, + // TODO: figure out if we want to just pull in the specific gitpod-* colors + teal: colors.teal, + sky: colors.sky, + rose: colors.rose, "gitpod-black": "#161616", "gitpod-gray": "#8E8787", "gitpod-red": "#CE4A3E",