Skip to content

Commit

Permalink
eirby/client side schema validation (#337)
Browse files Browse the repository at this point in the history
- Adds schema validation into editor
- Fixes tabbing bug that was blocking dev on this
- needs styling adjustment on tab container to show top tool tip.
  • Loading branch information
EdwardIrby authored Aug 30, 2023
1 parent 5fc83ae commit c254635
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 67 deletions.
8 changes: 7 additions & 1 deletion console/client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion console/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@
"@bufbuild/protobuf": "1.3.0",
"@headlessui/react": "1.7.16",
"@heroicons/react": "2.0.18",
"@monaco-editor/react": "^4.5.2",
"@monaco-editor/react": "4.5.2",
"@tailwindcss/forms": "^0.5.5",
"json-schema-faker": "0.5.0-rcv.46",
"json-schema": "0.4.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "6.15.0",
Expand Down
37 changes: 29 additions & 8 deletions console/client/src/features/verbs/VerbForm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import {JSONSchemaFaker, Schema} from 'json-schema-faker'
import React, {useEffect} from 'react'
import {JSONSchemaFaker} from 'json-schema-faker'
import React from 'react'
import {CodeBlock} from '../../components/CodeBlock'
import {useClient} from '../../hooks/use-client'
import {Module, Verb} from '../../protos/xyz/block/ftl/v1/console/console_pb'
import {VerbService} from '../../protos/xyz/block/ftl/v1/ftl_connect'
import {VerbRef} from '../../protos/xyz/block/ftl/v1/schema/schema_pb'
import Editor from '@monaco-editor/react'
import Editor, {Monaco} from '@monaco-editor/react'
import {useDarkMode} from '../../providers/dark-mode-provider'
import type {JSONSchema4, JSONSchema6, JSONSchema7} from 'json-schema'

export type Schema = JSONSchema4 | JSONSchema6 | JSONSchema7

type Props = {
module?: Module
Expand All @@ -21,18 +24,22 @@ export const VerbForm: React.FC<Props> = ({module, verb}) => {
const [editorText, setEditorText] = React.useState<string>('')
const [response, setResponse] = React.useState<string | null>(null)
const [error, setError] = React.useState<string | null>(null)
const [schema, setSchema] = React.useState<Schema>()
const [monaco, setMonaco] = React.useState<Monaco>()

useEffect(() => {
React.useEffect(() => {
if (verb?.jsonRequestSchema) {
JSONSchemaFaker.option('maxItems', 2)
JSONSchemaFaker.option('alwaysFakeOptionals', true)

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment
const schema = JSON.parse(verb.jsonRequestSchema) as Schema
setEditorText(JSON.stringify(JSONSchemaFaker.generate(schema), null, 2))
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const verbSchema = JSON.parse(verb.jsonRequestSchema) as Schema
setSchema(verbSchema)
setEditorText(
JSON.stringify(JSONSchemaFaker.generate(verbSchema), null, 2)
)
}
}, [])

function handleEditorChange(value: string | undefined, _) {
setEditorText(value ?? '')
}
Expand Down Expand Up @@ -67,6 +74,19 @@ export const VerbForm: React.FC<Props> = ({module, verb}) => {
setError(String(error))
}
}
const handleEditorWillMount = (monaco: Monaco) => {
setMonaco(monaco)
}

React.useEffect(() => {
schema &&
monaco?.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{schema, uri: 'http://myserver/foo-schema.json', fileMatch: ['*']},
],
})
}, [monaco, schema])

return (
<>
Expand All @@ -85,6 +105,7 @@ export const VerbForm: React.FC<Props> = ({module, verb}) => {
scrollBeyondLastLine: false,
}}
onChange={handleEditorChange}
beforeMount={handleEditorWillMount}
/>
</div>

Expand Down
122 changes: 65 additions & 57 deletions console/client/src/layout/IDELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {ModuleDetails} from '../features/modules/ModuleDetails'
import {ModulesList} from '../features/modules/ModulesList'
import {Timeline} from '../features/timeline/Timeline'
import {VerbTab} from '../features/verbs/VerbTab'
import {useClient} from '../hooks/use-client'
import {ConsoleService} from '../protos/xyz/block/ftl/v1/console/console_connect'
import {
NotificationType,
NotificationsContext,
Expand All @@ -29,22 +27,18 @@ import {
import {Navigation} from './Navigation'
import {Notification} from './Notification'
import {SidePanel} from './SidePanel'
import {modulesContext} from '../providers/modules-provider'
const selectedTabStyle = `${headerTextColor} ${headerColor}`
const unselectedTabStyle = `text-gray-300 bg-slate-100 dark:bg-slate-600`

export function IDELayout() {
const client = useClient(ConsoleService)
const {modules} = React.useContext(modulesContext)
const {tabs, activeTab, setActiveTab, setTabs} = React.useContext(TabsContext)
const {showNotification} = React.useContext(NotificationsContext)
const [searchParams, setSearchParams] = useSearchParams()
const [activeIndex, setActiveIndex] = React.useState(0)
const id = searchParams.get(TabSearchParams.id) as string
const type = searchParams.get(TabSearchParams.type) as string
// Set active tab index whenever our activeTab context changes
React.useEffect(() => {
const index = tabs.findIndex(tab => tab.id === activeTab?.id)
setActiveIndex(index)
}, [activeTab])

const handleCloseTab = (id: string, index: number) => {
const nextActiveTab = {
Expand All @@ -70,63 +64,77 @@ export function IDELayout() {
})
}

// Handle loading a page with the tab query parameters
React.useEffect(() => {
const validateTabs = async () => {
const modules = await client.getModules({})
const msg = invalidTab({id, type})
if (msg) {
// IDs an invalid tab ID and type fallback to timeline
setActiveTab({id: timelineTab.id, type: timelineTab.type})
// On intial mount we have no query params set for tabs so we want to skip setting invalidTabMessage
if (type === null && id === null) return
return showNotification({
title: 'Invalid Tab',
message: msg,
type: NotificationType.Error,
})
const msg = invalidTab({id, type})
if (msg) {
// Default fallback to timeline
setActiveTab({id: timelineTab.id, type: timelineTab.type})
if (type === null && id === null) return

return showNotification({
title: 'Invalid Tab',
message: msg,
type: NotificationType.Error,
})
}
// Handle timeline tab id
if (id === timelineTab.id) {
return setActiveTab({id: timelineTab.id, type: timelineTab.type})
}
if (modules.length) {
const ids = id.split('.')
// Handle edge case where the id contains and invalid module or verb
if (modules.length) {
const [moduleId, verbId] = ids
// Handle Module does not exist
const moduleExist = modules.find(module => module?.name === moduleId)
if (!moduleExist) {
showNotification({
title: 'Module not found',
message: `Module '${moduleId}' does not exist`,
type: NotificationType.Error,
})
return setActiveTab({id: timelineTab.id, type: timelineTab.type})
}
// Handle Verb does not exists
const verbExist = moduleExist?.verbs.some(
({verb}) => verb?.name === verbId
)
if (!verbExist) {
showNotification({
title: 'Verb not found',
message: `Verb '${verbId}' does not exist on module '${moduleId}'`,
type: NotificationType.Error,
})
return setActiveTab({id: timelineTab.id, type: timelineTab.type})
}
}
const inTabsList = tabs.some(
({id: tabId, type: tabType}) => tabId === id && tabType === type
)
// Tab is in tab list just set active tab
if (inTabsList) return setActiveTab({id, type})
// Get module and Verb ids
const [moduleId, verbId] = id.split('.')
// Check to see if they exist on controller
const moduleExist = modules.modules.find(
module => module?.name === moduleId
)
const verbExist = moduleExist?.verbs.some(
({verb}) => verb?.name === verbId
)
// Set tab if they both exists
if (moduleExist && verbExist) {
// Handle if tab is not already in tab list
if (
!tabs.some(
({id: tabId, type: tabType}) => tabId === id && tabType === type
)
) {
const newTab = {
id: moduleId,
label: verbId,
id,
label: ids[1],
type: TabType.Verb,
}
const nextTabs = [...tabs, newTab]
setActiveTab({id, type})
return setTabs(nextTabs)
}
if (moduleExist && !verbExist) {
return showNotification({
title: 'Verb not found',
message: `Verb '${verbId}' does not exist on module '${moduleId}'`,
type: NotificationType.Error,
})
}
if (!moduleExist) {
return showNotification({
title: 'Module not found',
message: `Module '${moduleId}' does not exist`,
type: NotificationType.Error,
})
setTabs(nextTabs)
return setActiveTab({id: newTab.id, type: newTab.type})
}
// Handle if tab is in tab list
return setActiveTab({id, type})
}
void validateTabs()
}, [id, type])
}, [id, type, modules])

// Set active tab index whenever our activeTab context changes
React.useEffect(() => {
const index = tabs.findIndex(tab => tab.id === activeTab?.id)
setActiveIndex(index)
}, [activeTab])

return (
<>
Expand Down

0 comments on commit c254635

Please sign in to comment.