Skip to content

Commit

Permalink
feat: console shows verb subtypes: cronjob, ingress, subscriber, all …
Browse files Browse the repository at this point in the history
…other verbs (#3256)

Fixes: #3219


https://github.com/user-attachments/assets/dccdc8e9-1c72-4de1-8538-0bdece0beed2

Use verb metadata to classify each verb as a subtype: cronjob, ingress,
subscriber, or none of the above. The backend guarantees these metadata
are mutually exclusive, so the frontend can just check which (if any)
exists in a verb proto.

Changes:
* Add new icons for each subtype
* Thread new icons through both existing callsites: module tree and
global fuzzy search
* Show decl type name on hover of each icon
* Add new verb types to filter dropdown
* Support groups in `Multiselect` to handle verb subtypes
* Make fuzzy search use the same icon for modules as the module tree.
They had accidentally diverged.

Next: verb page customizations per subtype
  • Loading branch information
deniseli authored Oct 30, 2024
1 parent b174d47 commit eb8b601
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 39 deletions.
74 changes: 59 additions & 15 deletions frontend/console/src/components/Multiselect.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ArrowDown01Icon, CheckmarkSquare02Icon, SquareIcon } from 'hugeicons-react'
import { ArrowDown01Icon, CheckmarkSquare02Icon, MinusSignSquareIcon, SquareIcon } from 'hugeicons-react'
import { Divider } from './Divider'

export interface MultiselectOpt {
group?: string
key: string
displayName: string
}
Expand All @@ -21,12 +22,57 @@ const getSelectionText = (selectedOpts: MultiselectOpt[], allOpts: MultiselectOp
return selectedOpts.map((o) => o.displayName).join(', ')
}

function getGroupsFromOpts(opts: MultiselectOpt[]): string[] {
return [...new Set(opts.map((o) => o.group).filter((g) => !!g))] as string[]
}

const GroupIcon = ({ group, allOpts, selectedOpts }: { group: string; allOpts: MultiselectOpt[]; selectedOpts: MultiselectOpt[] }) => {
const all = allOpts.filter((o) => o.group === group)
const selected = selectedOpts.filter((o) => o.group === group)
if (selected.length === 0) {
return <SquareIcon className='size-5' />
}
if (all.length !== selected.length) {
return <MinusSignSquareIcon className='size-5' />
}
return <CheckmarkSquare02Icon className='size-5' />
}

const optionClassName = (p: string) =>
`cursor-pointer py-1 px-${p} group flex items-center gap-2 select-none text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-200 hover:dark:bg-gray-700`

const Option = ({ o, p }: { o: MultiselectOpt; p: string }) => (
<ListboxOption className={optionClassName(p)} value={o}>
{({ selected }) => (
<div className='flex items-center gap-2'>
{selected ? <CheckmarkSquare02Icon className='size-5' /> : <SquareIcon className='size-5' />}
{o.displayName}
</div>
)}
</ListboxOption>
)

