Skip to content

Commit

Permalink
feat: add module tree with routing to basic decl pages (#2523)
Browse files Browse the repository at this point in the history
Fixes #2487

* New tree view
* Local storage persists the module expansion state and the width of the
tree
* Base page added for all decl types, with slightly more built out for
verb and data to show how it all hooks together



https://github.com/user-attachments/assets/36b9fb48-76ac-4fc7-aa34-54713675e462



Next: verb page code editor
  • Loading branch information
deniseli authored Aug 29, 2024
1 parent f84f7b8 commit 9de38e9
Show file tree
Hide file tree
Showing 12 changed files with 384 additions and 105 deletions.
4 changes: 2 additions & 2 deletions frontend/e2e/ftl-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ const ftlTest = base.extend<{
page: async ({ page }, use) => {
await page.goto('http://localhost:8892/modules')
await page.waitForFunction(() => {
const timeItem = document.querySelector('li#module-tree-item-time');
const echoItem = document.querySelector('li#module-tree-item-echo');
const timeItem = document.querySelector('li#module-tree-module-time');
const echoItem = document.querySelector('li#module-tree-module-echo');
return timeItem !== null && echoItem !== null;
});

Expand Down
56 changes: 56 additions & 0 deletions frontend/src/components/ResizableHorizontalPanels.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type React from 'react'
import { useRef, useState } from 'react'

interface ResizableHorizontalPanelsProps {
leftPanelContent: React.ReactNode
rightPanelContent: React.ReactNode
minLeftPanelWidth?: number
minRightPanelWidth?: number
leftPanelWidth: number
setLeftPanelWidth: (n: number) => void
}

export const ResizableHorizontalPanels: React.FC<ResizableHorizontalPanelsProps> = ({
leftPanelContent,
rightPanelContent,
minLeftPanelWidth = 100,
minRightPanelWidth = 100,
leftPanelWidth,
setLeftPanelWidth,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)

const startDragging = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(true)
}

const stopDragging = () => setIsDragging(false)

const onDrag = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !containerRef.current) {
return
}
const containerDims = containerRef.current.getBoundingClientRect()
const newWidth = e.clientX - containerDims.x
const maxWidth = containerDims.width - minRightPanelWidth
if (newWidth >= minLeftPanelWidth && newWidth <= maxWidth) {
setLeftPanelWidth(newWidth)
}
}

return (
<div ref={containerRef} className='flex flex-row h-full w-full' onMouseMove={onDrag} onMouseUp={stopDragging} onMouseLeave={stopDragging}>
<div style={{ width: `${leftPanelWidth}px` }} className='overflow-auto'>
{leftPanelContent}
</div>
<div
className='cursor-col-resize bg-gray-100 dark:bg-gray-900 hover:bg-indigo-600'
onMouseDown={startDragging}
style={{ width: '3px', cursor: 'col-resize' }}
/>
<div className='flex-1 overflow-auto'>{rightPanelContent}</div>
</div>
)
}
11 changes: 11 additions & 0 deletions frontend/src/features/modules/ModulePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useParams } from 'react-router-dom'

export const ModulePanel = () => {
const { moduleName } = useParams()

return (
<div className='flex-1 py-2 px-4'>
<p>Module: {moduleName}</p>
</div>
)
}
37 changes: 26 additions & 11 deletions frontend/src/features/modules/ModulesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import { useMemo } from 'react'
import type React from 'react'
import { useMemo, useState } from 'react'
import { useSchema } from '../../api/schema/use-schema'
import { ResizableHorizontalPanels } from '../../components/ResizableHorizontalPanels'
import { ModulesTree } from './ModulesTree'
import { moduleTreeFromSchema } from './module.utils'

