diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 4af1dcc0ee..a10f493de0 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -386,7 +386,7 @@ editableColumn.relationshipType = RelationshipType.MANY_TO_MANY } else if (editableColumn.type === FieldType.FORMULA) { editableColumn.formulaType = "dynamic" - editableColumn.responseType = field.responseType || FIELDS.STRING.type + editableColumn.responseType = field?.responseType || FIELDS.STRING.type } } diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 2acde47539..fbf74d1e9b 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -40,16 +40,19 @@ indentMore, indentLess, } from "@codemirror/commands" + import { setDiagnostics } from "@codemirror/lint" import { Compartment, EditorState } from "@codemirror/state" + import type { Extension } from "@codemirror/state" import { javascript } from "@codemirror/lang-javascript" import { EditorModes } from "./" import { themeStore } from "@/stores/portal" import type { EditorMode } from "@budibase/types" - import type { BindingCompletion } from "@/types" + import type { BindingCompletion, CodeValidator } from "@/types" + import { validateHbsTemplate } from "./validator/hbs" export let label: string | undefined = undefined - // TODO: work out what best type fits this export let completions: BindingCompletion[] = [] + export let validations: CodeValidator | null = null export let mode: EditorMode = EditorModes.Handlebars export let value: string | null = "" export let placeholder: string | null = null @@ -248,7 +251,7 @@ // None of this is reactive, but it never has been, so we just assume most // config flags aren't changed at runtime // TODO: work out type for base - const buildExtensions = (base: any[]) => { + const buildExtensions = (base: Extension[]) => { let complete = [...base] if (autocompleteEnabled) { @@ -340,6 +343,24 @@ return complete } + function validate( + value: string | null, + editor: EditorView | undefined, + mode: EditorMode, + validations: CodeValidator | null + ) { + if (!value || !validations || !editor) { + return + } + + if (mode === EditorModes.Handlebars) { + const diagnostics = validateHbsTemplate(value, validations) + editor.dispatch(setDiagnostics(editor.state, diagnostics)) + } + } + + $: validate(value, editor, mode, validations) + const initEditor = () => { const baseExtensions = buildBaseExtensions() @@ -366,7 +387,6 @@ {/if} -
diff --git a/packages/builder/src/components/common/CodeEditor/index.ts b/packages/builder/src/components/common/CodeEditor/index.ts index 5529484665..924b4e1b53 100644 --- a/packages/builder/src/components/common/CodeEditor/index.ts +++ b/packages/builder/src/components/common/CodeEditor/index.ts @@ -83,6 +83,8 @@ const helpersToCompletion = ( const helper = helpers[helperName] return { label: helperName, + args: helper.args, + requiresBlock: helper.requiresBlock, info: () => buildHelperInfoNode(helper), type: "helper", section: helperSection, @@ -136,9 +138,13 @@ export const hbAutocomplete = ( baseCompletions: BindingCompletionOption[] ): BindingCompletion => { function coreCompletion(context: CompletionContext) { - let bindingStart = context.matchBefore(EditorModes.Handlebars.match) + if (!baseCompletions.length) { + return null + } - let options = baseCompletions || [] + const bindingStart = context.matchBefore(EditorModes.Handlebars.match) + + const options = baseCompletions if (!bindingStart) { return null @@ -149,7 +155,7 @@ export const hbAutocomplete = ( return null } const query = bindingStart.text.replace(match[0], "") - let filtered = bindingFilter(options, query) + const filtered = bindingFilter(options, query) return { from: bindingStart.from + match[0].length, @@ -169,8 +175,12 @@ export const jsAutocomplete = ( baseCompletions: BindingCompletionOption[] ): BindingCompletion => { function coreCompletion(context: CompletionContext) { - let jsBinding = wrappedAutocompleteMatch(context) - let options = baseCompletions || [] + if (!baseCompletions.length) { + return null + } + + const jsBinding = wrappedAutocompleteMatch(context) + const options = baseCompletions if (jsBinding) { // Accommodate spaces @@ -209,6 +219,10 @@ function setAutocomplete( options: BindingCompletionOption[] ): BindingCompletion { return function (context: CompletionContext) { + if (!options.length) { + return null + } + if (wrappedAutocompleteMatch(context)) { return null } diff --git a/packages/builder/src/components/common/CodeEditor/validator/hbs.ts b/packages/builder/src/components/common/CodeEditor/validator/hbs.ts new file mode 100644 index 0000000000..c2b3b464da --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/validator/hbs.ts @@ -0,0 +1,113 @@ +/* global hbs */ +import Handlebars from "handlebars" +import type { Diagnostic } from "@codemirror/lint" +import { CodeValidator } from "@/types" + +function isMustacheStatement( + node: hbs.AST.Statement +): node is hbs.AST.MustacheStatement { + return node.type === "MustacheStatement" +} + +function isBlockStatement( + node: hbs.AST.Statement +): node is hbs.AST.BlockStatement { + return node.type === "BlockStatement" +} + +function isPathExpression( + node: hbs.AST.Statement +): node is hbs.AST.PathExpression { + return node.type === "PathExpression" +} + +export function validateHbsTemplate( + text: string, + validations: CodeValidator +): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + + try { + const ast = Handlebars.parse(text, {}) + + const lineOffsets: number[] = [] + let offset = 0 + for (const line of text.split("\n")) { + lineOffsets.push(offset) + offset += line.length + 1 // +1 for newline character + } + + function traverseNodes( + nodes: hbs.AST.Statement[], + options?: { + ignoreMissing?: boolean + } + ) { + const ignoreMissing = options?.ignoreMissing || false + nodes.forEach(node => { + if (isMustacheStatement(node) && isPathExpression(node.path)) { + const helperName = node.path.original + + const from = + lineOffsets[node.loc.start.line - 1] + node.loc.start.column + const to = lineOffsets[node.loc.end.line - 1] + node.loc.end.column + + if (!(helperName in validations)) { + if (!ignoreMissing) { + diagnostics.push({ + from, + to, + severity: "warning", + message: `"${helperName}" handler does not exist.`, + }) + } + return + } + + const { arguments: expectedArguments = [], requiresBlock } = + validations[helperName] + + if (requiresBlock && !isBlockStatement(node)) { + diagnostics.push({ + from, + to, + severity: "error", + message: `Helper "${helperName}" requires a body:\n{{#${helperName} ...}} [body] {{/${helperName}}}`, + }) + return + } + + const providedParams = node.params + + if (providedParams.length !== expectedArguments.length) { + diagnostics.push({ + from, + to, + severity: "error", + message: `Helper "${helperName}" expects ${ + expectedArguments.length + } parameters (${expectedArguments.join(", ")}), but got ${ + providedParams.length + }.`, + }) + } + } + + if (isBlockStatement(node)) { + traverseNodes(node.program.body, { ignoreMissing: true }) + } + }) + } + + traverseNodes(ast.body, { ignoreMissing: true }) + } catch (e: any) { + diagnostics.push({ + from: 0, + to: text.length, + severity: "error", + message: `The handlebars code is not valid:\n${e.message}`, + }) + } + + return diagnostics +} diff --git a/packages/builder/src/components/common/CodeEditor/validator/tests/hbs.spec.ts b/packages/builder/src/components/common/CodeEditor/validator/tests/hbs.spec.ts new file mode 100644 index 0000000000..9484c7a4a5 --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/validator/tests/hbs.spec.ts @@ -0,0 +1,142 @@ +import { validateHbsTemplate } from "../hbs" +import { CodeValidator } from "@/types" + +describe("hbs validator", () => { + it("validate empty strings", () => { + const text = "" + const validators = {} + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("validate strings without hbs expressions", () => { + const text = "first line\nand another one" + const validators = {} + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + describe("basic expressions", () => { + const validators = { + fieldName: {}, + } + + it("validate valid expressions", () => { + const text = "{{ fieldName }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("does not throw on missing validations", () => { + const text = "{{ anotherFieldName }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + // Waiting for missing fields validation + it.skip("throws on untrimmed invalid expressions", () => { + const text = " {{ anotherFieldName }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 4, + message: `"anotherFieldName" handler does not exist.`, + severity: "warning", + to: 26, + }, + ]) + }) + + // Waiting for missing fields validation + it.skip("throws on invalid expressions between valid lines", () => { + const text = + "literal expression\nthe value is {{ anotherFieldName }}\nanother expression" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 32, + message: `"anotherFieldName" handler does not exist.`, + severity: "warning", + to: 54, + }, + ]) + }) + + describe("expressions with whitespaces", () => { + const validators = { + [`field name`]: {}, + } + + it("validates expressions with whitespaces", () => { + const text = `{{ [field name] }}` + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + // Waiting for missing fields validation + it.skip("throws if not wrapped between brackets", () => { + const text = `{{ field name }}` + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `"field" handler does not exist.`, + severity: "warning", + to: 16, + }, + ]) + }) + }) + }) + + describe("expressions with parameters", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "c"], + }, + } + + it("validate valid params", () => { + const text = "{{ helperFunction 1 99 'a' }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("throws on too few params", () => { + const text = "{{ helperFunction 100 }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "helperFunction" expects 3 parameters (a, b, c), but got 1.`, + severity: "error", + to: 24, + }, + ]) + }) + + it("throws on too many params", () => { + const text = "{{ helperFunction 1 99 'a' 100 }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "helperFunction" expects 3 parameters (a, b, c), but got 4.`, + severity: "error", + to: 34, + }, + ]) + }) + }) +}) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 4bd37bf72c..2c35acdf2d 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -42,7 +42,7 @@ JSONValue, } from "@budibase/types" import type { Log } from "@budibase/string-templates" - import type { BindingCompletion, BindingCompletionOption } from "@/types" + import type { CodeValidator } from "@/types" const dispatch = createEventDispatcher() @@ -58,7 +58,7 @@ export let placeholder = null export let showTabBar = true - let mode: BindingMode | null + let mode: BindingMode let sidePanel: SidePanel | null let initialValueJS = value?.startsWith?.("{{ js ") let jsValue: string | null = initialValueJS ? value : null @@ -88,13 +88,37 @@ | null $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: requestEval(runtimeExpression, context, snippets) - $: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) - $: hbsCompletions = getHBSCompletions(bindingCompletions) - $: jsCompletions = getJSCompletions(bindingCompletions, snippets, { - useHelpers: allowHelpers, - useSnippets, - }) + + $: bindingOptions = bindingsToCompletions(bindings, editorMode) + $: helperOptions = allowHelpers ? getHelperCompletions(editorMode) : [] + $: snippetsOptions = + usingJS && useSnippets && snippets?.length ? snippets : [] + + $: completions = !usingJS + ? [hbAutocomplete([...bindingOptions, ...helperOptions])] + : [ + jsAutocomplete(bindingOptions), + jsHelperAutocomplete(helperOptions), + snippetAutoComplete(snippetsOptions), + ] + + $: validations = { + ...bindingOptions.reduce((validations, option) => { + validations[option.label] = { + arguments: [], + } + return validations + }, {}), + ...helperOptions.reduce((validations, option) => { + validations[option.label] = { + arguments: option.args, + requiresBlock: option.requiresBlock, + } + return validations + }, {}), + } + $: { // Ensure a valid side panel option is always selected if (sidePanel && !sidePanelOptions.includes(sidePanel)) { @@ -102,38 +126,6 @@ } } - const getHBSCompletions = (bindingCompletions: BindingCompletionOption[]) => { - return [ - hbAutocomplete([ - ...bindingCompletions, - ...getHelperCompletions(EditorModes.Handlebars), - ]), - ] - } - - const getJSCompletions = ( - bindingCompletions: BindingCompletionOption[], - snippets: Snippet[] | null, - config: { - useHelpers: boolean - useSnippets: boolean - } - ) => { - const completions: BindingCompletion[] = [] - if (bindingCompletions.length) { - completions.push(jsAutocomplete([...bindingCompletions])) - } - if (config.useHelpers) { - completions.push( - jsHelperAutocomplete([...getHelperCompletions(EditorModes.JS)]) - ) - } - if (config.useSnippets && snippets) { - completions.push(snippetAutoComplete(snippets)) - } - return completions - } - const getModeOptions = (allowHBS: boolean, allowJS: boolean) => { let options = [] if (allowHBS) { @@ -213,7 +205,7 @@ bindings: EnrichedBinding[], context: any, snippets: Snippet[] | null - ) => { + ): EnrichedBinding[] => { // Create a single big array to enrich in one go const bindingStrings = bindings.map(binding => { if (binding.runtimeBinding.startsWith('trim "')) { @@ -290,7 +282,7 @@ jsValue = null hbsValue = null updateValue(null) - mode = targetMode + mode = targetMode! targetMode = null } @@ -365,13 +357,14 @@ {/if}
{#if mode === BindingMode.Text} - {#key hbsCompletions} + {#key completions} {/key} {:else if mode === BindingMode.JavaScript} - {#key jsCompletions} + {#key completions} 0} + jsBindingWrapping={completions.length > 0} /> {/key} {/if} diff --git a/packages/builder/src/types/bindings.ts b/packages/builder/src/types/bindings.ts index 00571f1d8b..6ae5ddab88 100644 --- a/packages/builder/src/types/bindings.ts +++ b/packages/builder/src/types/bindings.ts @@ -5,4 +5,15 @@ export type BindingCompletion = (context: CompletionContext) => { options: Completion[] } | null -export type BindingCompletionOption = Completion +export interface BindingCompletionOption extends Completion { + args?: any[] + requiresBlock?: boolean +} + +export type CodeValidator = Record< + string, + { + arguments?: any[] + requiresBlock?: boolean + } +> diff --git a/packages/builder/tsconfig.json b/packages/builder/tsconfig.json index d698e2fe1d..a9e640123f 100644 --- a/packages/builder/tsconfig.json +++ b/packages/builder/tsconfig.json @@ -14,5 +14,6 @@ "assets/*": ["assets/*"], "@/*": ["src/*"] } - } + }, + "exclude": [] } diff --git a/packages/types/src/ui/bindings/helper.ts b/packages/types/src/ui/bindings/helper.ts index 5ebd305958..e772180264 100644 --- a/packages/types/src/ui/bindings/helper.ts +++ b/packages/types/src/ui/bindings/helper.ts @@ -1,4 +1,6 @@ export interface Helper { example: string description: string + args: any[] + requiresBlock?: boolean }