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
}