Skip to content

Commit

Permalink
feat(navigation): Search in environments switcher (#25374)
Browse files Browse the repository at this point in the history
  • Loading branch information
Twixes authored Oct 7, 2024
1 parent 22d2aff commit eb55c47
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 87 deletions.
183 changes: 99 additions & 84 deletions frontend/src/layout/navigation/EnvironmentSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand All @@ -40,77 +28,84 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?:
if (!currentOrganization) {
return null
}
const projectMapping = currentOrganization.projects.reduce<Record<number, [string, MenuItemWithEnvName[]]>>(
(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 && (
<LemonTag className="ml-1.5" type="highlight">
DEMO
</LemonTag>
)}
</>
),
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: <IconGear />,
tooltip: `Go to ${team.name} settings`,
tooltipPlacement: 'right',
onClick: onClickInside,
to: urls.project(team.id, urls.settings()),
},
icon: <div className="size-6" />, // 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: <UploadedLogo name={projectName} entityId={projectId} outlinedLettermark />,
disabledReason: 'Select an environment of this project',
onClick: () => {},
sideAction: {
icon: <IconPlus />,
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: <UploadedLogo name={projectName} entityId={projectId} outlinedLettermark />,
disabledReason: 'Select an environment of this project below',
onClick: () => {},
sideAction: {
icon: <IconPlus />,
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 && (
<LemonTag className="ml-1.5" type="highlight">
DEMO
</LemonTag>
)}
</>
),
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 <strong>{team.name}</strong>
{currentTeam?.project_id !== team.project_id && (
<>
{' '}
of project <strong>{projectName}</strong>
</>
)}
</>
),
onClick: onClickInside,
sideAction: {
icon: <IconGear />,
tooltip: "Go to this environment's settings",
tooltipPlacement: 'right',
onClick: onClickInside,
to: urls.project(team.id, urls.settings()),
},
icon: <div className="size-6" />, // 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 <Spinner />
Expand All @@ -119,15 +114,16 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?:
return (
<LemonMenuOverlay
items={[
{ title: 'Projects', items: [] },
{
items: [{ label: EnvironmentSwitcherSearch }],
},
...projectSections,
{
icon: <IconPlus />,
label: 'New project',
disabledReason: projectCreationForbiddenReason,
onClick: () => {
onClickInside?.()
// TODO: Use showCreateEnvironmentModal
guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateProjectModal, {
currentUsage: currentOrganization?.teams?.length,
})
Expand All @@ -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 (
<LemonInput
value={environmentSwitcherSearch}
onChange={setEnvironmentSwitcherSearch}
type="search"
autoFocus
placeholder="Search projects & environments"
className="min-w-64"
onClick={(e) => {
e.stopPropagation() // Prevent dropdown from closing
}}
/>
)
}
145 changes: 145 additions & 0 deletions frontend/src/layout/navigation/environmentsSwitcherLogic.tsx
Original file line number Diff line number Diff line change
@@ -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<T> extends FuseClass<T> {}

export type ProjectsMap = Map<
TeamBasicTypeWithProjectName['project_id'],
[TeamBasicTypeWithProjectName['project_name'], TeamBasicTypeWithProjectName[]]
>

export interface TeamBasicTypeWithProjectName extends TeamBasicType {
project_name: string
}

export const environmentSwitcherLogic = kea<environmentSwitcherLogicType>([
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<TeamBasicTypeWithProjectName> => {
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<Record<TeamBasicType['project_id'], number>>(
(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
},
],
}),
])
1 change: 1 addition & 0 deletions frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface LemonInputPropsBase
// NOTE: We explicitly pick rather than omit to ensure these components aren't used incorrectly
React.InputHTMLAttributes<HTMLInputElement>,
| 'className'
| 'onClick'
| 'onFocus'
| 'onBlur'
| 'autoFocus'
Expand Down
Loading

0 comments on commit eb55c47

Please sign in to comment.