diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index d6bbf19940..3060660d47 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -33,13 +33,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -50,14 +50,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -80,7 +80,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -92,14 +92,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -116,14 +116,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -140,14 +140,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -165,14 +165,14 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' steps: - name: Checkout repo and submodules - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -189,13 +189,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -219,7 +219,7 @@ jobs: if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') steps: - name: Checkout repo and submodules - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} @@ -249,7 +249,7 @@ jobs: - name: Check submodule merged to base branch if: ${{ steps.get_pro_commits.outputs.base_commit != '' }} - uses: actions/github-script@v4 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -269,7 +269,7 @@ jobs: if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') steps: - name: Checkout repo and submodules - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} @@ -299,7 +299,7 @@ jobs: - name: Check submodule merged to base branch if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }} - uses: actions/github-script@v4 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/close-featurebranch.yml b/.github/workflows/close-featurebranch.yml index 5da3eb52cd..0439aec443 100644 --- a/.github/workflows/close-featurebranch.yml +++ b/.github/workflows/close-featurebranch.yml @@ -17,7 +17,7 @@ jobs: github.event.label.name == 'feature-branch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: passeidireto/trigger-external-workflow-action@main env: PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }} diff --git a/.github/workflows/deploy-featurebranch.yml b/.github/workflows/deploy-featurebranch.yml index a5636fe912..eccc783dfb 100644 --- a/.github/workflows/deploy-featurebranch.yml +++ b/.github/workflows/deploy-featurebranch.yml @@ -17,7 +17,7 @@ jobs: contains(github.event.pull_request.labels.*.name, 'feature-branch') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: passeidireto/trigger-external-workflow-action@main env: PAYLOAD_BRANCH: ${{ github.head_ref }} diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 13d59d1019..483e895e98 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -28,7 +28,7 @@ jobs: run: | echo "Ref is not master, you must run this job from master." exit 1 - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} @@ -53,7 +53,7 @@ jobs: needs: [tag-release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: peter-evans/repository-dispatch@v2 with: diff --git a/lerna.json b/lerna.json index 3ab56dc0b1..65f04ecf2c 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.15.2", + "version": "2.15.7", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/account-portal b/packages/account-portal index 05c90ce551..e9af6686ba 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 05c90ce55144e260da6688335c16783eab79bf96 +Subproject commit e9af6686ba135c367e9145a53d26c68325b9bf68 diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 0fec786c31..b3179cbeea 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -179,6 +179,7 @@ const environment = { ...getPackageJsonFields(), DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, OFFLINE_MODE: process.env.OFFLINE_MODE, + SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS, _set(key: any, value: any) { process.env[key] = value // @ts-ignore diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 7bf26f3688..8001017092 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -2,6 +2,7 @@ export * as configs from "./configs" export * as events from "./events" export * as migrations from "./migrations" export * as users from "./users" +export * as userUtils from "./users/utils" export * as roles from "./security/roles" export * as permissions from "./security/permissions" export * as accounts from "./accounts" diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index a86a829b17..8d7b43d5b6 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -1,8 +1,8 @@ -const redis = require("../redis/init") -const { v4: uuidv4 } = require("uuid") -const { logWarn } = require("../logging") - +import * as redis from "../redis/init" +import { v4 as uuidv4 } from "uuid" +import { logWarn } from "../logging" import env from "../environment" +import { Duration } from "../utils" import { Session, ScannedSession, @@ -10,8 +10,10 @@ import { CreateSession, } from "@budibase/types" -// a week in seconds -const EXPIRY_SECONDS = 86400 * 7 +// a week expiry is the default +const EXPIRY_SECONDS = env.SESSION_EXPIRY_SECONDS + ? parseInt(env.SESSION_EXPIRY_SECONDS) + : Duration.fromDays(7).toSeconds() function makeSessionID(userId: string, sessionId: string) { return `${userId}/${sessionId}` diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index 4d0d216603..136cb4b8ad 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -251,7 +251,8 @@ export class UserDB { } const change = dbUser ? 0 : 1 // no change if there is existing user - const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0 + const creatorsChange = + (await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0 return UserDB.quotas.addUsers(change, creatorsChange, async () => { await validateUniqueUser(email, tenantId) @@ -335,7 +336,7 @@ export class UserDB { } newUser.userGroups = groups || [] newUsers.push(newUser) - if (isCreator(newUser)) { + if (await isCreator(newUser)) { newCreators.push(newUser) } } @@ -432,12 +433,16 @@ export class UserDB { _deleted: true, })) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) - const creatorsToDelete = usersToDelete.filter(isCreator) + + const creatorsEval = await Promise.all(usersToDelete.map(isCreator)) + const creatorsToDeleteCount = creatorsEval.filter( + creator => !!creator + ).length for (let user of usersToDelete) { await bulkDeleteProcessing(user) } - await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length) + await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount) // Build Response // index users by id @@ -486,7 +491,7 @@ export class UserDB { await db.remove(userId, dbUser._rev) - const creatorsToDelete = isCreator(dbUser) ? 1 : 0 + const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0 await UserDB.quotas.removeUsers(1, creatorsToDelete) await eventHelpers.handleDeleteEvents(dbUser) await cache.user.invalidateUser(userId) diff --git a/packages/backend-core/src/users/test/utils.spec.ts b/packages/backend-core/src/users/test/utils.spec.ts new file mode 100644 index 0000000000..0fe27f57a6 --- /dev/null +++ b/packages/backend-core/src/users/test/utils.spec.ts @@ -0,0 +1,67 @@ +import { User, UserGroup } from "@budibase/types" +import { generator, structures } from "../../../tests" +import { DBTestConfiguration } from "../../../tests/extra" +import { getGlobalDB } from "../../context" +import { isCreator } from "../utils" + +const config = new DBTestConfiguration() + +describe("Users", () => { + it("User is a creator if it is configured as a global builder", async () => { + const user: User = structures.users.user({ builder: { global: true } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it is configured as a global admin", async () => { + const user: User = structures.users.user({ admin: { global: true } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it is configured with creator permission", async () => { + const user: User = structures.users.user({ builder: { creator: true } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it is a builder in some application", async () => { + const user: User = structures.users.user({ builder: { apps: ["app1"] } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it has CREATOR permission in some application", async () => { + const user: User = structures.users.user({ roles: { app1: "CREATOR" } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it has ADMIN permission in some application", async () => { + const user: User = structures.users.user({ roles: { app1: "ADMIN" } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it remains to a group with ADMIN permissions", async () => { + const usersInGroup = 10 + const groupId = "gr_17abffe89e0b40268e755b952f101a59" + const group: UserGroup = { + ...structures.userGroups.userGroup(), + ...{ _id: groupId, roles: { app1: "ADMIN" } }, + } + const users: User[] = [] + for (const _ of Array.from({ length: usersInGroup })) { + const userId = `us_${generator.guid()}` + const user: User = structures.users.user({ + _id: userId, + userGroups: [groupId], + }) + users.push(user) + } + + await config.doInTenant(async () => { + const db = getGlobalDB() + await db.put(group) + for (let user of users) { + await db.put(user) + const creator = await isCreator(user) + expect(creator).toBe(true) + } + }) + }) +}) diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index cc2b4fc27f..638da4a5b1 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -309,7 +309,8 @@ export async function getCreatorCount() { let creators = 0 async function iterate(startPage?: string) { const page = await paginatedUsers({ bookmark: startPage }) - creators += page.data.filter(isCreator).length + const creatorsEval = await Promise.all(page.data.map(isCreator)) + creators += creatorsEval.filter(creator => !!creator).length if (page.hasNextPage) { await iterate(page.nextPage) } diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts index 0ef4b77998..348ad1532f 100644 --- a/packages/backend-core/src/users/utils.ts +++ b/packages/backend-core/src/users/utils.ts @@ -1,4 +1,4 @@ -import { CloudAccount } from "@budibase/types" +import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types" import * as accountSdk from "../accounts" import env from "../environment" import { getPlatformUser } from "./lookup" @@ -6,17 +6,48 @@ import { EmailUnavailableError } from "../errors" import { getTenantId } from "../context" import { sdk } from "@budibase/shared-core" import { getAccountByTenantId } from "../accounts" +import { BUILTIN_ROLE_IDS } from "../security/roles" +import * as context from "../context" // extract from shared-core to make easily accessible from backend-core export const isBuilder = sdk.users.isBuilder export const isAdmin = sdk.users.isAdmin -export const isCreator = sdk.users.isCreator export const isGlobalBuilder = sdk.users.isGlobalBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions +export async function isCreator(user?: User | ContextUser) { + const isCreatorByUserDefinition = sdk.users.isCreator(user) + if (!isCreatorByUserDefinition && user) { + return await isCreatorByGroupMembership(user) + } + return isCreatorByUserDefinition +} + +async function isCreatorByGroupMembership(user?: User | ContextUser) { + const userGroups = user?.userGroups || [] + if (userGroups.length > 0) { + const db = context.getGlobalDB() + const groups: UserGroup[] = [] + for (let groupId of userGroups) { + try { + const group = await db.get(groupId) + groups.push(group) + } catch (e: any) { + if (e.error !== "not_found") { + throw e + } + } + } + return groups.some(group => + Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN) + ) + } + return false +} + export async function validateUniqueUser(email: string, tenantId: string) { // check budibase users in other tenants if (env.MULTI_TENANCY) { diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index f2018272f6..cc169eac09 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -18,7 +18,6 @@ export default function positionDropdown(element, opts) { useAnchorWidth, offset = 5, customUpdate, - offsetBelow, } = opts if (!anchor) { return @@ -48,7 +47,7 @@ export default function positionDropdown(element, opts) { styles.top = anchorBounds.top - elementBounds.height - offset styles.maxHeight = maxHeight || 240 } else { - styles.top = anchorBounds.bottom + (offsetBelow || offset) + styles.top = anchorBounds.bottom + offset styles.maxHeight = maxHeight || window.innerHeight - anchorBounds.bottom - 20 } diff --git a/packages/bbui/src/Form/Core/Multiselect.svelte b/packages/bbui/src/Form/Core/Multiselect.svelte index d5d6515d2d..2243570cd5 100644 --- a/packages/bbui/src/Form/Core/Multiselect.svelte +++ b/packages/bbui/src/Form/Core/Multiselect.svelte @@ -15,8 +15,6 @@ export let autoWidth = false export let searchTerm = null export let customPopoverHeight - export let customPopoverOffsetBelow - export let customPopoverMaxHeight export let open = false export let loading @@ -98,7 +96,5 @@ {sort} {autoWidth} {customPopoverHeight} - {customPopoverOffsetBelow} - {customPopoverMaxHeight} {loading} /> diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index 94fbe73cf2..cfb1654403 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -37,8 +37,6 @@ export let sort = false export let searchTerm = null export let customPopoverHeight - export let customPopoverOffsetBelow - export let customPopoverMaxHeight export let align = "left" export let footer = null export let customAnchor = null @@ -156,9 +154,7 @@ on:close={() => (open = false)} useAnchorWidth={!autoWidth} maxWidth={autoWidth ? 400 : null} - maxHeight={customPopoverMaxHeight} customHeight={customPopoverHeight} - offsetBelow={customPopoverOffsetBelow} >
null export let getOptionColour = () => null export let getOptionSubtitle = () => null + export let compare = null export let useOptionIconImage = false export let isOptionEnabled export let readonly = false @@ -23,8 +24,6 @@ export let footer = null export let open = false export let tag = null - export let customPopoverOffsetBelow - export let customPopoverMaxHeight export let searchTerm = null export let loading @@ -34,13 +33,19 @@ $: fieldIcon = getFieldAttribute(getOptionIcon, value, options) $: fieldColour = getFieldAttribute(getOptionColour, value, options) + function compareOptionAndValue(option, value) { + return typeof compare === "function" + ? compare(option, value) + : option === value + } + const getFieldAttribute = (getAttribute, value, options) => { // Wait for options to load if there is a value but no options if (!options?.length) { return "" } - const index = options.findIndex( - (option, idx) => getOptionValue(option, idx) === value + const index = options.findIndex((option, idx) => + compareOptionAndValue(getOptionValue(option, idx), value) ) return index !== -1 ? getAttribute(options[index], index) : null } @@ -90,11 +95,9 @@ {autocomplete} {sort} {tag} - {customPopoverOffsetBelow} - {customPopoverMaxHeight} isPlaceholder={value == null || value === ""} placeholderOption={placeholder === false ? null : placeholder} - isOptionSelected={option => option === value} + isOptionSelected={option => compareOptionAndValue(option, value)} onSelectOption={selectOption} {loading} /> diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 8235b68faf..2119a37980 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -28,6 +28,7 @@ export let footer = null export let tag = null export let helpText = null + export let compare const dispatch = createEventDispatcher() const onChange = e => { value = e.detail @@ -65,6 +66,7 @@ {autocomplete} {customPopoverHeight} {tag} + {compare} on:change={onChange} on:click /> diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index a68430e973..5066e3aa05 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -18,7 +18,6 @@ export let useAnchorWidth = false export let dismissible = true export let offset = 5 - export let offsetBelow export let customHeight export let animate = true export let customZindex @@ -89,7 +88,6 @@ maxWidth, useAnchorWidth, offset, - offsetBelow, customUpdate: handlePostionUpdate, }} use:clickOutside={{ diff --git a/packages/builder/src/builderStore/componentUtils.js b/packages/builder/src/builderStore/componentUtils.js index 733ce0948e..a7ee3ff351 100644 --- a/packages/builder/src/builderStore/componentUtils.js +++ b/packages/builder/src/builderStore/componentUtils.js @@ -92,14 +92,7 @@ export const findAllMatchingComponents = (rootComponent, selector) => { } /** - * Recurses through the component tree and finds all components. - */ -export const findAllComponents = rootComponent => { - return findAllMatchingComponents(rootComponent, () => true) -} - -/** - * Finds the closest parent component which matches certain criteria + * Finds the closes parent component which matches certain criteria */ export const findClosestMatchingComponent = ( rootComponent, diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 85d5046d8c..52368a0723 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -1,7 +1,6 @@ import { cloneDeep } from "lodash/fp" import { get } from "svelte/store" import { - findAllComponents, findAllMatchingComponents, findComponent, findComponentPath, @@ -103,9 +102,6 @@ export const getAuthBindings = () => { return bindings } -/** - * Gets all bindings for environment variables - */ export const getEnvironmentBindings = () => { let envVars = get(environment).variables return envVars.map(variable => { @@ -134,22 +130,26 @@ export const toBindingsArray = (valueMap, prefix, category) => { if (!binding) { return acc } + let config = { type: "context", runtimeBinding: binding, readableBinding: `${prefix}.${binding}`, icon: "Brackets", } + if (category) { config.category = category } + acc.push(config) + return acc }, []) } /** - * Utility to covert a map of readable bindings to runtime + * Utility - coverting a map of readable bindings to runtime */ export const readableToRuntimeMap = (bindings, ctx) => { if (!bindings || !ctx) { @@ -162,7 +162,7 @@ export const readableToRuntimeMap = (bindings, ctx) => { } /** - * Utility to covert a map of runtime bindings to readable bindings + * Utility - coverting a map of runtime bindings to readable */ export const runtimeToReadableMap = (bindings, ctx) => { if (!bindings || !ctx) { @@ -188,23 +188,15 @@ export const getComponentBindableProperties = (asset, componentId) => { if (!def?.context) { return [] } - const contexts = Array.isArray(def.context) ? def.context : [def.context] // Get the bindings for the component - const componentContext = { - component, - definition: def, - contexts, - } - return generateComponentContextBindings(asset, componentContext) + return getProviderContextBindings(asset, component) } /** - * Gets all component contexts available to a certain component. This handles - * both global and local bindings, taking into account a component's position - * in the component tree. + * Gets all data provider components above a component. */ -export const getComponentContexts = ( +export const getContextProviderComponents = ( asset, componentId, type, @@ -213,55 +205,30 @@ export const getComponentContexts = ( if (!asset || !componentId) { return [] } - let map = {} - // Processes all contexts exposed by a component - const processContexts = scope => component => { + // Get the component tree leading up to this component, ignoring the component + // itself + const path = findComponentPath(asset.props, componentId) + if (!options?.includeSelf) { + path.pop() + } + + // Filter by only data provider components + return path.filter(component => { const def = store.actions.components.getDefinition(component._component) if (!def?.context) { - return + return false } - if (!map[component._id]) { - map[component._id] = { - component, - definition: def, - contexts: [], - } + + // If no type specified, return anything that exposes context + if (!type) { + return true } + + // Otherwise only match components with the specific context type const contexts = Array.isArray(def.context) ? def.context : [def.context] - contexts.forEach(context => { - // Ensure type matches - if (type && context.type !== type) { - return - } - // Ensure scope matches - let contextScope = context.scope || "global" - if (contextScope !== scope) { - return - } - // Ensure the context is compatible with the component's current settings - if (!isContextCompatibleWithComponent(context, component)) { - return - } - map[component._id].contexts.push(context) - }) - } - - // Process all global contexts - const allComponents = findAllComponents(asset.props) - allComponents.forEach(processContexts("global")) - - // Process all local contexts - const localComponents = findComponentPath(asset.props, componentId) - localComponents.forEach(processContexts("local")) - - // Exclude self if required - if (!options?.includeSelf) { - delete map[componentId] - } - - // Only return components which provide at least 1 matching context - return Object.values(map).filter(x => x.contexts.length > 0) + return contexts.find(context => context.type === type) != null + }) } /** @@ -273,19 +240,20 @@ export const getActionProviders = ( actionType, options = { includeSelf: false } ) => { - if (!asset) { + if (!asset || !componentId) { return [] } - // Get all components - const components = findAllComponents(asset.props) + // Get the component tree leading up to this component, ignoring the component + // itself + const path = findComponentPath(asset.props, componentId) + if (!options?.includeSelf) { + path.pop() + } // Find matching contexts and generate bindings let providers = [] - components.forEach(component => { - if (!options?.includeSelf && component._id === componentId) { - return - } + path.forEach(component => { const def = store.actions.components.getDefinition(component._component) const actions = (def?.actions || []).map(action => { return typeof action === "string" ? { type: action } : action @@ -349,135 +317,142 @@ export const getDatasourceForProvider = (asset, component) => { * Gets all bindable data properties from component data contexts. */ const getContextBindings = (asset, componentId) => { - // Get all available contexts for this component - const componentContexts = getComponentContexts(asset, componentId) + // Extract any components which provide data contexts + const dataProviders = getContextProviderComponents(asset, componentId) - // Generate bindings for each context - return componentContexts - .map(componentContext => { - return generateComponentContextBindings(asset, componentContext) - }) - .flat() + // Generate bindings for all matching components + return getProviderContextBindings(asset, dataProviders) } /** - * Generates a set of bindings for a given component context + * Gets the context bindings exposed by a set of data provider components. */ -const generateComponentContextBindings = (asset, componentContext) => { - const { component, definition, contexts } = componentContext - if (!component || !definition || !contexts?.length) { +const getProviderContextBindings = (asset, dataProviders) => { + if (!asset || !dataProviders) { return [] } + // Ensure providers is an array + if (!Array.isArray(dataProviders)) { + dataProviders = [dataProviders] + } + // Create bindings for each data provider let bindings = [] - contexts.forEach(context => { - if (!context?.type) { - return - } + dataProviders.forEach(component => { + const def = store.actions.components.getDefinition(component._component) + const contexts = Array.isArray(def.context) ? def.context : [def.context] - let schema - let table - let readablePrefix - let runtimeSuffix = context.suffix - - if (context.type === "form") { - // Forms do not need table schemas - // Their schemas are built from their component field names - schema = buildFormSchema(component, asset) - readablePrefix = "Fields" - } else if (context.type === "static") { - // Static contexts are fully defined by the components - schema = {} - const values = context.values || [] - values.forEach(value => { - schema[value.key] = { - name: value.label, - type: value.type || "string", - } - }) - } else if (context.type === "schema") { - // Schema contexts are generated dynamically depending on their data - const datasource = getDatasourceForProvider(asset, component) - if (!datasource) { + // Create bindings for each context block provided by this data provider + contexts.forEach(context => { + if (!context?.type) { return } - const info = getSchemaForDatasource(asset, datasource) - schema = info.schema - table = info.table - // Determine what to prefix bindings with - if (datasource.type === "jsonarray") { - // For JSON arrays, use the array name as the readable prefix - const split = datasource.label.split(".") - readablePrefix = split[split.length - 1] - } else if (datasource.type === "viewV2") { - // For views, use the view name - const view = Object.values(table?.views || {}).find( - view => view.id === datasource.id + let schema + let table + let readablePrefix + let runtimeSuffix = context.suffix + + if (context.type === "form") { + // Forms do not need table schemas + // Their schemas are built from their component field names + schema = buildFormSchema(component, asset) + readablePrefix = "Fields" + } else if (context.type === "static") { + // Static contexts are fully defined by the components + schema = {} + const values = context.values || [] + values.forEach(value => { + schema[value.key] = { + name: value.label, + type: value.type || "string", + } + }) + } else if (context.type === "schema") { + // Schema contexts are generated dynamically depending on their data + const datasource = getDatasourceForProvider(asset, component) + if (!datasource) { + return + } + const info = getSchemaForDatasource(asset, datasource) + schema = info.schema + table = info.table + + // Determine what to prefix bindings with + if (datasource.type === "jsonarray") { + // For JSON arrays, use the array name as the readable prefix + const split = datasource.label.split(".") + readablePrefix = split[split.length - 1] + } else if (datasource.type === "viewV2") { + // For views, use the view name + const view = Object.values(table?.views || {}).find( + view => view.id === datasource.id + ) + readablePrefix = view?.name + } else { + // Otherwise use the table name + readablePrefix = info.table?.name + } + } + if (!schema) { + return + } + + const keys = Object.keys(schema).sort() + + // Generate safe unique runtime prefix + let providerId = component._id + if (runtimeSuffix) { + providerId += `-${runtimeSuffix}` + } + + if (!filterCategoryByContext(component, context)) { + return + } + + const safeComponentId = makePropSafe(providerId) + + // Create bindable properties for each schema field + keys.forEach(key => { + const fieldSchema = schema[key] + + // Make safe runtime binding + const safeKey = key.split(".").map(makePropSafe).join(".") + const runtimeBinding = `${safeComponentId}.${safeKey}` + + // Optionally use a prefix with readable bindings + let readableBinding = component._instanceName + if (readablePrefix) { + readableBinding += `.${readablePrefix}` + } + readableBinding += `.${fieldSchema.name || key}` + + const bindingCategory = getComponentBindingCategory( + component, + context, + def ) - readablePrefix = view?.name - } else { - // Otherwise use the table name - readablePrefix = info.table?.name - } - } - if (!schema) { - return - } - const keys = Object.keys(schema).sort() - - // Generate safe unique runtime prefix - let providerId = component._id - if (runtimeSuffix) { - providerId += `-${runtimeSuffix}` - } - const safeComponentId = makePropSafe(providerId) - - // Create bindable properties for each schema field - keys.forEach(key => { - const fieldSchema = schema[key] - - // Make safe runtime binding - const safeKey = key.split(".").map(makePropSafe).join(".") - const runtimeBinding = `${safeComponentId}.${safeKey}` - - // Optionally use a prefix with readable bindings - let readableBinding = component._instanceName - if (readablePrefix) { - readableBinding += `.${readablePrefix}` - } - readableBinding += `.${fieldSchema.name || key}` - - // Determine which category this binding belongs in - const bindingCategory = getComponentBindingCategory( - component, - context, - definition - ) - - // Temporarily append scope for debugging - const scope = `[${(context.scope || "global").toUpperCase()}]` - - // Create the binding object - bindings.push({ - type: "context", - runtimeBinding, - readableBinding: `${scope} ${readableBinding}`, - // Field schema and provider are required to construct relationship - // datasource options, based on bindable properties - fieldSchema, - providerId, - // Table ID is used by JSON fields to know what table the field is in - tableId: table?._id, - component: component._component, - category: bindingCategory.category, - icon: bindingCategory.icon, - display: { - name: `${scope} ${fieldSchema.name || key}`, - type: fieldSchema.type, - }, + // Create the binding object + bindings.push({ + type: "context", + runtimeBinding, + readableBinding, + // Field schema and provider are required to construct relationship + // datasource options, based on bindable properties + fieldSchema, + providerId, + // Table ID is used by JSON fields to know what table the field is in + tableId: table?._id, + component: component._component, + category: bindingCategory.category, + icon: bindingCategory.icon, + display: { + name: fieldSchema.name || key, + type: fieldSchema.type, + }, + }) }) }) }) @@ -485,38 +460,25 @@ const generateComponentContextBindings = (asset, componentContext) => { return bindings } -/** - * Checks if a certain data context is compatible with a certain instance of a - * configured component. - */ -const isContextCompatibleWithComponent = (context, component) => { - if (!component) { - return false - } - const { _component, actionType } = component - const { type } = context - - // Certain types of form blocks only allow certain contexts +// Exclude a data context based on the component settings +const filterCategoryByContext = (component, context) => { + const { _component } = component if (_component.endsWith("formblock")) { if ( - (actionType === "Create" && type === "schema") || - (actionType === "View" && type === "form") + (component.actionType === "Create" && context.type === "schema") || + (component.actionType === "View" && context.type === "form") ) { return false } } - - // Allow the context by default return true } // Enrich binding category information for certain components const getComponentBindingCategory = (component, context, def) => { - // Default category to component name let icon = def.icon let category = component._instanceName - // Form block edge case if (component._component.endsWith("formblock")) { if (context.type === "form") { category = `${component._instanceName} - Fields` @@ -534,7 +496,7 @@ const getComponentBindingCategory = (component, context, def) => { } /** - * Gets all bindable properties from the logged-in user. + * Gets all bindable properties from the logged in user. */ export const getUserBindings = () => { let bindings = [] @@ -604,7 +566,6 @@ const getDeviceBindings = () => { /** * Gets all selected rows bindings for tables in the current asset. - * TODO: remove in future because we don't need a separate store for this */ const getSelectedRowsBindings = asset => { let bindings = [] @@ -647,9 +608,6 @@ const getSelectedRowsBindings = asset => { return bindings } -/** - * Generates a state binding for a certain key name - */ export const makeStateBinding = key => { return { type: "context", @@ -704,9 +662,6 @@ const getUrlBindings = asset => { return urlParamBindings.concat([queryParamsBinding]) } -/** - * Generates all bindings for role IDs - */ const getRoleBindings = () => { return (get(rolesStore) || []).map(role => { return { diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index dd54dcf13e..b58d196024 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -9,6 +9,7 @@ import { findComponent, findComponentPath } from "./componentUtils" import { RoleUtils } from "@budibase/frontend-core" import { createHistoryStore } from "builderStore/store/history" import { cloneDeep } from "lodash/fp" +import { getHoverStore } from "./store/hover" export const store = getFrontendStore() export const automationStore = getAutomationStore() @@ -16,6 +17,7 @@ export const themeStore = getThemeStore() export const temporalStore = getTemporalStore() export const userStore = getUserStore() export const deploymentStore = getDeploymentStore() +export const hoverStore = getHoverStore() // Setup history for screens export const screenHistoryStore = createHistoryStore({ diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index ce2cac9781..ff7c0d74b8 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -92,9 +92,6 @@ const INITIAL_FRONTEND_STATE = { // Onboarding onboarding: false, tourNodes: null, - - // UI state - hoveredComponentId: null, } export const getFrontendStore = () => { @@ -709,9 +706,10 @@ export const getFrontendStore = () => { else { if (setting.type === "dataProvider") { // Validate data provider exists, or else clear it - const providers = findAllMatchingComponents( - screen?.props, - component => component._component?.endsWith("/dataprovider") + const treeId = parent?._id || component._id + const path = findComponentPath(screen?.props, treeId) + const providers = path.filter(component => + component._component?.endsWith("/dataprovider") ) // Validate non-empty values const valid = providers?.some(dp => value.includes?.(dp._id)) @@ -733,16 +731,6 @@ export const getFrontendStore = () => { return null } - // Find all existing components of this type so that we can give this - // component a unique name - const screen = get(selectedScreen).props - const otherComponents = findAllMatchingComponents( - screen, - x => x._component === definition.component && x._id !== screen._id - ) - let name = definition.friendlyName || definition.name - name = `${name} ${otherComponents.length + 1}` - // Generate basic component structure let instance = { _id: Helpers.uuid(), @@ -752,7 +740,7 @@ export const getFrontendStore = () => { hover: {}, active: {}, }, - _instanceName: name, + _instanceName: `New ${definition.friendlyName || definition.name}`, ...presetProps, } @@ -1424,18 +1412,6 @@ export const getFrontendStore = () => { return state }) }, - hover: (componentId, notifyClient = true) => { - if (componentId === get(store).hoveredComponentId) { - return - } - store.update(state => { - state.hoveredComponentId = componentId - return state - }) - if (notifyClient) { - store.actions.preview.sendEvent("hover-component", componentId) - } - }, }, links: { save: async (url, title) => { diff --git a/packages/builder/src/builderStore/store/hover.js b/packages/builder/src/builderStore/store/hover.js new file mode 100644 index 0000000000..5db9272975 --- /dev/null +++ b/packages/builder/src/builderStore/store/hover.js @@ -0,0 +1,27 @@ +import { get, writable } from "svelte/store" +import { store as builder } from "builderStore" + +export const getHoverStore = () => { + const initialValue = { + componentId: null, + } + + const store = writable(initialValue) + + const update = (componentId, notifyClient = true) => { + if (componentId === get(store).componentId) { + return + } + store.update(state => { + state.componentId = componentId + return state + }) + if (notifyClient) { + builder.actions.preview.sendEvent("hover-component", componentId) + } + } + return { + subscribe: store.subscribe, + actions: { update }, + } +} diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index a5a3165aeb..af54e4d2da 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -184,8 +184,9 @@ } if ( - (idx === 0 && automation.trigger?.event === "row:update") || - automation.trigger?.event === "row:save" + idx === 0 && + (automation.trigger?.event === "row:update" || + automation.trigger?.event === "row:save") ) { if (name !== "id" && name !== "revision") return `trigger.row.${name}` } diff --git a/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte b/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte index c837247986..f9b688210a 100644 --- a/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte +++ b/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte @@ -88,8 +88,12 @@ hasValidated = false }) } + $: valid = - getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType) + getErrorCount(errors) === 0 && + allRequiredAttributesSet(relationshipType) && + fromId && + toId $: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY $: isManyToOne = relationshipType === RelationshipType.MANY_TO_ONE || diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js index bdcd3a7838..aa076fdd3e 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js @@ -1,4 +1,5 @@ -import { getComponentContexts } from "builderStore/dataBinding" +import { getContextProviderComponents } from "builderStore/dataBinding" +import { store } from "builderStore" import { capitalise } from "helpers" // Generates bindings for all components that provider "datasource like" @@ -7,49 +8,58 @@ import { capitalise } from "helpers" // Some examples are saving rows or duplicating rows. export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { // Get all form context providers - const formComponentContexts = getComponentContexts( + const formComponents = getContextProviderComponents( asset, componentId, "form", - { - includeSelf: nested, - } + { includeSelf: nested } ) + // Get all schema context providers - const schemaComponentContexts = getComponentContexts( + const schemaComponents = getContextProviderComponents( asset, componentId, "schema", - { - includeSelf: nested, - } + { includeSelf: nested } ) + // Generate contexts for all form providers + const formContexts = formComponents.map(component => ({ + component, + context: extractComponentContext(component, "form"), + })) + + // Generate contexts for all schema providers + const schemaContexts = schemaComponents.map(component => ({ + component, + context: extractComponentContext(component, "schema"), + })) + // Check for duplicate contexts by the same component. In this case, attempt // to label contexts with their suffixes - schemaComponentContexts.forEach(schemaContext => { + schemaContexts.forEach(schemaContext => { // Check if we have a form context for this component const id = schemaContext.component._id - const existing = formComponentContexts.find(x => x.component._id === id) + const existing = formContexts.find(x => x.component._id === id) if (existing) { - if (existing.contexts[0].suffix) { - const suffix = capitalise(existing.contexts[0].suffix) + if (existing.context.suffix) { + const suffix = capitalise(existing.context.suffix) existing.readableSuffix = ` - ${suffix}` } - if (schemaContext.contexts[0].suffix) { - const suffix = capitalise(schemaContext.contexts[0].suffix) + if (schemaContext.context.suffix) { + const suffix = capitalise(schemaContext.context.suffix) schemaContext.readableSuffix = ` - ${suffix}` } } }) // Generate bindings for all contexts - const allContexts = formComponentContexts.concat(schemaComponentContexts) - return allContexts.map(({ component, contexts, readableSuffix }) => { + const allContexts = formContexts.concat(schemaContexts) + return allContexts.map(({ component, context, readableSuffix }) => { let readableBinding = component._instanceName let runtimeBinding = component._id - if (contexts[0].suffix) { - runtimeBinding += `-${contexts[0].suffix}` + if (context.suffix) { + runtimeBinding += `-${context.suffix}` } if (readableSuffix) { readableBinding += readableSuffix @@ -60,3 +70,13 @@ export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { } }) } + +// Gets a context definition of a certain type from a component definition +const extractComponentContext = (component, contextType) => { + const def = store.actions.components.getDefinition(component?._component) + if (!def) { + return null + } + const contexts = Array.isArray(def.context) ? def.context : [def.context] + return contexts.find(context => context?.type === contextType) +} diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte index 63bfecf386..fade2db761 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte @@ -5,6 +5,7 @@ import { store } from "builderStore" import { Helpers } from "@budibase/bbui" import { getEventContextBindings } from "builderStore/dataBinding" + import { cloneDeep, isEqual } from "lodash/fp" export let componentInstance export let componentBindings @@ -17,8 +18,13 @@ const dispatch = createEventDispatcher() let focusItem + let cachedValue - $: buttonList = sanitizeValue(value) || [] + $: if (!isEqual(value, cachedValue)) { + cachedValue = cloneDeep(value) + } + + $: buttonList = sanitizeValue(cachedValue) || [] $: buttonCount = buttonList.length $: eventContextBindings = getEventContextBindings({ componentInstance, diff --git a/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte b/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte index 9fd220e798..83255ec325 100644 --- a/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte @@ -1,16 +1,15 @@ +