From 75e26c7027b6380574bb9307ce865cd64dd870ce Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 5 Sep 2023 12:02:57 -0700 Subject: [PATCH] Move tabs to dedicated component and update usage --- .../src/features/modules/ModuleDetails.tsx | 41 ++-- .../client/src/features/verbs/VerbForm.tsx | 19 +- console/client/src/layout/IDELayout.tsx | 187 +----------------- console/client/src/layout/Tabs.tsx | 134 +++++++++++++ .../client/src/providers/tabs-provider.tsx | 87 ++++---- console/client/src/utils/index.ts | 1 - console/client/src/utils/invalid-tab.utils.ts | 36 ---- 7 files changed, 213 insertions(+), 292 deletions(-) create mode 100644 console/client/src/layout/Tabs.tsx delete mode 100644 console/client/src/utils/invalid-tab.utils.ts diff --git a/console/client/src/features/modules/ModuleDetails.tsx b/console/client/src/features/modules/ModuleDetails.tsx index a2b843a111..0245cea27f 100644 --- a/console/client/src/features/modules/ModuleDetails.tsx +++ b/console/client/src/features/modules/ModuleDetails.tsx @@ -1,23 +1,19 @@ import React from 'react' -import {SelectedModuleContext} from '../../providers/selected-module-provider' -import { - TabSearchParams, - TabType, - TabsContext, - Tab, -} from '../../providers/tabs-provider' -import {textColor} from '../../utils' import {useSearchParams} from 'react-router-dom' -import {modulesContext} from '../../providers/modules-provider' import {Verb} from '../../protos/xyz/block/ftl/v1/console/console_pb' +import {modulesContext} from '../../providers/modules-provider' +import {SelectedModuleContext} from '../../providers/selected-module-provider' +import {TabsContext} from '../../providers/tabs-provider' +import {textColor} from '../../utils' +import {VerbTab} from '../verbs/VerbTab' export function ModuleDetails() { const modules = React.useContext(modulesContext) const {selectedModule, setSelectedModule} = React.useContext( SelectedModuleContext ) - const {tabs, setTabs, setActiveTab} = React.useContext(TabsContext) - const [searchParams, setSearchParams] = useSearchParams() + const {openTab} = React.useContext(TabsContext) + const [searchParams] = useSearchParams() const moduleId = searchParams.get('module') // When mounting with a valid module in query params set selected module React.useEffect(() => { @@ -36,23 +32,12 @@ export function ModuleDetails() { } const handleVerbClicked = (verb: Verb) => { - const tabId = [selectedModule.name, verb.verb?.name].join('.') - const index = tabs.findIndex(tab => tab.id === tabId) - const existingTab = index !== -1 - let newTab: Tab | undefined - if (!existingTab) { - newTab = { - id: [selectedModule.name, verb.verb?.name].join('.'), - label: verb.verb?.name ?? 'Verb', - type: TabType.Verb, - } - setTabs([...tabs, newTab]) - } - setActiveTab({id: tabId, type: TabType.Verb}) - setSearchParams({ - ...Object.fromEntries(searchParams), - [TabSearchParams.id]: newTab?.id ?? tabs[index].id, - [TabSearchParams.type]: TabType.Verb, + const verbId = [selectedModule.name, verb.verb?.name].join('.') + openTab({ + id: verbId, + label: verb.verb?.name ?? 'Verb', + isClosable: true, + component: , }) } diff --git a/console/client/src/features/verbs/VerbForm.tsx b/console/client/src/features/verbs/VerbForm.tsx index feb5a543db..8ecaa7b448 100644 --- a/console/client/src/features/verbs/VerbForm.tsx +++ b/console/client/src/features/verbs/VerbForm.tsx @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ +import Editor, {Monaco} from '@monaco-editor/react' +import type {JSONSchema4, JSONSchema6, JSONSchema7} from 'json-schema' import {JSONSchemaFaker} from 'json-schema-faker' import React from 'react' import {CodeBlock} from '../../components/CodeBlock' @@ -7,9 +9,8 @@ import {useClient} from '../../hooks/use-client' import {Module, Verb} from '../../protos/xyz/block/ftl/v1/console/console_pb' import {VerbService} from '../../protos/xyz/block/ftl/v1/ftl_connect' import {VerbRef} from '../../protos/xyz/block/ftl/v1/schema/schema_pb' -import Editor, {Monaco} from '@monaco-editor/react' import {useDarkMode} from '../../providers/dark-mode-provider' -import type {JSONSchema4, JSONSchema6, JSONSchema7} from 'json-schema' +import {TabsContext} from '../../providers/tabs-provider' export type Schema = JSONSchema4 | JSONSchema6 | JSONSchema7 @@ -18,9 +19,10 @@ type Props = { verb?: Verb } -export const VerbForm: React.FC = ({module, verb}) => { +export const VerbForm: React.FC = React.memo(({module, verb}) => { const client = useClient(VerbService) const {isDarkMode} = useDarkMode() + const {activeTabId} = React.useContext(TabsContext) const [editorText, setEditorText] = React.useState('') const [response, setResponse] = React.useState(null) const [error, setError] = React.useState(null) @@ -39,7 +41,8 @@ export const VerbForm: React.FC = ({module, verb}) => { JSON.stringify(JSONSchemaFaker.generate(verbSchema), null, 2) ) } - }, []) + }, [module, verb]) + function handleEditorChange(value: string | undefined, _) { setEditorText(value ?? '') } @@ -86,7 +89,7 @@ export const VerbForm: React.FC = ({module, verb}) => { {schema, uri: 'http://myserver/foo-schema.json', fileMatch: ['*']}, ], }) - }, [monaco, schema]) + }, [monaco, schema, activeTabId]) return ( <> @@ -96,10 +99,12 @@ export const VerbForm: React.FC = ({module, verb}) => { >
= ({module, verb}) => { )} ) -} +}) diff --git a/console/client/src/layout/IDELayout.tsx b/console/client/src/layout/IDELayout.tsx index 580cd380f6..d53b0cdf73 100644 --- a/console/client/src/layout/IDELayout.tsx +++ b/console/client/src/layout/IDELayout.tsx @@ -1,141 +1,18 @@ -import {Tab} from '@headlessui/react' -import {XMarkIcon} from '@heroicons/react/24/outline' -import React from 'react' -import {useSearchParams} from 'react-router-dom' import {ModuleDetails} from '../features/modules/ModuleDetails' import {ModulesList} from '../features/modules/ModulesList' -import {Timeline} from '../features/timeline/Timeline' -import {VerbTab} from '../features/verbs/VerbTab' -import { - NotificationType, - NotificationsContext, -} from '../providers/notifications-provider' -import { - TabSearchParams, - TabType, - TabsContext, - timelineTab, -} from '../providers/tabs-provider' import { bgColor, headerColor, headerTextColor, - invalidTab, panelColor, textColor, } from '../utils' import {Navigation} from './Navigation' import {Notification} from './Notification' import {SidePanel} from './SidePanel' -import {modulesContext} from '../providers/modules-provider' -const selectedTabStyle = `${headerTextColor} ${headerColor}` -const unselectedTabStyle = `text-gray-300 bg-slate-100 dark:bg-slate-600` +import {Tabs} from './Tabs' export function IDELayout() { - const {modules} = React.useContext(modulesContext) - const {tabs, activeTab, setActiveTab, setTabs} = React.useContext(TabsContext) - const {showNotification} = React.useContext(NotificationsContext) - const [searchParams, setSearchParams] = useSearchParams() - const [activeIndex, setActiveIndex] = React.useState(0) - const id = searchParams.get(TabSearchParams.id) as string - const type = searchParams.get(TabSearchParams.type) as string - - const handleCloseTab = (id: string, index: number) => { - const nextActiveTab = { - id: tabs[index - 1].id, - type: tabs[index - 1].type, - } - setSearchParams({ - ...Object.fromEntries(searchParams), - [TabSearchParams.id]: nextActiveTab.id, - [TabSearchParams.type]: nextActiveTab.type, - }) - setActiveTab(nextActiveTab) - setTabs(tabs.filter(tab => tab.id !== id)) - } - - const handleChangeTab = (index: number) => { - const nextActiveTab = tabs[index] - setActiveTab({id: nextActiveTab.id, type: nextActiveTab.type}) - setSearchParams({ - ...Object.fromEntries(searchParams), - [TabSearchParams.id]: nextActiveTab.id, - [TabSearchParams.type]: nextActiveTab.type, - }) - } - - // Handle loading a page with the tab query parameters - React.useEffect(() => { - const msg = invalidTab({id, type}) - if (msg) { - // Default fallback to timeline - setActiveTab({id: timelineTab.id, type: timelineTab.type}) - if (type === null && id === null) return - - return showNotification({ - title: 'Invalid Tab', - message: msg, - type: NotificationType.Error, - }) - } - // Handle timeline tab id - if (id === timelineTab.id) { - return setActiveTab({id: timelineTab.id, type: timelineTab.type}) - } - if (modules.length) { - const ids = id.split('.') - // Handle edge case where the id contains and invalid module or verb - if (modules.length) { - const [moduleId, verbId] = ids - // Handle Module does not exist - const moduleExist = modules.find(module => module?.name === moduleId) - if (!moduleExist) { - showNotification({ - title: 'Module not found', - message: `Module '${moduleId}' does not exist`, - type: NotificationType.Error, - }) - return setActiveTab({id: timelineTab.id, type: timelineTab.type}) - } - // Handle Verb does not exists - const verbExist = moduleExist?.verbs.some( - ({verb}) => verb?.name === verbId - ) - if (!verbExist) { - showNotification({ - title: 'Verb not found', - message: `Verb '${verbId}' does not exist on module '${moduleId}'`, - type: NotificationType.Error, - }) - return setActiveTab({id: timelineTab.id, type: timelineTab.type}) - } - } - // Handle if tab is not already in tab list - if ( - !tabs.some( - ({id: tabId, type: tabType}) => tabId === id && tabType === type - ) - ) { - const newTab = { - id, - label: ids[1], - type: TabType.Verb, - } - const nextTabs = [...tabs, newTab] - setTabs(nextTabs) - return setActiveTab({id: newTab.id, type: newTab.type}) - } - // Handle if tab is in tab list - return setActiveTab({id, type}) - } - }, [id, type, modules]) - - // Set active tab index whenever our activeTab context changes - React.useEffect(() => { - const index = tabs.findIndex(tab => tab.id === activeTab?.id) - setActiveIndex(index) - }, [activeTab]) - return ( <>
@@ -173,67 +50,7 @@ export function IDELayout() {
-
- -
- - {tabs.map(({label, id}, i) => { - return ( - - - {label} - - {i !== 0 && ( - - )} - - ) - })} - -
-
-
- - {tabs.map(({id}, i) => { - return i === 0 ? ( - - - - ) : ( - - - - ) - })} - -
-
-
+
diff --git a/console/client/src/layout/Tabs.tsx b/console/client/src/layout/Tabs.tsx new file mode 100644 index 0000000000..c815370361 --- /dev/null +++ b/console/client/src/layout/Tabs.tsx @@ -0,0 +1,134 @@ +import {Tab as TabComponent} from '@headlessui/react' +import {XMarkIcon} from '@heroicons/react/24/outline' +import React from 'react' +import {useSearchParams} from 'react-router-dom' +import {Timeline} from '../features/timeline/Timeline' +import {VerbTab} from '../features/verbs/VerbTab' +import {modulesContext} from '../providers/modules-provider' +import { + NotificationType, + NotificationsContext, +} from '../providers/notifications-provider' +import {Tab, TabsContext} from '../providers/tabs-provider' +import {headerColor, headerTextColor, panelColor} from '../utils' + +const selectedTabStyle = `${headerTextColor} ${headerColor}` +const unselectedTabStyle = `text-gray-300 bg-slate-100 dark:bg-slate-600` + +export const Tabs = () => { + const [searchParams] = useSearchParams() + const {tabs, activeTabId, openTab, closeTab} = React.useContext(TabsContext) + const {showNotification} = React.useContext(NotificationsContext) + const {modules} = React.useContext(modulesContext) + + React.useEffect(() => { + openTab({ + id: 'timeline', + label: 'Timeline', + isClosable: false, + component: , + } as Tab) + }, []) + + React.useEffect(() => { + const verbRef = searchParams.get('verb') + if (!verbRef || modules.length === 0) return + + const [moduleName, verbName] = verbRef.split('.') + const module = modules.find(module => module?.name === moduleName) + if (!module) { + showNotification( + { + title: 'Module not found', + message: `Module '${moduleName}' does not exist`, + type: NotificationType.Error, + }, + 10000 + ) + return + } + + const verb = module?.verbs.find(v => v.verb?.name === verbName) + if (!verb) { + showNotification( + { + title: 'Verb not found', + message: `Verb '${verbName}' does not exist on module '${moduleName}'`, + type: NotificationType.Error, + }, + 10000 + ) + return + } + + openTab({ + id: verbRef, + label: verb.verb?.name ?? 'Verb', + isClosable: true, + component: , + }) + }, [modules]) + + const handleChangeTab = (index: number) => { + const tab = tabs[index] + openTab(tab) + } + + return ( +
+ tab.id === activeTabId)} + onChange={handleChangeTab} + > +
+ + {tabs.map(tab => { + return ( + + + {tab.label} + + {tab.isClosable && ( + + )} + + ) + })} + +
+
+
+ + {tabs.map(tab => ( + + {tab.component} + + ))} + +
+
+
+ ) +} diff --git a/console/client/src/providers/tabs-provider.tsx b/console/client/src/providers/tabs-provider.tsx index 89074717a5..b331da692a 100644 --- a/console/client/src/providers/tabs-provider.tsx +++ b/console/client/src/providers/tabs-provider.tsx @@ -1,56 +1,73 @@ import React from 'react' - -export const TabType = { - Timeline: 'timeline', - Verb: 'verb', -} as const +import {useSearchParams} from 'react-router-dom' export type Tab = { id: string label: string - type: (typeof TabType)[keyof typeof TabType] -} - -export const TabSearchParams = { - id: 'tab-id', - type: 'tab-type', -} as const - -export const timelineTab = { - id: 'timeline', - label: 'Timeline', - type: TabType.Timeline, + isClosable: boolean + component: React.ReactNode } -export type ActiveTab = - | { - id: string - type: string - } - | undefined - type TabsContextType = { tabs: Tab[] - activeTab?: ActiveTab - setTabs: React.Dispatch> - setActiveTab: React.Dispatch> + activeTabId?: string + openTab: (tab: Tab) => void + closeTab: (tabId: string) => void } export const TabsContext = React.createContext({ tabs: [], - activeTab: undefined, - setTabs: () => {}, - setActiveTab: () => {}, + activeTabId: undefined, + openTab: () => {}, + closeTab: () => {}, }) export const TabsProvider = (props: React.PropsWithChildren) => { - const [tabs, setTabs] = React.useState([timelineTab]) - const [activeTab, setActiveTab] = React.useState< - {id: string; type: string} | undefined - >() + const [searchParams, setSearchParams] = useSearchParams() + const [tabs, setTabs] = React.useState([]) + const [activeTabId, setActiveTabId] = React.useState() + + const updateParams = (tab: Tab) => { + if (tab.id !== 'timeline') { + setSearchParams({ + ...Object.fromEntries(searchParams), + verb: tab.id, + }) + } + } + + const openTab = (tab: Tab) => { + setTabs(prevTabs => { + // Add the tab if it doesn't exist + if (!prevTabs.some(existingTab => existingTab.id === tab.id)) { + return [...prevTabs, tab] + } + return prevTabs + }) + + setActiveTabId(tab.id) + updateParams(tab) + } + + const closeTab = (tabId: string) => { + const newTabs = tabs.filter(tab => tab.id !== tabId) + const closedTabIndex = tabs.findIndex(tab => tab.id === tabId) + + if (activeTabId === tabId) { + const activeTab = newTabs[closedTabIndex - 1] + setActiveTabId(activeTab?.id) + updateParams(activeTab) + } + + if (newTabs.length === 1 && newTabs[0].id === 'timeline') { + searchParams.delete('verb') + setSearchParams(searchParams) + } + setTabs(newTabs) + } return ( - + {props.children} ) diff --git a/console/client/src/utils/index.ts b/console/client/src/utils/index.ts index c8aa35d723..58879281be 100644 --- a/console/client/src/utils/index.ts +++ b/console/client/src/utils/index.ts @@ -1,4 +1,3 @@ export * from './date.utils' -export * from './invalid-tab.utils' export * from './react.utils' export * from './style.utils' diff --git a/console/client/src/utils/invalid-tab.utils.ts b/console/client/src/utils/invalid-tab.utils.ts deleted file mode 100644 index 09b1a52a2b..0000000000 --- a/console/client/src/utils/invalid-tab.utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {TabType, timelineTab, TabSearchParams} from '../providers/tabs-provider' -export const invalidTab = ({ - id, - type, -}: { - id?: string - type?: string -}): string | undefined => { - // No ID or type - if (!id || !type) { - return `Required tab field undefined: ${JSON.stringify( - {[TabSearchParams.type]: type, [TabSearchParams.id]: id}, - null, - 2 - ).replace(/":/g, '" :')}` - } - // Invalid type - const invalidType = Object.values(TabType).some(v => v === type) - if (!invalidType) { - return `Invalid tab type: ${type}` - } - - // Type is timeline but id is wrong - if (type === TabType.Timeline) { - if (id !== timelineTab.id) { - return `invalid timeline id: ${id}` - } - } - // Type is verb but invalid type - if (type === TabType.Verb) { - const verbIdArray = id.split('.') - if (type === TabType.Verb && verbIdArray.length !== 2) { - return `Invalid verb ${id}` - } - } -}