From 294535d9e2eb59f2e17d5e7838d006a8cf113e92 Mon Sep 17 00:00:00 2001 From: Denise Li Date: Tue, 24 Sep 2024 18:07:30 -0400 Subject: [PATCH] feat: facelift to console schema snippets (#2804) Fixes https://github.com/TBD54566975/ftl/issues/2759 * Snippet text comes from module schema * In the backend we have some [custom grouping](https://github.com/TBD54566975/ftl/blob/05eee6aa6be61ba3e910c15ac7ffb892b9b9897b/backend/schema/module.go) rules to reduce vertical spacing, which would be nice to reproduce in the frontend. They're pretty small and keeping them in sync isn't critical, so the rules can probably just be copy pastad. * Drop triangle and add border * Syntax highlighting * Make the snippets handle page borders - position to the top / left when there is not enough space on the screen --- backend/schema/module.go | 3 +- .../src/features/modules/ModulePanel.tsx | 6 +- .../src/features/modules/decls/DeclLink.tsx | 66 ++++++----- .../features/modules/decls/DeclSnippet.tsx | 16 --- .../features/modules/schema/LinkTokens.tsx | 13 ++- .../src/features/modules/schema/Schema.tsx | 39 ++++--- .../modules/schema/UnderlyingType.tsx | 24 ++-- .../features/modules/schema/schema.utils.ts | 106 +++++++++++++++++- 8 files changed, 190 insertions(+), 83 deletions(-) delete mode 100644 frontend/console/src/features/modules/decls/DeclSnippet.tsx 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] : '', + } +}