diff --git a/frontend/e2e/ftl-test.ts b/frontend/e2e/ftl-test.ts index 8ebed3a76d..f802f89b2e 100644 --- a/frontend/e2e/ftl-test.ts +++ b/frontend/e2e/ftl-test.ts @@ -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; }); diff --git a/frontend/src/components/ResizableHorizontalPanels.tsx b/frontend/src/components/ResizableHorizontalPanels.tsx new file mode 100644 index 0000000000..7338e10f04 --- /dev/null +++ b/frontend/src/components/ResizableHorizontalPanels.tsx @@ -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 = ({ + leftPanelContent, + rightPanelContent, + minLeftPanelWidth = 100, + minRightPanelWidth = 100, + leftPanelWidth, + setLeftPanelWidth, +}) => { + const containerRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + const startDragging = (e: React.MouseEvent) => { + e.preventDefault() + setIsDragging(true) + } + + const stopDragging = () => setIsDragging(false) + + const onDrag = (e: React.MouseEvent) => { + 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 ( +
+
+ {leftPanelContent} +
+
+
{rightPanelContent}
+
+ ) +} diff --git a/frontend/src/components/ResizeableVerticalPanels.tsx b/frontend/src/components/ResizableVerticalPanels.tsx similarity index 100% rename from frontend/src/components/ResizeableVerticalPanels.tsx rename to frontend/src/components/ResizableVerticalPanels.tsx diff --git a/frontend/src/features/modules/ModulePanel.tsx b/frontend/src/features/modules/ModulePanel.tsx new file mode 100644 index 0000000000..3b9e30bc5d --- /dev/null +++ b/frontend/src/features/modules/ModulePanel.tsx @@ -0,0 +1,11 @@ +import { useParams } from 'react-router-dom' + +export const ModulePanel = () => { + const { moduleName } = useParams() + + return ( +
+

Module: {moduleName}

+
+ ) +} diff --git a/frontend/src/features/modules/ModulesPage.tsx b/frontend/src/features/modules/ModulesPage.tsx index 3114e56b2e..41a3abdcb5 100644 --- a/frontend/src/features/modules/ModulesPage.tsx +++ b/frontend/src/features/modules/ModulesPage.tsx @@ -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 ( +
+

Content

+
+ ) +} + +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 ( -
-
- -
+ function setTreeWidthWithLS(newWidth: number) { + localStorage.setItem(treeWidthStorageKey, `${newWidth}`) + setTreeWidth(newWidth) + } -
-

Content

-
-
+ return ( + } + rightPanelContent={body} + leftPanelWidth={treeWidth} + setLeftPanelWidth={setTreeWidthWithLS} + /> ) } diff --git a/frontend/src/features/modules/ModulesTree.tsx b/frontend/src/features/modules/ModulesTree.tsx index be26511bf9..b408b6e74c 100644 --- a/frontend/src/features/modules/ModulesTree.tsx +++ b/frontend/src/features/modules/ModulesTree.tsx @@ -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 = () => Exported + +type IconMap = Record & { 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 ( +
  • + { + e.preventDefault() + navigate(href) + }} + > + +
  • + ) +} + +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 ( +
  • + + toggleExpansion(module.name)} + > + + + {module.decls.map((d, i) => ( + + ))} + + +
  • + ) +} 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 ( -
    +