Skip to content

Commit

Permalink
feat: new console pages for config, data, database, enum, secret, and…
Browse files Browse the repository at this point in the history
… typealias (#2617)

Part 1 of #2616

Adds (or significantly adds to) pages for the following decl types:
* config
* data
* database
* enum
* secret
* typealias

Not yet added: FSM, topic, subscription

Adds components for:
* `DeclLink`: takes a ref and links to the page for that decl
* `TypeEl`: renders an appropriate string for a given Type, with links
if appropriate
* `PanelHeader` renders the standard decl panel page header (e.g.
comments, export badge)
  • Loading branch information
deniseli authored Sep 5, 2024
1 parent 1a8ec26 commit 21956b5
Show file tree
Hide file tree
Showing 12 changed files with 301 additions and 22 deletions.
31 changes: 26 additions & 5 deletions frontend/console/src/features/modules/ModulesTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,24 @@ const DeclNode = ({ decl, href, isSelected }: { decl: Decl; href: string; isSele
return []
}
const navigate = useNavigate()
const declRef = useRef<HTMLDivElement>(null)

// Scroll to the selected decl on page load
useEffect(() => {
if (isSelected && declRef.current) {
const { top } = declRef.current.getBoundingClientRect()
const { innerHeight } = window
if (top < 64 || top > innerHeight) {
declRef.current.scrollIntoView()
}
}
}, [isSelected])

const Icon = useMemo(() => icons[decl.value.case || ''] || CodeIcon, [decl.value.case])
return (
<li className='my-1'>
<div
ref={declRef}
id={`decl-${decl.value.value.name}`}
className={classNames(
isSelected ? 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-gray-600' : 'hover:bg-gray-200 hover:dark:bg-gray-700',
Expand All @@ -72,17 +86,24 @@ const ModuleSection = ({ module, isExpanded, toggleExpansion }: { module: Module
const { moduleName, declName } = useParams()
const navigate = useNavigate()
const isSelected = useMemo(() => moduleName === module.name, [moduleName, module.name])
const selectedRef = useRef<HTMLButtonElement>(null)
const refProp = isSelected ? { ref: selectedRef } : {}
const moduleRef = useRef<HTMLButtonElement>(null)

// Scroll to the selected module on the first page load
useEffect(() => selectedRef.current?.scrollIntoView(), [])
// Scroll to the selected module on page load
useEffect(() => {
if (isSelected && !declName && moduleRef.current) {
const { top } = moduleRef.current.getBoundingClientRect()
const { innerHeight } = window
if (top < 64 || top > innerHeight) {
moduleRef.current.scrollIntoView()
}
}
}, [moduleName]) // moduleName is the selected module; module.name is the one being rendered

return (
<li key={module.name} id={`module-tree-module-${module.name}`} className='my-2'>
<Disclosure as='div' defaultOpen={isExpanded}>
<DisclosureButton
{...refProp}
ref={moduleRef}
className={classNames(
isSelected ? 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-gray-600' : 'hover:bg-gray-200 hover:dark:bg-gray-700',
'group flex w-full modules-center gap-x-2 space-y-1 rounded-md px-2 text-left text-sm font-medium leading-6',
Expand Down
16 changes: 16 additions & 0 deletions frontend/console/src/features/modules/decls/ConfigPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Config } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { PanelHeader } from './PanelHeader'
import { TypeEl } from './TypeEl'

export const ConfigPanel = ({ value, moduleName, declName }: { value: Config; moduleName: string; declName: string }) => {
return (
<div className='py-2 px-4'>
<PanelHeader exported={false} comments={value.comments}>
Config: {moduleName}.{declName}
</PanelHeader>
<div className='text-sm my-4'>
Type: <TypeEl t={value.type} />
</div>
</div>
)
}
25 changes: 11 additions & 14 deletions frontend/console/src/features/modules/decls/DataPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import { Badge } from '../../../components/Badge'
import type { Data } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { PanelHeader } from './PanelHeader'
import { TypeEl } from './TypeEl'

export const DataPanel = ({ value, moduleName, declName }: { value: Data; moduleName: string; declName: string }) => {
const maybeTypeParams = value.typeParameters.length === 0 ? '' : `<${value.typeParameters.map((p) => p.name).join(', ')}>`
return (
<div className='flex-1 py-2 px-4'>
{value.export ? (
<div>
<Badge name='Exported' />
</div>
) : (
[]
)}
<div className='inline-block mr-3 align-middle'>
<p>
data: {moduleName}.{declName}
</p>
{value.comments.length > 0 ? <p className='text-xs my-1'>{value.comments}</p> : []}
<div className='py-2 px-4'>
<PanelHeader exported={value.export} comments={value.comments}>
data: {moduleName}.{declName}
{maybeTypeParams}
</PanelHeader>
{value.fields.length === 0 || <div className='mt-8 mb-3'>Fields</div>}
<div className='text-xs font-mono inline-grid grid-cols-2 gap-x-4 gap-y-2' style={{ gridTemplateColumns: 'auto auto' }}>
{value.fields.map((f, i) => [<span key={`field-name-${i}`}>{f.name}</span>, <TypeEl key={`field-type-${i}`} t={f.type} />])}
</div>
</div>
)
Expand Down
13 changes: 13 additions & 0 deletions frontend/console/src/features/modules/decls/DatabasePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Database } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { PanelHeader } from './PanelHeader'

export const DatabasePanel = ({ value, moduleName, declName }: { value: Database; moduleName: string; declName: string }) => {
return (
<div className='py-2 px-4'>
<PanelHeader exported={false} comments={value.comments}>
Database: {moduleName}.{declName}
</PanelHeader>
<div className='text-sm my-4'>Type: {value.type}</div>
</div>
)
}
31 changes: 31 additions & 0 deletions frontend/console/src/features/modules/decls/DeclLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useSchema } from '../../../api/schema/use-schema'
import type { PullSchemaResponse } from '../../../protos/xyz/block/ftl/v1/ftl_pb.ts'

export const DeclLink = ({ moduleName, declName }: { moduleName?: string; declName: string }) => {
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 ? `${moduleName}.${declName}` : declName

if (!decl) {
return str
}

return (
<Link
className='rounded-md cursor-pointer text-indigo-600 dark:text-indigo-400 hover:bg-gray-100 hover:dark:bg-gray-700 p-1 -m-1'
to={`/modules/${moduleName}/${decl.value.case}/${declName}`}
>
{str}
</Link>
)
}
19 changes: 17 additions & 2 deletions frontend/console/src/features/modules/decls/DeclPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,40 @@ import { useParams } from 'react-router-dom'
import { useSchema } from '../../../api/schema/use-schema'
import { VerbPage } from '../../verbs/VerbPage'
import { declFromSchema } from '../module.utils'
import { ConfigPanel } from './ConfigPanel'
import { DataPanel } from './DataPanel'
import { DatabasePanel } from './DatabasePanel'
import { EnumPanel } from './EnumPanel'
import { SecretPanel } from './SecretPanel'
import { TypeAliasPanel } from './TypeAliasPanel'

export const DeclPanel = () => {
const { moduleName, declCase, declName } = useParams()
if (!moduleName || !declName) {
// Should be impossible, but validate anyway for type safety
return []
return
}

const schema = useSchema()
const decl = useMemo(() => declFromSchema(moduleName, declName, schema?.data || []), [schema?.data, moduleName, declCase, declName])
if (!decl) {
return []
return
}

const nameProps = { moduleName, declName }
switch (decl.value.case) {
case 'config':
return <ConfigPanel value={decl.value.value} {...nameProps} />
case 'data':
return <DataPanel value={decl.value.value} {...nameProps} />
case 'database':
return <DatabasePanel value={decl.value.value} {...nameProps} />
case 'enum':
return <EnumPanel value={decl.value.value} {...nameProps} />
case 'secret':
return <SecretPanel value={decl.value.value} {...nameProps} />
case 'typeAlias':
return <TypeAliasPanel value={decl.value.value} {...nameProps} />
case 'verb':
return <VerbPage {...nameProps} />
}
Expand Down
75 changes: 75 additions & 0 deletions frontend/console/src/features/modules/decls/EnumPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Enum, Type, Value } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { classNames } from '../../../utils'
import { PanelHeader } from './PanelHeader'
import { TypeEl } from './TypeEl'

const VariantComments = ({ comments, fullRow }: { comments?: string[]; fullRow?: boolean }) => {
if (!comments) {
return
}
return comments.map((c, i) => (
<div key={i} className={classNames('text-gray-500 dark:text-gray-400 mb-0.5', fullRow ? 'col-start-1 col-end-3' : '')}>
{c}
</div>
))
}

const VariantValue = ({ name, value }: { name?: string; value?: Value }) => {
const v = value?.value.value?.value
if (v === undefined) {
return
}
const valueText = value?.value.case === 'intValue' ? v.toString() : `"${v}"`
return (
<div className='mb-3'>
{name && `${name} = `}
{valueText}
</div>
)
}

const VariantNameAndType = ({ name, t }: { name: string; t: Type }) => {
return [
<span key='n' className='mb-3'>
{name}
</span>,
<TypeEl key='t' t={t} />,
]
}

const ValueEnumVariants = ({ value }: { value: Enum }) => {
return value.variants.map((v) => [<VariantComments key='c' comments={v.comments} />, <VariantValue key='v' name={v.name} value={v.value} />])
}

const TypeEnumVariants = ({ value }: { value: Enum }) => {
return (
<div className='inline-grid grid-cols-2 gap-x-4' style={{ gridTemplateColumns: 'auto auto' }}>
{value.variants.map((v) => [
<VariantComments key='c' fullRow comments={v.comments} />,
<VariantNameAndType key='n' name={v.name} t={v.value?.value.value?.value as Type} />,
])}
</div>
)
}

function enumType(value: Enum): string {
if (!value.type) {
return 'Type'
}
return value.type.value.case === 'string' ? 'String' : 'Int'
}

export const EnumPanel = ({ value, moduleName, declName }: { value: Enum; moduleName: string; declName: string }) => {
const isValueEnum = value.type !== undefined
return (
<div className='py-2 px-4'>
<PanelHeader exported={value.export} comments={value.comments}>
{enumType(value)} Enum: {moduleName}.{declName}
</PanelHeader>
<div className='mt-8'>
<div className='mb-2'>Variants</div>
<div className='text-xs font-mono'>{isValueEnum ? <ValueEnumVariants value={value} /> : <TypeEnumVariants value={value} />}</div>
</div>
</div>
)
}
16 changes: 16 additions & 0 deletions frontend/console/src/features/modules/decls/PanelHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { Badge } from '../../../components/Badge'

export const PanelHeader = ({ children, exported, comments }: { children?: ReactNode; exported: boolean; comments?: string[] }) => {
return (
<div className='flex-1'>
{exported && (
<div className='mb-2'>
<Badge name='Exported' />
</div>
)}
{children}
{comments && comments.length > 0 && <p className='text-xs my-1'>{comments}</p>}
</div>
)
}
16 changes: 16 additions & 0 deletions frontend/console/src/features/modules/decls/SecretPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Secret } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { PanelHeader } from './PanelHeader'
import { TypeEl } from './TypeEl'

export const SecretPanel = ({ value, moduleName, declName }: { value: Secret; moduleName: string; declName: string }) => {
return (
<div className='py-2 px-4'>
<PanelHeader exported={false} comments={value.comments}>
Secret: {moduleName}.{declName}
</PanelHeader>
<div className='text-sm my-4'>
Type: <TypeEl t={value.type} />
</div>
</div>
)
}
16 changes: 16 additions & 0 deletions frontend/console/src/features/modules/decls/TypeAliasPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { TypeAlias } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { PanelHeader } from './PanelHeader'
import { TypeEl } from './TypeEl'

export const TypeAliasPanel = ({ value, moduleName, declName }: { value: TypeAlias; moduleName: string; declName: string }) => {
return (
<div className='py-2 px-4'>
<PanelHeader exported={value.export} comments={value.comments}>
Type Alias: {moduleName}.{declName}
</PanelHeader>
<div className='text-sm my-4'>
Underlying type: <TypeEl t={value.type} />
</div>
</div>
)
}
63 changes: 63 additions & 0 deletions frontend/console/src/features/modules/decls/TypeEl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Optional, Ref, Array as SchArray, Map as SchMap, Type } from '../../../protos/xyz/block/ftl/v1/schema/schema_pb'
import { DeclLink } from './DeclLink'

// TypeParams ironically is built to work with the `Type` message, not the
// `TypeParameter` message, which just has a simple string param name without
// any higher level type information.
const TypeParams = ({ types }: { types?: (Type | undefined)[] }) => {
const definedTypes = types?.filter((t) => t !== undefined)
if (!definedTypes || definedTypes.length === 0) {
return
}
return (
<span>
<span>{'<'}</span>
{definedTypes.map((t, i) => [<TypeEl key='t' t={t} />, i === definedTypes.length - 1 ? '' : ', '])}
<span>{'>'}</span>
</span>
)
}

export const TypeEl = ({ t }: { t?: Type }) => {
if (!t) {
return ''
}

const v = t.value.value
if (!v) {
return t.value.case
}

switch (t.value.case) {
case 'array':
return (
<span>
array
<TypeParams types={[(v as SchArray).element]} />
</span>
)
case 'map':
return (
<span>
map
<TypeParams types={[(v as SchMap).key, (v as SchMap).value]} />
</span>
)
case 'optional':
return (
<span>
optional
<TypeParams types={[(v as Optional).type]} />
</span>
)
case 'ref':
return (
<span>
<DeclLink moduleName={(v as Ref).module} declName={(v as Ref).name} />
<TypeParams types={(v as Ref).typeParameters} />
</span>
)
default:
return t.value.case || ''
}
}
2 changes: 1 addition & 1 deletion frontend/console/src/features/modules/decls/VerbPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const ioBlockClassName =
'rounded-md inline-block align-middle w-40 bg-gray-200 dark:bg-gray-900 my-3 mr-3 py-1 px-2 hover:bg-gray-100 hover:cursor-pointer hover:dark:bg-gray-700'
const IOBlock = ({ heading, t }: { heading: string; t?: Type }) => {
if (!t) {
return []
return
}
if (t.value.case === 'ref') {
return <DataRef heading={heading} r={t.value.value} />
Expand Down

0 comments on commit 21956b5

Please sign in to comment.