From eb55c47bd1179711259caf371a8744390037638d Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Mon, 7 Oct 2024 18:09:36 +0200 Subject: [PATCH] feat(navigation): Search in environments switcher (#25374) --- .../layout/navigation/EnvironmentSwitcher.tsx | 183 ++++++++++-------- .../navigation/environmentsSwitcherLogic.tsx | 145 ++++++++++++++ .../lib/lemon-ui/LemonInput/LemonInput.tsx | 1 + .../src/lib/lemon-ui/LemonMenu/LemonMenu.tsx | 7 +- .../lib/lemon-ui/Lettermark/Lettermark.tsx | 2 +- tsconfig.json | 1 + 6 files changed, 252 insertions(+), 87 deletions(-) create mode 100644 frontend/src/layout/navigation/environmentsSwitcherLogic.tsx diff --git a/frontend/src/layout/navigation/EnvironmentSwitcher.tsx b/frontend/src/layout/navigation/EnvironmentSwitcher.tsx index be8322e71df..4744ed1571a 100644 --- a/frontend/src/layout/navigation/EnvironmentSwitcher.tsx +++ b/frontend/src/layout/navigation/EnvironmentSwitcher.tsx @@ -1,14 +1,9 @@ import { IconGear, IconPlus } from '@posthog/icons' -import { LemonTag, Spinner } from '@posthog/lemon-ui' +import { LemonInput, LemonTag, Spinner } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' -import { - LemonMenuItemLeafCallback, - LemonMenuItemLeafLink, - LemonMenuOverlay, - LemonMenuSection, -} from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { LemonMenuItem, LemonMenuOverlay, LemonMenuSection } from 'lib/lemon-ui/LemonMenu/LemonMenu' import { UploadedLogo } from 'lib/lemon-ui/UploadedLogo' import { removeFlagIdIfPresent, removeProjectIdIfPresent } from 'lib/utils/router-utils' import { useMemo } from 'react' @@ -19,17 +14,10 @@ import { urls } from 'scenes/urls' import { AvailableFeature } from '~/types' import { globalModalsLogic } from '../GlobalModals' - -type MenuItemWithEnvName = - | (LemonMenuItemLeafLink & { - /** Extra menu item metadata, just for sorting the environments before we display them. */ - envName: string - }) - | (LemonMenuItemLeafCallback & { - envName?: never - }) +import { environmentSwitcherLogic } from './environmentsSwitcherLogic' export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?: () => void }): JSX.Element { + const { sortedProjectsMap } = useValues(environmentSwitcherLogic) const { currentOrganization, projectCreationForbiddenReason } = useValues(organizationLogic) const { currentTeam } = useValues(teamLogic) const { guardAvailableFeature } = useValues(upgradeModalLogic) @@ -40,77 +28,84 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?: if (!currentOrganization) { return null } - const projectMapping = currentOrganization.projects.reduce>( - (acc, project) => { - acc[project.id] = [project.name, []] - return acc - }, - {} - ) - for (const team of currentOrganization.teams) { - const [projectName, envItems] = projectMapping[team.project_id] - envItems.push({ - label: ( - <> - {team.name} - {team.is_demo && ( - - DEMO - - )} - - ), - envName: team.name, - active: currentTeam?.id === team.id, - to: determineProjectSwitchUrl(location.pathname, team.id), - tooltip: - currentTeam?.id === team.id - ? 'Currently active environment' - : `Switch to the ${team.name} environment of ${projectName}`, - onClick: onClickInside, - sideAction: { - icon: , - tooltip: `Go to ${team.name} settings`, - tooltipPlacement: 'right', - onClick: onClickInside, - to: urls.project(team.id, urls.settings()), - }, - icon:
, // Icon-sized filler - }) - } - const sortedProjects = Object.entries(projectMapping).sort( - // The project with the active environment always comes first - otherwise sorted alphabetically by name - ([, [aProjectName, aEnvItems]], [, [bProjectName]]) => - aEnvItems.find((item) => item.active) ? -Infinity : aProjectName.localeCompare(bProjectName) - ) - const projectSectionsResult = [] - for (const [projectId, [projectName, envItems]] of sortedProjects) { - // The environment that's active always comes first - otherwise sorted alphabetically by name - envItems.sort((a, b) => (b.active ? Infinity : a.envName!.localeCompare(b.envName!))) - envItems.unshift({ - label: projectName, - icon: , - disabledReason: 'Select an environment of this project', - onClick: () => {}, - sideAction: { - icon: , - tooltip: `New environment within ${projectName}`, - tooltipPlacement: 'right', - disabledReason: projectCreationForbiddenReason?.replace('project', 'environment'), - onClick: () => { - onClickInside?.() - guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateEnvironmentModal, { - currentUsage: currentOrganization?.teams?.length, - }) + const projectSectionsResult: LemonMenuSection[] = [] + for (const [projectId, [projectName, projectTeams]] of sortedProjectsMap.entries()) { + const projectItems: LemonMenuItem[] = [ + { + label: projectName, + icon: , + disabledReason: 'Select an environment of this project below', + onClick: () => {}, + sideAction: { + icon: , + tooltip: `New environment within ${projectName}`, + tooltipPlacement: 'right', + disabledReason: projectCreationForbiddenReason?.replace('project', 'environment'), + onClick: () => { + onClickInside?.() + guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateEnvironmentModal, { + currentUsage: currentOrganization?.teams?.length, + }) + }, + 'data-attr': 'new-environment-button', }, - 'data-attr': 'new-environment-button', }, - }) - projectSectionsResult.push({ items: envItems }) + ] + for (const team of projectTeams) { + projectItems.push({ + label: ( + <> + {team.name} + {team.is_demo && ( + + DEMO + + )} + + ), + key: team.id, + active: currentTeam?.id === team.id, + to: determineProjectSwitchUrl(location.pathname, team.id), + tooltip: + currentTeam?.id === team.id ? ( + 'Currently active environment' + ) : ( + <> + Switch to environment {team.name} + {currentTeam?.project_id !== team.project_id && ( + <> + {' '} + of project {projectName} + + )} + + ), + onClick: onClickInside, + sideAction: { + icon: , + tooltip: "Go to this environment's settings", + tooltipPlacement: 'right', + onClick: onClickInside, + to: urls.project(team.id, urls.settings()), + }, + icon:
, // Icon-sized filler + }) + } + projectSectionsResult.push({ key: projectId, items: projectItems }) } return projectSectionsResult - }, [currentTeam, currentOrganization, location]) + }, [ + currentOrganization, + sortedProjectsMap, + projectCreationForbiddenReason, + onClickInside, + guardAvailableFeature, + showCreateEnvironmentModal, + currentTeam?.id, + currentTeam?.project_id, + location.pathname, + ]) if (!projectSections) { return @@ -119,7 +114,9 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?: return ( , @@ -127,7 +124,6 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?: disabledReason: projectCreationForbiddenReason, onClick: () => { onClickInside?.() - // TODO: Use showCreateEnvironmentModal guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateProjectModal, { currentUsage: currentOrganization?.teams?.length, }) @@ -148,3 +144,22 @@ function determineProjectSwitchUrl(pathname: string, newTeamId: number): string route = removeFlagIdIfPresent(route) return urls.project(newTeamId, route) } + +function EnvironmentSwitcherSearch(): JSX.Element { + const { environmentSwitcherSearch } = useValues(environmentSwitcherLogic) + const { setEnvironmentSwitcherSearch } = useActions(environmentSwitcherLogic) + + return ( + { + e.stopPropagation() // Prevent dropdown from closing + }} + /> + ) +} diff --git a/frontend/src/layout/navigation/environmentsSwitcherLogic.tsx b/frontend/src/layout/navigation/environmentsSwitcherLogic.tsx new file mode 100644 index 00000000000..0484fdbdff4 --- /dev/null +++ b/frontend/src/layout/navigation/environmentsSwitcherLogic.tsx @@ -0,0 +1,145 @@ +import FuseClass from 'fuse.js' +import { actions, connect, kea, path, reducers, selectors } from 'kea' +import { organizationLogic } from 'scenes/organizationLogic' +import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' + +import { ProjectBasicType, TeamBasicType } from '~/types' + +import type { environmentSwitcherLogicType } from './environmentsSwitcherLogicType' + +// Helping kea-typegen navigate the exported default class for Fuse +export interface Fuse extends FuseClass {} + +export type ProjectsMap = Map< + TeamBasicTypeWithProjectName['project_id'], + [TeamBasicTypeWithProjectName['project_name'], TeamBasicTypeWithProjectName[]] +> + +export interface TeamBasicTypeWithProjectName extends TeamBasicType { + project_name: string +} + +export const environmentSwitcherLogic = kea([ + path(['layout', 'navigation', 'environmentsSwitcherLogic']), + connect({ + values: [userLogic, ['user'], teamLogic, ['currentTeam'], organizationLogic, ['currentOrganization']], + }), + actions({ + setEnvironmentSwitcherSearch: (input: string) => ({ input }), + }), + reducers({ + environmentSwitcherSearch: [ + '', + { + setEnvironmentSwitcherSearch: (_, { input }) => input, + }, + ], + }), + selectors({ + allTeamsSorted: [ + (s) => [s.currentOrganization, s.currentTeam], + (currentOrganization, currentTeam): TeamBasicTypeWithProjectName[] => { + const collection: TeamBasicTypeWithProjectName[] = [] + if (currentOrganization) { + const projectIdToName = Object.fromEntries( + currentOrganization.projects.map((project) => [project.id, project.name]) + ) + for (const team of currentOrganization.teams) { + collection.push({ + ...team, + project_name: projectIdToName[team.project_id], + }) + } + } + collection.sort((a, b) => { + // Sorting logic: + // 1. first by whether the team is the current team, + // 2. then by whether the project is the current project, + // 3. then by project name, + // 4. then by team name + if (a.id === currentTeam?.id) { + return -1 + } else if (b.id === currentTeam?.id) { + return 1 + } + if (a.project_id !== b.project_id) { + if (a.project_id === currentTeam?.project_id) { + return -1 + } else if (b.project_id === currentTeam?.project_id) { + return 1 + } + return a.project_name.localeCompare(b.project_name) + } + return a.name.localeCompare(b.name) + }) + return collection + }, + ], + teamsFuse: [ + (s) => [s.allTeamsSorted], + (allTeamsSorted): Fuse => { + return new FuseClass(allTeamsSorted, { keys: ['name', 'project_name'] }) + }, + ], + projectsSorted: [ + (s) => [s.currentOrganization, s.currentTeam], + (currentOrganization, currentTeam): ProjectBasicType[] => { + // Includes projects that have no environments + if (!currentOrganization) { + return [] + } + const collection: ProjectBasicType[] = currentOrganization.projects.slice() + collection.sort((a, b) => { + // Sorting logic: 1. first by whether the project is the current project, 2. then by project name + if (a.id === currentTeam?.id) { + return -1 + } else if (b.id === currentTeam?.id) { + return 1 + } + return a.name.localeCompare(b.name) + }) + return collection + }, + ], + sortedProjectsMap: [ + (s) => [s.projectsSorted, s.allTeamsSorted, s.teamsFuse, s.environmentSwitcherSearch], + (projectsSorted, allTeamsSorted, teamsFuse, environmentSwitcherSearch): ProjectsMap => { + // Using a map so that insertion order is preserved + // (JS objects don't preserve the order for keys that are numbers) + const projectsWithTeamsSorted: ProjectsMap = new Map() + + if (environmentSwitcherSearch) { + const matchingTeams = teamsFuse.search(environmentSwitcherSearch).map((result) => result.item) + const projectIdToTopTeamRank = matchingTeams.reduce>( + (acc, team, index) => { + if (!acc[team.project_id]) { + acc[team.project_id] = index + } + return acc + }, + {} + ) + matchingTeams.sort( + (a, b) => projectIdToTopTeamRank[a.project_id] - projectIdToTopTeamRank[b.project_id] + ) + for (const team of matchingTeams) { + if (!projectsWithTeamsSorted.has(team.project_id)) { + projectsWithTeamsSorted.set(team.project_id, [team.project_name, []]) + } + projectsWithTeamsSorted.get(team.project_id)![1].push(team) + } + } else { + for (const project of projectsSorted) { + projectsWithTeamsSorted.set(project.id, [project.name, []]) + } + for (const team of allTeamsSorted) { + projectsWithTeamsSorted.get(team.project_id)![1].push(team) + } + } + + return projectsWithTeamsSorted + }, + ], + }), +]) diff --git a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx index f5d7883a3f2..b67585ead66 100644 --- a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx +++ b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx @@ -14,6 +14,7 @@ interface LemonInputPropsBase // NOTE: We explicitly pick rather than omit to ensure these components aren't used incorrectly React.InputHTMLAttributes, | 'className' + | 'onClick' | 'onFocus' | 'onBlur' | 'autoFocus' diff --git a/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx b/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx index dfcb806d43d..63a351c4a1e 100644 --- a/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx +++ b/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx @@ -16,6 +16,7 @@ export interface LemonMenuItemBase 'icon' | 'sideIcon' | 'sideAction' | 'disabledReason' | 'tooltip' | 'active' | 'status' | 'data-attr' > { label: string | JSX.Element + key?: React.Key /** True if the item is a custom element. */ custom?: boolean } @@ -46,6 +47,7 @@ export type LemonMenuItemLeaf = LemonMenuItemLeafCallback | LemonMenuItemLeafLin export interface LemonMenuItemCustom { /** A label that's a component means it will be rendered directly, and not wrapped in a button. */ label: () => JSX.Element + key?: React.Key active?: never items?: never keyboardShortcut?: never @@ -57,6 +59,7 @@ export type LemonMenuItem = LemonMenuItemLeaf | LemonMenuItemCustom | LemonMenuI export interface LemonMenuSection { title?: string | React.ReactNode + key?: React.Key items: (LemonMenuItem | false | null)[] footer?: string | React.ReactNode } @@ -171,7 +174,7 @@ export function LemonMenuSectionList({
    {sections.map((section, i) => { const sectionElement = ( -
  • +
  • {section.title ? ( typeof section.title === 'string' ? ( @@ -221,7 +224,7 @@ export function LemonMenuItemList({ return (
      {items.map((item, index) => ( -
    • +
    • (func const representation = name ? typeof name === 'number' ? String(Math.floor(name)) - : name.toLocaleUpperCase().charAt(0) + : String.fromCodePoint(name.codePointAt(0)!).toLocaleUpperCase() : '?' const colorIndex = color ? color : typeof index === 'number' ? (index % NUM_LETTERMARK_STYLES) + 1 : undefined diff --git a/tsconfig.json b/tsconfig.json index b566bf6c160..d939f59bb1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "resolveJsonModule": true, // Include modules imported with .json extension "noEmit": true, // Do not emit output (meaning do not compile code, only perform type checking) "jsx": "react-jsx", // Support JSX in .tsx files + "target": "ES2015", "sourceMap": true, // Generate corrresponding .map file "declaration": true, // Generate corresponding .d.ts file "noUnusedLocals": true, // Report errors on unused locals