Skip to content

Commit

Permalink
feat: add decl type filter + collapse all button to console's module …
Browse files Browse the repository at this point in the history
  • Loading branch information
deniseli authored Sep 27, 2024
1 parent 9f72cf6 commit 67f2db1
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 13 deletions.
62 changes: 62 additions & 0 deletions frontend/console/src/components/Multiselect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Listbox } from '@headlessui/react'
import { ArrowDown01Icon, Tick01Icon } from 'hugeicons-react'

export interface MultiselectOpt {
key: string
displayName: string
}

export function sortMultiselectOpts(o: MultiselectOpt[]) {
return o.sort((a: MultiselectOpt, b: MultiselectOpt) => (a.key < b.key ? -1 : 1))
}

export const Multiselect = ({
allOpts,
selectedOpts,
onChange,
}: { allOpts: MultiselectOpt[]; selectedOpts: MultiselectOpt[]; onChange: (types: MultiselectOpt[]) => void }) => {
sortMultiselectOpts(selectedOpts)
return (
<div className='w-full'>
<Listbox multiple value={selectedOpts} onChange={onChange}>
<div className='relative w-[calc(100%-0.75rem)] items-center'>
<Listbox.Button className='w-full m-1.5 py-1 px-2 text-sm border-gray-200 dark:border-white/5 rounded-md text-sm text-gray-500 hover:text-gray-900 hover:dark:text-gray-200 bg-gray-300 dark:bg-gray-800'>
<span className='block truncate w-[calc(100%-30px)] h-5'>{selectedOpts.map((o) => o.displayName).join(', ')}</span>
<span className='pointer-events-none absolute inset-y-0 right-0 flex items-center pr-1'>
<ArrowDown01Icon className='w-5' />
</span>
</Listbox.Button>
</div>
<Listbox.Options
anchor='bottom'
transition
className='w-[var(--button-width)] min-w-48 mt-1 rounded-xl border dark:border-white/5 bg-white/90 dark:bg-gray-700 transition duration-100 ease-in truncate drop-shadow-lg z-20'
>
{allOpts.map((o) => (
<Listbox.Option
className='cursor-pointer py-1.5 px-2 group flex items-center gap-2 select-none text-sm dark:text-white hover:bg-gray-200 hover:dark:bg-gray-800'
key={o.key}
value={o}
>
<div className='w-4'>
<Tick01Icon className='size-4 invisible group-data-[selected]:visible' />
</div>
{o.displayName}
</Listbox.Option>
))}
<div className='w-full mt-2 text-center text-sm dark:text-white border-t dark:border-white/5 bg-white dark:bg-gray-700'>
<div
className='w-1/2 p-2 inline-block hover:bg-gray-200 hover:dark:bg-white/10 cursor-pointer text-clip border-r dark:border-white/5'
onClick={() => onChange(allOpts)}
>
Select All
</div>
<div className='w-1/2 p-2 inline-block hover:bg-gray-200 hover:dark:bg-white/10 cursor-pointer text-clip' onClick={() => onChange([])}>
Deselect All
</div>
</div>
</Listbox.Options>
</Listbox>
</div>
)
}
79 changes: 69 additions & 10 deletions frontend/console/src/features/modules/ModulesTree.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { ArrowRight01Icon, CircleArrowRight02Icon, CodeIcon, FileExportIcon, PackageIcon } from 'hugeicons-react'
import { ArrowRight01Icon, CircleArrowRight02Icon, CodeIcon, FileExportIcon, PackageIcon, SquareArrowShrink02Icon } from 'hugeicons-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { Multiselect, sortMultiselectOpts } from '../../components/Multiselect'
import type { MultiselectOpt } from '../../components/Multiselect'
import type { Decl } from '../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { classNames } from '../../utils'
import type { ModuleTreeItem } from './module.utils'
import { addModuleToLocalStorageIfMissing, declIcons, declUrl, listExpandedModulesFromLocalStorage, toggleModuleExpansionInLocalStorage } from './module.utils'
import {
addModuleToLocalStorageIfMissing,
collapseAllModulesInLocalStorage,
declIcons,
declUrl,
listExpandedModulesFromLocalStorage,
toggleModuleExpansionInLocalStorage,
} from './module.utils'
import { declTypeMultiselectOpts } from './schema/schema.utils'

