Skip to content

Commit

Permalink
feat: facelift to console schema snippets (#2804)
Browse files Browse the repository at this point in the history
Fixes #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
  • Loading branch information
deniseli authored Sep 24, 2024
1 parent 80d399f commit 294535d
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 83 deletions.
3 changes: 2 additions & 1 deletion backend/schema/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})}},
Expand Down
6 changes: 5 additions & 1 deletion frontend/console/src/features/modules/ModulePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@ export const ModulePanel = () => {

if (!module) return

return <Schema schema={module.schema} />
return (
<div className='mt-4 mx-4'>
<Schema schema={module.schema} />
</div>
)
}
66 changes: 37 additions & 29 deletions frontend/console/src/features/modules/decls/DeclLink.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className='absolute p-4 mt-4 -ml-1 rounded-md bg-gray-200 dark:bg-gray-900 text-gray-700 dark:text-white text-xs font-normal z-10 drop-shadow-xl'>
<div className='-mt-7 mb-2 text-gray-200 dark:text-gray-900'>
<svg height='20' width='20'>
<title>triangle</title>
<polygon points='11,0 9,0 0,20 20,20' fill='currentColor' />
</svg>
</div>
<DeclSnippet decl={decl} />
<div
ref={ref}
style={style}
className={classNames(
toTop ? 'bottom-full' : '',
horizontalAlignmentClassNames,
'absolute p-4 rounded-md border-solid border border border-gray-400 bg-gray-200 dark:border-gray-800 dark:bg-gray-700 text-gray-700 dark:text-white text-xs font-normal z-10 drop-shadow-xl cursor-default',
)}
>
<Schema schema={decl.schema} containerRect={containerRect} />
</div>
)
}
Expand All @@ -26,34 +38,30 @@ 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

if (!decl) {
return str
}

const navigate = useNavigate()
const linkRef = useRef<HTMLSpanElement>(null)
return (
<span
className={classNames(textColors, 'inline-block rounded-md cursor-pointer hover:bg-gray-100 hover:dark:bg-gray-700 p-1 -m-1 relative')}
onClick={() => 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 && <SnippetContainer decl={decl} />}
<span ref={linkRef} className={textColors} onClick={() => navigate(`/modules/${moduleName}/${decl.declType}/${declName}`)}>
{str}
</span>
{!slim && isHovering && <SnippetContainer decl={decl} linkRect={linkRef?.current?.getBoundingClientRect()} containerRect={containerRect} />}
</span>
)
}
16 changes: 0 additions & 16 deletions frontend/console/src/features/modules/decls/DeclSnippet.tsx

This file was deleted.

13 changes: 8 additions & 5 deletions frontend/console/src/features/modules/schema/LinkTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className='font-bold'>
<DeclLink slim moduleName={moduleName} declName={token} />
<DeclLink slim moduleName={moduleName} declName={token} containerRect={containerRect} />
</span>
)
}
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 (
<span>
<LinkToken token={splitToken[0]} />
(<UnderlyingType token={splitToken[1]} />
<LinkToken token={splitToken[0]} containerRect={containerRect} />
(<UnderlyingType token={splitToken[1]} containerRect={containerRect} />
</span>
)
}
39 changes: 21 additions & 18 deletions frontend/console/src/features/modules/schema/Schema.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,52 @@
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 [<LinkToken key='l' token={token.slice(0, token.length - 1)} />, token.slice(-1)]
return [<LinkToken key='l' token={token.slice(0, token.length - 1)} containerRect={containerRect} />, token.slice(-1)]
}
if (declType === 'verb') {
return <LinkVerbNameToken token={token} />
return <LinkVerbNameToken token={token} containerRect={containerRect} />
}
return <LinkToken token={token} />
return <LinkToken token={token} containerRect={containerRect} />
}

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
}

// Parse type(s) out of the headline signature
const offset = 4
if (i - offset >= 0 && tokens.slice(0, i - offset + 1).includes(declType)) {
return <UnderlyingType token={token} />
return <UnderlyingType token={token} containerRect={containerRect} />
}

// 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 <UnderlyingType token={token} />
return <UnderlyingType token={token} containerRect={containerRect} />
}
if (i === 6 && tokens[4] === '+subscribe') {
return <DeclLink moduleName={moduleName} declName={token} textColors='font-bold text-green-700 dark:text-green-400' />
return <DeclLink moduleName={moduleName} declName={token} textColors='font-bold text-green-700 dark:text-green-400' containerRect={containerRect} />
}
const plusIndex = tokens.findIndex((t) => t.startsWith('+'))
if (i >= 6 && (i < plusIndex || plusIndex === -1)) {
return <UnderlyingType token={token} />
return <UnderlyingType token={token} containerRect={containerRect} />
}
}
}

