From 246395e9e9f7f08d862150863d0003fb56e333e1 Mon Sep 17 00:00:00 2001 From: Denise Li Date: Thu, 19 Sep 2024 21:57:52 -0400 Subject: [PATCH] feat: new console module page (#2723) Screencast: https://sq-tbd.slack.com/archives/C04PEQERFM0/p1726706223996289 --------- Co-authored-by: Wes --- frontend/console/e2e/module.spec.ts | 3 +- .../src/features/modules/ModulePanel.tsx | 16 ++- .../src/features/modules/decls/DeclLink.tsx | 19 ++- .../features/modules/schema/LinkTokens.tsx | 25 ++++ .../src/features/modules/schema/Schema.tsx | 121 ++++++++++++++++++ .../modules/schema/UnderlyingType.tsx | 75 +++++++++++ .../features/modules/schema/schema.utils.ts | 30 +++++ 7 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 frontend/console/src/features/modules/schema/LinkTokens.tsx create mode 100644 frontend/console/src/features/modules/schema/Schema.tsx create mode 100644 frontend/console/src/features/modules/schema/UnderlyingType.tsx create mode 100644 frontend/console/src/features/modules/schema/schema.utils.ts diff --git a/frontend/console/e2e/module.spec.ts b/frontend/console/e2e/module.spec.ts index 9646ca33fc..52e9fea8ad 100644 --- a/frontend/console/e2e/module.spec.ts +++ b/frontend/console/e2e/module.spec.ts @@ -10,6 +10,5 @@ ftlTest('shows verbs for deployment', async ({ page }) => { await expect(page).toHaveURL(/\/modules\/echo/) - await expect(page.getByText('Deployment', { exact: true })).toBeVisible() - await expect(page.getByText('Created Deployment dpl-echo')).toBeVisible() + await expect(page.getByText('module echo {')).toBeVisible() }) diff --git a/frontend/console/src/features/modules/ModulePanel.tsx b/frontend/console/src/features/modules/ModulePanel.tsx index 7b378b8bba..4938a5ee5e 100644 --- a/frontend/console/src/features/modules/ModulePanel.tsx +++ b/frontend/console/src/features/modules/ModulePanel.tsx @@ -1,8 +1,20 @@ +import { useMemo } from 'react' import { useParams } from 'react-router-dom' -import { DeploymentPage } from '../deployments/DeploymentPage' +import { useModules } from '../../api/modules/use-modules' +import { Schema } from './schema/Schema' export const ModulePanel = () => { const { moduleName } = useParams() + const modules = useModules() - return + const module = useMemo(() => { + if (!modules.isSuccess || modules.data.modules.length === 0) { + return + } + return modules.data.modules.find((module) => module.name === moduleName) + }, [modules?.data, moduleName]) + + if (!module) return + + return } diff --git a/frontend/console/src/features/modules/decls/DeclLink.tsx b/frontend/console/src/features/modules/decls/DeclLink.tsx index 48baf55132..6c0c58f88a 100644 --- a/frontend/console/src/features/modules/decls/DeclLink.tsx +++ b/frontend/console/src/features/modules/decls/DeclLink.tsx @@ -3,12 +3,13 @@ import { useNavigate } from 'react-router-dom' import { useSchema } from '../../../api/schema/use-schema' import type { PullSchemaResponse } from '../../../protos/xyz/block/ftl/v1/ftl_pb.ts' import type { Decl } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb' +import { classNames } from '../../../utils' import { DeclSnippet } from './DeclSnippet' const SnippetContainer = ({ decl }: { decl: Decl }) => { return ( -
-
+
+
triangle @@ -19,7 +20,13 @@ const SnippetContainer = ({ decl }: { decl: Decl }) => { ) } -export const DeclLink = ({ moduleName, declName }: { moduleName?: string; declName: string }) => { +// When `slim` is true, print only the decl name, not the module name, and show nothing on hover. +export const DeclLink = ({ + moduleName, + declName, + slim, + textColors = 'text-indigo-600 dark:text-indigo-400', +}: { moduleName?: string; declName: string; slim?: boolean; textColors?: string }) => { const [isHovering, setIsHovering] = useState(false) const schema = useSchema() const decl = useMemo(() => { @@ -31,7 +38,7 @@ export const DeclLink = ({ moduleName, declName }: { moduleName?: string; declNa return module.schema.decls.find((d) => d.value.value?.name === declName) }, [moduleName, declName, schema?.data]) - const str = moduleName ? `${moduleName}.${declName}` : declName + const str = moduleName && slim !== true ? `${moduleName}.${declName}` : declName if (!decl) { return str @@ -40,13 +47,13 @@ export const DeclLink = ({ moduleName, declName }: { moduleName?: string; declNa const navigate = useNavigate() return ( navigate(`/modules/${moduleName}/${decl.value.case}/${declName}`)} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > {str} - {isHovering && } + {!slim && isHovering && } ) } diff --git a/frontend/console/src/features/modules/schema/LinkTokens.tsx b/frontend/console/src/features/modules/schema/LinkTokens.tsx new file mode 100644 index 0000000000..af6fb71980 --- /dev/null +++ b/frontend/console/src/features/modules/schema/LinkTokens.tsx @@ -0,0 +1,25 @@ +import { useParams } from 'react-router-dom' +import { DeclLink } from '../decls/DeclLink' +import { UnderlyingType } from './UnderlyingType' + +export const LinkToken = ({ token }: { token: string }) => { + const { moduleName } = useParams() + if (token.match(/^\w+$/)) { + return ( + + + + ) + } + return token +} + +export const LinkVerbNameToken = ({ token }: { token: string }) => { + const splitToken = token.split('(') + return ( + + + ( + + ) +} diff --git a/frontend/console/src/features/modules/schema/Schema.tsx b/frontend/console/src/features/modules/schema/Schema.tsx new file mode 100644 index 0000000000..5a3361f6b4 --- /dev/null +++ b/frontend/console/src/features/modules/schema/Schema.tsx @@ -0,0 +1,121 @@ +import { useParams } from 'react-router-dom' +import { classNames } from '../../../utils' +import { DeclLink } from '../decls/DeclLink' +import { LinkToken, LinkVerbNameToken } from './LinkTokens' +import { UnderlyingType } from './UnderlyingType' +import { commentPrefix, declTypes, isFirstLineOfBlock, specialChars, staticKeywords } from './schema.utils' + +function maybeRenderDeclName(token: string, declType: string, tokens: string[], i: number) { + const offset = declType === 'database' ? 4 : 2 + if (i - offset < 0 || declType !== tokens[i - offset]) { + return + } + if (declType === 'enum') { + return [, token.slice(-1)] + } + if (declType === 'verb') { + return + } + return +} + +function maybeRenderUnderlyingType(token: string, declType: string, tokens: string[], i: number, moduleName: string) { + if (declType === 'database') { + return + } + + // Parse type(s) out of the headline signature + const offset = 4 + if (i - offset >= 0 && tokens.slice(0, i - offset + 1).includes(declType)) { + return + } + + // Parse type(s) out of nested lines + if (tokens.length > 4 && tokens.slice(0, 4).filter((t) => t !== ' ').length === 0) { + if (i === 6 && tokens[4] === '+calls') { + return + } + if (i === 6 && tokens[4] === '+subscribe') { + return + } + const plusIndex = tokens.findIndex((t) => t.startsWith('+')) + if (i >= 6 && (i < plusIndex || plusIndex === -1)) { + return + } + } +} + +const SchemaLine = ({ line }: { line: string }) => { + const { moduleName } = useParams() + if (line.startsWith(commentPrefix)) { + return {line} + } + const tokens = line.split(/( )/).filter((l) => l !== '') + let declType: string + return tokens.map((token, i) => { + if (token.trim() === '') { + return {token} + } + if (specialChars.includes(token)) { + return {token} + } + if (staticKeywords.includes(token)) { + return ( + + {token} + + ) + } + if (declTypes.includes(token) && tokens.length > 2 && tokens[2] !== ' ') { + declType = token + return ( + + {token} + + ) + } + if (token[0] === '+' && token.slice(1).match(/^\w+$/)) { + return ( + + {token} + + ) + } + + const numQuotesBefore = (tokens.slice(0, i).join('').match(/"/g) || []).length + (token.match(/^".+/) ? 1 : 0) + const numQuotesAfter = + ( + tokens + .slice(i + 1, tokens.length) + .join('') + .match(/"/g) || [] + ).length + (token.match(/.+"$/) ? 1 : 0) + if (numQuotesBefore % 2 === 1 && numQuotesAfter % 2 === 1) { + return ( + + {token} + + ) + } + + const maybeDeclName = maybeRenderDeclName(token, declType, tokens, i) + if (maybeDeclName) { + return {maybeDeclName} + } + const maybeUnderlyingType = maybeRenderUnderlyingType(token, declType, tokens, i, moduleName || '') + if (maybeUnderlyingType) { + return {maybeUnderlyingType} + } + return {token} + }) +} + +export const Schema = ({ schema }: { schema: string }) => { + const ll = schema.split('\n') + const lines = ll.map((l, i) => ( +
+ +
+ )) + return
{lines}
+} diff --git a/frontend/console/src/features/modules/schema/UnderlyingType.tsx b/frontend/console/src/features/modules/schema/UnderlyingType.tsx new file mode 100644 index 0000000000..c187ad76d2 --- /dev/null +++ b/frontend/console/src/features/modules/schema/UnderlyingType.tsx @@ -0,0 +1,75 @@ +import { DeclLink } from '../decls/DeclLink' + +export const UnderlyingType = ({ token }: { token: string }) => { + if (token.match(/^\[.+\]$/)) { + // Handles lists: [elementType] + return ( + + [] + + ) + } + + if (token.match(/^{.+:$/)) { + // Handles first token of map: {KeyType: ValueType} + return ( + + {'{'} + : + + ) + } + + if (token.match(/.+}$/)) { + // Handles last token of map: {KeyType: ValueType} + return ( + + + {'}'} + + ) + } + + if (token.match(/^.+\?$/)) { + // Handles optional: elementType? + return ( + + ? + + ) + } + + if (token.match(/^.+\)$/)) { + // Handles closing parens in param list of verb signature: verb echo(inputType) outputType + return ( + + ) + + ) + } + + const maybeSplitRef = token.split('.') + if (maybeSplitRef.length < 2) { + // Not linkable because it's not a ref + return {token} + } + const moduleName = maybeSplitRef[0] + const declName = maybeSplitRef[1].split('<')[0] + const primaryTypeEl = ( + + ]/)[0]} textColors='font-bold text-green-700 dark:text-green-400' /> + {[',', '>'].includes(declName.slice(-1)) ? declName.slice(-1) : ''} + + ) + const hasTypeParams = maybeSplitRef[1].includes('<') + if (!hasTypeParams) { + return primaryTypeEl + } + return ( + + {primaryTypeEl} + {'<'} + + + ) +} diff --git a/frontend/console/src/features/modules/schema/schema.utils.ts b/frontend/console/src/features/modules/schema/schema.utils.ts new file mode 100644 index 0000000000..11a140af5c --- /dev/null +++ b/frontend/console/src/features/modules/schema/schema.utils.ts @@ -0,0 +1,30 @@ +export const commentPrefix = ' //' + +export const staticKeywords = ['module', 'export'] + +export const declTypes = ['config', 'data', 'database', 'enum', 'fsm', 'topic', 'typealias', 'secret', 'subscription', 'verb'] + +export const specialChars = ['{', '}', '='] + +export function isFirstLineOfBlock(ll: string[], i: number): boolean { + if (i === 0) { + // Never add space for the first block + return false + } + if (ll[i].startsWith(' ')) { + // Never add space for nested lines + return false + } + if (ll[i - 1].startsWith(commentPrefix)) { + // Prior line is a comment + return false + } + if (ll[i].startsWith(commentPrefix)) { + return true + } + const tokens = ll[i].trim().split(' ') + if (!tokens || tokens.length === 0) { + return false + } + return staticKeywords.includes(tokens[0]) || declTypes.includes(tokens[0]) +}