mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-24 00:47:50 +01:00
feat(navigation): Search in environments switcher (#25374)
This commit is contained in:
parent
22d2aff23c
commit
eb55c47bd1
@ -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<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 />
|
||||
@ -119,7 +114,9 @@ export function EnvironmentSwitcherOverlay({ onClickInside }: { onClickInside?:
|
||||
return (
|
||||
<LemonMenuOverlay
|
||||
items={[
|
||||
{ title: 'Projects', items: [] },
|
||||
{
|
||||
items: [{ label: EnvironmentSwitcherSearch }],
|
||||
},
|
||||
...projectSections,
|
||||
{
|
||||
icon: <IconPlus />,
|
||||
@ -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 (
|
||||
<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
frontend/src/layout/navigation/environmentsSwitcherLogic.tsx
Normal file
145
frontend/src/layout/navigation/environmentsSwitcherLogic.tsx
Normal 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
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
@ -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'
|
||||
|
@ -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({
|
||||
<ul>
|
||||
{sections.map((section, i) => {
|
||||
const sectionElement = (
|
||||
<li key={i}>
|
||||
<li key={section.key || i}>
|
||||
<section className="space-y-px">
|
||||
{section.title ? (
|
||||
typeof section.title === 'string' ? (
|
||||
@ -221,7 +224,7 @@ export function LemonMenuItemList({
|
||||
return (
|
||||
<ul className="space-y-px">
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>
|
||||
<li key={item.key || index}>
|
||||
<LemonMenuItemButton
|
||||
item={item}
|
||||
size={buttonSize}
|
||||
|
@ -36,7 +36,7 @@ export const Lettermark = React.forwardRef<HTMLDivElement, LettermarkProps>(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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user