const SchemaLine = ({ line }: { line: string }) => {
const SchemaLine = ({ line, containerRect }: { line: string; containerRect?: DOMRect }) => {
const { moduleName } = useParams()
if (line.startsWith(commentPrefix)) {
return <span className='text-gray-500 dark:text-gray-400'>{line}</span>
Expand Down Expand Up @@ -98,24 +99,26 @@ const SchemaLine = ({ line }: { line: string }) => {
)
}

const maybeDeclName = maybeRenderDeclName(token, declType, tokens, i)
const maybeDeclName = maybeRenderDeclName(token, declType, tokens, i, containerRect)
if (maybeDeclName) {
return <span key={i}>{maybeDeclName}</span>
}
const maybeUnderlyingType = maybeRenderUnderlyingType(token, declType, tokens, i, moduleName || '')
const maybeUnderlyingType = maybeRenderUnderlyingType(token, declType, tokens, i, moduleName || '', containerRect)
if (maybeUnderlyingType) {
return <span key={i}>{maybeUnderlyingType}</span>
}
return <span key={i}>{token}</span>
})
}

export const Schema = ({ schema }: { schema: string }) => {
const ll = schema.split('\n')
export const Schema = ({ schema, containerRect }: { schema: string; containerRect?: DOMRect }) => {
const ref = useRef<HTMLDivElement>(null)
const rect = ref?.current?.getBoundingClientRect()
const ll = useMemo(() => schema.split('\n'), [schema])
const lines = ll.map((l, i) => (
<div key={i} className={classNames('mb-1', isFirstLineOfBlock(ll, i) ? 'mt-4' : '')}>
<SchemaLine line={l} />
<div ref={ref} key={i} className={classNames('mb-1', shouldAddLeadingSpace(ll, i) ? 'mt-4' : '')}>
<SchemaLine line={l} containerRect={containerRect || rect} />
</div>
))
return <div className='mt-4 mx-4 whitespace-pre font-mono text-xs'>{lines}</div>
return <div className='whitespace-pre font-mono text-xs'>{lines}</div>
}
24 changes: 16 additions & 8 deletions frontend/console/src/features/modules/schema/UnderlyingType.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className='text-green-700 dark:text-green-400'>
[<UnderlyingType token={token.slice(1, token.length - 1)} />]
[<UnderlyingType token={token.slice(1, token.length - 1)} containerRect={containerRect} />]
</span>
)
}
Expand All @@ -15,7 +15,7 @@ export const UnderlyingType = ({ token }: { token: string }) => {
return (
<span className='text-green-700 dark:text-green-400'>
{'{'}
<UnderlyingType token={token.slice(1, token.length - 1)} />:
<UnderlyingType token={token.slice(1, token.length - 1)} containerRect={containerRect} />:
</span>
)
}
Expand All @@ -24,7 +24,7 @@ export const UnderlyingType = ({ token }: { token: string }) => {
// Handles last token of map: {KeyType: ValueType}
return (
<span className='text-green-700 dark:text-green-400'>
<UnderlyingType token={token.slice(0, token.length - 1)} />
<UnderlyingType token={token.slice(0, token.length - 1)} containerRect={containerRect} />
{'}'}
</span>
)
Expand All @@ -34,7 +34,7 @@ export const UnderlyingType = ({ token }: { token: string }) => {
// Handles optional: elementType?
return (
<span className='text-green-700 dark:text-green-400'>
<UnderlyingType token={token.slice(0, token.length - 1)} />?
<UnderlyingType token={token.slice(0, token.length - 1)} containerRect={containerRect} />?
</span>
)
}
Expand All @@ -43,7 +43,7 @@ export const UnderlyingType = ({ token }: { token: string }) => {
// Handles closing parens in param list of verb signature: verb echo(inputType) outputType
return (
<span>
<UnderlyingType token={token.slice(0, token.length - 1)} />)
<UnderlyingType token={token.slice(0, token.length - 1)} containerRect={containerRect} />)
</span>
)
}
Expand All @@ -57,7 +57,12 @@ export const UnderlyingType = ({ token }: { token: string }) => {
const declName = maybeSplitRef[1].split('<')[0]
const primaryTypeEl = (
<span className='text-green-700 dark:text-green-400'>
<DeclLink moduleName={moduleName} declName={declName.split(/[,>]/)[0]} textColors='font-bold text-green-700 dark:text-green-400' />
<DeclLink
moduleName={moduleName}
declName={declName.split(/[,>]/)[0]}
textColors='font-bold text-green-700 dark:text-green-400'
containerRect={containerRect}
/>
{[',', '>'].includes(declName.slice(-1)) ? declName.slice(-1) : ''}
</span>
)
Expand All @@ -69,7 +74,10 @@ export const UnderlyingType = ({ token }: { token: string }) => {
<span className='text-green-700 dark:text-green-400'>
{primaryTypeEl}
{'<'}
<UnderlyingType token={maybeSplitRef.length === 2 ? maybeSplitRef[1].split('<')[1] : `${maybeSplitRef[1].split('<')[1]}.${maybeSplitRef.slice(2)}`} />
<UnderlyingType
token={maybeSplitRef.length === 2 ? maybeSplitRef[1].split('<')[1] : `${maybeSplitRef[1].split('<')[1]}.${maybeSplitRef.slice(2)}`}
containerRect={containerRect}
/>
</span>
)
}
Loading

0 comments on commit 294535d

Please sign in to comment.