Skip to content

Commit

Permalink
feat: new console module page (#2723)
Browse files Browse the repository at this point in the history
  • Loading branch information
deniseli and wesbillman authored Sep 20, 2024
1 parent 3585981 commit 246395e
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 10 deletions.
3 changes: 1 addition & 2 deletions frontend/console/e2e/module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
16 changes: 14 additions & 2 deletions frontend/console/src/features/modules/ModulePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 <DeploymentPage moduleName={moduleName || ''} />
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 <Schema schema={module.schema} />
}
19 changes: 13 additions & 6 deletions frontend/console/src/features/modules/decls/DeclLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className='absolute p-4 mt-4 -ml-1 rounded-md dark:bg-gray-700 dark:text-white text-xs'>
<div className='absolute -mt-7 dark:text-gray-700'>
<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' />
Expand All @@ -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(() => {
Expand All @@ -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
Expand All @@ -40,13 +47,13 @@ export const DeclLink = ({ moduleName, declName }: { moduleName?: string; declNa
const navigate = useNavigate()
return (
<span
className='inline-block rounded-md cursor-pointer text-indigo-600 dark:text-indigo-400 hover:bg-gray-100 hover:dark:bg-gray-700 p-1 -m-1'
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}`)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{str}
{isHovering && <SnippetContainer decl={decl} />}
{!slim && isHovering && <SnippetContainer decl={decl} />}
</span>
)
}
25 changes: 25 additions & 0 deletions frontend/console/src/features/modules/schema/LinkTokens.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className='font-bold'>
<DeclLink slim moduleName={moduleName} declName={token} />
</span>
)
}
return token
}

export const LinkVerbNameToken = ({ token }: { token: string }) => {
const splitToken = token.split('(')
return (
<span>
<LinkToken token={splitToken[0]} />
(<UnderlyingType token={splitToken[1]} />
</span>
)
}
121 changes: 121 additions & 0 deletions frontend/console/src/features/modules/schema/Schema.tsx
Original file line number Diff line number Diff line change
@@ -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 [<LinkToken key='l' token={token.slice(0, token.length - 1)} />, token.slice(-1)]
}
if (declType === 'verb') {
return <LinkVerbNameToken token={token} />
}
return <LinkToken token={token} />
}

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 <UnderlyingType token={token} />
}

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

const SchemaLine = ({ line }: { line: string }) => {
const { moduleName } = useParams()
if (line.startsWith(commentPrefix)) {
return <span className='text-gray-500 dark:text-gray-400'>{line}</span>
}
const tokens = line.split(/( )/).filter((l) => l !== '')
let declType: string
return tokens.map((token, i) => {
if (token.trim() === '') {
return <span key={i}>{token}</span>
}
if (specialChars.includes(token)) {
return <span key={i}>{token}</span>
}
if (staticKeywords.includes(token)) {
return (
<span key={i} className='text-fuchsia-700 dark:text-fuchsia-400'>
{token}
</span>
)
}
if (declTypes.includes(token) && tokens.length > 2 && tokens[2] !== ' ') {
declType = token
return (
<span key={i} className='text-fuchsia-700 dark:text-fuchsia-400'>
{token}
</span>
)
}
if (token[0] === '+' && token.slice(1).match(/^\w+$/)) {
return (
<span key={i} className='text-fuchsia-700 dark:text-fuchsia-400'>
{token}
</span>
)
}

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 (
<span key={i} className='text-rose-700 dark:text-rose-300'>
{token}
</span>
)
}

const maybeDeclName = maybeRenderDeclName(token, declType, tokens, i)
if (maybeDeclName) {
return <span key={i}>{maybeDeclName}</span>
}
const maybeUnderlyingType = maybeRenderUnderlyingType(token, declType, tokens, i, moduleName || '')
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')
const lines = ll.map((l, i) => (
<div key={i} className={classNames('mb-1', isFirstLineOfBlock(ll, i) ? 'mt-4' : '')}>
<SchemaLine line={l} />
</div>
))
return <div className='mt-4 mx-4 whitespace-pre font-mono text-xs'>{lines}</div>
}
75 changes: 75 additions & 0 deletions frontend/console/src/features/modules/schema/UnderlyingType.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DeclLink } from '../decls/DeclLink'

export const UnderlyingType = ({ token }: { token: string }) => {
if (token.match(/^\[.+\]$/)) {
// Handles lists: [elementType]
return (
<span className='text-green-700 dark:text-green-400'>
[<UnderlyingType token={token.slice(1, token.length - 1)} />]
</span>
)
}

if (token.match(/^{.+:$/)) {
// Handles first token of map: {KeyType: ValueType}
return (
<span className='text-green-700 dark:text-green-400'>
{'{'}
<UnderlyingType token={token.slice(1, token.length - 1)} />:
</span>
)
}

if (token.match(/.+}$/)) {
// 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)} />
{'}'}
</span>
)
}

if (token.match(/^.+\?$/)) {
// Handles optional: elementType?
return (
<span className='text-green-700 dark:text-green-400'>
<UnderlyingType token={token.slice(0, token.length - 1)} />?
</span>
)
}

if (token.match(/^.+\)$/)) {
// Handles closing parens in param list of verb signature: verb echo(inputType) outputType
return (
<span>
<UnderlyingType token={token.slice(0, token.length - 1)} />)
</span>
)
}

const maybeSplitRef = token.split('.')
if (maybeSplitRef.length < 2) {
// Not linkable because it's not a ref
return <span className='text-green-700 dark:text-green-400'>{token}</span>
}
const moduleName = maybeSplitRef[0]
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' />
{[',', '>'].includes(declName.slice(-1)) ? declName.slice(-1) : ''}
</span>
)
const hasTypeParams = maybeSplitRef[1].includes('<')
if (!hasTypeParams) {
return primaryTypeEl
}
return (
<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)}`} />
</span>
)
}
30 changes: 30 additions & 0 deletions frontend/console/src/features/modules/schema/schema.utils.ts
Original file line number Diff line number Diff line change
@@ -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])
}

0 comments on commit 246395e

Please sign in to comment.