Merge branch 'master' into fix/relationship-select

This commit is contained in:
Michael Drury 2025-02-17 14:42:18 +00:00 committed by GitHub
commit 79c960d86f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 672 additions and 153 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.4.9", "version": "3.4.11",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

View File

@ -67,6 +67,15 @@ describe("utils", () => {
}) })
}) })
it("gets appId from query params", async () => {
const ctx = structures.koa.newContext()
const expected = db.generateAppID()
ctx.query = { appId: expected }
const actual = await utils.getAppIdFromCtx(ctx)
expect(actual).toBe(expected)
})
it("doesn't get appId from url when previewing", async () => { it("doesn't get appId from url when previewing", async () => {
const ctx = structures.koa.newContext() const ctx = structures.koa.newContext()
const appId = db.generateAppID() const appId = db.generateAppID()

View File

@ -101,6 +101,11 @@ export async function getAppIdFromCtx(ctx: Ctx) {
appId = confirmAppId(pathId) appId = confirmAppId(pathId)
} }
// look in queryParams
if (!appId && ctx.query?.appId) {
appId = confirmAppId(ctx.query?.appId as string)
}
// lookup using custom url - prod apps only // lookup using custom url - prod apps only
// filter out the builder preview path which collides with the prod app path // filter out the builder preview path which collides with the prod app path
// to ensure we don't load all apps excessively // to ensure we don't load all apps excessively

View File

@ -386,7 +386,7 @@
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} else if (editableColumn.type === FieldType.FORMULA) { } else if (editableColumn.type === FieldType.FORMULA) {
editableColumn.formulaType = "dynamic" editableColumn.formulaType = "dynamic"
editableColumn.responseType = field.responseType || FIELDS.STRING.type editableColumn.responseType = field?.responseType || FIELDS.STRING.type
} }
} }

View File

