diff --git a/.github/workflows/readme-openapi.yml b/.github/workflows/readme-openapi.yml index 9f42f6141b..27a68e69cf 100644 --- a/.github/workflows/readme-openapi.yml +++ b/.github/workflows/readme-openapi.yml @@ -20,7 +20,7 @@ jobs: - run: yarn --frozen-lockfile - name: Install OpenAPI pkg - run: yarn global add openapi + run: yarn global add rdme@8.6.6 - name: update specs - run: cd packages/server && yarn specs && openapi specs/openapi.yaml --key=${{ secrets.README_API_KEY }} --id=6728a74f5918b50036c61841 + run: cd packages/server && yarn specs && rdme openapi specs/openapi.yaml --key=${{ secrets.README_API_KEY }} --id=67c16880add6da002352069a diff --git a/eslint.config.mjs b/eslint.config.mjs index c497974612..2f4072a188 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -94,6 +94,15 @@ export default [ allowImportExportEverywhere: true, }, }, + + plugins: { + ...config.plugins, + "@typescript-eslint": tseslint.plugin, + }, + rules: { + ...config.rules, + "@typescript-eslint/consistent-type-imports": "error", + }, })), ...tseslint.configs.strict.map(config => ({ ...config, diff --git a/lerna.json b/lerna.json index 8972a00565..d20097b6e5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.21", + "version": "3.4.22", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index 064cc630d8..33b42e0131 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -478,7 +478,7 @@ export async function deleteFolder( if (existingObjectsResponse.Contents?.length === 0) { return } - const deleteParams: any = { + const deleteParams: { Bucket: string; Delete: { Objects: any[] } } = { Bucket: bucketName, Delete: { Objects: [], @@ -489,10 +489,12 @@ export async function deleteFolder( deleteParams.Delete.Objects.push({ Key: content.Key }) }) - const deleteResponse = await client.deleteObjects(deleteParams) - // can only empty 1000 items at once - if (deleteResponse.Deleted?.length === 1000) { - return deleteFolder(bucketName, folder) + if (deleteParams.Delete.Objects.length) { + const deleteResponse = await client.deleteObjects(deleteParams) + // can only empty 1000 items at once + if (deleteResponse.Deleted?.length === 1000) { + return deleteFolder(bucketName, folder) + } } } diff --git a/packages/bbui/src/Actions/position_dropdown.ts b/packages/bbui/src/Actions/position_dropdown.ts index 424baf91f3..edfe901921 100644 --- a/packages/bbui/src/Actions/position_dropdown.ts +++ b/packages/bbui/src/Actions/position_dropdown.ts @@ -27,7 +27,7 @@ export type UpdateHandler = ( interface Opts { anchor?: HTMLElement - align: PopoverAlignment + align: PopoverAlignment | `${PopoverAlignment}` maxHeight?: number maxWidth?: number minWidth?: number diff --git a/packages/bbui/src/Badge/Badge.svelte b/packages/bbui/src/Badge/Badge.svelte index e4ec7d4f33..dadbaa3317 100644 --- a/packages/bbui/src/Badge/Badge.svelte +++ b/packages/bbui/src/Badge/Badge.svelte @@ -11,6 +11,7 @@ export let active = false export let inactive = false export let hoverable = false + export let outlineColor = null @@ -29,6 +30,7 @@ class:spectrum-Label--seafoam={seafoam} class:spectrum-Label--active={active} class:spectrum-Label--inactive={inactive} + style={outlineColor ? `border: 2px solid ${outlineColor}` : ""} > diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 1f38389a63..5650f5a812 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -64,7 +64,7 @@ import { setContext, createEventDispatcher, onDestroy } from "svelte" import { generate } from "shortid" - export let title + export let title = "" export let forceModal = false const dispatch = createEventDispatcher() diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index 2f12935f53..46b3140be8 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -28,6 +28,9 @@ { - if (isMustacheStatement(node) && isPathExpression(node.path)) { + if ( + (isMustacheStatement(node) || isBlockStatement(node)) && + isPathExpression(node.path) + ) { const helperName = node.path.original const from = @@ -75,21 +78,64 @@ export function validateHbsTemplate( message: `Helper "${helperName}" requires a body:\n{{#${helperName} ...}} [body] {{/${helperName}}}`, }) return + } else if (!requiresBlock && isBlockStatement(node)) { + diagnostics.push({ + from, + to, + severity: "error", + message: `Helper "${helperName}" should not contain a body.`, + }) + return } - const providedParams = node.params + let providedParamsCount = node.params.length + if (isBlockStatement(node)) { + // Block body counts as a parameter + providedParamsCount++ + } - if (providedParams.length !== expectedArguments.length) { + const optionalArgMatcher = new RegExp(/^\[(.+)\]$/) + const optionalArgs = expectedArguments.filter(a => + optionalArgMatcher.test(a) + ) + + if ( + !optionalArgs.length && + providedParamsCount !== expectedArguments.length + ) { diagnostics.push({ from, to, severity: "error", message: `Helper "${helperName}" expects ${ expectedArguments.length - } parameters (${expectedArguments.join(", ")}), but got ${ - providedParams.length - }.`, + } parameters (${expectedArguments.join( + ", " + )}), but got ${providedParamsCount}.`, }) + } else if (optionalArgs.length) { + const maxArgs = expectedArguments.length + const minArgs = maxArgs - optionalArgs.length + if ( + minArgs > providedParamsCount || + maxArgs < providedParamsCount + ) { + const parameters = expectedArguments + .map(a => { + const test = optionalArgMatcher.exec(a) + if (!test?.[1]) { + return a + } + return `${test[1]} (optional)` + }) + .join(", ") + diagnostics.push({ + from, + to, + severity: "error", + message: `Helper "${helperName}" expects between ${minArgs} to ${expectedArguments.length} parameters (${parameters}), but got ${providedParamsCount}.`, + }) + } } } 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 index 9484c7a4a5..5d2cfa06c2 100644 --- a/packages/builder/src/components/common/CodeEditor/validator/tests/hbs.spec.ts +++ b/packages/builder/src/components/common/CodeEditor/validator/tests/hbs.spec.ts @@ -2,7 +2,7 @@ import { validateHbsTemplate } from "../hbs" import { CodeValidator } from "@/types" describe("hbs validator", () => { - it("validate empty strings", () => { + it("validates empty strings", () => { const text = "" const validators = {} @@ -10,7 +10,7 @@ describe("hbs validator", () => { expect(result).toHaveLength(0) }) - it("validate strings without hbs expressions", () => { + it("validates strings without hbs expressions", () => { const text = "first line\nand another one" const validators = {} @@ -23,7 +23,7 @@ describe("hbs validator", () => { fieldName: {}, } - it("validate valid expressions", () => { + it("validates valid expressions", () => { const text = "{{ fieldName }}" const result = validateHbsTemplate(text, validators) @@ -98,45 +98,193 @@ describe("hbs validator", () => { }) describe("expressions with parameters", () => { - const validators: CodeValidator = { - helperFunction: { - arguments: ["a", "b", "c"], - }, - } + describe("basic expression", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "c"], + }, + } - it("validate valid params", () => { - const text = "{{ helperFunction 1 99 'a' }}" + it("validates valid params", () => { + const text = "{{ helperFunction 1 99 'a' }}" - const result = validateHbsTemplate(text, validators) - expect(result).toHaveLength(0) + 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, + }, + ]) + }) }) - 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, + describe("body expressions", () => { + const validators: CodeValidator = { + bodyFunction: { + arguments: ["a", "b", "c"], + requiresBlock: true, }, - ]) + nonBodyFunction: { + arguments: ["a", "b"], + }, + } + + it("validates valid params", () => { + const text = "{{#bodyFunction 1 99 }}body{{/bodyFunction}}" + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("validates empty bodies", () => { + const text = "{{#bodyFunction 1 99 }}{{/bodyFunction}}" + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("validates too little parameters", () => { + const text = "{{#bodyFunction 1 }}{{/bodyFunction}}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "bodyFunction" expects 3 parameters (a, b, c), but got 2.`, + severity: "error", + to: 37, + }, + ]) + }) + + it("validates too many parameters", () => { + const text = "{{#bodyFunction 1 99 'a' 0 }}{{/bodyFunction}}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "bodyFunction" expects 3 parameters (a, b, c), but got 5.`, + severity: "error", + to: 46, + }, + ]) + }) + + it("validates non-supported body usages", () => { + const text = "{{#nonBodyFunction 1 99}}{{/nonBodyFunction}}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "nonBodyFunction" should not contain a body.`, + severity: "error", + to: 45, + }, + ]) + }) }) - it("throws on too many params", () => { - const text = "{{ helperFunction 1 99 'a' 100 }}" + describe("optional parameters", () => { + it("supports empty parameters", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "[c]"], + }, + } + const text = "{{ helperFunction 1 99 }}" - 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, - }, - ]) + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("supports valid parameters", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "[c]"], + }, + } + const text = "{{ helperFunction 1 99 'a' }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toHaveLength(0) + }) + + it("returns a valid message on missing parameters", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "[c]"], + }, + } + const text = "{{ helperFunction 1 }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "helperFunction" expects between 2 to 3 parameters (a, b, c (optional)), but got 1.`, + severity: "error", + to: 22, + }, + ]) + }) + + it("returns a valid message on too many parameters", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "[c]"], + }, + } + const text = "{{ helperFunction 1 2 3 4 }}" + + const result = validateHbsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: `Helper "helperFunction" expects between 2 to 3 parameters (a, b, c (optional)), but got 4.`, + severity: "error", + to: 28, + }, + ]) + }) }) }) + + it("validates wrong hbs code", () => { + const text = "{{#fieldName}}{{/wrong}}" + + const result = validateHbsTemplate(text, {}) + expect(result).toEqual([ + { + from: 0, + message: `The handlebars code is not valid:\nfieldName doesn't match wrong - 1:3`, + severity: "error", + to: text.length, + }, + ]) + }) }) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 3715719565..d9833a34c8 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -27,12 +27,11 @@ } from "../CodeEditor" import BindingSidePanel from "./BindingSidePanel.svelte" import EvaluationSidePanel from "./EvaluationSidePanel.svelte" - import SnippetSidePanel from "./SnippetSidePanel.svelte" import { BindingHelpers } from "./utils" import { capitalise } from "@/helpers" import { Utils, JsonFormatter } from "@budibase/frontend-core" import { licensing } from "@/stores/portal" - import { BindingMode, SidePanel } from "@budibase/types" + import { BindingMode } from "@budibase/types" import type { EnrichedBinding, Snippet, @@ -44,6 +43,8 @@ import type { Log } from "@budibase/string-templates" import type { CodeValidator } from "@/types" + type SidePanel = "Bindings" | "Evaluation" + const dispatch = createEventDispatcher() export let bindings: EnrichedBinding[] = [] @@ -55,7 +56,7 @@ export let context = null export let snippets: Snippet[] | null = null export let autofocusEditor = false - export let placeholder = null + export let placeholder: string | null = null export let showTabBar = true let mode: BindingMode @@ -71,14 +72,13 @@ let expressionError: string | undefined let evaluating = false - $: useSnippets = allowSnippets && !$licensing.isFreePlan + const SidePanelIcons: Record = { + Bindings: "FlashOn", + Evaluation: "Play", + } + $: editorModeOptions = getModeOptions(allowHBS, allowJS) - $: sidePanelOptions = getSidePanelOptions( - bindings, - context, - allowSnippets, - mode - ) + $: sidePanelOptions = getSidePanelOptions(bindings, context) $: enrichedBindings = enrichBindings(bindings, context, snippets) $: usingJS = mode === BindingMode.JavaScript $: editorMode = @@ -93,7 +93,9 @@ $: bindingOptions = bindingsToCompletions(enrichedBindings, editorMode) $: helperOptions = allowHelpers ? getHelperCompletions(editorMode) : [] $: snippetsOptions = - usingJS && useSnippets && snippets?.length ? snippets : [] + usingJS && allowSnippets && !$licensing.isFreePlan && snippets?.length + ? snippets + : [] $: completions = !usingJS ? [hbAutocomplete([...bindingOptions, ...helperOptions])] @@ -137,21 +139,13 @@ return options } - const getSidePanelOptions = ( - bindings: EnrichedBinding[], - context: any, - useSnippets: boolean, - mode: BindingMode | null - ) => { - let options = [] + const getSidePanelOptions = (bindings: EnrichedBinding[], context: any) => { + let options: SidePanel[] = [] if (bindings?.length) { - options.push(SidePanel.Bindings) + options.push("Bindings") } if (context && Object.keys(context).length > 0) { - options.push(SidePanel.Evaluation) - } - if (useSnippets && mode === BindingMode.JavaScript) { - options.push(SidePanel.Snippets) + options.push("Evaluation") } return options } @@ -342,14 +336,15 @@ {/each}
- {#each sidePanelOptions as panel} + {#each sidePanelOptions as panelOption} changeSidePanel(panel)} + selected={sidePanel === panelOption} + on:click={() => changeSidePanel(panelOption)} + tooltip={panelOption} > - + {/each}
@@ -415,16 +410,19 @@
- {#if sidePanel === SidePanel.Bindings} + {#if sidePanel === "Bindings"} - {:else if sidePanel === SidePanel.Evaluation} + {:else if sidePanel === "Evaluation"} - {:else if sidePanel === SidePanel.Snippets} - {/if}
diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index c45d7999e5..251bf19b49 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -1,34 +1,69 @@ - -
- {#if hoverTarget.description} -
- - {@html hoverTarget.description} -
- {/if} - {#if hoverTarget.code} - -
{@html hoverTarget.code}
- {/if} -
+ {#if hoverTarget} +
+ {#if hoverTarget.description} +
+ + {@html hoverTarget.description} +
+ {/if} + {#if hoverTarget.code} + {#if mode === BindingMode.JavaScript} + + {:else if mode === BindingMode.Text} + +
{@html hoverTarget.code}
+ {/if} + {/if} +
+ {/if}
@@ -164,6 +274,25 @@ on:click={() => (selectedCategory = null)} /> {selectedCategory} + {#if selectedCategory === "Snippets"} + {#if enableSnippets} +
+ +
+ {:else} +
+ + Premium + +
+ {/if} + {/if} {/if} @@ -173,7 +302,7 @@
@@ -230,7 +359,8 @@ {#each category.bindings as binding}
  • showBindingPopover(binding, e.target)} + on:mouseenter={e => + showBindingPopover(binding, e.currentTarget)} on:mouseleave={hidePopover} on:click={() => addBinding(binding)} > @@ -264,9 +394,10 @@ {#each filteredHelpers as helper}
  • showHelperPopover(helper, e.target)} - on:mouseleave={hidePopover} - on:click={() => addHelper(helper, mode.name === "javascript")} + on:mouseenter={e => + showHelperPopover(helper, e.currentTarget)} + on:click={() => + addHelper(helper, mode === BindingMode.JavaScript)} > {helper.displayText} @@ -278,10 +409,48 @@
  • {/if} {/if} + + {#if selectedCategory === "Snippets" || search} +
    + {#if enableSnippets && filteredSnippets.length} + {#each filteredSnippets as snippet} +
  • showSnippet(snippet, e.currentTarget)} + on:mouseleave={hidePopover} + on:click={() => addSnippet(snippet)} + > + {snippet.name} + editSnippet(e, snippet)} + /> +
  • + {/each} + {:else if !search} +
    + + Snippets let you create reusable JS functions and values that + can all be managed in one place + + {#if enableSnippets} + + {:else} + + {/if} +
    + {/if} +
    + {/if} {/if} + + diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index 4862217b13..7f5772cab1 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -1,4 +1,4 @@ - - - - -
    - -
    - {#if enableSnippets} - {#if searching} -
    - -
    - - {:else} -
    Snippets
    - - - {/if} - {:else} -
    - Snippets - - Premium - -
    - {/if} -
    -
    - {#if enableSnippets && filteredSnippets?.length} - {#each filteredSnippets as snippet} -
    showSnippet(snippet, e.target)} - on:mouseleave={hidePopover} - on:click={() => addSnippet(snippet)} - > - {snippet.name} - editSnippet(e, snippet)} - /> -
    - {/each} - {:else} -
    - - Snippets let you create reusable JS functions and values that can - all be managed in one place - - {#if enableSnippets} - - {:else} - - {/if} -
    - {/if} -
    -
    -
    - - -
    - {#key hoveredSnippet} - - {/key} -
    -
    - - - - diff --git a/packages/builder/src/components/common/bindings/index.js b/packages/builder/src/components/common/bindings/index.js index a2d4479959..5a9c6f661b 100644 --- a/packages/builder/src/components/common/bindings/index.js +++ b/packages/builder/src/components/common/bindings/index.js @@ -8,5 +8,3 @@ export { default as DrawerBindableSlot } from "./DrawerBindableSlot.svelte" export { default as EvaluationSidePanel } from "./EvaluationSidePanel.svelte" export { default as ModalBindableInput } from "./ModalBindableInput.svelte" export { default as ServerBindingPanel } from "./ServerBindingPanel.svelte" -export { default as SnippetDrawer } from "./SnippetDrawer.svelte" -export { default as SnippetSidePanel } from "./SnippetSidePanel.svelte" diff --git a/packages/builder/src/constants/completions.js b/packages/builder/src/constants/completions.ts similarity index 62% rename from packages/builder/src/constants/completions.js rename to packages/builder/src/constants/completions.ts index e539a8084a..79cfbf61bf 100644 --- a/packages/builder/src/constants/completions.js +++ b/packages/builder/src/constants/completions.ts @@ -1,10 +1,10 @@ import { getManifest, helpersToRemoveForJs } from "@budibase/string-templates" +import { Helper } from "@budibase/types" -export function handlebarsCompletions() { +export function handlebarsCompletions(): Helper[] { const manifest = getManifest() - - return Object.keys(manifest).flatMap(key => - Object.entries(manifest[key]).map(([helperName, helperConfig]) => ({ + return Object.values(manifest).flatMap(helpersObj => + Object.entries(helpersObj).map(([helperName, helperConfig]) => ({ text: helperName, path: helperName, example: helperConfig.example, @@ -14,6 +14,7 @@ export function handlebarsCompletions() { allowsJs: !helperConfig.requiresBlock && !helpersToRemoveForJs.includes(helperName), + args: helperConfig.args, })) ) } diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js index 739ecc9494..c6171c72a4 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -373,6 +373,18 @@ const getContextBindings = (asset, componentId) => { .flat() } +export const makeReadableKeyPropSafe = key => { + if (!key.includes(" ")) { + return key + } + + if (new RegExp(/^\[(.+)\]$/).test(key.test)) { + return key + } + + return `[${key}]` +} + /** * Generates a set of bindings for a given component context */ @@ -457,11 +469,11 @@ const generateComponentContextBindings = (asset, componentContext) => { const runtimeBinding = `${safeComponentId}.${safeKey}` // Optionally use a prefix with readable bindings - let readableBinding = component._instanceName + let readableBinding = makeReadableKeyPropSafe(component._instanceName) if (readablePrefix) { readableBinding += `.${readablePrefix}` } - readableBinding += `.${fieldSchema.name || key}` + readableBinding += `.${makeReadableKeyPropSafe(fieldSchema.name || key)}` // Determine which category this binding belongs in const bindingCategory = getComponentBindingCategory( @@ -473,7 +485,7 @@ const generateComponentContextBindings = (asset, componentContext) => { bindings.push({ type: "context", runtimeBinding, - readableBinding: `${readableBinding}`, + readableBinding, // Field schema and provider are required to construct relationship // datasource options, based on bindable properties fieldSchema, @@ -1354,13 +1366,14 @@ const bindingReplacement = ( } // work from longest to shortest const convertFromProps = bindableProperties + // TODO check whitespaces .map(el => el[convertFrom]) .sort((a, b) => { return b.length - a.length }) const boundValues = textWithBindings.match(regex) || [] let result = textWithBindings - for (let boundValue of boundValues) { + for (const boundValue of boundValues) { let newBoundValue = boundValue // we use a search string, where any time we replace something we blank it out // in the search, working from longest to shortest so always use best match first diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderGroupPopover.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderGroupPopover.svelte new file mode 100644 index 0000000000..367d84e029 --- /dev/null +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderGroupPopover.svelte @@ -0,0 +1,31 @@ + + +
    + +
    + + diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index 2260892913..737edd69f7 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -37,6 +37,7 @@ import { emailValidator } from "@/helpers/validation" import { fly } from "svelte/transition" import InfoDisplay from "../design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" + import BuilderGroupPopover from "./BuilderGroupPopover.svelte" let query = null let loaded = false @@ -197,12 +198,19 @@ return } const update = await users.get(user._id) + const newRoles = { + ...update.roles, + [prodAppId]: role, + } + // make sure no undefined/null roles (during removal) + for (let [appId, role] of Object.entries(newRoles)) { + if (!role) { + delete newRoles[appId] + } + } await users.save({ ...update, - roles: { - ...update.roles, - [prodAppId]: role, - }, + roles: newRoles, }) await searchUsers(query, $builderStore.builderSidePanel, loaded) } @@ -539,6 +547,10 @@ creationAccessType = Constants.Roles.CREATOR } } + + const itemCountText = (word, count) => { + return `${count} ${word}${count !== 1 ? "s" : ""}` + } @@ -701,13 +713,11 @@ >
    -
    +
    {group.name}
    - {`${group.users?.length} user${ - group.users?.length != 1 ? "s" : "" - }`} + {itemCountText("user", group.users?.length)}
    @@ -741,16 +751,33 @@
    Access
    {#each allUsers as user} + {@const userGroups = sdk.users.getUserAppGroups( + $appStore.appId, + user, + $groups + )}
    -
    - {user.email} +
    +
    + {user.email} +
    + {#if userGroups.length} +
    +
    + {itemCountText("group", userGroups.length)} +
    + +
    + {/if}
    diff --git a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte index 6c480d9ef8..f02c2fe058 100644 --- a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte +++ b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte @@ -98,7 +98,9 @@ $: privileged = sdk.users.isAdminOrGlobalBuilder(user) $: nameLabel = getNameLabel(user) $: filteredGroups = getFilteredGroups(internalGroups, searchTerm) - $: availableApps = getAvailableApps($appsStore.apps, privileged, user?.roles) + $: availableApps = user + ? getApps(user, sdk.users.userAppAccessList(user, $groups || [])) + : [] $: userGroups = $groups.filter(x => { return x.users?.find(y => { return y._id === userId @@ -107,23 +109,19 @@ $: globalRole = users.getUserRole(user) $: isTenantOwner = tenantOwner?.email && tenantOwner.email === user?.email - const getAvailableApps = (appList, privileged, roles) => { - let availableApps = appList.slice() - if (!privileged) { - availableApps = availableApps.filter(x => { - let roleKeys = Object.keys(roles || {}) - return roleKeys.concat(user?.builder?.apps).find(y => { - return x.appId === appsStore.extractAppId(y) - }) - }) - } + const getApps = (user, appIds) => { + let availableApps = $appsStore.apps + .slice() + .filter(app => + appIds.find(id => id === appsStore.getProdAppID(app.devId)) + ) return availableApps.map(app => { const prodAppId = appsStore.getProdAppID(app.devId) return { name: app.name, devId: app.devId, icon: app.icon, - role: getRole(prodAppId, roles), + role: getRole(prodAppId, user), } }) } @@ -136,7 +134,7 @@ return groups.filter(group => group.name?.toLowerCase().includes(search)) } - const getRole = (prodAppId, roles) => { + const getRole = (prodAppId, user) => { if (privileged) { return Constants.Roles.ADMIN } @@ -145,7 +143,21 @@ return Constants.Roles.CREATOR } - return roles[prodAppId] + if (user?.roles[prodAppId]) { + return user.roles[prodAppId] + } + + // check if access via group for creator + const foundGroup = $groups?.find( + group => group.roles[prodAppId] || group.builder?.apps[prodAppId] + ) + if (foundGroup.builder?.apps[prodAppId]) { + return Constants.Roles.CREATOR + } + // can't tell how groups will control role + if (foundGroup.roles[prodAppId]) { + return Constants.Roles.GROUP + } } const getNameLabel = user => { diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte index 9429cfbc23..5adc38ebc6 100644 --- a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte @@ -15,7 +15,9 @@ } -{#if value === Constants.Roles.CREATOR} +{#if value === Constants.Roles.GROUP} + Controlled by group +{:else if value === Constants.Roles.CREATOR} Can edit {:else} import { CoreSelect, CoreMultiselect } from "@budibase/bbui" - import { FieldType } from "@budibase/types" + import { FieldType, InternalTable } from "@budibase/types" import { fetchData, Utils } from "@budibase/frontend-core" import { getContext } from "svelte" import Field from "./Field.svelte" import type { SearchFilter, RelationshipFieldMetadata, - Table, Row, } from "@budibase/types" - const { API } = getContext("sdk") - export let field: string | undefined = undefined export let label: string | undefined = undefined export let placeholder: any = undefined @@ -20,10 +17,10 @@ export let readonly: boolean = false export let validation: any export let autocomplete: boolean = true - export let defaultValue: string | undefined = undefined + export let defaultValue: string | string[] | undefined = undefined export let onChange: any export let filter: SearchFilter[] - export let datasourceType: "table" | "user" | "groupUser" = "table" + export let datasourceType: "table" | "user" = "table" export let primaryDisplay: string | undefined = undefined export let span: number | undefined = undefined export let helpText: string | undefined = undefined @@ -32,191 +29,305 @@ | FieldType.BB_REFERENCE | FieldType.BB_REFERENCE_SINGLE = FieldType.LINK - type RelationshipValue = { _id: string; [key: string]: any } - type OptionObj = Record - type OptionsObjType = Record + type BasicRelatedRow = { _id: string; primaryDisplay: string } + type OptionsMap = Record + const { API } = getContext("sdk") + + // Field state let fieldState: any let fieldApi: any let fieldSchema: RelationshipFieldMetadata | undefined - let tableDefinition: Table | null | undefined - let searchTerm: any - let open: boolean - let selectedValue: string[] | string - // need a cast version of this for reactivity, components below aren't typed - $: castSelectedValue = selectedValue as any + // Local UI state + let searchTerm: any + let open: boolean = false + + // Options state + let options: BasicRelatedRow[] = [] + let optionsMap: OptionsMap = {} + let loadingMissingOptions: boolean = false + + // Determine if we can select multiple rows or not $: multiselect = [FieldType.LINK, FieldType.BB_REFERENCE].includes(type) && fieldSchema?.relationshipType !== "one-to-many" - $: linkedTableId = fieldSchema?.tableId! - $: fetch = fetchData({ - API, - datasource: { - // typing here doesn't seem correct - we have the correct datasourceType options - // but when we configure the fetchData, it seems to think only "table" is valid - type: datasourceType as any, - tableId: linkedTableId, - }, - options: { - filter, - limit: 100, - }, - }) - $: tableDefinition = $fetch.definition - $: selectedValue = multiselect - ? flatten(fieldState?.value) ?? [] - : flatten(fieldState?.value)?.[0] - $: component = multiselect ? CoreMultiselect : CoreSelect - $: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay + // Get the proper string representation of the value + $: realValue = fieldState?.value + $: selectedValue = parseSelectedValue(realValue, multiselect) + $: selectedIDs = getSelectedIDs(selectedValue) - let optionsObj: OptionsObjType = {} - const debouncedFetchRows = Utils.debounce(fetchRows, 250) + // If writable, we use a fetch to load options + $: linkedTableId = fieldSchema?.tableId + $: writable = !disabled && !readonly + $: fetch = createFetch(writable, datasourceType, filter, linkedTableId) - $: { - if (primaryDisplay && fieldState && !optionsObj) { - // Persist the initial values as options, allowing them to be present in the dropdown, - // even if they are not in the inital fetch results - let valueAsSafeArray = fieldState.value || [] - if (!Array.isArray(valueAsSafeArray)) { - valueAsSafeArray = [fieldState.value] - } - optionsObj = valueAsSafeArray.reduce( - ( - accumulator: OptionObj, - value: { _id: string; primaryDisplay: any } - ) => { - // fieldState has to be an array of strings to be valid for an update - // therefore we cannot guarantee value will be an object - // https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for - if (!value._id) { - return accumulator + // Attempt to determine the primary display field to use + $: tableDefinition = $fetch?.definition + $: primaryDisplayField = primaryDisplay || tableDefinition?.primaryDisplay + + // Build our options map + $: rows = $fetch?.rows || [] + $: processOptions(realValue, rows, primaryDisplayField) + + // If we ever have a value selected for which we don't have an option, we must + // fetch those rows to ensure we can render them as options + $: missingIDs = selectedIDs.filter(id => !optionsMap[id]) + $: loadMissingOptions(missingIDs, linkedTableId, primaryDisplayField) + + // Convert our options map into an array for display + $: updateOptions(optionsMap) + $: !open && sortOptions() + + // Search for new options when search term changes + $: debouncedSearchOptions(searchTerm || "", primaryDisplayField) + + // Ensure backwards compatibility + $: enrichedDefaultValue = enrichDefaultValue(defaultValue) + + // We need to cast value to pass it down, as those components aren't typed + $: emptyValue = multiselect ? [] : null + $: displayValue = missingIDs.length ? emptyValue : (selectedValue as any) + + // Ensures that we flatten any objects so that only the IDs of the selected + // rows are passed down. Not sure how this can be an object to begin with? + const parseSelectedValue = ( + value: any, + multiselect: boolean + ): undefined | string | string[] => { + return multiselect ? flatten(value) : flatten(value)[0] + } + + // Where applicable, creates the fetch instance to load row options + const createFetch = ( + writable: boolean, + dsType: typeof datasourceType, + filter: SearchFilter[], + linkedTableId?: string + ) => { + if (!linkedTableId) { + return undefined + } + const datasource = + datasourceType === "table" + ? { + type: datasourceType, + tableId: fieldSchema?.tableId!, } - accumulator[value._id] = { - _id: value._id, - [primaryDisplay]: value.primaryDisplay, + : { + type: datasourceType, + tableId: InternalTable.USER_METADATA, } - return accumulator - }, - {} - ) - } - } - - $: enrichedOptions = enrichOptions(optionsObj, $fetch.rows) - const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => { - const result = (fetchResults || [])?.reduce((accumulator, row) => { - if (!accumulator[row._id!]) { - accumulator[row._id!] = row - } - return accumulator - }, optionsObj || {}) - - return Object.values(result) - } - $: { - // We don't want to reorder while the dropdown is open, to avoid UX jumps - if (!open && primaryDisplay) { - enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => { - const selectedValues = flatten(fieldState?.value) || [] - - const aIsSelected = selectedValues.find( - (v: RelationshipValue) => v === a._id - ) - const bIsSelected = selectedValues.find( - (v: RelationshipValue) => v === b._id - ) - if (aIsSelected && !bIsSelected) { - return -1 - } else if (!aIsSelected && bIsSelected) { - return 1 - } - - return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number - }) - } - } - - $: { - if (filter || defaultValue) { - forceFetchRows() - } - } - $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) - - const forceFetchRows = async () => { - // if the filter has changed, then we need to reset the options, clear the selection, and re-fetch - optionsObj = {} - fieldApi?.setValue([]) - selectedValue = [] - debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) - } - async function fetchRows( - searchTerm: any, - primaryDisplay: string, - defaultVal: string | string[] - ) { - const allRowsFetched = - $fetch.loaded && - !Object.keys($fetch.query?.string || {}).length && - !$fetch.hasNextPage - // Don't request until we have the primary display or default value has been fetched - if (allRowsFetched || !primaryDisplay) { - return - } - // must be an array - const defaultValArray: string[] = !defaultVal - ? [] - : !Array.isArray(defaultVal) - ? defaultVal.split(",") - : defaultVal - - if ( - defaultVal && - optionsObj && - defaultValArray.some(val => !optionsObj[val]) - ) { - await fetch.update({ - query: { oneOf: { _id: defaultValArray } }, - }) - } - - if ( - (Array.isArray(selectedValue) && - selectedValue.some(val => !optionsObj[val])) || - (selectedValue && !optionsObj[selectedValue as string]) - ) { - await fetch.update({ - query: { - oneOf: { - _id: Array.isArray(selectedValue) ? selectedValue : [selectedValue], - }, - }, - }) - } - - // Ensure we match all filters, rather than any - // @ts-expect-error this doesn't fit types, but don't want to change it yet - const baseFilter: any = (filter || []).filter(x => x.operator !== "allOr") - await fetch.update({ - filter: [ - ...baseFilter, - { - // Use a big numeric prefix to avoid clashing with an existing filter - field: `999:${primaryDisplay}`, - operator: "string", - value: searchTerm, - }, - ], + return fetchData({ + API, + datasource, + options: { + filter, + limit: writable ? 100 : 1, + }, }) } - const flatten = (values: any | any[]) => { + // Small helper to represent the selected value as an array + const getSelectedIDs = ( + selectedValue: undefined | string | string[] + ): string[] => { + if (!selectedValue) { + return [] + } + return Array.isArray(selectedValue) ? selectedValue : [selectedValue] + } + + // Builds a map of all available options, in a consistent structure + const processOptions = ( + realValue: any | any[], + rows: Row[], + primaryDisplay?: string + ) => { + // First ensure that all options included in the value are present as valid + // options. These can be basic related row shapes which already include + // a value for primary display + if (realValue) { + const valueArray = Array.isArray(realValue) ? realValue : [realValue] + for (let val of valueArray) { + const option = parseOption(val, primaryDisplay) + if (option) { + optionsMap[option._id] = option + } + } + } + + // Process all rows loaded from our fetch + for (let row of rows) { + const option = parseOption(row, primaryDisplay) + if (option) { + optionsMap[option._id] = option + } + } + + // Reassign to trigger reactivity + optionsMap = optionsMap + } + + // Parses a row-like structure into a properly shaped option + const parseOption = ( + option: any | BasicRelatedRow | Row, + primaryDisplay?: string + ): BasicRelatedRow | null => { + if (!option || typeof option !== "object" || !option?._id) { + return null + } + // If this is a basic related row shape (_id and PD only) then just use + // that + if (Object.keys(option).length === 2 && "primaryDisplay" in option) { + return { + _id: option._id, + primaryDisplay: ensureString(option.primaryDisplay), + } + } + // Otherwise use the primary display field specified + if (primaryDisplay) { + return { + _id: option._id, + primaryDisplay: ensureString( + option[primaryDisplay as keyof typeof option] + ), + } + } else { + return { + _id: option._id, + primaryDisplay: option._id, + } + } + } + + // Loads any rows which are selected and aren't present in the currently + // available option set. This is typically only IDs specified as default + // values. + const loadMissingOptions = async ( + missingIDs: string[], + linkedTableId?: string, + primaryDisplay?: string + ) => { + if ( + loadingMissingOptions || + !missingIDs.length || + !linkedTableId || + !primaryDisplay + ) { + return + } + loadingMissingOptions = true + try { + const res = await API.searchTable(linkedTableId, { + query: { + oneOf: { + _id: missingIDs, + }, + }, + }) + for (let row of res.rows) { + const option = parseOption(row, primaryDisplay) + if (option) { + optionsMap[option._id] = option + } + } + + // Reassign to trigger reactivity + optionsMap = optionsMap + updateOptions(optionsMap) + } catch (error) { + console.error("Error loading missing row IDs", error) + } finally { + // Ensure we have some sort of option for all IDs + for (let id of missingIDs) { + if (!optionsMap[id]) { + optionsMap[id] = { + _id: id, + primaryDisplay: id, + } + } + } + loadingMissingOptions = false + } + } + + // Updates the options list to reflect the currently available options + const updateOptions = (optionsMap: OptionsMap) => { + let newOptions = Object.values(optionsMap) + + // Only override options if the quantity of options changes + if (newOptions.length !== options.length) { + options = newOptions + sortOptions() + } + } + + // Sorts the options list by selected state, then by primary display + const sortOptions = () => { + // Create a quick lookup map so we can test whether options are selected + const selectedMap: Record = selectedIDs.reduce( + (map, id) => ({ ...map, [id]: true }), + {} + ) + options.sort((a, b) => { + const aSelected = !!selectedMap[a._id] + const bSelected = !!selectedMap[b._id] + if (aSelected === bSelected) { + return a.primaryDisplay < b.primaryDisplay ? -1 : 1 + } else { + return aSelected ? -1 : 1 + } + }) + } + + // Util to ensure a value is stringified + const ensureString = (val: any): string => { + return typeof val === "string" ? val : JSON.stringify(val) + } + + // We previously included logic to manually process default value, which + // should not be done as it is handled by the core form logic. + // This logic included handling a comma separated list of IDs, so for + // backwards compatibility we must now unfortunately continue to handle that + // at this level. + const enrichDefaultValue = (val: any) => { + if (!val || typeof val !== "string") { + return val + } + return val.includes(",") ? val.split(",") : val + } + + // Searches for new options matching the given term + async function searchOptions(searchTerm: string, primaryDisplay?: string) { + if (!primaryDisplay) { + return + } + + // Ensure we match all filters, rather than any + let newFilter: any = filter + if (searchTerm) { + // @ts-expect-error this doesn't fit types, but don't want to change it yet + newFilter = (newFilter || []).filter(x => x.operator !== "allOr") + newFilter.push({ + // Use a big numeric prefix to avoid clashing with an existing filter + field: `999:${primaryDisplay}`, + operator: "string", + value: searchTerm, + }) + } + await fetch?.update({ + filter: newFilter, + }) + } + const debouncedSearchOptions = Utils.debounce(searchOptions, 250) + + // Flattens an array of row-like objects into a simple array of row IDs + const flatten = (values: any | any[]): string[] => { if (!values) { return [] } - if (!Array.isArray(values)) { values = [values] } @@ -226,16 +337,11 @@ return values } - const getDisplayName = (row: Row) => { - return row?.[primaryDisplay!] || "-" - } - const handleChange = (e: any) => { let value = e.detail if (!multiselect) { value = value == null ? [] : [value] } - if ( type === FieldType.BB_REFERENCE_SINGLE && value && @@ -243,7 +349,6 @@ ) { value = value[0] || null } - const changed = fieldApi.setValue(value) if (onChange && changed) { onChange({ @@ -251,12 +356,6 @@ }) } } - - const loadMore = () => { - if (!$fetch.loading) { - fetch.nextPage() - } - } {#if fieldState} option.primaryDisplay} getOptionValue={option => option._id} + {options} {placeholder} + {autocomplete} bind:searchTerm - loading={$fetch.loading} bind:open + on:change={handleChange} + on:loadMore={() => fetch?.nextPage()} /> {/if} diff --git a/packages/client/src/components/error-states/ComponentErrorState.svelte b/packages/client/src/components/error-states/ComponentErrorState.svelte index b2e7c92eae..1bcd5f21fa 100644 --- a/packages/client/src/components/error-states/ComponentErrorState.svelte +++ b/packages/client/src/components/error-states/ComponentErrorState.svelte @@ -1,7 +1,7 @@