Skip to content

Commit

Permalink
feat: add global search to console (#2625)
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman authored Sep 5, 2024
1 parent 775b2de commit 4f4ca4e
Show file tree
Hide file tree
Showing 8 changed files with 243 additions and 41 deletions.
13 changes: 13 additions & 0 deletions frontend/console/e2e/command-palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, ftlTest } from './ftl-test'

ftlTest('shows command palette results', async ({ page }) => {
await page.goto('http://localhost:8892')

await page.click('#command-palette-search')
await page.fill('#search-input', 'echo')

// Command palette should be showing the echo parts
await expect(page.getByText('echo.EchoRequest')).toBeVisible()
await expect(page.getByText('echo.EchoReponse')).toBeVisible()
await expect(page.getByText('echo.echo')).toBeVisible()
})
98 changes: 98 additions & 0 deletions frontend/console/src/features/command-pallete/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions, Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
import { ArrowRight01Icon, CellsIcon } from 'hugeicons-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSchema } from '../../api/schema/use-schema'
import { type PaletteItem, paletteItems } from './command-palette.utils'

type CommandPaletteProps = {
isOpen: boolean
onClose: () => void
}

export const CommandPalette: React.FC<CommandPaletteProps> = ({ isOpen, onClose }) => {
const navigate = useNavigate()
const { data: schemaData } = useSchema()
const [query, setQuery] = useState('')
const [items, setItems] = useState<PaletteItem[]>([])

useEffect(() => {
if (schemaData) {
const newItems = paletteItems(schemaData)
setItems(newItems)
}
}, [schemaData])

const filteredItems = query === '' ? [] : items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()))

const handleClose = () => {
onClose()
setQuery('')
}

if (!isOpen) return