const ExportedIcon = () => (
<span className='w-4' title='Exported'>
Expand Down Expand Up @@ -55,7 +65,12 @@ const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSele
)
}

const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: ModuleTreeItem; isExpanded: boolean; toggleExpansion: (m: string) => void }) => {
const ModuleSection = ({
module,
isExpanded,
toggleExpansion,
selectedDeclTypes,
}: { module: ModuleTreeItem; isExpanded: boolean; toggleExpansion: (m: string) => void; selectedDeclTypes: MultiselectOpt[] }) => {
const { moduleName, declName } = useParams()
const navigate = useNavigate()
const isSelected = useMemo(() => moduleName === module.name, [moduleName, module.name])
Expand All @@ -72,6 +87,11 @@ const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: Module
}
}, [moduleName]) // moduleName is the selected module; module.name is the one being rendered

const filteredDecls = useMemo(
() => module.decls.filter((d) => d.value?.case && !!selectedDeclTypes.find((o) => o.key === d.value.case)),
[module.decls, selectedDeclTypes],
)

return (
<li key={module.name} id={`module-tree-module-${module.name}`} className='my-2'>
<div
Expand All @@ -92,13 +112,13 @@ const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: Module
navigate(`/modules/${module.name}`)
}}
/>
{module.decls.length === 0 || (
<ArrowRight01Icon aria-hidden='true' className='ml-auto mr-2 h-4 w-4 shrink-0 group-data-[open]:rotate-90 group-data-[open]:text-gray-500' />
{filteredDecls.length === 0 || (
<ArrowRight01Icon aria-hidden='true' className={`ml-auto mr-2 h-4 w-4 shrink-0 ${isExpanded ? 'rotate-90 text-gray-500' : ''}`} />
)}
</div>
{isExpanded && (
<ul>
{module.decls.map((d, i) => (
{filteredDecls.map((d, i) => (
<DeclNode key={i} decl={d} href={declUrl(module.name, d)} isSelected={isSelected && declName === d.value.value?.name} />
))}
</ul>
Expand All @@ -107,29 +127,68 @@ const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: Module
)
}

const declTypesSearchParamKey = 'dt'

export const ModulesTree = ({ modules }: { modules: ModuleTreeItem[] }) => {
const { moduleName, declName } = useParams()

const [searchParams, setSearchParams] = useSearchParams()
const declTypeKeysFromUrl = searchParams.getAll(declTypesSearchParamKey)
const declTypesFromUrl = declTypeMultiselectOpts.filter((o) => declTypeKeysFromUrl.includes(o.key))
const [selectedDeclTypes, setSelectedDeclTypes] = useState(declTypesFromUrl.length === 0 ? declTypeMultiselectOpts : declTypesFromUrl)

const [expandedModules, setExpandedModules] = useState(listExpandedModulesFromLocalStorage())
useEffect(() => {
if (declName) {
addModuleToLocalStorageIfMissing(moduleName)
}
}, [moduleName, declName])

function toggleFn(moduleName: string) {
function msOnChange(opts: MultiselectOpt[]) {
const params = new URLSearchParams()
if (opts.length !== declTypeMultiselectOpts.length) {
for (const o of sortMultiselectOpts(opts)) {
params.append(declTypesSearchParamKey, o.key)
}
}
setSearchParams(params)
setSelectedDeclTypes(opts)
}

function toggle(moduleName: string) {
toggleModuleExpansionInLocalStorage(moduleName)
setExpandedModules(listExpandedModulesFromLocalStorage())
return
}

function collapseAll() {
collapseAllModulesInLocalStorage()
setExpandedModules([])
}

modules.sort((m1, m2) => Number(m1.isBuiltin) - Number(m2.isBuiltin))
return (
<div className={'flex grow flex-col h-full gap-y-5 overflow-y-auto bg-gray-100 dark:bg-gray-900'}>
<nav>
<div className='sticky top-0 border-b border-gray-300 bg-gray-100 dark:border-gray-800 dark:bg-gray-900 z-10'>
<span className='block w-[calc(100%-30px)]'>
<Multiselect allOpts={declTypeMultiselectOpts} selectedOpts={selectedDeclTypes} onChange={msOnChange} />
</span>
<span
className='absolute inset-y-0 right-0 flex items-center h-7 px-1 mx-1 my-1.5 rounded-md cursor-pointer bg-gray-300 hover:bg-gray-400 dark:bg-gray-800 hover:dark:bg-gray-700'
onClick={collapseAll}
>
<SquareArrowShrink02Icon className='w-5 dark:text-gray-300' />
</span>
</div>
<ul>
{modules.map((m) => (
<ModuleSection key={m.name} module={m} isExpanded={expandedModules.includes(m.name)} toggleExpansion={toggleFn} />
<ModuleSection
key={m.name}
module={m}
isExpanded={expandedModules.includes(m.name)}
toggleExpansion={toggle}
selectedDeclTypes={selectedDeclTypes}
/>
))}
</ul>
</nav>
Expand Down
4 changes: 3 additions & 1 deletion frontend/console/src/features/modules/module.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const declFromSchema = (moduleName: string, declName: string, schema: Pul
return module.schema.decls.find((d) => d.value.value?.name === declName)
}

export const listExpandedModulesFromLocalStorage = () => (localStorage.getItem('tree_m') || '').split(',')
export const listExpandedModulesFromLocalStorage = () => (localStorage.getItem('tree_m') || '').split(',').filter((s) => s !== '')

export const toggleModuleExpansionInLocalStorage = (moduleName: string) => {
const expanded = listExpandedModulesFromLocalStorage()
Expand All @@ -116,6 +116,8 @@ export const addModuleToLocalStorageIfMissing = (moduleName?: string) => {
}
}

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

type IconMap = Record<string, React.FC<Omit<HugeiconsProps, 'ref'> & React.RefAttributes<SVGSVGElement>>>
export const declIcons: IconMap = {
config: Settings02Icon,
Expand Down
43 changes: 43 additions & 0 deletions frontend/console/src/features/modules/schema/schema.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,49 @@ export const staticKeywords = ['module', 'export']

export const declTypes = ['config', 'data', 'database', 'enum', 'fsm', 'topic', 'typealias', 'secret', 'subscription', 'verb']

export const declTypeMultiselectOpts = [
{
key: 'config',
displayName: 'Config',
},
{
key: 'data',
displayName: 'Data',
},
{
key: 'database',
displayName: 'Database',
},
{
key: 'enum',
displayName: 'Enum',
},
{
key: 'fsm',
displayName: 'FSM',
},
{
key: 'topic',
displayName: 'Topic',
},
{
key: 'typealias',
displayName: 'Type Alias',
},
{
key: 'secret',
displayName: 'FSM',
},
{
key: 'subscription',
displayName: 'Subscription',
},
{
key: 'verb',
displayName: 'Verb',
},
]

// Keep these in sync with backend/schema/module.go#L86-L95
const skipNewLineDeclTypes = ['config', 'secret', 'database', 'topic', 'subscription']
const skipGapAfterTypes: { [key: string]: string[] } = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Timestamp } from '@bufbuild/protobuf'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { Backward02Icon, Forward02Icon, PauseIcon, PlayIcon, Tick02Icon, UnfoldLessIcon } from 'hugeicons-react'
import { ArrowDown01Icon, Backward02Icon, Forward02Icon, PauseIcon, PlayIcon, Tick02Icon } from 'hugeicons-react'
import { useEffect, useState } from 'react'
import { bgColor, borderColor, classNames, formatTimestampShort, formatTimestampTime, panelColor, textColor } from '../../../utils'

Expand Down Expand Up @@ -117,7 +117,7 @@ export const TimelineTimeControls = ({
>
<span className='block truncate'>{selected.label}</span>
<span className='pointer-events-none absolute inset-y-0 right-0 flex items-center pr-1'>
<UnfoldLessIcon className='h-5 w-5 text-gray-400' aria-hidden='true' />
<ArrowDown01Icon className='h-5 w-5 text-gray-400' aria-hidden='true' />
</span>
</ListboxButton>

Expand Down

0 comments on commit 67f2db1

Please sign in to comment.