export const ModulesPage = () => {
const treeWidthStorageKey = 'tree_w'

export const ModulesPanel = () => {
return (
<div className='flex-1 py-2 px-4'>
<p>Content</p>
</div>
)
}

export const ModulesPage = ({ body }: { body: React.ReactNode }) => {
const schema = useSchema()
const tree = useMemo(() => moduleTreeFromSchema(schema?.data || []), [schema?.data])
const [treeWidth, setTreeWidth] = useState(Number(localStorage.getItem(treeWidthStorageKey)) || 300)

return (
<div className='flex h-full'>
<div className='w-64 h-full'>
<ModulesTree modules={tree} />
</div>
function setTreeWidthWithLS(newWidth: number) {
localStorage.setItem(treeWidthStorageKey, `${newWidth}`)
setTreeWidth(newWidth)
}

<div className='flex-1 py-2 px-4'>
<p>Content</p>
</div>
</div>
return (
<ResizableHorizontalPanels
leftPanelContent={<ModulesTree modules={tree} />}
rightPanelContent={body}
leftPanelWidth={treeWidth}
setLeftPanelWidth={setTreeWidthWithLS}
/>
)
}
168 changes: 121 additions & 47 deletions frontend/src/features/modules/ModulesTree.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,134 @@
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import { ChevronRightIcon } from '@heroicons/react/24/outline'
import {
ArrowRightCircleIcon,
BellAlertIcon,
BellIcon,
BoltIcon,
BookOpenIcon,
ChevronRightIcon,
CircleStackIcon,
CodeBracketSquareIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
LockClosedIcon,
NumberedListIcon,
SquaresPlusIcon,
TableCellsIcon,
} from '@heroicons/react/24/outline'
import type { ForwardRefExoticComponent, SVGProps } from 'react'
import { useEffect, useMemo } 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'

// This could alternatively be an icon, but we'd need to pick a good one.
const ExportBadge = () => <span className='text-xs py-0.5 px-1.5 bg-gray-200 dark:bg-gray-800 dark:text-gray-300 rounded-md'>Exported</span>

type IconMap = Record<string, ForwardRefExoticComponent<SVGProps<SVGSVGElement> & { title?: string; titleId?: string }>>
const icons: IconMap = {
config: Cog6ToothIcon,
data: TableCellsIcon,
database: CircleStackIcon,
enum: NumberedListIcon,
fsm: SquaresPlusIcon,
topic: BellIcon,
typeAlias: DocumentDuplicateIcon,
secret: LockClosedIcon,
subscription: BellAlertIcon,
verb: BoltIcon,
}

type WithExport = { export?: boolean }

const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSelected: boolean }) => {
if (!decl.value || !decl.value.case || !decl.value.value) {
return []
}
const navigate = useNavigate()
const Icon = useMemo(() => icons[decl.value.case || ''] || CodeBracketSquareIcon, [decl.value.case])
return (
<li className='my-1'>
<DisclosureButton
className={classNames(
isSelected ? 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-gray-600' : 'hover:bg-gray-200 hover:dark:bg-gray-700',
'group flex items-center gap-x-2 rounded-md pl-4 pr-2 text-sm font-light leading-6 w-full',
)}
onClick={(e) => {
e.preventDefault()
navigate(href)
}}
>
<Icon aria-hidden='true' className='size-4 shrink-0' />
{decl.value.value.name}
{(decl.value.value as WithExport).export === true ? <ExportBadge /> : []}
</DisclosureButton>
</li>
)
}

const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: ModuleTreeItem; isExpanded: boolean; toggleExpansion: (m: string) => void }) => {
const { moduleName, declName } = useParams()
const isSelected = useMemo(() => moduleName === module.name, [moduleName, module.name])
const navigate = useNavigate()
return (
<li key={module.name} id={`module-tree-module-${module.name}`} className='my-2'>
<Disclosure as='div' defaultOpen={isExpanded}>
<DisclosureButton
className={classNames(
isSelected ? 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-gray-600' : 'hover:bg-gray-200 hover:dark:bg-gray-700',
'group flex w-full modules-center gap-x-2 space-y-1 rounded-md px-2 text-left text-sm font-medium leading-6',
)}
onClick={() => toggleExpansion(module.name)}
>
<BookOpenIcon aria-hidden='true' className='size-4 my-1 shrink-0 ' />
{module.name}
<ArrowRightCircleIcon
aria-hidden='true'
className='size-4 shrink-0 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigate(`/modules/${module.name}`)
}}
/>
{module.decls.length === 0 || (
<ChevronRightIcon aria-hidden='true' className='ml-auto h-4 w-4 shrink-0 group-data-[open]:rotate-90 group-data-[open]:text-gray-500' />
)}
</DisclosureButton>
<DisclosurePanel as='ul' className='px-2'>
{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}
/>
))}
</DisclosurePanel>
</Disclosure>
</li>
)
}

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