return (
<Dialog className='relative z-10' open={isOpen} onClose={handleClose}>
<DialogBackdrop
transition
className='fixed inset-0 bg-gray-900 dark:bg-gray-900 bg-opacity-40 dark:bg-opacity-60 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in'
/>

<div className='fixed inset-0 z-10 w-screen overflow-y-auto p-4 sm:p-6 md:p-20'>
<DialogPanel
transition
className='mx-auto max-w-xl transform rounded-xl bg-white dark:bg-gray-800 p-2 shadow-2xl ring-1 ring-black dark:ring-gray-700 ring-opacity-5 transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in'
>
<Combobox
onChange={(item: PaletteItem) => {
if (item) {
navigate(item.url)
handleClose()
}
}}
>
<ComboboxInput
id='command-palette-search-input'
autoFocus
className='w-full rounded-md border-0 bg-gray-100 dark:bg-gray-900 px-4 py-2.5 text-gray-900 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-300 focus:ring-0 sm:text-sm'
placeholder='Search...'
onChange={(event) => setQuery(event.target.value)}
onBlur={handleClose}
/>

{filteredItems.length > 0 && (
<ComboboxOptions static className='-mb-2 max-h-72 scroll-py-2 overflow-y-auto py-2 text-sm text-gray-800 dark:text-gray-300'>
{filteredItems.map((item) => (
<ComboboxOption
key={item.id}
value={item}
className='group flex cursor-default select-none rounded-md px-2 py-2 data-[focus]:bg-indigo-600 data-[focus]:text-white dark:data-[focus]:bg-indigo-500'
>
<div className='flex size-10 flex-none items-center justify-center rounded-lg'>
<item.icon className='size-5 text-gray-500 dark:text-gray-400 group-data-[focus]:text-white' aria-hidden='true' />
</div>
<div className='ml-2 flex-auto'>
<p className='text-sm font-medium text-gray-700 dark:text-gray-200 group-data-[focus]:text-white'>{item.title}</p>
<p className='mt-0.5 text-xs font-roboto-mono text-gray-500 dark:text-gray-400 group-data-[focus]:text-gray-300'>{item.subtitle}</p>
</div>
<div className='mr-2 flex items-center justify-end'>
<ArrowRight01Icon className='size-5 text-gray-200 dark:text-gray-400 hidden group-data-[focus]:block' aria-hidden='true' />
</div>
</ComboboxOption>
))}
</ComboboxOptions>
)}

{query !== '' && filteredItems.length === 0 && (
<div className='px-4 py-14 text-center sm:px-14'>
<CellsIcon className='mx-auto h-6 w-6 text-gray-400 dark:text-gray-500' aria-hidden='true' />
<p className='mt-4 text-sm text-gray-900 dark:text-gray-200'>No items found using that search term.</p>
</div>
)}
</Combobox>
</DialogPanel>
</div>
</Dialog>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { CellsIcon, type HugeiconsProps } from 'hugeicons-react'
import type { PullSchemaResponse } from '../../protos/xyz/block/ftl/v1/ftl_pb'
import { declIcons, declUrl } from '../modules/module.utils'

export interface PaletteItem {
id: string
icon: React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>
title: string
subtitle?: string
url: string
}

export const paletteItems = (schema: PullSchemaResponse[]): PaletteItem[] => {
const items: PaletteItem[] = []

for (const module of schema) {
items.push({
id: `${module.moduleName}-module`,
icon: CellsIcon,
title: module.moduleName,
subtitle: module.moduleName,
url: `/modules/${module.moduleName}`,
})

for (const decl of module.schema?.decls ?? []) {
if (!decl.value || !decl.value.case || !decl.value.value) {
return []
}

items.push({
id: `${module.moduleName}-${decl.value.value.name}`,
icon: declIcons[decl.value.case],
title: decl.value.value.name,
subtitle: `${module.moduleName}.${decl.value.value.name}`,
url: declUrl(module.moduleName, decl),
})
}
}

return items
}
43 changes: 4 additions & 39 deletions frontend/console/src/features/modules/ModulesTree.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,18 @@
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import {
AnonymousIcon,
ArrowRight01Icon,
BubbleChatIcon,
CircleArrowRight02Icon,
CodeIcon,
DatabaseIcon,
FileExportIcon,
FlowIcon,
FunctionIcon,
type HugeiconsProps,
LeftToRightListNumberIcon,
MessageIncoming02Icon,
PackageIcon,
Settings02Icon,
SquareLock02Icon,
} from 'hugeicons-react'
import { ArrowRight01Icon, CircleArrowRight02Icon, CodeIcon, FileExportIcon, PackageIcon } from 'hugeicons-react'
import { useEffect, useMemo, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import type { Decl } from '../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { classNames } from '../../utils'
import type { ModuleTreeItem } from './module.utils'
import { addModuleToLocalStorageIfMissing, listExpandedModulesFromLocalStorage, toggleModuleExpansionInLocalStorage } from './module.utils'
import { addModuleToLocalStorageIfMissing, declIcons, declUrl, listExpandedModulesFromLocalStorage, toggleModuleExpansionInLocalStorage } from './module.utils'

const ExportedIcon = () => (
<span className='w-4' title='Exported'>
<FileExportIcon className='size-4 text-indigo-500 -ml-1' />
</span>
)

type IconMap = Record<string, React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>>
const icons: IconMap = {
config: Settings02Icon,
data: CodeIcon,
database: DatabaseIcon,
enum: LeftToRightListNumberIcon,
fsm: FlowIcon,
topic: BubbleChatIcon,
typeAlias: AnonymousIcon,
secret: SquareLock02Icon,
subscription: MessageIncoming02Icon,
verb: FunctionIcon,
}

type WithExport = { export?: boolean }

const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSelected: boolean }) => {
Expand All @@ -63,7 +33,7 @@ const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSele
}
}, [isSelected])

const Icon = useMemo(() => icons[decl.value.case || ''] || CodeIcon, [decl.value.case])
const Icon = useMemo(() => declIcons[decl.value.case || ''] || CodeIcon, [decl.value.case])
return (
<li className='my-1'>
<div
Expand Down Expand Up @@ -130,12 +100,7 @@ const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: Module
</DisclosureButton>
<DisclosurePanel as='ul'>
{module.decls.map((d, i) => (
<DeclNode
key={i}
decl={d}
href={`/modules/${module.name}/${d.value.case}/${d.value.value?.name}`}
isSelected={isSelected && declName === d.value.value?.name}
/>
<DeclNode key={i} decl={d} href={declUrl(module.name, d)} isSelected={isSelected && declName === d.value.value?.name} />
))}
</DisclosurePanel>
</Disclosure>
Expand Down
29 changes: 29 additions & 0 deletions frontend/console/src/features/modules/module.utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import {
AnonymousIcon,
BubbleChatIcon,
CodeIcon,
DatabaseIcon,
FlowIcon,
FunctionIcon,
type HugeiconsProps,
LeftToRightListNumberIcon,
MessageIncoming02Icon,
Settings02Icon,
SquareLock02Icon,
} from 'hugeicons-react'
import type { Module } from '../../protos/xyz/block/ftl/v1/console/console_pb'
import type { PullSchemaResponse } from '../../protos/xyz/block/ftl/v1/ftl_pb'
import type { Decl } from '../../protos/xyz/block/ftl/v1/schema/schema_pb'
Expand Down Expand Up @@ -102,3 +115,19 @@ export const addModuleToLocalStorageIfMissing = (moduleName?: string) => {
localStorage.setItem('tree_m', [...expanded, moduleName].join(','))
}
}

