0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

feat(navigation): Search in environments switcher (#25374)

This commit is contained in:
Michael Matloka 2024-10-07 18:09:36 +02:00 committed by GitHub
parent 22d2aff23c
commit eb55c47bd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 252 additions and 87 deletions

View File

@ -1,14 +1,9 @@
import { IconGear, IconPlus } from '@posthog/icons' 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 { useActions, useValues } from 'kea'
import { router } from 'kea-router' import { router } from 'kea-router'
import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic'
import { import { LemonMenuItem, LemonMenuOverlay, LemonMenuSection } from 'lib/lemon-ui/LemonMenu/LemonMenu'
LemonMenuItemLeafCallback,
LemonMenuItemLeafLink,
LemonMenuOverlay,
LemonMenuSection,
} from 'lib/lemon-ui/LemonMenu/LemonMenu'
import { UploadedLogo } from 'lib/lemon-ui/UploadedLogo' import { UploadedLogo } from 'lib/lemon-ui/UploadedLogo'
import { removeFlagIdIfPresent, removeProjectIdIfPresent } from 'lib/utils/router-utils' import { removeFlagIdIfPresent, removeProjectIdIfPresent } from 'lib/utils/router-utils'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -19,17 +14,10 @@ import { urls } from 'scenes/urls'
import { AvailableFeature } from '~/types' import { AvailableFeature } from '~/types'
import { globalModalsLogic } from '../GlobalModals' import { globalModalsLogic } from '../GlobalModals'
import { environmentSwitcherLogic } from './environmentsSwitcherLogic'
type MenuItemWithEnvName =
| (LemonMenuItemLeafLink & {
/** Extra menu item metadata, just for sorting the environments before we display them. */
envName: string
})
| (LemonMenuItemLeafCallback & {
envName?: never
})
export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?: () => void }): JSX.Element { export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?: () => void }): JSX.Element {
const { sortedProjectsMap } = useValues(environmentSwitcherLogic)
const { currentOrganization, projectCreationForbiddenReason } = useValues(organizationLogic) const { currentOrganization, projectCreationForbiddenReason } = useValues(organizationLogic)
const { currentTeam } = useValues(teamLogic) const { currentTeam } = useValues(teamLogic)
const { guardAvailableFeature } = useValues(upgradeModalLogic) const { guardAvailableFeature } = useValues(upgradeModalLogic)
@ -40,77 +28,84 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?:
if (!currentOrganization) { if (!currentOrganization) {
return null 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 projectSectionsResult: LemonMenuSection[] = []
const [projectName, envItems] = projectMapping[team.project_id] for (const [projectId, [projectName, projectTeams]] of sortedProjectsMap.entries()) {
envItems.push({ const projectItems: LemonMenuItem[] = [
label: ( {
<> label: projectName,
{team.name} icon: <UploadedLogo name={projectName} entityId={projectId} outlinedLettermark />,
{team.is_demo && ( disabledReason: 'Select an environment of this project below',
<LemonTag className="ml-1.5" type="highlight"> onClick: () => {},
DEMO sideAction: {
</LemonTag> icon: <IconPlus />,
)} tooltip: `New environment within ${projectName}`,
</> tooltipPlacement: 'right',
), disabledReason: projectCreationForbiddenReason?.replace('project', 'environment'),
envName: team.name, onClick: () => {
active: currentTeam?.id === team.id, onClickInside?.()
to: determineProjectSwitchUrl(location.pathname, team.id), guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateEnvironmentModal, {
tooltip: currentUsage: currentOrganization?.teams?.length,
currentTeam?.id === team.id })
? 'Currently active environment' },
: `Switch to the ${team.name} environment of ${projectName}`, 'data-attr': 'new-environment-button',
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,
})
}, },
'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 return projectSectionsResult
}, [currentTeam, currentOrganization, location]) }, [
currentOrganization,
sortedProjectsMap,
projectCreationForbiddenReason,
onClickInside,
guardAvailableFeature,
showCreateEnvironmentModal,
currentTeam?.id,
currentTeam?.project_id,
location.pathname,
])
if (!projectSections) { if (!projectSections) {
return <Spinner /> return <Spinner />
@ -119,7 +114,9 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?:
return ( return (
<LemonMenuOverlay <LemonMenuOverlay
items={[ items={[
{ title: 'Projects', items: [] }, {
items: [{ label: EnvironmentSwitcherSearch }],
},
...projectSections, ...projectSections,
{ {
icon: <IconPlus />, icon: <IconPlus />,
@ -127,7 +124,6 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?:
disabledReason: projectCreationForbiddenReason, disabledReason: projectCreationForbiddenReason,
onClick: () => { onClick: () => {
onClickInside?.() onClickInside?.()
// TODO: Use showCreateEnvironmentModal
guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateProjectModal, { guardAvailableFeature(AvailableFeature.ORGANIZATIONS_PROJECTS, showCreateProjectModal, {
currentUsage: currentOrganization?.teams?.length, currentUsage: currentOrganization?.teams?.length,
}) })
@ -148,3 +144,22 @@ function determineProjectSwitchUrl(pathname: string, newTeamId: number): string
route = removeFlagIdIfPresent(route) route = removeFlagIdIfPresent(route)
return urls.project(newTeamId, 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
}}
/>
)
}

View File

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

View File

@ -14,6 +14,7 @@ interface LemonInputPropsBase
// NOTE: We explicitly pick rather than omit to ensure these components aren't used incorrectly // NOTE: We explicitly pick rather than omit to ensure these components aren't used incorrectly
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
| 'className' | 'className'
| 'onClick'
| 'onFocus' | 'onFocus'
| 'onBlur' | 'onBlur'
| 'autoFocus' | 'autoFocus'

View File

@ -16,6 +16,7 @@ export interface LemonMenuItemBase
'icon' | 'sideIcon' | 'sideAction' | 'disabledReason' | 'tooltip' | 'active' | 'status' | 'data-attr' 'icon' | 'sideIcon' | 'sideAction' | 'disabledReason' | 'tooltip' | 'active' | 'status' | 'data-attr'
> { > {
label: string | JSX.Element label: string | JSX.Element
key?: React.Key
/** True if the item is a custom element. */ /** True if the item is a custom element. */
custom?: boolean custom?: boolean
} }
@ -46,6 +47,7 @@ export type LemonMenuItemLeaf = LemonMenuItemLeafCallback | LemonMenuItemLeafLin
export interface LemonMenuItemCustom { export interface LemonMenuItemCustom {
/** A label that's a component means it will be rendered directly, and not wrapped in a button. */ /** A label that's a component means it will be rendered directly, and not wrapped in a button. */
label: () => JSX.Element label: () => JSX.Element
key?: React.Key
active?: never active?: never
items?: never items?: never
keyboardShortcut?: never keyboardShortcut?: never
@ -57,6 +59,7 @@ export type LemonMenuItem = LemonMenuItemLeaf | LemonMenuItemCustom | LemonMenuI
export interface LemonMenuSection { export interface LemonMenuSection {
title?: string | React.ReactNode title?: string | React.ReactNode
key?: React.Key
items: (LemonMenuItem | false | null)[] items: (LemonMenuItem | false | null)[]
footer?: string | React.ReactNode footer?: string | React.ReactNode
} }
@ -171,7 +174,7 @@ export function LemonMenuSectionList({
<ul> <ul>
{sections.map((section, i) => { {sections.map((section, i) => {
const sectionElement = ( const sectionElement = (
<li key={i}> <li key={section.key || i}>
<section className="space-y-px"> <section className="space-y-px">
{section.title ? ( {section.title ? (
typeof section.title === 'string' ? ( typeof section.title === 'string' ? (
@ -221,7 +224,7 @@ export function LemonMenuItemList({
return ( return (
<ul className="space-y-px"> <ul className="space-y-px">
{items.map((item, index) => ( {items.map((item, index) => (
<li key={index}> <li key={item.key || index}>
<LemonMenuItemButton <LemonMenuItemButton
item={item} item={item}
size={buttonSize} size={buttonSize}

View File

@ -36,7 +36,7 @@ export const Lettermark = React.forwardRef<HTMLDivElement, LettermarkProps>(func
const representation = name const representation = name
? typeof name === 'number' ? typeof name === 'number'
? String(Math.floor(name)) ? 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 const colorIndex = color ? color : typeof index === 'number' ? (index % NUM_LETTERMARK_STYLES) + 1 : undefined

View File

@ -27,6 +27,7 @@
"resolveJsonModule": true, // Include modules imported with .json extension "resolveJsonModule": true, // Include modules imported with .json extension
"noEmit": true, // Do not emit output (meaning do not compile code, only perform type checking) "noEmit": true, // Do not emit output (meaning do not compile code, only perform type checking)
"jsx": "react-jsx", // Support JSX in .tsx files "jsx": "react-jsx", // Support JSX in .tsx files
"target": "ES2015",
"sourceMap": true, // Generate corrresponding .map file "sourceMap": true, // Generate corrresponding .map file
"declaration": true, // Generate corresponding .d.ts file "declaration": true, // Generate corresponding .d.ts file
"noUnusedLocals": true, // Report errors on unused locals "noUnusedLocals": true, // Report errors on unused locals