export const Multiselect = ({
allOpts,
selectedOpts,
onChange,
}: { allOpts: MultiselectOpt[]; selectedOpts: MultiselectOpt[]; onChange: (types: MultiselectOpt[]) => void }) => {
sortMultiselectOpts(selectedOpts)

const groups = getGroupsFromOpts(allOpts)
function toggleGroup(group: string) {
const selected = selectedOpts.filter((o) => o.group === group)
const xgroupSelectedOpts = selectedOpts.filter((o) => o.group !== group)
if (selected.length === 0) {
// Select all in group
const allInGroup = allOpts.filter((o) => o.group === group)
onChange([...xgroupSelectedOpts, ...allInGroup])
} else {
// Deselect all in group
onChange(xgroupSelectedOpts)
}
}

return (
<div className='w-full'>
<Listbox multiple value={selectedOpts} onChange={onChange}>
Expand All @@ -43,20 +89,18 @@ export const Multiselect = ({
transition
className='w-[var(--button-width)] min-w-48 mt-1 pt-1 rounded-md border dark:border-white/5 bg-white dark:bg-gray-800 transition duration-100 ease-in truncate drop-shadow-lg z-20'
>
{allOpts.map((o) => (
<ListboxOption
className='cursor-pointer py-1 px-2 group flex items-center gap-2 select-none text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-200 hover:dark:bg-gray-700'
key={o.key}
value={o}
>
{({ selected }) => (
<div className='flex items-center gap-2'>
{selected ? <CheckmarkSquare02Icon className='size-5' /> : <SquareIcon className='size-5' />}
{o.displayName}
</div>
)}
</ListboxOption>
))}
{allOpts
.filter((o) => !o.group)
.map((o) => (
<Option key={o.key} o={o} p='2' />
))}
{groups.map((group) => [
<div key={group} onClick={() => toggleGroup(group)} className={optionClassName('2')}>
<GroupIcon group={group} allOpts={allOpts} selectedOpts={selectedOpts} />
{group}
</div>,
...allOpts.filter((o) => o.group === group).map((o) => <Option key={o.key} o={o} p='6' />),
])}

<div className='w-full text-center text-xs mt-2'>
<Divider />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ isOpen, onClose
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'>
<div title={item.iconType} 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'>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { CellsIcon, type HugeiconsProps } from 'hugeicons-react'
import { type HugeiconsProps, PackageIcon } from 'hugeicons-react'
import type { PullSchemaResponse } from '../../protos/xyz/block/ftl/v1/ftl_pb'
import { declIcon, declUrl } from '../modules/module.utils'
import { declIcon, declTypeName, declUrl } from '../modules/module.utils'

export interface PaletteItem {
id: string
icon: React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>
iconType: string
title: string
subtitle?: string
url: string
Expand All @@ -16,7 +17,8 @@ export const paletteItems = (schema: PullSchemaResponse[]): PaletteItem[] => {
for (const module of schema) {
items.push({
id: `${module.moduleName}-module`,
icon: CellsIcon,
icon: PackageIcon,
iconType: 'module',
title: module.moduleName,
subtitle: module.moduleName,
url: `/modules/${module.moduleName}`,
Expand All @@ -29,7 +31,8 @@ export const paletteItems = (schema: PullSchemaResponse[]): PaletteItem[] => {

items.push({
id: `${module.moduleName}-${decl.value.value.name}`,
icon: declIcon(decl.value.case),
icon: declIcon(decl.value.case, decl.value.value),
iconType: declTypeName(decl.value.case, decl.value.value),
title: decl.value.value.name,
subtitle: `${module.moduleName}.${decl.value.value.name}`,
url: declUrl(module.moduleName, decl),
Expand All @@ -39,7 +42,8 @@ export const paletteItems = (schema: PullSchemaResponse[]): PaletteItem[] => {
for (const field of decl.value.value.fields) {
items.push({
id: `${module.moduleName}-${decl.value.value.name}-${field.name}`,
icon: declIcon(decl.value.case),
icon: declIcon(decl.value.case, decl.value.value),
iconType: declTypeName(decl.value.case, decl.value.value),
title: field.name,
subtitle: `${module.moduleName}.${decl.value.value.name}.${field.name}`,
url: declUrl(module.moduleName, decl),
Expand Down
17 changes: 11 additions & 6 deletions frontend/console/src/features/modules/ModulesTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { Link, useParams, useSearchParams } from 'react-router-dom'
import { Multiselect, sortMultiselectOpts } from '../../components/Multiselect'
import type { MultiselectOpt } from '../../components/Multiselect'
import { classNames } from '../../utils'
import type { ModuleTreeItem } from './module.utils'
import type { DeclInfo, ModuleTreeItem } from './module.utils'
import {
type DeclInfo,
addModuleToLocalStorageIfMissing,
collapseAllModulesInLocalStorage,
declIcon,
declSumTypeIsExported,
declTypeName,
declUrlFromInfo,
getHideUnexportedFromLocalStorage,
hasHideUnexportedInLocalStorage,
Expand All @@ -34,7 +34,8 @@ const DeclNode = ({ decl, href, isSelected }: { decl: DeclInfo; href: string; is
}
}, [isSelected])

const Icon = useMemo(() => declIcon(decl.declType), [decl.declType])
const Icon = useMemo(() => declIcon(decl.declType, decl.value), [decl])
const declType = useMemo(() => declTypeName(decl.declType, decl.value), [decl])
return (
<li className='my-1'>
<Link id={`decl-${decl.value.name}`} to={href}>
Expand All @@ -46,7 +47,9 @@ const DeclNode = ({ decl, href, isSelected }: { decl: DeclInfo; href: string; is
'group flex items-center gap-x-2 pl-4 pr-2 text-sm font-light leading-6 w-full cursor-pointer scroll-mt-10',
)}
>
<Icon aria-hidden='true' className='size-4 shrink-0 ml-3' />
<span title={declType}>
<Icon aria-hidden='true' className='size-4 shrink-0 ml-3' />
</span>
{decl.value.name}
</div>
</Link>
Expand Down Expand Up @@ -79,7 +82,7 @@ const ModuleSection = ({
const filteredDecls = useMemo(
() =>
module.decls
.filter((d) => !!selectedDeclTypes.find((o) => o.key === d.declType))
.filter((d) => !!selectedDeclTypes.find((o) => o.key === declTypeName(d.declType, d.value)))
.filter((d) => !hideUnexported || (isSelected && declName === d.value.name) || declSumTypeIsExported(d.value)),
[module.decls, selectedDeclTypes, hideUnexported, isSelected, declName],
)
Expand All @@ -95,7 +98,9 @@ const ModuleSection = ({
)}
onClick={() => toggleExpansion(module.name)}
>
<PackageIcon aria-hidden='true' className='size-4 my-1 ml-3 shrink-0' />
<span title='module'>
<PackageIcon aria-hidden='true' className='size-4 my-1 ml-3 shrink-0' />
</span>
{module.name}
<Link to={`/modules/${module.name}`} onClick={(e) => e.stopPropagation()}>
<CircleArrowRight02Icon id={`module-${module.name}-view-icon`} className='size-4 shrink-0 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600' />
Expand Down
70 changes: 59 additions & 11 deletions frontend/console/src/features/modules/module.utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {
AnonymousIcon,
BubbleChatIcon,
Clock01Icon,
CodeIcon,
DatabaseIcon,
FunctionIcon,
type HugeiconsProps,
InternetIcon,
LeftToRightListNumberIcon,
MessageIncoming02Icon,
MessageProgrammingIcon,
Settings02Icon,
SquareLock02Icon,
} from 'hugeicons-react'
Expand Down Expand Up @@ -170,20 +173,38 @@ export const addModuleToLocalStorageIfMissing = (moduleName?: string) => {

export const collapseAllModulesInLocalStorage = () => localStorage.setItem('tree_m', '')

export const declIcon = (declCase?: string) => {
const declIcons: Record<string, React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>> = {
config: Settings02Icon,
data: CodeIcon,
database: DatabaseIcon,
enum: LeftToRightListNumberIcon,
topic: BubbleChatIcon,
typealias: AnonymousIcon,
secret: SquareLock02Icon,
subscription: MessageIncoming02Icon,
verb: FunctionIcon,
export const declTypeName = (declCase: string, decl: DeclSumType) => {
const normalizedDeclCase = declCase?.toLowerCase()
if (normalizedDeclCase === 'verb') {
const vt = verbTypeFromMetadata(decl as Verb)
if (vt) {
return vt
}
}
return normalizedDeclCase || ''
}

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

export const declIcon = (declCase: string, decl: DeclSumType) => {
const normalizedDeclCase = declCase?.toLowerCase()

// Verbs have subtypes as defined by metadata
const maybeVerbIcon = verbIcon(normalizedDeclCase, decl)
if (maybeVerbIcon) {
return maybeVerbIcon
}

if (!normalizedDeclCase || !declIcons[normalizedDeclCase]) {
console.warn(`No icon for decl case: ${declCase}`)
return CodeIcon
Expand All @@ -192,6 +213,33 @@ export const declIcon = (declCase?: string) => {
return declIcons[normalizedDeclCase]
}

const verbIcons: Record<string, React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>> = {
cronjob: Clock01Icon,
ingress: InternetIcon,
subscriber: MessageProgrammingIcon,
}

const verbIcon = (declCase: string, decl: DeclSumType) => {
if (declCase !== 'verb') {
return
}
const vt = verbTypeFromMetadata(decl as Verb)
if (!vt || !verbIcons[vt]) {
return declIcons.verb
}

return verbIcons[vt]
}

// Most metadata is not mutually exclusive, but schema validation guarantees
// that the ones in this list are.
const verbTypesFromMetadata = ['cronjob', 'ingress', 'subscriber']

export const verbTypeFromMetadata = (verb: Verb) => {
const found = verb.metadata.find((m) => m.value.case && verbTypesFromMetadata.includes(m.value.case.toLowerCase()))
return found?.value.case?.toLowerCase()
}

export const declUrl = (moduleName: string, decl: Decl) => `/modules/${moduleName}/${decl.value.case?.toLowerCase()}/${decl.value.value?.name}`

export const declUrlFromInfo = (moduleName: string, decl: DeclInfo) => `/modules/${moduleName}/${decl.declType}/${decl.value.name}`
Expand Down
18 changes: 17 additions & 1 deletion frontend/console/src/features/modules/schema/schema.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,24 @@ export const declTypeMultiselectOpts = [
displayName: 'Subscription',
},
{
group: 'Verb',
key: 'cronjob',
displayName: 'Cron Job',
},
{
group: 'Verb',
key: 'ingress',
displayName: 'Ingress Verb',
},
{
group: 'Verb',
key: 'subscriber',
displayName: 'Subscriber',
},
{
group: 'Verb',
key: 'verb',
displayName: 'Verb',
displayName: 'All Other Verbs',
},
]

Expand Down

0 comments on commit eb8b601

Please sign in to comment.