diff --git a/backend/schema/module.go b/backend/schema/module.go index ac5b48fe85..fa039869d4 100644 --- a/backend/schema/module.go +++ b/backend/schema/module.go @@ -82,7 +82,8 @@ func (m *Module) String() string { } fmt.Fprintf(w, "module %s {\n", m.Name) - // print decls with spacing rules + // Print decls with spacing rules + // Keep these in sync with frontend/console/src/features/modules/schema/schema.utils.ts typeSpacingRules := map[reflect.Type]spacingRule{ reflect.TypeOf(&Config{}): {gapWithinType: false}, reflect.TypeOf(&Secret{}): {gapWithinType: false, skipGapAfterTypes: []reflect.Type{reflect.TypeOf(&Config{})}}, diff --git a/frontend/console/src/features/modules/ModulePanel.tsx b/frontend/console/src/features/modules/ModulePanel.tsx index 4938a5ee5e..6b62a67eae 100644 --- a/frontend/console/src/features/modules/ModulePanel.tsx +++ b/frontend/console/src/features/modules/ModulePanel.tsx @@ -16,5 +16,9 @@ export const ModulePanel = () => { if (!module) return - return + return ( +
+ +
+ ) } diff --git a/frontend/console/src/features/modules/decls/DeclLink.tsx b/frontend/console/src/features/modules/decls/DeclLink.tsx index 6c0c58f88a..cfe85f781b 100644 --- a/frontend/console/src/features/modules/decls/DeclLink.tsx +++ b/frontend/console/src/features/modules/decls/DeclLink.tsx @@ -1,21 +1,33 @@ -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' 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 { useModules } from '../../../api/modules/use-modules' import { classNames } from '../../../utils' -import { DeclSnippet } from './DeclSnippet' +import { Schema } from '../schema/Schema' +import { type DeclSchema, declFromModules } from '../schema/schema.utils' -const SnippetContainer = ({ decl }: { decl: Decl }) => { +const SnippetContainer = ({ decl, linkRect, containerRect }: { decl: DeclSchema; linkRect?: DOMRect; containerRect?: DOMRect }) => { + const ref = useRef(null) + const snipRect = ref?.current?.getBoundingClientRect() + + const hasRects = !!snipRect && !!linkRect + const toTop = hasRects && window.innerHeight - linkRect.top - linkRect.height < snipRect.height + linkRect.height + const fitsToRight = hasRects && window.innerWidth - linkRect.left >= snipRect.width + const fitsToLeft = hasRects && !!containerRect && linkRect.left - containerRect.x + linkRect.width >= snipRect.width + const horizontalAlignmentClassNames = fitsToRight ? '-ml-1' : fitsToLeft ? '-translate-x-full left-full ml-0' : '' + const style = { + transform: !fitsToRight && !fitsToLeft ? `translateX(-${(linkRect?.left || 0) - (containerRect?.left || 0)}px)` : undefined, + } return ( -
-
- - triangle - - -
- +
+
) } @@ -26,17 +38,12 @@ export const DeclLink = ({ declName, slim, textColors = 'text-indigo-600 dark:text-indigo-400', -}: { moduleName?: string; declName: string; slim?: boolean; textColors?: string }) => { + containerRect, +}: { moduleName?: string; declName: string; slim?: boolean; textColors?: string; containerRect?: DOMRect }) => { + const navigate = useNavigate() + const modules = useModules() + const decl = useMemo(() => (moduleName ? declFromModules(moduleName, declName, modules) : undefined), [moduleName, declName, modules?.data]) const [isHovering, setIsHovering] = useState(false) - const schema = useSchema() - const decl = useMemo(() => { - const modules = (schema?.data || []) as PullSchemaResponse[] - const module = modules.find((m: PullSchemaResponse) => m.moduleName === moduleName) - if (!module?.schema) { - return - } - return module.schema.decls.find((d) => d.value.value?.name === declName) - }, [moduleName, declName, schema?.data]) const str = moduleName && slim !== true ? `${moduleName}.${declName}` : declName @@ -44,16 +51,17 @@ export const DeclLink = ({ return str } - const navigate = useNavigate() + const linkRef = useRef(null) return ( navigate(`/modules/${moduleName}/${decl.value.case}/${declName}`)} + className='inline-block rounded-md cursor-pointer hover:bg-gray-400/30 hover:dark:bg-gray-900/30 p-1 -m-1 relative' onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > - {str} - {!slim && isHovering && } + navigate(`/modules/${moduleName}/${decl.declType}/${declName}`)}> + {str} + + {!slim && isHovering && } ) } diff --git a/frontend/console/src/features/modules/decls/DeclSnippet.tsx b/frontend/console/src/features/modules/decls/DeclSnippet.tsx deleted file mode 100644 index 55f96efbe2..0000000000 --- a/frontend/console/src/features/modules/decls/DeclSnippet.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Decl } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb' -import { DataSnippet } from './DataSnippet' -import { EnumSnippet } from './EnumSnippet' -import { TypeAliasSnippet } from './TypeAliasSnippet' - -export const DeclSnippet = ({ decl }: { decl: Decl }) => { - switch (decl.value.case) { - case 'data': - return - case 'enum': - return - case 'typeAlias': - return - } - return
under construction: {decl.value.case}
-} diff --git a/frontend/console/src/features/modules/schema/LinkTokens.tsx b/frontend/console/src/features/modules/schema/LinkTokens.tsx index af6fb71980..b3af85ef85 100644 --- a/frontend/console/src/features/modules/schema/LinkTokens.tsx +++ b/frontend/console/src/features/modules/schema/LinkTokens.tsx @@ -2,24 +2,27 @@ import { useParams } from 'react-router-dom' import { DeclLink } from '../decls/DeclLink' import { UnderlyingType } from './UnderlyingType' -export const LinkToken = ({ token }: { token: string }) => { +export const LinkToken = ({ token, containerRect }: { token: string; containerRect?: DOMRect }) => { const { moduleName } = useParams() if (token.match(/^\w+$/)) { return ( - + ) } return token } -export const LinkVerbNameToken = ({ token }: { token: string }) => { +export const LinkVerbNameToken = ({ token, containerRect }: { token: string; containerRect?: DOMRect }) => { const splitToken = token.split('(') + if (splitToken.length < 2) { + return + } return ( - - ( + + ( ) } diff --git a/frontend/console/src/features/modules/schema/Schema.tsx b/frontend/console/src/features/modules/schema/Schema.tsx index 5a3361f6b4..97f952f840 100644 --- a/frontend/console/src/features/modules/schema/Schema.tsx +++ b/frontend/console/src/features/modules/schema/Schema.tsx @@ -1,25 +1,26 @@ +import { useMemo, useRef } from 'react' 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' +import { commentPrefix, declTypes, shouldAddLeadingSpace, specialChars, staticKeywords } from './schema.utils' -function maybeRenderDeclName(token: string, declType: string, tokens: string[], i: number) { +function maybeRenderDeclName(token: string, declType: string, tokens: string[], i: number, containerRect?: DOMRect) { const offset = declType === 'database' ? 4 : 2 if (i - offset < 0 || declType !== tokens[i - offset]) { return } if (declType === 'enum') { - return [, token.slice(-1)] + return [, token.slice(-1)] } if (declType === 'verb') { - return + return } - return + return } -function maybeRenderUnderlyingType(token: string, declType: string, tokens: string[], i: number, moduleName: string) { +function maybeRenderUnderlyingType(token: string, declType: string, tokens: string[], i: number, moduleName: string, containerRect?: DOMRect) { if (declType === 'database') { return } @@ -27,25 +28,25 @@ function maybeRenderUnderlyingType(token: string, declType: string, tokens: stri // Parse type(s) out of the headline signature const offset = 4 if (i - offset >= 0 && tokens.slice(0, i - offset + 1).includes(declType)) { - return + 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 + return } if (i === 6 && tokens[4] === '+subscribe') { - return + return } const plusIndex = tokens.findIndex((t) => t.startsWith('+')) if (i >= 6 && (i < plusIndex || plusIndex === -1)) { - return + return } } } -const SchemaLine = ({ line }: { line: string }) => { +const SchemaLine = ({ line, containerRect }: { line: string; containerRect?: DOMRect }) => { const { moduleName } = useParams() if (line.startsWith(commentPrefix)) { return {line} @@ -98,11 +99,11 @@ const SchemaLine = ({ line }: { line: string }) => { ) } - const maybeDeclName = maybeRenderDeclName(token, declType, tokens, i) + const maybeDeclName = maybeRenderDeclName(token, declType, tokens, i, containerRect) if (maybeDeclName) { return {maybeDeclName} } - const maybeUnderlyingType = maybeRenderUnderlyingType(token, declType, tokens, i, moduleName || '') + const maybeUnderlyingType = maybeRenderUnderlyingType(token, declType, tokens, i, moduleName || '', containerRect) if (maybeUnderlyingType) { return {maybeUnderlyingType} } @@ -110,12 +111,14 @@ const SchemaLine = ({ line }: { line: string }) => { }) } -export const Schema = ({ schema }: { schema: string }) => { - const ll = schema.split('\n') +export const Schema = ({ schema, containerRect }: { schema: string; containerRect?: DOMRect }) => { + const ref = useRef(null) + const rect = ref?.current?.getBoundingClientRect() + const ll = useMemo(() => schema.split('\n'), [schema]) const lines = ll.map((l, i) => ( -
- +
+
)) - return
{lines}
+ return
{lines}
} diff --git a/frontend/console/src/features/modules/schema/UnderlyingType.tsx b/frontend/console/src/features/modules/schema/UnderlyingType.tsx index c187ad76d2..ee912ffc51 100644 --- a/frontend/console/src/features/modules/schema/UnderlyingType.tsx +++ b/frontend/console/src/features/modules/schema/UnderlyingType.tsx @@ -1,11 +1,11 @@ import { DeclLink } from '../decls/DeclLink' -export const UnderlyingType = ({ token }: { token: string }) => { +export const UnderlyingType = ({ token, containerRect }: { token: string; containerRect?: DOMRect }) => { if (token.match(/^\[.+\]$/)) { // Handles lists: [elementType] return ( - [] + [] ) } @@ -15,7 +15,7 @@ export const UnderlyingType = ({ token }: { token: string }) => { return ( {'{'} - : + : ) } @@ -24,7 +24,7 @@ export const UnderlyingType = ({ token }: { token: string }) => { // Handles last token of map: {KeyType: ValueType} return ( - + {'}'} ) @@ -34,7 +34,7 @@ export const UnderlyingType = ({ token }: { token: string }) => { // Handles optional: elementType? return ( - ? + ? ) } @@ -43,7 +43,7 @@ export const UnderlyingType = ({ token }: { token: string }) => { // Handles closing parens in param list of verb signature: verb echo(inputType) outputType return ( - ) + ) ) } @@ -57,7 +57,12 @@ export const UnderlyingType = ({ token }: { token: string }) => { const declName = maybeSplitRef[1].split('<')[0] const primaryTypeEl = ( - ]/)[0]} textColors='font-bold text-green-700 dark:text-green-400' /> + ]/)[0]} + textColors='font-bold text-green-700 dark:text-green-400' + containerRect={containerRect} + /> {[',', '>'].includes(declName.slice(-1)) ? declName.slice(-1) : ''} ) @@ -69,7 +74,10 @@ export const UnderlyingType = ({ token }: { token: string }) => { {primaryTypeEl} {'<'} - + ) } diff --git a/frontend/console/src/features/modules/schema/schema.utils.ts b/frontend/console/src/features/modules/schema/schema.utils.ts index 11a140af5c..0e7b6591d6 100644 --- a/frontend/console/src/features/modules/schema/schema.utils.ts +++ b/frontend/console/src/features/modules/schema/schema.utils.ts @@ -1,30 +1,126 @@ +import type { UseQueryResult } from '@tanstack/react-query' +import type { GetModulesResponse } from '../../../protos/xyz/block/ftl/v1/console/console_pb' + export const commentPrefix = ' //' export const staticKeywords = ['module', 'export'] export const declTypes = ['config', 'data', 'database', 'enum', 'fsm', 'topic', 'typealias', 'secret', 'subscription', 'verb'] +// Keep these in sync with backend/schema/module.go#L86-L95 +const skipNewLineDeclTypes = ['config', 'secret', 'database', 'topic', 'subscription'] +const skipGapAfterTypes: { [key: string]: string[] } = { + secret: ['config'], + subscription: ['topic'], +} + export const specialChars = ['{', '}', '='] -export function isFirstLineOfBlock(ll: string[], i: number): boolean { +export function shouldAddLeadingSpace(lines: string[], i: number): boolean { + if (!isFirstLineOfBlock(lines, i)) { + return false + } + + for (const j in skipNewLineDeclTypes) { + if (declTypeAndPriorLineMatch(lines, i, skipNewLineDeclTypes[j], skipNewLineDeclTypes[j])) { + return false + } + } + + for (const declType in skipGapAfterTypes) { + for (const j in skipGapAfterTypes[declType]) { + if (declTypeAndPriorLineMatch(lines, i, declType, skipGapAfterTypes[declType][j])) { + return false + } + } + } + + return true +} + +function declTypeAndPriorLineMatch(lines: string[], i: number, declType: string, priorDeclType: string): boolean { + if (i === 0 || lines.length === 1) { + return false + } + return regexForDeclType(declType).exec(lines[i]) !== null && regexForDeclType(priorDeclType).exec(lines[i - 1]) !== null +} + +function regexForDeclType(declType: string) { + return new RegExp(`^ (export )?${declType} \\w+`) +} + +function isFirstLineOfBlock(lines: string[], i: number): boolean { if (i === 0) { // Never add space for the first block return false } - if (ll[i].startsWith(' ')) { + if (lines[i].startsWith(' ')) { // Never add space for nested lines return false } - if (ll[i - 1].startsWith(commentPrefix)) { + if (lines[i - 1].startsWith(commentPrefix)) { // Prior line is a comment return false } - if (ll[i].startsWith(commentPrefix)) { + if (lines[i].startsWith(commentPrefix)) { return true } - const tokens = ll[i].trim().split(' ') + const tokens = lines[i].trim().split(' ') if (!tokens || tokens.length === 0) { return false } return staticKeywords.includes(tokens[0]) || declTypes.includes(tokens[0]) } + +export interface DeclSchema { + schema: string + declType: string +} + +export function declFromModules(moduleName: string, declName: string, modules: UseQueryResult) { + if (!modules.isSuccess || modules.data.modules.length === 0) { + return + } + const module = modules.data.modules.find((module) => module.name === moduleName) + if (!module?.schema) { + return + } + return declFromModuleSchemaString(declName, module.schema) +} + +export function declFromModuleSchemaString(declName: string, schema: string) { + const lines = schema.split('\n') + const foundIdx = lines.findIndex((line) => { + const regex = new RegExp(`^ (export )?\\w+ ${declName}`) + return line.match(regex) + }) + + if (foundIdx === -1) { + return + } + + const line = lines[foundIdx] + let out = line + let subLineIdx = foundIdx + 1 + while (subLineIdx < lines.length && lines[subLineIdx].startsWith(' ')) { + out += `\n${lines[subLineIdx]}` + subLineIdx++ + } + // Check for closing parens + if (subLineIdx < lines.length && line.endsWith('{') && lines[subLineIdx] === ' }') { + out += '\n }' + } + + // Scan backwards for comments + subLineIdx = foundIdx - 1 + while (subLineIdx >= 0 && lines[subLineIdx].startsWith(commentPrefix)) { + out = `${lines[subLineIdx]}\n${out}}` + subLineIdx-- + } + + const regexExecd = new RegExp(` (\\w+) ${declName}`).exec(line) + return { + schema: out, + declType: regexExecd ? regexExecd[1] : '', + } +}