modules.sort((m1, m2) => Number(m1.isBuiltin) - Number(m2.isBuiltin))

const expandedModules = listExpandedModulesFromLocalStorage()
return (
<div className='flex grow flex-col h-full gap-y-5 overflow-y-auto border-r border-gray-200 dark:border-gray-600 py-2 px-6'>
<div className='flex grow flex-col h-full gap-y-5 overflow-y-auto bg-gray-100 dark:bg-gray-900 px-6'>
<nav className='flex flex-1 flex-col'>
<ul className='flex flex-1 flex-col gap-y-7'>
<li>
<ul className='-mx-2'>
{modules.map((item) => (
<li key={item.name} id={`module-tree-item-${item.name}`}>
{!item.children ? (
<a
href={item.href}
className={classNames(
item.current ? '' : 'hover:bg-gray-50 hover:dark:bg-gray-700',
'group flex gap-x-3 rounded-md px-2 text-sm font-semibold leading-6',
)}
>
<item.icon aria-hidden='true' className='size-3 shrink-0' />
{item.name}
</a>
) : (
<Disclosure as='div' defaultOpen={item.expanded}>
<DisclosureButton
className={classNames(
item.current ? '' : 'hover:bg-gray-50 hover:dark:bg-gray-700',
'group flex w-full items-center gap-x-2 rounded-md px-2 text-left text-sm font-semibold leading-6',
)}
>
<item.icon aria-hidden='true' className='size-4 shrink-0 ' />
{item.name}
<ChevronRightIcon aria-hidden='true' className='ml-auto h-5 w-5 shrink-0 group-data-[open]:rotate-90 group-data-[open]:text-gray-500' />
</DisclosureButton>
<DisclosurePanel as='ul' className='px-2'>
{item.children.map((subItem) => (
<li key={subItem.name}>
<DisclosureButton
as='a'
href={subItem.href}
className={classNames(
subItem.current ? '' : 'hover:bg-gray-50 hover:dark:bg-gray-700',
'group flex items-center gap-x-2 rounded-md pl-4 pr-2 text-sm leading-6',
)}
>
<subItem.icon aria-hidden='true' className='size-4 shrink-0' />
{subItem.name}
</DisclosureButton>
</li>
))}
</DisclosurePanel>
</Disclosure>
)}
</li>
{modules.map((m) => (
<ModuleSection key={m.name} module={m} isExpanded={expandedModules.includes(m.name)} toggleExpansion={toggleModuleExpansionInLocalStorage} />
))}
</ul>
</li>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/features/modules/decls/DataPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Badge } from '../../../components/Badge'
import type { Data } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'

export const DataPanel = ({ value, moduleName, declName }: { value: Data; moduleName: string; declName: string }) => {
return (
<div className='flex-1 py-2 px-4'>
{value.export ? (
<div>
<Badge name='Exported' />
</div>
) : (
[]
)}
<div className='inline-block mr-3 align-middle'>
<p>
data: {moduleName}.{declName}
</p>
{value.comments.length > 0 ? <p className='text-xs my-1'>{value.comments}</p> : []}
</div>
</div>
)
}
35 changes: 35 additions & 0 deletions frontend/src/features/modules/decls/DeclPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { useSchema } from '../../../api/schema/use-schema'
import { declFromSchema } from '../module.utils'
import { DataPanel } from './DataPanel'
import { VerbPanel } from './VerbPanel'

export const DeclPanel = () => {
const { moduleName, declCase, declName } = useParams()
if (!moduleName || !declName) {
// Should be impossible, but validate anyway for type safety
return []
}

const schema = useSchema()
const decl = useMemo(() => declFromSchema(moduleName, declName, schema?.data || []), [schema?.data, moduleName, declCase, declName])
if (!decl) {
return []
}

const nameProps = { moduleName, declName }
switch (decl.value.case) {
case 'data':
return <DataPanel value={decl.value.value} {...nameProps} />
case 'verb':
return <VerbPanel value={decl.value.value} {...nameProps} />
}
return (
<div className='flex-1 py-2 px-4'>
<p>
{declCase} declaration: {moduleName}.{declName}
</p>
</div>
)
}
Loading

0 comments on commit 9de38e9

Please sign in to comment.