type IconMap = Record<string, React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>>
export const declIcons: IconMap = {
config: Settings02Icon,
data: CodeIcon,
database: DatabaseIcon,
enum: LeftToRightListNumberIcon,
fsm: FlowIcon,
topic: BubbleChatIcon,
typeAlias: AnonymousIcon,
secret: SquareLock02Icon,
subscription: MessageIncoming02Icon,
verb: FunctionIcon,
}

export const declUrl = (moduleName: string, decl: Decl) => `/modules/${moduleName}/${decl.value.case}/${decl.value.value?.name}`
2 changes: 1 addition & 1 deletion frontend/console/src/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const Layout = () => {
const version = status.data?.controllers?.[0]?.version

return (
<div className='min-w-[800px] max-w-full max-h-full h-full flex flex-col dark:bg-gray-800 bg-white text-gray-700 dark:text-gray-200'>
<div className='min-w-[700px] max-w-full max-h-full h-full flex flex-col dark:bg-gray-800 bg-white text-gray-700 dark:text-gray-200'>
<Navigation version={version} />
<main className='flex-1' style={{ height: 'calc(100vh - 64px)' }}>
<Outlet />
Expand Down
9 changes: 8 additions & 1 deletion frontend/console/src/layout/navigation/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { CellsIcon, Database01Icon, ListViewIcon, WorkflowSquare06Icon } from 'hugeicons-react'
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import { DarkModeSwitch } from '../../components'
import { CommandPalette } from '../../features/command-pallete/CommandPalette'
import { classNames } from '../../utils'
import { SearchInput } from './SearchInput'
import { Version } from './Version'

const navigation = [
Expand All @@ -12,6 +15,8 @@ const navigation = [
]

export const Navigation = ({ version }: { version?: string }) => {
const [isCommandPalleteOpen, setIsCommandPalleteOpen] = useState(false)

return (
<nav className='bg-indigo-600'>
<div className='mx-auto pl-3 pr-4'>
Expand All @@ -37,8 +42,10 @@ export const Navigation = ({ version }: { version?: string }) => {
</div>
</div>
</div>
<SearchInput onFocus={() => setIsCommandPalleteOpen(true)} />
<CommandPalette isOpen={isCommandPalleteOpen} onClose={() => setIsCommandPalleteOpen(false)} />
<div>
<div className='ml-4 flex items-center space-x-4'>
<div className='ml-2 flex items-center space-x-4'>
<Version version={version} />
<DarkModeSwitch />
</div>
Expand Down
49 changes: 49 additions & 0 deletions frontend/console/src/layout/navigation/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Search01Icon } from 'hugeicons-react'
import type React from 'react'
import { useEffect } from 'react'

type SearchInputProps = {
onFocus: () => void
}

export const SearchInput: React.FC<SearchInputProps> = ({ onFocus }) => {
const shortcutText = window.navigator.userAgent.includes('Mac') ? '⌘ + K' : 'Ctrl + K'

useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
onFocus()
}
}

window.addEventListener('keydown', handleKeydown)

return () => {
window.removeEventListener('keydown', handleKeydown)
}
}, [onFocus])

return (
<div className='flex flex-1 items-center justify-center px-2 lg:ml-6 lg:justify-end'>
<div className='w-full max-w-lg lg:max-w-xs'>
<label htmlFor='search' className='sr-only'>
Search
</label>
<div
id='command-palette-search'
className='relative block w-full cursor-pointer rounded-md border border-indigo-700 bg-indigo-700/50 py-1.5 pl-10 pr-3 text-indigo-200 placeholder:text-indigo-300 sm:text-sm sm:leading-6 hover:border-indigo-500 hover:ring-indigo-500 focus-within:border-indigo-400 focus-within:ring-indigo-400'
onClick={onFocus}
>
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
<Search01Icon aria-hidden='true' className='h-5 w-5 text-indigo-300' />
</div>
<span className='text-indigo-300'>Search</span>
<div className='absolute inset-y-0 right-0 flex items-center pr-3'>
<span className='text-indigo-200 text-xs bg-indigo-600 px-2 py-1 rounded-md'>{shortcutText}</span>
</div>
</div>
</div>
</div>
)
}

0 comments on commit 4f4ca4e

Please sign in to comment.