diff --git a/.circleci/config.yml b/.circleci/config.yml index f29ffd5d6bf..2f24d254243 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -127,6 +127,11 @@ jobs: - run: name: '`yarn prettier` changes committed?' command: yarn prettier --check + - run: + name: '`yarn jsonSchemas` changes committed?' + command: | + yarn jsonSchemas + git diff --exit-code test_unit: <<: *defaults diff --git a/.eslintignore b/.eslintignore index 9d1d7d42721..33842a51f29 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,7 @@ .next .yarn /docs/export +/docs/schemas /packages/**/dist /packages/toolpad-app/.next /packages/toolpad-app/public diff --git a/docs/data/pages.ts b/docs/data/pages.ts index f70eeda8c36..f30f2d1148b 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -1,9 +1,15 @@ import type { MuiPage } from '@mui/monorepo/docs/src/MuiPage'; +import SchemaIcon from '@mui/icons-material/Schema'; +import BuildIcon from '@mui/icons-material/Build'; +import CodeIcon from '@mui/icons-material/Code'; +import DescriptionIcon from '@mui/icons-material/Description'; +import TableViewIcon from '@mui/icons-material/TableView'; +import VisibilityIcon from '@mui/icons-material/Visibility'; const pages: MuiPage[] = [ { pathname: '/toolpad/getting-started', - icon: 'DescriptionIcon', + icon: DescriptionIcon, children: [ { pathname: '/toolpad/getting-started/overview' }, { pathname: '/toolpad/getting-started/quickstart' }, @@ -13,7 +19,7 @@ const pages: MuiPage[] = [ }, { pathname: '/toolpad/connecting-to-datasources', - icon: 'TableViewIcon', + icon: TableViewIcon, children: [ { pathname: '/toolpad/connecting-to-datasources/queries', @@ -31,7 +37,7 @@ const pages: MuiPage[] = [ { pathname: '/toolpad/building-ui', title: 'Building UI', - icon: 'VisibilityIcon', + icon: VisibilityIcon, children: [ { pathname: '/toolpad/building-ui/component-library', @@ -53,27 +59,17 @@ const pages: MuiPage[] = [ }, { pathname: '/toolpad/data-binding', - icon: 'CodeIcon', + icon: CodeIcon, }, { pathname: '/toolpad/deployment', - icon: 'BuildIcon', + icon: BuildIcon, + }, + { + pathname: '/toolpad/schema-reference', + title: 'Schema Reference', + icon: SchemaIcon, }, - // { - // pathname: '/toolpad/versioning-and-deploying', - // title: 'Versioning & deploying [TODO]', - // icon: 'ToggleOnIcon', - // children: [ - // { - // pathname: '/toolpad/versioning-and-deploying/releases', - // }, - // ], - // }, - // { - // pathname: '/toolpad/faq', - // title: 'FAQ [TODO]', - // icon: 'ReaderIcon', - // }, ]; export default pages; diff --git a/docs/package.json b/docs/package.json index b0eb172bab9..974fd2e8acd 100644 --- a/docs/package.json +++ b/docs/package.json @@ -55,6 +55,7 @@ "express": "4.18.2", "fg-loadcss": "3.1.0", "fs-extra": "11.1.1", + "invariant": "2.2.4", "jss-rtl": "0.3.0", "lodash": "4.17.21", "lz-string": "1.5.0", @@ -89,6 +90,7 @@ "@babel/plugin-transform-react-constant-elements": "7.21.3", "@babel/preset-typescript": "7.21.5", "@types/doctrine": "0.0.5", + "@types/json-schema": "7.0.11", "@types/react-is": "^18.2.0", "cpy-cli": "4.2.0", "cross-fetch": "3.1.5", diff --git a/docs/pages/toolpad/schema-reference/index.tsx b/docs/pages/toolpad/schema-reference/index.tsx new file mode 100644 index 00000000000..584fb7edeeb --- /dev/null +++ b/docs/pages/toolpad/schema-reference/index.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { GetStaticProps } from 'next'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import SchemaReference, { + SchemaReferenceProps, +} from '../../../src/modules/components/SchemaReference'; + +export const getStaticProps: GetStaticProps = async () => { + const schemaFile = path.join(process.cwd(), './schemas/v1/definitions.json'); + const content = await fs.readFile(schemaFile, { encoding: 'utf-8' }); + + return { + props: { + definitions: JSON.parse(content), + }, + }; +}; + +export default function ApiReference(props: SchemaReferenceProps) { + return ; +} diff --git a/docs/schemas/v1/definitions.json b/docs/schemas/v1/definitions.json new file mode 100644 index 00000000000..2ef1754e8bc --- /dev/null +++ b/docs/schemas/v1/definitions.json @@ -0,0 +1,616 @@ +{ + "type": "object", + "properties": { + "Page": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string", + "const": "v1", + "description": "Defines the version of this object. Used in determining compatibility between Toolpad \"page\" objects." + }, + "kind": { + "type": "string", + "const": "page", + "description": "Describes the nature of this Toolpad \"page\" object." + }, + "spec": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Serves as a canonical id of the page." + }, + "title": { + "type": "string", + "description": "Title for this page." + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/definitions/NameStringValuePair" + }, + "description": "Parameters for the page. These can be set inside of the url query string." + }, + "queries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A name for the query" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/JsExpressionBinding" + } + ], + "description": "Activates or deactivates the query. When deactivated the data won't be loaded when the page opens." + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name" + }, + "value": { + "anyOf": [ + {}, + { + "$ref": "#/definitions/JsExpressionBinding" + } + ], + "description": "The value" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "A name/value pair." + }, + "description": "Parameters to pass to this query." + }, + "mode": { + "type": "string", + "enum": [ + "query", + "mutation" + ], + "description": "How to fetch this query." + }, + "query": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rest", + "description": "Designates this object as a fetch query." + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/JsExpressionBinding" + } + ], + "description": "The URL of the request" + }, + "method": { + "type": "string", + "description": "The request method." + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/definitions/BindableNameStringValue" + }, + "description": "Extra request headers." + }, + "searchParams": { + "type": "array", + "items": { + "$ref": "#/definitions/BindableNameStringValue" + }, + "description": "Extra url query parameters." + }, + "body": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "raw" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/JsExpressionBinding" + } + ] + }, + "contentType": { + "type": "string" + } + }, + "required": [ + "kind", + "content", + "contentType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "urlEncoded" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/BindableNameStringValue" + } + } + }, + "required": [ + "kind", + "content" + ], + "additionalProperties": false + } + ], + "description": "The request body." + }, + "transformEnabled": { + "type": "boolean", + "description": "Run a custom transformer on the response." + }, + "transform": { + "type": "string", + "description": "The custom transformer to run when enabled." + }, + "response": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "raw" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false, + "description": "Don't interpret this body at all." + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "json" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false, + "description": "Interpret the fetch response as JSON" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "csv" + }, + "headers": { + "type": "boolean", + "description": "First row contains headers" + } + }, + "required": [ + "kind", + "headers" + ], + "additionalProperties": false, + "description": "Interpret the fetch response as CSV" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "xml" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false, + "description": "Interpret the fetch response as XML" + } + ], + "description": "How to parse the response." + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "local", + "description": "Designates this object as a local function query." + }, + "function": { + "type": "string", + "description": "The function to be executed on the backend by this query." + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + ], + "description": "Query definition" + }, + "transform": { + "type": "string", + "description": "Transformation to run on the response" + }, + "transformEnabled": { + "type": "boolean", + "description": "Enable the transformation" + }, + "refetchInterval": { + "type": "number", + "description": "Interval to rerun this query at" + }, + "cacheTime": { + "type": "number", + "description": "Time to cache before refetching" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "description": "Queries that are used by the page. These will load data when the page opens." + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/Element" + }, + "description": "The content of the page. This defines the UI." + }, + "display": { + "type": "string", + "enum": [ + "standalone", + "shell" + ], + "description": "Display mode of the page. This can also be set at runtime with the toolpad-display query parameter" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "description": "Defines the shape of this \"page\" object" + } + }, + "required": [ + "apiVersion", + "kind", + "spec" + ], + "additionalProperties": false + }, + "Theme": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string", + "const": "v1", + "description": "Defines the version of this object. Used in determining compatibility between Toolpad \"theme\" objects." + }, + "kind": { + "type": "string", + "const": "theme", + "description": "Describes the nature of this Toolpad \"theme\" object." + }, + "spec": { + "type": "object", + "properties": { + "palette.mode": { + "type": "string", + "enum": [ + "light", + "dark" + ], + "description": "The MUI theme palette mode." + }, + "palette.primary.main": { + "type": "string", + "description": "The primary theme color." + }, + "palette.secondary.main": { + "type": "string", + "description": "The secondary theme color." + } + }, + "additionalProperties": false, + "description": "Defines the shape of this \"theme\" object" + } + }, + "required": [ + "apiVersion", + "kind", + "spec" + ], + "additionalProperties": false + } + }, + "required": [ + "Page", + "Theme" + ], + "additionalProperties": false, + "definitions": { + "Json": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/Json" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Json" + } + } + ], + "description": "A JSON compatible value, anything that is serializable to JSON." + }, + "JsExpressionBinding": { + "type": "object", + "properties": { + "$$jsExpression": { + "type": "string", + "description": "The expression to be evaluated." + } + }, + "required": [ + "$$jsExpression" + ], + "additionalProperties": false, + "description": "A binding that evaluates an expression and returns the result." + }, + "JsExpressionAction": { + "type": "object", + "properties": { + "$$jsExpressionAction": { + "type": "string", + "description": "The code to be executed." + } + }, + "required": [ + "$$jsExpressionAction" + ], + "additionalProperties": false, + "description": "A javascript expression to be executed when this action is triggered." + }, + "NavigationAction": { + "type": "object", + "properties": { + "$$navigationAction": { + "type": "object", + "properties": { + "page": { + "type": "string", + "description": "The page that is being navigated to" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "anyOf": [ + {}, + { + "$ref": "#/definitions/JsExpressionBinding" + } + ] + }, + "description": "Parameters to pass when navigating to this page" + } + }, + "required": [ + "page", + "parameters" + ], + "additionalProperties": false + } + }, + "required": [ + "$$navigationAction" + ], + "additionalProperties": false, + "description": "A navigation from one page to another, optionally passing parameters to the next page." + }, + "BindableProp": { + "anyOf": [ + { + "$ref": "#/definitions/Json" + }, + { + "$ref": "#/definitions/JsExpressionBinding" + }, + { + "$ref": "#/definitions/JsExpressionAction" + }, + { + "$ref": "#/definitions/NavigationAction" + }, + { + "$ref": "#/definitions/Template" + } + ] + }, + "Element": { + "type": "object", + "properties": { + "component": { + "type": "string", + "description": "The component that this element was based on." + }, + "name": { + "type": "string", + "description": "a name for this component, which is used to reference it inside bindings." + }, + "layout": { + "type": "object", + "properties": { + "horizontalAlign": { + "type": "string", + "description": "Lays out the element along the horizontal axis." + }, + "verticalAlign": { + "type": "string", + "description": "Lays out the element along the vertical axis." + }, + "columnSize": { + "type": "number", + "description": "The width this element takes up, expressed in terms of columns on the page." + } + }, + "additionalProperties": false, + "description": "Layout properties for this element." + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/Element" + }, + "description": "The children of this element." + }, + "props": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/BindableProp" + }, + "description": "The properties to configure this instance of the component." + } + }, + "required": [ + "component", + "name" + ], + "additionalProperties": false, + "description": "The instance of a component. Used to build user interfaces in pages." + }, + "Template": { + "type": "object", + "properties": { + "$$template": { + "type": "array", + "items": { + "$ref": "#/definitions/Element" + }, + "description": "The subtree, that describes the UI to be rendered by the template." + } + }, + "required": [ + "$$template" + ], + "additionalProperties": false, + "description": "Describes a fragment of Toolpad elements, to be used as a template." + }, + "NameStringValuePair": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name" + }, + "value": { + "type": "string", + "description": "The value" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false, + "description": "a name/value pair with a string value." + }, + "BindableNameStringValue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/JsExpressionBinding" + } + ], + "description": "The value" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false, + "description": "A name/value pair where the value is dynamically bindable to strings." + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/docs/src/modules/components/SchemaReference.tsx b/docs/src/modules/components/SchemaReference.tsx new file mode 100644 index 00000000000..6b701ea01e2 --- /dev/null +++ b/docs/src/modules/components/SchemaReference.tsx @@ -0,0 +1,545 @@ +import * as React from 'react'; +import MarkdownElement from '@mui/monorepo/docs/src/modules/components/MarkdownElement'; +import AppLayoutDocs from '@mui/monorepo/docs/src/modules/components/AppLayoutDocs'; +import Ad from '@mui/monorepo/docs/src/modules/components/Ad'; +import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; +import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; +import { + ButtonBase, + Collapse, + Tooltip, + TooltipProps, + Typography, + styled, + tooltipClasses, +} from '@mui/material'; +import invariant from 'invariant'; +import clsx from 'clsx'; +import { interleave } from '../utils/react'; + +const EMPTY_OBJECT = {}; + +const SchemaContext = React.createContext<{ [key: string]: JSONSchema7Definition }>(EMPTY_OBJECT); + +const TooltipContext = React.createContext<{} | null>(null); + +const SchemaTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))({ + [`& .${tooltipClasses.tooltip}`]: { + padding: 0, + }, +}); + +const classNames = { + indent: 'jsonschema-indent', + open: 'jsonschema-open', + arrow: 'jsonschema-arrow', + description: 'jsonschema-description', + name: 'jsonschema-name', + keyword: 'jsonschema-keyword', + comment: 'jsonschema-comment', + divider: 'jsonschema-divider', + objectLabel: 'jsonschema-object-label', + constString: 'jsonschema-const-string', +}; + +const Wrapper = styled('div')(({ theme }) => ({ + fontFamily: 'Menlo,Consolas,"Droid Sans Mono",monospace;', + backgroundColor: 'rgb(0, 30, 60)', + color: 'rgb(255, 255, 255)', + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + fontSize: '0.8125rem', + border: 1, + borderStyle: 'solid', + borderColor: 'rgba(194, 224, 255, 0.08)', + whiteSpace: 'pre-wrap', + [`& .${classNames.indent}`]: { + marginLeft: '2ch', + }, + [`& .${classNames.objectLabel}`]: { + fontFamily: theme.typography.fontFamily, + display: 'inline-flex', + alignItems: 'center', + color: '#b2b2b2', + [`& .${classNames.arrow}`]: { + fontSize: 'small', + }, + [`&.${classNames.open} .${classNames.arrow}`]: { + transform: 'rotate(90deg)', + }, + }, + [`& .${classNames.open}`]: { + fontSize: 'small', + [`& .${classNames.arrow}`]: { + transform: 'rotate(90deg)', + }, + }, + [`& .${classNames.open}`]: { + fontSize: 'small', + [`& .${classNames.arrow}`]: { + transform: 'rotate(90deg)', + }, + }, + [`& .${classNames.comment}`]: { + color: '#b2b2b2', + }, + [`& .${classNames.description}`]: { + fontFamily: theme.typography.fontFamily, + color: '#b2b2b2', + whiteSpace: 'normal', + }, + [`& .${classNames.name}`]: { + scrollMarginTop: 'calc(var(--MuiDocs-header-height) + 32px)', + color: '#ffffff', + }, + [`& .${classNames.constString}`]: { + color: '#a6e22e', + }, + [`& .${classNames.keyword}`]: { + color: '#66d9ef', + }, + '& ul': { + listStyle: 'disc', + color: '#B2BAC2', + paddingLeft: '3ch', + }, + [`& .${classNames.divider}`]: { + backgroundColor: 'rgba(194, 224, 255, 0.08)', + margin: theme.spacing(1, 0), + }, + '& a': { + color: '#66B2FF', + }, + ul: { + marginBottom: 0, + }, +})); + +export interface SchemaReferenceProps { + disableAd?: boolean; + definitions: JSONSchema7; +} + +function getConstClass(type: string) { + switch (type) { + case 'string': + return classNames.constString; + default: + return undefined; + } +} + +interface CollapsibleLabelProps { + children?: React.ReactNode; + open?: boolean; + onOpenChange?: React.Dispatch>; +} + +function CollapsibleLabel({ children, open, onOpenChange }: CollapsibleLabelProps) { + return ( + onOpenChange?.((isOpen) => !isOpen)} + > + {children} + + ); +} + +interface JsonSchemaTypeDisplayProps { + schema: JSONSchema7; + open?: boolean; + onOpenChange?: React.Dispatch>; +} + +function JsonSchemaTypeDisplay({ schema, open, onOpenChange }: JsonSchemaTypeDisplayProps) { + let types: string[] = []; + if (typeof schema.const !== 'undefined') { + return ( + {JSON.stringify(schema.const)} + ); + } + + if (typeof schema.enum !== 'undefined') { + return ( + + {interleave( + schema.enum.map((enumValue) => { + const asString = JSON.stringify(enumValue); + return ( + + {asString} + + ); + }), + ' | ', + )} + + ); + } + + if (schema.type === 'object') { + return ( + + object + + ); + } + + if (schema.type === 'array') { + return array of ; + } + + if (schema.anyOf) { + return ( + + any of{' '} + + ); + } + + if (schema.type) { + types = Array.isArray(schema.type) ? schema.type : [schema.type]; + } else if (!schema.anyOf) { + types = ['any']; + } + + return ( + + {interleave( + types.map((type) => ( + + {type} + + )), + ' | ', + )} + + ); +} + +interface JsonSchemaItemDisplayProps { + schema?: JSONSchema7Definition; + idPrefix: string; +} + +function JsonSchemaItemDisplay({ schema, idPrefix }: JsonSchemaItemDisplayProps) { + if (!schema || typeof schema === 'boolean') { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return ; +} + +interface JsonSchemaItemsDisplayProps { + schema: JSONSchema7; + idPrefix: string; +} + +function JsonSchemaItemsDisplay({ schema, idPrefix }: JsonSchemaItemsDisplayProps) { + if (Array.isArray(schema.items)) { + return ( +
    + {schema.items.map((item, i) => ( +
  • + +
  • + ))} +
+ ); + } + + return ; +} + +interface JsonSchemaPropertiesDisplayProps { + schema: JSONSchema7; + idPrefix: string; + open?: boolean; +} + +function JsonSchemaPropertiesDisplay({ schema, idPrefix, open }: JsonSchemaPropertiesDisplayProps) { + const properties: [string, JSONSchema7Definition][] = []; + + if (schema.properties) { + properties.push(...Object.entries(schema.properties)); + } + + if (schema.additionalProperties) { + properties.push(['*', schema.additionalProperties]); + } + + return properties.length > 0 ? ( + + {interleave( + properties.map(([propName, propSchema]) => { + return ( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + + ); + }), +
, + )} +
+ ) : null; +} + +interface DefinitionTooltipProps { + name: string; +} + +function DefinitionTooltip({ name }: DefinitionTooltipProps) { + const definitions = React.useContext(SchemaContext); + const definition = definitions[name]; + + if (!definition || typeof definition === 'boolean') { + return null; + } + + return ( + + + {/* eslint-disable-next-line @typescript-eslint/no-use-before-define */} + + + + ); +} + +interface JsonSchemaValueDisplayProps { + schema: JSONSchema7; + idPrefix: string; +} + +function JsonSchemaValueDisplay({ schema, idPrefix }: JsonSchemaValueDisplayProps) { + const [detailsOpen, setDetailsOpen] = React.useState(true); + + if (schema.$ref) { + if (schema.$ref.startsWith('#/definitions/')) { + const definition = schema.$ref.slice('#/definitions/'.length); + if (!definition.includes('/')) { + const hash = `definition-${definition}`; + return ( + }> + + {definition} + + + ); + } + } + } + + return ( + + + + + + {schema.items ? : null} + + {schema.anyOf ? ( + +
    + {schema.anyOf.map((subSchema, i) => { + return ( +
  • + +
  • + ); + })} +
+
+ ) : null} +
+ ); +} + +interface JsonDescriptionDisplayProps { + schema: JSONSchema7; +} + +function JsonDescriptionDisplay({ schema }: JsonDescriptionDisplayProps) { + if (schema.description) { + return
{schema.description}
; + } + + return null; +} + +interface JsonSchemaNameValueDisplayProps { + name?: string; + schema: JSONSchema7Definition; + idPrefix?: string; +} + +function JsonSchemaNameValueDisplay({ + name, + schema, + idPrefix = '', +}: JsonSchemaNameValueDisplayProps) { + invariant(typeof schema === 'object', `Expected an object but got ${typeof schema}`); + + const properties: [string, JSONSchema7Definition][] = []; + + if (schema.properties) { + properties.push(...Object.entries(schema.properties)); + } + + if (schema.additionalProperties) { + properties.push(['*', schema.additionalProperties]); + } + + const tooltipContext = React.useContext(TooltipContext); + const isInsideTooltip = !!tooltipContext; + + const id = `${idPrefix}-${name || ''}`; + const anchor = isInsideTooltip ? undefined : id; + + return ( + + + {name ? ( + + + {name} + + :{' '} + + ) : null} + + + + ); +} + +interface HeadingProps { + hash: string; + level: 'h2' | 'h3'; + title: string; +} + +function Heading({ hash, level: Level, title }: HeadingProps) { + return ( + + {title} + + + + + + + ); +} + +interface JsonSchemaDisplayProps { + name: string; + hash: string; + schema: JSONSchema7; + idPrefix?: string; +} + +function JsonSchemaDisplay({ name, hash, schema, idPrefix = '' }: JsonSchemaDisplayProps) { + return ( + + + {schema.description} + + + + + ); +} + +export default function SchemaReference({ disableAd, definitions }: SchemaReferenceProps) { + const toc = [ + { + text: 'Files', + hash: 'files', + introduction: `These are the various files supported by toolpad.`, + children: Object.entries(definitions.properties || {}).map(([name, content]) => ({ + text: name, + hash: `file-${name}`, + content, + children: [], + })), + }, + { + text: 'Definitions', + hash: 'definitions', + introduction: `These are shared definitions used throughout Toolpad files.`, + children: Object.entries(definitions.definitions || {}).map(([name, content]) => ({ + text: name, + hash: `definition-${name}`, + content, + children: [], + })), + }, + ]; + + const description = 'An exhaustive reference for the Toolpad file formats.'; + + return ( + + + +

Schema Reference

+ + + {description} + {disableAd ? null : } + + + {toc.map((tocNode) => { + return ( + + + + {tocNode.introduction} + + {tocNode.children.map((tocItemNode) => { + invariant(typeof tocItemNode.content !== 'boolean', 'Invalid top level schema'); + return ( + + ); + })} + + ); + })} +
+ + + + + +
+
+ ); +} diff --git a/docs/src/modules/utils/react.tsx b/docs/src/modules/utils/react.tsx new file mode 100644 index 00000000000..e7441848c12 --- /dev/null +++ b/docs/src/modules/utils/react.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as ReactIs from 'react-is'; + +/** + * Like `Array.prototype.join`, but for React nodes. + */ +export function interleave(items: React.ReactNode[], separator: React.ReactNode): React.ReactNode { + const result: React.ReactNode[] = []; + + for (let i = 0; i < items.length; i += 1) { + if (i > 0) { + if (ReactIs.isElement(separator)) { + result.push(React.cloneElement(separator, { key: `separator-${i}` })); + } else { + result.push(separator); + } + } + + const item = items[i]; + result.push(item); + } + + return {result}; +} diff --git a/package.json b/package.json index a3611b964ab..c39456047bd 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "test:integration": "rm -rf ./node_modules/.vite && playwright test --config ./test/playwright.config.ts", "test": "lerna run test", "check-types": "lerna run check-types", - "toolpad": "toolpad" + "toolpad": "toolpad", + "jsonSchemas": "ts-node --esm ./scripts/generateJsonSchemas.mts" }, "devDependencies": { "@jest/globals": "29.5.0", @@ -90,13 +91,16 @@ "yarn-deduplicate": "6.0.1" }, "dependencies": { + "@janpotoms/zod-to-json-schema": "3.20.6", "archiver": "5.3.1", "cross-env": "7.0.3", "dotenv-cli": "7.2.1", "inquirer": "9.2.2", "semver": "7.5.0", + "ts-node": "10.9.1", "tsup": "6.7.0", - "yargs": "17.7.2" + "yargs": "17.7.2", + "zod": "3.21.4" }, "engines": { "npm": "please-use-yarn", diff --git a/packages/toolpad-app/src/server/schema.ts b/packages/toolpad-app/src/server/schema.ts index 08139e87330..df5b3a83228 100644 --- a/packages/toolpad-app/src/server/schema.ts +++ b/packages/toolpad-app/src/server/schema.ts @@ -4,41 +4,60 @@ export const API_VERSION = 'v1'; function toolpadObjectSchema(kind: K, spec: T) { return z.object({ - apiVersion: z.literal(API_VERSION), - kind: z.literal(kind), - spec, + apiVersion: z + .literal(API_VERSION) + .describe( + `Defines the version of this object. Used in determining compatibility between Toolpad "${kind}" objects.`, + ), + kind: z.literal(kind).describe(`Describes the nature of this Toolpad "${kind}" object.`), + spec: spec.describe(`Defines the shape of this "${kind}" object`), }); } const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); type Literal = z.infer; type Json = Literal | { [key: string]: Json } | Json[]; -const jsonSchema: z.ZodType = z.lazy(() => - z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]), -); +export const jsonSchema: z.ZodType = z + .lazy(() => z.union([...literalSchema.options, z.array(jsonSchema), z.record(jsonSchema)])) + .describe('A JSON compatible value, anything that is serializable to JSON.'); function nameValuePairSchema(valueType: V) { - return z.object({ name: z.string(), value: valueType }); + return z + .object({ + name: z.string().describe('The name'), + value: valueType.describe(valueType.description ?? 'The value'), + }) + .describe('A name/value pair.'); } -const jsExpressionBindingSchema = z.object({ - $$jsExpression: z.string(), -}); +export const jsExpressionBindingSchema = z + .object({ + $$jsExpression: z.string().describe('The expression to be evaluated.'), + }) + .describe('A binding that evaluates an expression and returns the result.'); function bindableSchema(valueType: V) { - return z.union([jsExpressionBindingSchema, valueType]); + return z.union([valueType, jsExpressionBindingSchema]); } -const jsExpressionActionSchema = z.object({ - $$jsExpressionAction: z.string(), -}); - -const navigationActionSchema = z.object({ - $$navigationAction: z.object({ - page: z.string(), - parameters: z.record(bindableSchema(z.any())), - }), -}); +const jsExpressionActionSchema = z + .object({ + $$jsExpressionAction: z.string().describe('The code to be executed.'), + }) + .describe('A javascript expression to be executed when this action is triggered.'); + +const navigationActionSchema = z + .object({ + $$navigationAction: z.object({ + page: z.string().describe('The page that is being navigated to'), + parameters: z + .record(bindableSchema(z.any())) + .describe('Parameters to pass when navigating to this page'), + }), + }) + .describe( + 'A navigation from one page to another, optionally passing parameters to the next page.', + ); export type NavigationAction = z.infer; @@ -47,7 +66,9 @@ const fetchModeSchema = z.union([ z.literal('mutation').describe('Fetch on manual action only'), ]); -const nameStringValuePairSchema = nameValuePairSchema(z.string()); +const nameStringValuePairSchema = nameValuePairSchema(z.string()).describe( + 'a name/value pair with a string value.', +); const rawBodySchema = z.object({ kind: z.literal('raw'), @@ -55,51 +76,62 @@ const rawBodySchema = z.object({ contentType: z.string(), }); +const bindableNameStringValueSchema = nameValuePairSchema(bindableSchema(z.string())).describe( + 'A name/value pair where the value is dynamically bindable to strings.', +); + const urlEncodedBodySchema = z.object({ kind: z.literal('urlEncoded'), - content: z.array(nameValuePairSchema(bindableSchema(z.string()))), + content: z.array(bindableNameStringValueSchema), }); const fetchBodySchema = z.discriminatedUnion('kind', [rawBodySchema, urlEncodedBodySchema]); export type FetchBody = z.infer; -const rawResponseTypeSchema = z.object({ - kind: z.literal('raw'), -}); - -const jsonResponseTypeSchema = z.object({ - kind: z.literal('json'), -}); - -const csvResponseTypeSchema = z.object({ - kind: z.literal('csv'), - headers: z.boolean().describe('First row contains headers'), -}); - -const xmlResponseTypeSchema = z.object({ - kind: z.literal('xml'), -}); - -const responseTypeSchema = z.discriminatedUnion('kind', [ - rawResponseTypeSchema, - jsonResponseTypeSchema, - csvResponseTypeSchema, - xmlResponseTypeSchema, -]); +const rawResponseTypeSchema = z + .object({ + kind: z.literal('raw'), + }) + .describe("Don't interpret this body at all."); + +const jsonResponseTypeSchema = z + .object({ + kind: z.literal('json'), + }) + .describe('Interpret the fetch response as JSON'); + +const csvResponseTypeSchema = z + .object({ + kind: z.literal('csv'), + headers: z.boolean().describe('First row contains headers'), + }) + .describe('Interpret the fetch response as CSV'); + +const xmlResponseTypeSchema = z + .object({ + kind: z.literal('xml'), + }) + .describe('Interpret the fetch response as XML'); + +const responseTypeSchema = z + .discriminatedUnion('kind', [ + rawResponseTypeSchema, + jsonResponseTypeSchema, + csvResponseTypeSchema, + xmlResponseTypeSchema, + ]) + .describe('Describes how a the fetch response is to be interpreted.'); export type ResponseType = z.infer; const fetchQueryConfigSchema = z.object({ - kind: z.literal('rest'), + kind: z.literal('rest').describe('Designates this object as a fetch query.'), url: bindableSchema(z.string()).optional().describe('The URL of the request'), method: z.string().optional().describe('The request method.'), - headers: z - .array(nameValuePairSchema(bindableSchema(z.string()))) - .optional() - .describe('Extra request headers.'), + headers: z.array(bindableNameStringValueSchema).optional().describe('Extra request headers.'), searchParams: z - .array(nameValuePairSchema(bindableSchema(z.string()))) + .array(bindableNameStringValueSchema) .optional() .describe('Extra url query parameters.'), body: fetchBodySchema.optional().describe('The request body.'), @@ -111,8 +143,11 @@ const fetchQueryConfigSchema = z.object({ export type FetchQueryConfig = z.infer; const localQueryConfigSchema = z.object({ - kind: z.literal('local'), - function: z.string().optional(), + kind: z.literal('local').describe('Designates this object as a local function query.'), + function: z + .string() + .optional() + .describe('The function to be executed on the backend by this query.'), }); export type LocalQueryConfig = z.infer; @@ -126,12 +161,16 @@ export type QueryConfig = z.infer; const querySchema = z.object({ name: z.string().describe('A name for the query'), - enabled: bindableSchema(z.boolean()).optional().describe('This query is active'), + enabled: bindableSchema(z.boolean()) + .optional() + .describe( + "Activates or deactivates the query. When deactivated the data won't be loaded when the page opens.", + ), parameters: z .array(nameValuePairSchema(bindableSchema(z.any()))) .optional() - .describe('Parameters to pass to this query'), - mode: fetchModeSchema.optional().describe('How to fetch this query'), + .describe('Parameters to pass to this query.'), + mode: fetchModeSchema.optional().describe('How to fetch this query.'), query: queryConfigSchema.optional().describe('Query definition'), transform: z.string().optional().describe('Transformation to run on the response'), transformEnabled: z.boolean().optional().describe('Enable the transformation'), @@ -147,25 +186,41 @@ export type Template = { let elementSchema: z.ZodType; -const templateSchema: z.ZodType