@ -40,16 +40,19 @@
indentMore, indentMore,
indentLess, indentLess,
} from "@codemirror/commands" } from "@codemirror/commands"
import { setDiagnostics } from "@codemirror/lint"
import { Compartment, EditorState } from "@codemirror/state" import { Compartment, EditorState } from "@codemirror/state"
import type { Extension } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./" import { EditorModes } from "./"
import { themeStore } from "@/stores/portal" import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types" 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 export let label: string | undefined = undefined
// TODO: work out what best type fits this
export let completions: BindingCompletion[] = [] export let completions: BindingCompletion[] = []
export let validations: CodeValidator | null = null
export let mode: EditorMode = EditorModes.Handlebars export let mode: EditorMode = EditorModes.Handlebars
export let value: string | null = "" export let value: string | null = ""
export let placeholder: string | null = 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 // None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime // config flags aren't changed at runtime
// TODO: work out type for base // TODO: work out type for base
const buildExtensions = (base: any[]) => { const buildExtensions = (base: Extension[]) => {
let complete = [...base] let complete = [...base]
if (autocompleteEnabled) { if (autocompleteEnabled) {
@ -340,6 +343,24 @@
return complete 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 initEditor = () => {
const baseExtensions = buildBaseExtensions() const baseExtensions = buildBaseExtensions()
@ -366,7 +387,6 @@
<Label size="S">{label}</Label> <Label size="S">{label}</Label>
</div> </div>
{/if} {/if}
<div class={`code-editor ${mode?.name || ""}`}> <div class={`code-editor ${mode?.name || ""}`}>
<div tabindex="-1" bind:this={textarea} /> <div tabindex="-1" bind:this={textarea} />
</div> </div>

View File

@ -83,6 +83,8 @@ const helpersToCompletion = (
const helper = helpers[helperName] const helper = helpers[helperName]
return { return {
label: helperName, label: helperName,
args: helper.args,
requiresBlock: helper.requiresBlock,
info: () => buildHelperInfoNode(helper), info: () => buildHelperInfoNode(helper),
type: "helper", type: "helper",
section: helperSection, section: helperSection,
@ -136,9 +138,13 @@ export const hbAutocomplete = (
baseCompletions: BindingCompletionOption[] baseCompletions: BindingCompletionOption[]
): BindingCompletion => { ): BindingCompletion => {
function coreCompletion(context: CompletionContext) { 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) { if (!bindingStart) {
return null return null
@ -149,7 +155,7 @@ export const hbAutocomplete = (
return null return null
} }
const query = bindingStart.text.replace(match[0], "") const query = bindingStart.text.replace(match[0], "")
let filtered = bindingFilter(options, query) const filtered = bindingFilter(options, query)
return { return {
from: bindingStart.from + match[0].length, from: bindingStart.from + match[0].length,
@ -169,8 +175,12 @@ export const jsAutocomplete = (
baseCompletions: BindingCompletionOption[] baseCompletions: BindingCompletionOption[]
): BindingCompletion => { ): BindingCompletion => {
function coreCompletion(context: CompletionContext) { function coreCompletion(context: CompletionContext) {
let jsBinding = wrappedAutocompleteMatch(context) if (!baseCompletions.length) {
let options = baseCompletions || [] return null
}
const jsBinding = wrappedAutocompleteMatch(context)
const options = baseCompletions
if (jsBinding) { if (jsBinding) {
// Accommodate spaces // Accommodate spaces
@ -209,6 +219,10 @@ function setAutocomplete(
options: BindingCompletionOption[] options: BindingCompletionOption[]
): BindingCompletion { ): BindingCompletion {
return function (context: CompletionContext) { return function (context: CompletionContext) {
if (!options.length) {
return null
}
if (wrappedAutocompleteMatch(context)) { if (wrappedAutocompleteMatch(context)) {
return null return null
} }

View File

@ -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
}

View File

@ -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,
},
])
})
})
})

View File

@ -42,7 +42,7 @@
JSONValue, JSONValue,
} from "@budibase/types" } from "@budibase/types"
import type { Log } from "@budibase/string-templates" import type { Log } from "@budibase/string-templates"
import type { BindingCompletion, BindingCompletionOption } from "@/types" import type { CodeValidator } from "@/types"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -58,7 +58,7 @@
export let placeholder = null export let placeholder = null
export let showTabBar = true export let showTabBar = true
let mode: BindingMode | null let mode: BindingMode
let sidePanel: SidePanel | null let sidePanel: SidePanel | null
let initialValueJS = value?.startsWith?.("{{ js ") let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue: string | null = initialValueJS ? value : null let jsValue: string | null = initialValueJS ? value : null
@ -88,13 +88,37 @@
| null | null
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets) $: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
$: hbsCompletions = getHBSCompletions(bindingCompletions)
$: jsCompletions = getJSCompletions(bindingCompletions, snippets, { $: bindingOptions = bindingsToCompletions(bindings, editorMode)
useHelpers: allowHelpers, $: helperOptions = allowHelpers ? getHelperCompletions(editorMode) : []
useSnippets, $: snippetsOptions =
}) usingJS && useSnippets && snippets?.length ? snippets : []
$: completions = !usingJS
? [hbAutocomplete([...bindingOptions, ...helperOptions])]
: [
jsAutocomplete(bindingOptions),
jsHelperAutocomplete(helperOptions),
snippetAutoComplete(snippetsOptions),
]
$: validations = {
...bindingOptions.reduce<CodeValidator>((validations, option) => {
validations[option.label] = {
arguments: [],
}
return validations
}, {}),
...helperOptions.reduce<CodeValidator>((validations, option) => {
validations[option.label] = {
arguments: option.args,
requiresBlock: option.requiresBlock,
}
return validations
}, {}),
}
$: { $: {
// Ensure a valid side panel option is always selected // Ensure a valid side panel option is always selected
if (sidePanel && !sidePanelOptions.includes(sidePanel)) { 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) => { const getModeOptions = (allowHBS: boolean, allowJS: boolean) => {
let options = [] let options = []
if (allowHBS) { if (allowHBS) {
@ -213,7 +205,7 @@
bindings: EnrichedBinding[], bindings: EnrichedBinding[],
context: any, context: any,
snippets: Snippet[] | null snippets: Snippet[] | null
) => { ): EnrichedBinding[] => {
// Create a single big array to enrich in one go // Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => { const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) { if (binding.runtimeBinding.startsWith('trim "')) {
@ -290,7 +282,7 @@
jsValue = null jsValue = null
hbsValue = null hbsValue = null
updateValue(null) updateValue(null)
mode = targetMode mode = targetMode!
targetMode = null targetMode = null
} }
@ -365,13 +357,14 @@
{/if} {/if}
<div class="editor"> <div class="editor">
{#if mode === BindingMode.Text} {#if mode === BindingMode.Text}
{#key hbsCompletions} {#key completions}
<CodeEditor <CodeEditor
value={hbsValue} value={hbsValue}
on:change={onChangeHBSValue} on:change={onChangeHBSValue}
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
completions={hbsCompletions} {completions}
{validations}
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder={placeholder || placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"} "Add bindings by typing {{ or use the menu on the right"}
@ -379,18 +372,18 @@
/> />
{/key} {/key}
{:else if mode === BindingMode.JavaScript} {:else if mode === BindingMode.JavaScript}
{#key jsCompletions} {#key completions}
<CodeEditor <CodeEditor
value={jsValue ? decodeJSBinding(jsValue) : jsValue} value={jsValue ? decodeJSBinding(jsValue) : jsValue}
on:change={onChangeJSValue} on:change={onChangeJSValue}
completions={jsCompletions} {completions}
mode={EditorModes.JS} mode={EditorModes.JS}
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder={placeholder || placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"} "Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping={bindingCompletions.length > 0} jsBindingWrapping={completions.length > 0}
/> />
{/key} {/key}
{/if} {/if}

View File

@ -43,7 +43,6 @@
<EditComponentPopover <EditComponentPopover
{anchor} {anchor}
componentInstance={item} componentInstance={item}
{componentBindings}
{bindings} {bindings}
on:change on:change
parseSettings={updatedNestedFlags} parseSettings={updatedNestedFlags}

View File

@ -1,13 +1,13 @@
<script> <script>
import { Icon, Popover, Layout } from "@budibase/bbui" import { Icon, Popover, Layout } from "@budibase/bbui"
import { componentStore } from "@/stores/builder" import { componentStore, selectedScreen } from "@/stores/builder"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import ComponentSettingsSection from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte" import ComponentSettingsSection from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import { getComponentBindableProperties } from "@/dataBinding"
export let anchor export let anchor
export let componentInstance export let componentInstance
export let componentBindings
export let bindings export let bindings
export let parseSettings export let parseSettings
@ -28,6 +28,10 @@
} }
$: componentDef = componentStore.getDefinition(componentInstance._component) $: componentDef = componentStore.getDefinition(componentInstance._component)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef) $: parsedComponentDef = processComponentDefinitionSettings(componentDef)
$: componentBindings = getComponentBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
const open = () => { const open = () => {
isOpen = true isOpen = true

View File

@ -45,7 +45,6 @@
<EditComponentPopover <EditComponentPopover
{anchor} {anchor}
componentInstance={item} componentInstance={item}
{componentBindings}
{bindings} {bindings}
{parseSettings} {parseSettings}
on:change on:change

View File

@ -22,25 +22,59 @@
export let propertyFocus = false export let propertyFocus = false
export let info = null export let info = null
export let disableBindings = false export let disableBindings = false
export let wide export let wide = false
export let contextAccess = null
let highlightType let highlightType
let domElement let domElement
$: highlightedProp = $builderStore.highlightedSetting $: highlightedProp = $builderStore.highlightedSetting
$: allBindings = getAllBindings(bindings, componentBindings, nested) $: allBindings = getAllBindings(
bindings,
componentBindings,
nested,
contextAccess
)
$: safeValue = getSafeValue(value, defaultValue, allBindings) $: safeValue = getSafeValue(value, defaultValue, allBindings)
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val) $: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
$: isHighlighted = highlightedProp?.key === key $: isHighlighted = highlightedProp?.key === key
$: highlightType = isHighlighted ? `highlighted-${highlightedProp?.type}` : "" $: highlightType = isHighlighted ? `highlighted-${highlightedProp?.type}` : ""
$: highlightedProp && isHighlighted && scrollToElement(domElement)
const getAllBindings = (bindings, componentBindings, nested) => { const getAllBindings = (
if (!nested) { bindings,
componentBindings,
nested,
contextAccess
) => {
// contextAccess is a bit of an escape hatch to get around how we render
// certain settings types by using a pseudo component definition, leading
// to problems with the nested flag
if (contextAccess != null) {
// Optionally include global bindings
let allBindings = contextAccess.global ? bindings : []
// Optionally include or exclude self (component) bindings.
// If this is a nested setting then we will already have our own context
// bindings mixed in, so if we don't want self context we need to filter
// them out.
if (contextAccess.self) {
return [...allBindings, ...componentBindings]
} else {
return allBindings.filter(binding => {
return !componentBindings.some(componentBinding => {
return componentBinding.runtimeBinding === binding.runtimeBinding
})
})
}
}
// Otherwise just honour the normal nested flag
if (nested) {
return [...bindings, ...componentBindings]
} else {
return bindings return bindings
} }
return [...(componentBindings || []), ...(bindings || [])]
} }
// Handle a value change of any type // Handle a value change of any type
@ -81,8 +115,6 @@
block: "center", block: "center",
}) })
} }
$: highlightedProp && isHighlighted && scrollToElement(domElement)
</script> </script>
<div <div

View File

@ -147,6 +147,7 @@
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}
{bindings} {bindings}
{componentBindings}
/> />
{/if} {/if}
</Panel> </Panel>

View File

@ -151,6 +151,7 @@
propertyFocus={$builderStore.propertyFocus === setting.key} propertyFocus={$builderStore.propertyFocus === setting.key}
info={setting.info} info={setting.info}
disableBindings={setting.disableBindings} disableBindings={setting.disableBindings}
contextAccess={setting.contextAccess}
props={{ props={{
// Generic settings // Generic settings
placeholder: setting.placeholder || null, placeholder: setting.placeholder || null,

View File

@ -19,6 +19,7 @@
export let conditions = [] export let conditions = []
export let bindings = [] export let bindings = []
export let componentBindings = []
const flipDurationMs = 150 const flipDurationMs = 150
const actionOptions = [ const actionOptions = [
@ -55,6 +56,7 @@
] ]
let dragDisabled = true let dragDisabled = true
$: settings = componentStore $: settings = componentStore
.getComponentSettings($selectedComponent?._component) .getComponentSettings($selectedComponent?._component)
?.concat({ ?.concat({
@ -213,7 +215,10 @@
options: definition.options, options: definition.options,
placeholder: definition.placeholder, placeholder: definition.placeholder,
}} }}
nested={definition.nested}
contextAccess={definition.contextAccess}
{bindings} {bindings}
{componentBindings}
/> />
{:else} {:else}
<Select disabled placeholder=" " /> <Select disabled placeholder=" " />

View File

@ -64,7 +64,12 @@
Show, hide and update components in response to conditions being met. Show, hide and update components in response to conditions being met.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={() => save()}>Save</Button> <Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} /> <ConditionalUIDrawer
slot="body"
bind:conditions={tempValue}
{bindings}
{componentBindings}
/>
</Drawer> </Drawer>
<style> <style>

View File

@ -1,12 +1,8 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { findComponentParent, findComponentPath } from "@/helpers/components" import { findComponentParent, findComponentPath } from "@/helpers/components"
import { selectedScreen, componentStore } from "@/stores/builder" import { selectedScreen, componentStore } from "@/stores/builder"
import { DropPosition } from "@budibase/types"
export const DropPosition = { export { DropPosition } from "@budibase/types"
ABOVE: "above",
BELOW: "below",
INSIDE: "inside",
}
const initialState = { const initialState = {
source: null, source: null,

View File

@ -1,20 +1,20 @@
import { layoutStore } from "./layouts.js" import { layoutStore } from "./layouts"
import { appStore } from "./app.js" import { appStore } from "./app"
import { componentStore, selectedComponent } from "./components" import { componentStore, selectedComponent } from "./components"
import { navigationStore } from "./navigation.js" import { navigationStore } from "./navigation"
import { themeStore } from "./theme.js" import { themeStore } from "./theme"
import { screenStore, selectedScreen, sortedScreens } from "./screens" import { screenStore, selectedScreen, sortedScreens } from "./screens"
import { builderStore } from "./builder.js" import { builderStore } from "./builder"
import { hoverStore } from "./hover.js" import { hoverStore } from "./hover"
import { previewStore } from "./preview.js" import { previewStore } from "./preview"
import { import {
automationStore, automationStore,
selectedAutomation, selectedAutomation,
automationHistoryStore, automationHistoryStore,
} from "./automations.js" } from "./automations"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js" import { userStore, userSelectedResourceMap, isOnlyUser } from "./users"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments"
import { contextMenuStore } from "./contextMenu.js" import { contextMenuStore } from "./contextMenu"
import { snippets } from "./snippets" import { snippets } from "./snippets"
import { import {
screenComponentsList, screenComponentsList,

View File

@ -5,4 +5,15 @@ export type BindingCompletion = (context: CompletionContext) => {
options: Completion[] options: Completion[]
} | null } | null
export type BindingCompletionOption = Completion export interface BindingCompletionOption extends Completion {
args?: any[]
requiresBlock?: boolean
}
export type CodeValidator = Record<
string,
{
arguments?: any[]
requiresBlock?: boolean
}
>

View File

@ -14,5 +14,6 @@
"assets/*": ["assets/*"], "assets/*": ["assets/*"],
"@/*": ["src/*"] "@/*": ["src/*"]
} }
} },
"exclude": []
} }

View File

@ -3089,7 +3089,21 @@
{ {
"type": "tableConditions", "type": "tableConditions",
"label": "Conditions", "label": "Conditions",
"key": "conditions" "key": "conditions",
"contextAccess": {
"global": true,
"self": false
}
},
{
"type": "text",
"label": "Format",
"key": "format",
"info": "Changing format will display values as text",
"contextAccess": {
"global": false,
"self": true
}
} }
] ]
}, },
@ -7686,7 +7700,8 @@
{ {
"type": "columns/grid", "type": "columns/grid",
"key": "columns", "key": "columns",
"resetOn": "table" "resetOn": "table",
"nested": true
} }
] ]
}, },

View File

@ -1,6 +1,6 @@
<script> <script>
import { getContext, onDestroy, onMount, setContext } from "svelte" import { getContext, onDestroy, onMount, setContext } from "svelte"
import { builderStore } from "@/stores/builder.js" import { builderStore } from "@/stores/builder"
import { blockStore } from "@/stores/blocks" import { blockStore } from "@/stores/blocks"
const component = getContext("component") const component = getContext("component")

View File

@ -1,7 +1,7 @@
<script> <script>
import { getContext, onDestroy } from "svelte" import { getContext, onDestroy } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { builderStore } from "../stores/builder.js" import { builderStore } from "../stores/builder"
import Component from "@/components/Component.svelte" import Component from "@/components/Component.svelte"
export let type export let type

View File

@ -5,7 +5,7 @@
import { get, derived, readable } from "svelte/store" import { get, derived, readable } from "svelte/store"
import { featuresStore } from "@/stores" import { featuresStore } from "@/stores"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
// import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
// table is actually any datasource, but called table for legacy compatibility // table is actually any datasource, but called table for legacy compatibility
export let table export let table
@ -47,8 +47,8 @@
$: currentTheme = $context?.device?.theme $: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light") $: darkMode = !currentTheme?.includes("light")
$: parsedColumns = getParsedColumns(columns) $: parsedColumns = getParsedColumns(columns)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons) $: enrichedButtons = enrichButtons(buttons)
$: schemaOverrides = getSchemaOverrides(parsedColumns, $context)
$: selectedRows = deriveSelectedRows(gridContext) $: selectedRows = deriveSelectedRows(gridContext)
$: styles = patchStyles($component.styles, minHeight) $: styles = patchStyles($component.styles, minHeight)
$: data = { selectedRows: $selectedRows } $: data = { selectedRows: $selectedRows }
@ -97,15 +97,19 @@
})) }))
} }
const getSchemaOverrides = columns => { const getSchemaOverrides = (columns, context) => {
let overrides = {} let overrides = {}
columns.forEach((column, idx) => { columns.forEach((column, idx) => {
overrides[column.field] = { overrides[column.field] = {
displayName: column.label, displayName: column.label,
order: idx, order: idx,
conditions: column.conditions,
visible: !!column.active, visible: !!column.active,
// format: createFormatter(column), conditions: enrichConditions(column.conditions, context),
format: createFormatter(column),
// Small hack to ensure we react to all changes, as our
// memoization cannot compare differences in functions
rand: column.conditions?.length ? Math.random() : null,
} }
if (column.width) { if (column.width) {
overrides[column.field].width = column.width overrides[column.field].width = column.width
@ -114,12 +118,24 @@
return overrides return overrides
} }
// const createFormatter = column => { const enrichConditions = (conditions, context) => {
// if (typeof column.format !== "string" || !column.format.trim().length) { return conditions?.map(condition => {
// return null return {
// } ...condition,
// return row => processStringSync(column.format, { [id]: row }) referenceValue: processStringSync(
// } condition.referenceValue || "",
context
),
}
})
}
const createFormatter = column => {
if (typeof column.format !== "string" || !column.format.trim().length) {
return null
}
return row => processStringSync(column.format, { [id]: row })
}
const enrichButtons = buttons => { const enrichButtons = buttons => {
if (!buttons?.length) { if (!buttons?.length) {

View File

@ -2,7 +2,7 @@
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { CoreDropzone, ProgressCircle, Helpers } from "@budibase/bbui" import { CoreDropzone, ProgressCircle, Helpers } from "@budibase/bbui"
import { getContext, onMount, onDestroy } from "svelte" import { getContext, onMount, onDestroy } from "svelte"
import { builderStore } from "@/stores/builder.js" import { builderStore } from "@/stores/builder"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
export let datasourceId export let datasourceId

View File

@ -19,7 +19,6 @@ import type { ActionTypes } from "@/constants"
import { Readable } from "svelte/store" import { Readable } from "svelte/store"
import { import {
Screen, Screen,
Layout,
Theme, Theme,
AppCustomTheme, AppCustomTheme,
PreviewDevice, PreviewDevice,
@ -48,7 +47,6 @@ declare global {
// Data from builder // Data from builder
"##BUDIBASE_APP_ID##"?: string "##BUDIBASE_APP_ID##"?: string
"##BUDIBASE_IN_BUILDER##"?: true "##BUDIBASE_IN_BUILDER##"?: true
"##BUDIBASE_PREVIEW_LAYOUT##"?: Layout
"##BUDIBASE_PREVIEW_SCREEN##"?: Screen "##BUDIBASE_PREVIEW_SCREEN##"?: Screen
"##BUDIBASE_SELECTED_COMPONENT_ID##"?: string "##BUDIBASE_SELECTED_COMPONENT_ID##"?: string
"##BUDIBASE_PREVIEW_ID##"?: number "##BUDIBASE_PREVIEW_ID##"?: number
@ -59,13 +57,8 @@ declare global {
"##BUDIBASE_PREVIEW_NAVIGATION##"?: AppNavigation "##BUDIBASE_PREVIEW_NAVIGATION##"?: AppNavigation
"##BUDIBASE_HIDDEN_COMPONENT_IDS##"?: string[] "##BUDIBASE_HIDDEN_COMPONENT_IDS##"?: string[]
"##BUDIBASE_USED_PLUGINS##"?: Plugin[] "##BUDIBASE_USED_PLUGINS##"?: Plugin[]
"##BUDIBASE_LOCATION##"?: {
protocol: string
hostname: string
port: string
}
"##BUDIBASE_SNIPPETS##"?: Snippet[] "##BUDIBASE_SNIPPETS##"?: Snippet[]
"##BUDIBASE_COMPONENT_ERRORS##"?: Record<string, UIComponentError>[] "##BUDIBASE_COMPONENT_ERRORS##"?: Record<string, UIComponentError[]>
"##BUDIBASE_CUSTOM_COMPONENTS##"?: CustomComponent[] "##BUDIBASE_CUSTOM_COMPONENTS##"?: CustomComponent[]
// Other flags // Other flags
@ -115,7 +108,6 @@ const loadBudibase = async () => {
builderStore.set({ builderStore.set({
...get(builderStore), ...get(builderStore),
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"], inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"], screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"], selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],
previewId: window["##BUDIBASE_PREVIEW_ID##"], previewId: window["##BUDIBASE_PREVIEW_ID##"],
@ -125,7 +117,6 @@ const loadBudibase = async () => {
navigation: window["##BUDIBASE_PREVIEW_NAVIGATION##"], navigation: window["##BUDIBASE_PREVIEW_NAVIGATION##"],
hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"], hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"],
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"], usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
location: window["##BUDIBASE_LOCATION##"],
snippets: window["##BUDIBASE_SNIPPETS##"], snippets: window["##BUDIBASE_SNIPPETS##"],
componentErrors: window["##BUDIBASE_COMPONENT_ERRORS##"], componentErrors: window["##BUDIBASE_COMPONENT_ERRORS##"],
}) })

View File

@ -2,9 +2,39 @@ import { writable, get } from "svelte/store"
import { API } from "@/api" import { API } from "@/api"
import { devToolsStore } from "./devTools.js" import { devToolsStore } from "./devTools.js"
import { eventStore } from "./events.js" import { eventStore } from "./events.js"
import {
ComponentDefinition,
DropPosition,
PingSource,
PreviewDevice,
Screen,
Theme,
AppCustomTheme,
AppNavigation,
Plugin,
Snippet,
UIComponentError,
} from "@budibase/types"
interface BuilderStore {
inBuilder: boolean
screen?: Screen | null
selectedComponentId?: string | null
editMode: boolean
previewId?: number | null
theme?: Theme | null
customTheme?: AppCustomTheme | null
previewDevice?: PreviewDevice
navigation?: AppNavigation | null
hiddenComponentIds?: string[]
usedPlugins?: Plugin[] | null
metadata: { componentId: string; step: number } | null
snippets?: Snippet[] | null
componentErrors?: Record<string, UIComponentError[]>
}
const createBuilderStore = () => { const createBuilderStore = () => {
const initialState = { const initialState: BuilderStore = {
inBuilder: false, inBuilder: false,
screen: null, screen: null,
selectedComponentId: null, selectedComponentId: null,
@ -16,17 +46,13 @@ const createBuilderStore = () => {
navigation: null, navigation: null,
hiddenComponentIds: [], hiddenComponentIds: [],
usedPlugins: null, usedPlugins: null,
eventResolvers: {},
metadata: null, metadata: null,
snippets: null, snippets: null,
componentErrors: {}, componentErrors: {},
// Legacy - allow the builder to specify a layout
layout: null,
} }
const store = writable(initialState) const store = writable(initialState)
const actions = { const actions = {
selectComponent: id => { selectComponent: (id: string) => {
if (id === get(store).selectedComponentId) { if (id === get(store).selectedComponentId) {
return return
} }
@ -38,46 +64,59 @@ const createBuilderStore = () => {
devToolsStore.actions.setAllowSelection(false) devToolsStore.actions.setAllowSelection(false)
eventStore.actions.dispatchEvent("select-component", { id }) eventStore.actions.dispatchEvent("select-component", { id })
}, },
updateProp: (prop, value) => { updateProp: (prop: string, value: any) => {
eventStore.actions.dispatchEvent("update-prop", { prop, value }) eventStore.actions.dispatchEvent("update-prop", { prop, value })
}, },
updateStyles: async (styles, id) => { updateStyles: async (styles: Record<string, any>, id: string) => {
await eventStore.actions.dispatchEvent("update-styles", { await eventStore.actions.dispatchEvent("update-styles", {
styles, styles,
id, id,
}) })
}, },
keyDown: (key, ctrlKey) => { keyDown: (key: string, ctrlKey: boolean) => {
eventStore.actions.dispatchEvent("key-down", { key, ctrlKey }) eventStore.actions.dispatchEvent("key-down", { key, ctrlKey })
}, },
duplicateComponent: (id, mode = "below", selectComponent = true) => { duplicateComponent: (
id: string,
mode = DropPosition.BELOW,
selectComponent = true
) => {
eventStore.actions.dispatchEvent("duplicate-component", { eventStore.actions.dispatchEvent("duplicate-component", {
id, id,
mode, mode,
selectComponent, selectComponent,
}) })
}, },
deleteComponent: id => { deleteComponent: (id: string) => {
eventStore.actions.dispatchEvent("delete-component", { id }) eventStore.actions.dispatchEvent("delete-component", { id })
}, },
notifyLoaded: () => { notifyLoaded: () => {
eventStore.actions.dispatchEvent("preview-loaded") eventStore.actions.dispatchEvent("preview-loaded")
}, },
analyticsPing: async ({ embedded }) => { analyticsPing: async ({ embedded }: { embedded: boolean }) => {
try { try {
await API.analyticsPing({ source: "app", embedded }) await API.analyticsPing({ source: PingSource.APP, embedded })
} catch (error) { } catch (error) {
// Do nothing // Do nothing
} }
}, },
moveComponent: async (componentId, destinationComponentId, mode) => { moveComponent: async (
componentId: string,
destinationComponentId: string,
mode: DropPosition
) => {
await eventStore.actions.dispatchEvent("move-component", { await eventStore.actions.dispatchEvent("move-component", {
componentId, componentId,
destinationComponentId, destinationComponentId,
mode, mode,
}) })
}, },
dropNewComponent: (component, parent, index, props) => { dropNewComponent: (
component: string,
parent: string,
index: number,
props: Record<string, any>
) => {
eventStore.actions.dispatchEvent("drop-new-component", { eventStore.actions.dispatchEvent("drop-new-component", {
component, component,
parent, parent,
@ -85,7 +124,7 @@ const createBuilderStore = () => {
props, props,
}) })
}, },
setEditMode: enabled => { setEditMode: (enabled: boolean) => {
if (enabled === get(store).editMode) { if (enabled === get(store).editMode) {
return return
} }
@ -94,18 +133,18 @@ const createBuilderStore = () => {
requestAddComponent: () => { requestAddComponent: () => {
eventStore.actions.dispatchEvent("request-add-component") eventStore.actions.dispatchEvent("request-add-component")
}, },
highlightSetting: setting => { highlightSetting: (setting: string) => {
eventStore.actions.dispatchEvent("highlight-setting", { setting }) eventStore.actions.dispatchEvent("highlight-setting", { setting })
}, },
ejectBlock: (id, definition) => { ejectBlock: (id: string, definition: ComponentDefinition) => {
eventStore.actions.dispatchEvent("eject-block", { id, definition }) eventStore.actions.dispatchEvent("eject-block", { id, definition })
}, },
updateUsedPlugin: (name, hash) => { updateUsedPlugin: (name: string, hash: string) => {
// Check if we used this plugin // Check if we used this plugin
const used = get(store)?.usedPlugins?.find(x => x.name === name) const used = get(store)?.usedPlugins?.find(x => x.name === name)
if (used) { if (used) {
store.update(state => { store.update(state => {
state.usedPlugins = state.usedPlugins.filter(x => x.name !== name) state.usedPlugins = state.usedPlugins!.filter(x => x.name !== name)
state.usedPlugins.push({ state.usedPlugins.push({
...used, ...used,
hash, hash,
@ -117,13 +156,13 @@ const createBuilderStore = () => {
// Notify the builder so we can reload component definitions // Notify the builder so we can reload component definitions
eventStore.actions.dispatchEvent("reload-plugin") eventStore.actions.dispatchEvent("reload-plugin")
}, },
addParentComponent: (componentId, parentType) => { addParentComponent: (componentId: string, parentType: string) => {
eventStore.actions.dispatchEvent("add-parent-component", { eventStore.actions.dispatchEvent("add-parent-component", {
componentId, componentId,
parentType, parentType,
}) })
}, },
setMetadata: metadata => { setMetadata: (metadata: { componentId: string; step: number }) => {
store.update(state => ({ store.update(state => ({
...state, ...state,
metadata, metadata,
@ -132,7 +171,7 @@ const createBuilderStore = () => {
} }
return { return {
...store, ...store,
set: state => store.set({ ...initialState, ...state }), set: (state: BuilderStore) => store.set({ ...initialState, ...state }),
actions, actions,
} }
} }

View File

@ -1,6 +1,6 @@
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { appStore } from "../app.js" import { appStore } from "../app.js"
import { builderStore } from "../builder.js" import { builderStore } from "../builder"
export const devToolsEnabled = derived( export const devToolsEnabled = derived(
[appStore, builderStore], [appStore, builderStore],

View File

@ -1,5 +1,5 @@
import { appStore } from "../app.js" import { appStore } from "../app.js"
import { builderStore } from "../builder.js" import { builderStore } from "../builder"
import { derivedMemo } from "@budibase/frontend-core" import { derivedMemo } from "@budibase/frontend-core"
export const snippets = derivedMemo( export const snippets = derivedMemo(

View File

@ -36,11 +36,6 @@ const createScreenStore = () => {
activeScreen = Helpers.cloneDeep($builderStore.screen) activeScreen = Helpers.cloneDeep($builderStore.screen)
screens = [activeScreen] screens = [activeScreen]
// Legacy - allow the builder to specify a layout
if ($builderStore.layout) {
activeLayout = $builderStore.layout
}
// Attach meta // Attach meta
const errors = $builderStore.componentErrors || {} const errors = $builderStore.componentErrors || {}
const attachComponentMeta = component => { const attachComponentMeta = component => {

View File

@ -31,7 +31,7 @@ export const deriveStores = (context: StoreContext): ConditionDerivedStore => {
// Derive and memoize the cell conditions present in our columns so that we // Derive and memoize the cell conditions present in our columns so that we
// only recompute condition metadata when absolutely necessary // only recompute condition metadata when absolutely necessary
const conditions = derivedMemo(columns, $columns => { const conditions = derivedMemo(columns, $columns => {
let newConditions = [] let newConditions: UICondition[] = []
for (let column of $columns) { for (let column of $columns) {
for (let condition of column.conditions || []) { for (let condition of column.conditions || []) {
newConditions.push({ newConditions.push({

View File

@ -80,7 +80,6 @@
// Set some flags so the app knows we're in the builder // Set some flags so the app knows we're in the builder
window["##BUDIBASE_IN_BUILDER##"] = true window["##BUDIBASE_IN_BUILDER##"] = true
window["##BUDIBASE_APP_ID##"] = appId window["##BUDIBASE_APP_ID##"] = appId
window["##BUDIBASE_PREVIEW_LAYOUT##"] = layout
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
window["##BUDIBASE_PREVIEW_ID##"] = Math.random() window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
@ -90,7 +89,6 @@
window["##BUDIBASE_PREVIEW_NAVIGATION##"] = navigation window["##BUDIBASE_PREVIEW_NAVIGATION##"] = navigation
window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds
window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins
window["##BUDIBASE_LOCATION##"] = location
window["##BUDIBASE_SNIPPETS##"] = snippets window["##BUDIBASE_SNIPPETS##"] = snippets
window['##BUDIBASE_COMPONENT_ERRORS##'] = componentErrors window['##BUDIBASE_COMPONENT_ERRORS##'] = componentErrors

View File

@ -2,12 +2,14 @@ import Router from "@koa/router"
import * as controller from "../controllers/backup" import * as controller from "../controllers/backup"
import authorized from "../../middleware/authorized" import authorized from "../../middleware/authorized"
import { permissions } from "@budibase/backend-core" import { permissions } from "@budibase/backend-core"
import ensureTenantAppOwnership from "../../middleware/ensureTenantAppOwnership"
const router: Router = new Router() const router: Router = new Router()
router.post( router.post(
"/api/backups/export", "/api/backups/export",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),
ensureTenantAppOwnership,
controller.exportAppDump controller.exportAppDump
) )

View File

@ -0,0 +1,19 @@
import { tenancy, utils, context } from "@budibase/backend-core"
import { UserCtx } from "@budibase/types"
async function ensureTenantAppOwnership(ctx: UserCtx, next: any) {
const appId = await utils.getAppIdFromCtx(ctx)
if (!appId) {
ctx.throw(400, "appId must be provided")
}
const appTenantId = context.getTenantIDFromAppID(appId)
const tenantId = tenancy.getTenantId()
if (appTenantId !== tenantId) {
ctx.throw(403, "Unauthorized")
}
await next()
}
export default ensureTenantAppOwnership

View File

@ -0,0 +1,75 @@
import ensureTenantAppOwnership from "../ensureTenantAppOwnership"
import { tenancy, utils } from "@budibase/backend-core"
jest.mock("@budibase/backend-core", () => ({
...jest.requireActual("@budibase/backend-core"),
tenancy: {
getTenantId: jest.fn(),
},
utils: {
getAppIdFromCtx: jest.fn(),
},
}))
class TestConfiguration {
constructor(appId = "tenant_1") {
this.next = jest.fn()
this.throw = jest.fn()
this.middleware = ensureTenantAppOwnership
this.ctx = {
next: this.next,
throw: this.throw,
}
utils.getAppIdFromCtx.mockResolvedValue(appId)
}
async executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
afterEach() {
jest.clearAllMocks()
}
}
describe("Ensure Tenant Ownership Middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
it("calls next() when appId matches tenant ID", async () => {
tenancy.getTenantId.mockReturnValue("tenant_1")
await config.executeMiddleware()
expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx)
expect(config.next).toHaveBeenCalled()
})
it("throws when tenant appId does not match tenant ID", async () => {
const appId = "app_dev_tenant3_fce449c4d75b4e4a9c7a6980d82a3e22"
utils.getAppIdFromCtx.mockResolvedValue(appId)
tenancy.getTenantId.mockReturnValue("tenant_2")
await config.executeMiddleware()
expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx)
expect(config.throw).toHaveBeenCalledWith(403, "Unauthorized")
})
it("throws 400 when appId is missing", async () => {
utils.getAppIdFromCtx.mockResolvedValue(null)
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(400, "appId must be provided")
})
})

View File

@ -1,4 +1,6 @@
export interface Helper { export interface Helper {
example: string example: string
description: string description: string
args: any[]
requiresBlock?: boolean
} }

View File

@ -48,6 +48,11 @@ export interface ComponentSetting {
selectAllFields?: boolean selectAllFields?: boolean
resetOn?: string | string[] resetOn?: string | string[]
settings?: ComponentSetting[] settings?: ComponentSetting[]
nested?: boolean
dependsOn?: DependsOnComponentSetting dependsOn?: DependsOnComponentSetting
sectionDependsOn?: DependsOnComponentSetting sectionDependsOn?: DependsOnComponentSetting
contextAccess?: {
global: boolean
self: boolean
}
} }

View File

@ -1,9 +1,15 @@
import { CalculationType, FieldSchema, FieldType, UIRow } from "@budibase/types" import {
CalculationType,
FieldSchema,
FieldType,
UICondition,
UIRow,
} from "@budibase/types"
export type UIColumn = FieldSchema & { export type UIColumn = FieldSchema & {
label: string label: string
readonly: boolean readonly: boolean
conditions: any conditions?: UICondition[]
format?: (row: UIRow) => any format?: (row: UIRow) => any
related?: { related?: {
field: string field: string

View File

@ -3,7 +3,7 @@ import { FieldType, SearchFilter } from "@budibase/types"
export interface UICondition { export interface UICondition {
column: string column: string
type: FieldType type: FieldType
referenceValue: string referenceValue: any
operator: SearchFilter["operator"] operator: SearchFilter["operator"]
metadataKey: string metadataKey: string
metadataValue: string metadataValue: string

View File

@ -1,2 +1,8 @@
// type purely to capture structures that the type is unknown, but maybe known later // type purely to capture structures that the type is unknown, but maybe known later
export type UIObject = Record<string, any> export type UIObject = Record<string, any>
export const enum DropPosition {
ABOVE = "above",
BELOW = "below",
INSIDE = "inside",
}