1
0
Fork 0
mirror of synced 2024-07-19 13:15:49 +12:00

Merge branch 'master' into 12251-budi-7719-invite-new-user-panel-should-pass-the-search-email-address-into-the-email-field-of-the-add-user-page

This commit is contained in:
Conor Webb 2024-01-26 11:09:16 +00:00 committed by GitHub
commit f7e39f8436
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 1172 additions and 961 deletions

View file

@ -33,13 +33,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -50,14 +50,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -80,7 +80,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@ -92,14 +92,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -116,14 +116,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -140,14 +140,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -165,14 +165,14 @@ jobs:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -189,13 +189,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn 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') if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase')
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
@ -249,7 +249,7 @@ jobs:
- name: Check submodule merged to base branch - name: Check submodule merged to base branch
if: ${{ steps.get_pro_commits.outputs.base_commit != '' }} if: ${{ steps.get_pro_commits.outputs.base_commit != '' }}
uses: actions/github-script@v4 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | 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') if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase')
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
@ -299,7 +299,7 @@ jobs:
- name: Check submodule merged to base branch - name: Check submodule merged to base branch
if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }} if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }}
uses: actions/github-script@v4 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View file

@ -17,7 +17,7 @@ jobs:
github.event.label.name == 'feature-branch' github.event.label.name == 'feature-branch'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: passeidireto/trigger-external-workflow-action@main - uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }} PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }}

View file

@ -17,7 +17,7 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'feature-branch') contains(github.event.pull_request.labels.*.name, 'feature-branch')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: passeidireto/trigger-external-workflow-action@main - uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.head_ref }} PAYLOAD_BRANCH: ${{ github.head_ref }}

View file

@ -28,7 +28,7 @@ jobs:
run: | run: |
echo "Ref is not master, you must run this job from master." echo "Ref is not master, you must run this job from master."
exit 1 exit 1
- uses: actions/checkout@v2 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
@ -53,7 +53,7 @@ jobs:
needs: [tag-release] needs: [tag-release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: peter-evans/repository-dispatch@v2 - uses: peter-evans/repository-dispatch@v2
with: with:

View file

@ -1,5 +1,5 @@
{ {
"version": "2.15.2", "version": "2.15.7",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

@ -1 +1 @@
Subproject commit 05c90ce55144e260da6688335c16783eab79bf96 Subproject commit e9af6686ba135c367e9145a53d26c68325b9bf68

View file

@ -179,6 +179,7 @@ const environment = {
...getPackageJsonFields(), ...getPackageJsonFields(),
DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER,
OFFLINE_MODE: process.env.OFFLINE_MODE, OFFLINE_MODE: process.env.OFFLINE_MODE,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
_set(key: any, value: any) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
// @ts-ignore // @ts-ignore

View file

@ -2,6 +2,7 @@ export * as configs from "./configs"
export * as events from "./events" export * as events from "./events"
export * as migrations from "./migrations" export * as migrations from "./migrations"
export * as users from "./users" export * as users from "./users"
export * as userUtils from "./users/utils"
export * as roles from "./security/roles" export * as roles from "./security/roles"
export * as permissions from "./security/permissions" export * as permissions from "./security/permissions"
export * as accounts from "./accounts" export * as accounts from "./accounts"

View file

@ -1,8 +1,8 @@
const redis = require("../redis/init") import * as redis from "../redis/init"
const { v4: uuidv4 } = require("uuid") import { v4 as uuidv4 } from "uuid"
const { logWarn } = require("../logging") import { logWarn } from "../logging"
import env from "../environment" import env from "../environment"
import { Duration } from "../utils"
import { import {
Session, Session,
ScannedSession, ScannedSession,
@ -10,8 +10,10 @@ import {
CreateSession, CreateSession,
} from "@budibase/types" } from "@budibase/types"
// a week in seconds // a week expiry is the default
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = env.SESSION_EXPIRY_SECONDS
? parseInt(env.SESSION_EXPIRY_SECONDS)
: Duration.fromDays(7).toSeconds()
function makeSessionID(userId: string, sessionId: string) { function makeSessionID(userId: string, sessionId: string) {
return `${userId}/${sessionId}` return `${userId}/${sessionId}`

View file

@ -251,7 +251,8 @@ export class UserDB {
} }
const change = dbUser ? 0 : 1 // no change if there is existing user 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 () => { return UserDB.quotas.addUsers(change, creatorsChange, async () => {
await validateUniqueUser(email, tenantId) await validateUniqueUser(email, tenantId)
@ -335,7 +336,7 @@ export class UserDB {
} }
newUser.userGroups = groups || [] newUser.userGroups = groups || []
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) { if (await isCreator(newUser)) {
newCreators.push(newUser) newCreators.push(newUser)
} }
} }
@ -432,12 +433,16 @@ export class UserDB {
_deleted: true, _deleted: true,
})) }))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) 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) { for (let user of usersToDelete) {
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length) await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount)
// Build Response // Build Response
// index users by id // index users by id
@ -486,7 +491,7 @@ export class UserDB {
await db.remove(userId, dbUser._rev) 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 UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)

View file

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

View file

@ -309,7 +309,8 @@ export async function getCreatorCount() {
let creators = 0 let creators = 0
async function iterate(startPage?: string) { async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage }) 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) { if (page.hasNextPage) {
await iterate(page.nextPage) await iterate(page.nextPage)
} }

View file

@ -1,4 +1,4 @@
import { CloudAccount } from "@budibase/types" import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
import * as accountSdk from "../accounts" import * as accountSdk from "../accounts"
import env from "../environment" import env from "../environment"
import { getPlatformUser } from "./lookup" import { getPlatformUser } from "./lookup"
@ -6,17 +6,48 @@ import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context" import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts" 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 // extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin export const isAdmin = sdk.users.isAdmin
export const isCreator = sdk.users.isCreator
export const isGlobalBuilder = sdk.users.isGlobalBuilder export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions 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<UserGroup>(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) { export async function validateUniqueUser(email: string, tenantId: string) {
// check budibase users in other tenants // check budibase users in other tenants
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {

View file

@ -18,7 +18,6 @@ export default function positionDropdown(element, opts) {
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
offsetBelow,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -48,7 +47,7 @@ export default function positionDropdown(element, opts) {
styles.top = anchorBounds.top - elementBounds.height - offset styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240 styles.maxHeight = maxHeight || 240
} else { } else {
styles.top = anchorBounds.bottom + (offsetBelow || offset) styles.top = anchorBounds.bottom + offset
styles.maxHeight = styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20 maxHeight || window.innerHeight - anchorBounds.bottom - 20
} }

View file

@ -15,8 +15,6 @@
export let autoWidth = false export let autoWidth = false
export let searchTerm = null export let searchTerm = null
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let open = false export let open = false
export let loading export let loading
@ -98,7 +96,5 @@
{sort} {sort}
{autoWidth} {autoWidth}
{customPopoverHeight} {customPopoverHeight}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
{loading} {loading}
/> />

View file

@ -37,8 +37,6 @@
export let sort = false export let sort = false
export let searchTerm = null export let searchTerm = null
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let align = "left" export let align = "left"
export let footer = null export let footer = null
export let customAnchor = null export let customAnchor = null
@ -156,9 +154,7 @@
on:close={() => (open = false)} on:close={() => (open = false)}
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
maxHeight={customPopoverMaxHeight}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
offsetBelow={customPopoverOffsetBelow}
> >
<div <div
class="popover-content" class="popover-content"

View file

@ -12,6 +12,7 @@
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null export let getOptionSubtitle = () => null
export let compare = null
export let useOptionIconImage = false export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled
export let readonly = false export let readonly = false
@ -23,8 +24,6 @@
export let footer = null export let footer = null
export let open = false export let open = false
export let tag = null export let tag = null
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let searchTerm = null export let searchTerm = null
export let loading export let loading
@ -34,13 +33,19 @@
$: fieldIcon = getFieldAttribute(getOptionIcon, value, options) $: fieldIcon = getFieldAttribute(getOptionIcon, value, options)
$: fieldColour = getFieldAttribute(getOptionColour, 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) => { const getFieldAttribute = (getAttribute, value, options) => {
// Wait for options to load if there is a value but no options // Wait for options to load if there is a value but no options
if (!options?.length) { if (!options?.length) {
return "" return ""
} }
const index = options.findIndex( const index = options.findIndex((option, idx) =>
(option, idx) => getOptionValue(option, idx) === value compareOptionAndValue(getOptionValue(option, idx), value)
) )
return index !== -1 ? getAttribute(options[index], index) : null return index !== -1 ? getAttribute(options[index], index) : null
} }
@ -90,11 +95,9 @@
{autocomplete} {autocomplete}
{sort} {sort}
{tag} {tag}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false ? null : placeholder} placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => compareOptionAndValue(option, value)}
onSelectOption={selectOption} onSelectOption={selectOption}
{loading} {loading}
/> />

View file

@ -28,6 +28,7 @@
export let footer = null export let footer = null
export let tag = null export let tag = null
export let helpText = null export let helpText = null
export let compare
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -65,6 +66,7 @@
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
{tag} {tag}
{compare}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View file

@ -18,7 +18,6 @@
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 5 export let offset = 5
export let offsetBelow
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex export let customZindex
@ -89,7 +88,6 @@
maxWidth, maxWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
offsetBelow,
customUpdate: handlePostionUpdate, customUpdate: handlePostionUpdate,
}} }}
use:clickOutside={{ use:clickOutside={{

View file

@ -92,14 +92,7 @@ export const findAllMatchingComponents = (rootComponent, selector) => {
} }
/** /**
* Recurses through the component tree and finds all components. * Finds the closes parent component which matches certain criteria
*/
export const findAllComponents = rootComponent => {
return findAllMatchingComponents(rootComponent, () => true)
}
/**
* Finds the closest parent component which matches certain criteria
*/ */
export const findClosestMatchingComponent = ( export const findClosestMatchingComponent = (
rootComponent, rootComponent,

View file

@ -1,7 +1,6 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store" import { get } from "svelte/store"
import { import {
findAllComponents,
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
findComponentPath, findComponentPath,
@ -103,9 +102,6 @@ export const getAuthBindings = () => {
return bindings return bindings
} }
/**
* Gets all bindings for environment variables
*/
export const getEnvironmentBindings = () => { export const getEnvironmentBindings = () => {
let envVars = get(environment).variables let envVars = get(environment).variables
return envVars.map(variable => { return envVars.map(variable => {
@ -134,22 +130,26 @@ export const toBindingsArray = (valueMap, prefix, category) => {
if (!binding) { if (!binding) {
return acc return acc
} }
let config = { let config = {
type: "context", type: "context",
runtimeBinding: binding, runtimeBinding: binding,
readableBinding: `${prefix}.${binding}`, readableBinding: `${prefix}.${binding}`,
icon: "Brackets", icon: "Brackets",
} }
if (category) { if (category) {
config.category = category config.category = category
} }
acc.push(config) acc.push(config)
return acc 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) => { export const readableToRuntimeMap = (bindings, ctx) => {
if (!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) => { export const runtimeToReadableMap = (bindings, ctx) => {
if (!bindings || !ctx) { if (!bindings || !ctx) {
@ -188,23 +188,15 @@ export const getComponentBindableProperties = (asset, componentId) => {
if (!def?.context) { if (!def?.context) {
return [] return []
} }
const contexts = Array.isArray(def.context) ? def.context : [def.context]
// Get the bindings for the component // Get the bindings for the component
const componentContext = { return getProviderContextBindings(asset, component)
component,
definition: def,
contexts,
}
return generateComponentContextBindings(asset, componentContext)
} }
/** /**
* Gets all component contexts available to a certain component. This handles * Gets all data provider components above a component.
* both global and local bindings, taking into account a component's position
* in the component tree.
*/ */
export const getComponentContexts = ( export const getContextProviderComponents = (
asset, asset,
componentId, componentId,
type, type,
@ -213,55 +205,30 @@ export const getComponentContexts = (
if (!asset || !componentId) { if (!asset || !componentId) {
return [] return []
} }
let map = {}
// Processes all contexts exposed by a component // Get the component tree leading up to this component, ignoring the component
const processContexts = scope => 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) const def = store.actions.components.getDefinition(component._component)
if (!def?.context) { if (!def?.context) {
return return false
} }
if (!map[component._id]) {
map[component._id] = { // If no type specified, return anything that exposes context
component, if (!type) {
definition: def, return true
contexts: [],
}
} }
// Otherwise only match components with the specific context type
const contexts = Array.isArray(def.context) ? def.context : [def.context] const contexts = Array.isArray(def.context) ? def.context : [def.context]
contexts.forEach(context => { return contexts.find(context => context.type === type) != null
// 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)
} }
/** /**
@ -273,19 +240,20 @@ export const getActionProviders = (
actionType, actionType,
options = { includeSelf: false } options = { includeSelf: false }
) => { ) => {
if (!asset) { if (!asset || !componentId) {
return [] return []
} }
// Get all components // Get the component tree leading up to this component, ignoring the component
const components = findAllComponents(asset.props) // itself
const path = findComponentPath(asset.props, componentId)
if (!options?.includeSelf) {
path.pop()
}
// Find matching contexts and generate bindings // Find matching contexts and generate bindings
let providers = [] let providers = []
components.forEach(component => { path.forEach(component => {
if (!options?.includeSelf && component._id === componentId) {
return
}
const def = store.actions.components.getDefinition(component._component) const def = store.actions.components.getDefinition(component._component)
const actions = (def?.actions || []).map(action => { const actions = (def?.actions || []).map(action => {
return typeof action === "string" ? { type: action } : 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. * Gets all bindable data properties from component data contexts.
*/ */
const getContextBindings = (asset, componentId) => { const getContextBindings = (asset, componentId) => {
// Get all available contexts for this component // Extract any components which provide data contexts
const componentContexts = getComponentContexts(asset, componentId) const dataProviders = getContextProviderComponents(asset, componentId)
// Generate bindings for each context // Generate bindings for all matching components
return componentContexts return getProviderContextBindings(asset, dataProviders)
.map(componentContext => {
return generateComponentContextBindings(asset, componentContext)
})
.flat()
} }
/** /**
* 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 getProviderContextBindings = (asset, dataProviders) => {
const { component, definition, contexts } = componentContext if (!asset || !dataProviders) {
if (!component || !definition || !contexts?.length) {
return [] return []
} }
// Ensure providers is an array
if (!Array.isArray(dataProviders)) {
dataProviders = [dataProviders]
}
// Create bindings for each data provider // Create bindings for each data provider
let bindings = [] let bindings = []
contexts.forEach(context => { dataProviders.forEach(component => {
if (!context?.type) { const def = store.actions.components.getDefinition(component._component)
return const contexts = Array.isArray(def.context) ? def.context : [def.context]
}
let schema // Create bindings for each context block provided by this data provider
let table contexts.forEach(context => {
let readablePrefix if (!context?.type) {
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 return
} }
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
table = info.table
// Determine what to prefix bindings with let schema
if (datasource.type === "jsonarray") { let table
// For JSON arrays, use the array name as the readable prefix let readablePrefix
const split = datasource.label.split(".") let runtimeSuffix = context.suffix
readablePrefix = split[split.length - 1]
} else if (datasource.type === "viewV2") { if (context.type === "form") {
// For views, use the view name // Forms do not need table schemas
const view = Object.values(table?.views || {}).find( // Their schemas are built from their component field names
view => view.id === datasource.id 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() // Create the binding object
bindings.push({
// Generate safe unique runtime prefix type: "context",
let providerId = component._id runtimeBinding,
if (runtimeSuffix) { readableBinding,
providerId += `-${runtimeSuffix}` // Field schema and provider are required to construct relationship
} // datasource options, based on bindable properties
const safeComponentId = makePropSafe(providerId) fieldSchema,
providerId,
// Create bindable properties for each schema field // Table ID is used by JSON fields to know what table the field is in
keys.forEach(key => { tableId: table?._id,
const fieldSchema = schema[key] component: component._component,
category: bindingCategory.category,
// Make safe runtime binding icon: bindingCategory.icon,
const safeKey = key.split(".").map(makePropSafe).join(".") display: {
const runtimeBinding = `${safeComponentId}.${safeKey}` name: fieldSchema.name || key,
type: fieldSchema.type,
// 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,
},
}) })
}) })
}) })
@ -485,38 +460,25 @@ const generateComponentContextBindings = (asset, componentContext) => {
return bindings return bindings
} }
/** // Exclude a data context based on the component settings
* Checks if a certain data context is compatible with a certain instance of a const filterCategoryByContext = (component, context) => {
* configured component. const { _component } = 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
if (_component.endsWith("formblock")) { if (_component.endsWith("formblock")) {
if ( if (
(actionType === "Create" && type === "schema") || (component.actionType === "Create" && context.type === "schema") ||
(actionType === "View" && type === "form") (component.actionType === "View" && context.type === "form")
) { ) {
return false return false
} }
} }
// Allow the context by default
return true return true
} }
// Enrich binding category information for certain components // Enrich binding category information for certain components
const getComponentBindingCategory = (component, context, def) => { const getComponentBindingCategory = (component, context, def) => {
// Default category to component name
let icon = def.icon let icon = def.icon
let category = component._instanceName let category = component._instanceName
// Form block edge case
if (component._component.endsWith("formblock")) { if (component._component.endsWith("formblock")) {
if (context.type === "form") { if (context.type === "form") {
category = `${component._instanceName} - Fields` 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 = () => { export const getUserBindings = () => {
let bindings = [] let bindings = []
@ -604,7 +566,6 @@ const getDeviceBindings = () => {
/** /**
* Gets all selected rows bindings for tables in the current asset. * 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 => { const getSelectedRowsBindings = asset => {
let bindings = [] let bindings = []
@ -647,9 +608,6 @@ const getSelectedRowsBindings = asset => {
return bindings return bindings
} }
/**
* Generates a state binding for a certain key name
*/
export const makeStateBinding = key => { export const makeStateBinding = key => {
return { return {
type: "context", type: "context",
@ -704,9 +662,6 @@ const getUrlBindings = asset => {
return urlParamBindings.concat([queryParamsBinding]) return urlParamBindings.concat([queryParamsBinding])
} }
/**
* Generates all bindings for role IDs
*/
const getRoleBindings = () => { const getRoleBindings = () => {
return (get(rolesStore) || []).map(role => { return (get(rolesStore) || []).map(role => {
return { return {

View file

@ -9,6 +9,7 @@ import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history" import { createHistoryStore } from "builderStore/store/history"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { getHoverStore } from "./store/hover"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
@ -16,6 +17,7 @@ export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
export const userStore = getUserStore() export const userStore = getUserStore()
export const deploymentStore = getDeploymentStore() export const deploymentStore = getDeploymentStore()
export const hoverStore = getHoverStore()
// Setup history for screens // Setup history for screens
export const screenHistoryStore = createHistoryStore({ export const screenHistoryStore = createHistoryStore({

View file

@ -92,9 +92,6 @@ const INITIAL_FRONTEND_STATE = {
// Onboarding // Onboarding
onboarding: false, onboarding: false,
tourNodes: null, tourNodes: null,
// UI state
hoveredComponentId: null,
} }
export const getFrontendStore = () => { export const getFrontendStore = () => {
@ -709,9 +706,10 @@ export const getFrontendStore = () => {
else { else {
if (setting.type === "dataProvider") { if (setting.type === "dataProvider") {
// Validate data provider exists, or else clear it // Validate data provider exists, or else clear it
const providers = findAllMatchingComponents( const treeId = parent?._id || component._id
screen?.props, const path = findComponentPath(screen?.props, treeId)
component => component._component?.endsWith("/dataprovider") const providers = path.filter(component =>
component._component?.endsWith("/dataprovider")
) )
// Validate non-empty values // Validate non-empty values
const valid = providers?.some(dp => value.includes?.(dp._id)) const valid = providers?.some(dp => value.includes?.(dp._id))
@ -733,16 +731,6 @@ export const getFrontendStore = () => {
return null 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 // Generate basic component structure
let instance = { let instance = {
_id: Helpers.uuid(), _id: Helpers.uuid(),
@ -752,7 +740,7 @@ export const getFrontendStore = () => {
hover: {}, hover: {},
active: {}, active: {},
}, },
_instanceName: name, _instanceName: `New ${definition.friendlyName || definition.name}`,
...presetProps, ...presetProps,
} }
@ -1424,18 +1412,6 @@ export const getFrontendStore = () => {
return state 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: { links: {
save: async (url, title) => { save: async (url, title) => {

View file

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

View file

@ -184,8 +184,9 @@
} }
if ( if (
(idx === 0 && automation.trigger?.event === "row:update") || idx === 0 &&
automation.trigger?.event === "row:save" (automation.trigger?.event === "row:update" ||
automation.trigger?.event === "row:save")
) { ) {
if (name !== "id" && name !== "revision") return `trigger.row.${name}` if (name !== "id" && name !== "revision") return `trigger.row.${name}`
} }

View file

@ -88,8 +88,12 @@
hasValidated = false hasValidated = false
}) })
} }
$: valid = $: valid =
getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType) getErrorCount(errors) === 0 &&
allRequiredAttributesSet(relationshipType) &&
fromId &&
toId
$: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY $: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
$: isManyToOne = $: isManyToOne =
relationshipType === RelationshipType.MANY_TO_ONE || relationshipType === RelationshipType.MANY_TO_ONE ||

View file

@ -1,4 +1,5 @@
import { getComponentContexts } from "builderStore/dataBinding" import { getContextProviderComponents } from "builderStore/dataBinding"
import { store } from "builderStore"
import { capitalise } from "helpers" import { capitalise } from "helpers"
// Generates bindings for all components that provider "datasource like" // 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. // Some examples are saving rows or duplicating rows.
export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => {
// Get all form context providers // Get all form context providers
const formComponentContexts = getComponentContexts( const formComponents = getContextProviderComponents(
asset, asset,
componentId, componentId,
"form", "form",
{ { includeSelf: nested }
includeSelf: nested,
}
) )
// Get all schema context providers // Get all schema context providers
const schemaComponentContexts = getComponentContexts( const schemaComponents = getContextProviderComponents(
asset, asset,
componentId, componentId,
"schema", "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 // Check for duplicate contexts by the same component. In this case, attempt
// to label contexts with their suffixes // to label contexts with their suffixes
schemaComponentContexts.forEach(schemaContext => { schemaContexts.forEach(schemaContext => {
// Check if we have a form context for this component // Check if we have a form context for this component
const id = schemaContext.component._id 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) {
if (existing.contexts[0].suffix) { if (existing.context.suffix) {
const suffix = capitalise(existing.contexts[0].suffix) const suffix = capitalise(existing.context.suffix)
existing.readableSuffix = ` - ${suffix}` existing.readableSuffix = ` - ${suffix}`
} }
if (schemaContext.contexts[0].suffix) { if (schemaContext.context.suffix) {
const suffix = capitalise(schemaContext.contexts[0].suffix) const suffix = capitalise(schemaContext.context.suffix)
schemaContext.readableSuffix = ` - ${suffix}` schemaContext.readableSuffix = ` - ${suffix}`
} }
} }
}) })
// Generate bindings for all contexts // Generate bindings for all contexts
const allContexts = formComponentContexts.concat(schemaComponentContexts) const allContexts = formContexts.concat(schemaContexts)
return allContexts.map(({ component, contexts, readableSuffix }) => { return allContexts.map(({ component, context, readableSuffix }) => {
let readableBinding = component._instanceName let readableBinding = component._instanceName
let runtimeBinding = component._id let runtimeBinding = component._id
if (contexts[0].suffix) { if (context.suffix) {
runtimeBinding += `-${contexts[0].suffix}` runtimeBinding += `-${context.suffix}`
} }
if (readableSuffix) { if (readableSuffix) {
readableBinding += 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)
}

View file

@ -5,6 +5,7 @@
import { store } from "builderStore" import { store } from "builderStore"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { getEventContextBindings } from "builderStore/dataBinding" import { getEventContextBindings } from "builderStore/dataBinding"
import { cloneDeep, isEqual } from "lodash/fp"
export let componentInstance export let componentInstance
export let componentBindings export let componentBindings
@ -17,8 +18,13 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let focusItem let focusItem
let cachedValue
$: buttonList = sanitizeValue(value) || [] $: if (!isEqual(value, cachedValue)) {
cachedValue = cloneDeep(value)
}
$: buttonList = sanitizeValue(cachedValue) || []
$: buttonCount = buttonList.length $: buttonCount = buttonList.length
$: eventContextBindings = getEventContextBindings({ $: eventContextBindings = getEventContextBindings({
componentInstance, componentInstance,

View file

@ -1,16 +1,15 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { currentAsset } from "builderStore" import { currentAsset, store } from "builderStore"
import { findAllMatchingComponents } from "builderStore/componentUtils" import { findComponentPath } from "builderStore/componentUtils"
export let value export let value
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}` const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: providers = findAllMatchingComponents($currentAsset?.props, c => $: path = findComponentPath($currentAsset?.props, $store.selectedComponentId)
c._component?.endsWith("/dataprovider") $: providers = path.filter(c => c._component?.endsWith("/dataprovider"))
)
</script> </script>
<Select <Select

View file

@ -1,5 +1,6 @@
<script> <script>
import { import {
getContextProviderComponents,
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
@ -29,7 +30,6 @@
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { findAllComponents } from "builderStore/componentUtils"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte" import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "api" import { API } from "api"
@ -75,13 +75,12 @@
...query, ...query,
type: "query", type: "query",
})) }))
$: dataProviders = findAllComponents($currentAsset.props) $: contextProviders = getContextProviderComponents(
.filter(component => { $currentAsset,
return ( $store.selectedComponentId
component._component?.endsWith("/dataprovider") && )
component._id !== $store.selectedComponentId $: dataProviders = contextProviders
) .filter(component => component._component?.endsWith("/dataprovider"))
})
.map(provider => ({ .map(provider => ({
label: provider._instanceName, label: provider._instanceName,
name: provider._instanceName, name: provider._instanceName,

View file

@ -35,6 +35,7 @@
export let bindingDrawerLeft export let bindingDrawerLeft
export let allowHelpers = true export let allowHelpers = true
export let customButtonText = null export let customButtonText = null
export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
name, name,
@ -112,7 +113,12 @@
on:blur={changed} on:blur={changed}
/> />
{#if options} {#if options}
<Select bind:value={field.value} on:change={changed} {options} /> <Select
bind:value={field.value}
{compare}
on:change={changed}
{options}
/>
{:else if bindings && bindings.length} {:else if bindings && bindings.length}
<DrawerBindableInput <DrawerBindableInput
{bindings} {bindings}

View file

@ -1,6 +1,6 @@
<script> <script>
import KeyValueBuilder from "../KeyValueBuilder.svelte" import KeyValueBuilder from "../KeyValueBuilder.svelte"
import { SchemaTypeOptions } from "constants/backend" import { SchemaTypeOptionsExpanded } from "constants/backend"
export let schema export let schema
export let onSchemaChange = () => {} export let onSchemaChange = () => {}
@ -24,6 +24,7 @@
object={schema} object={schema}
name="field" name="field"
headings headings
options={SchemaTypeOptions} options={SchemaTypeOptionsExpanded}
compare={(option, value) => option.type === value.type}
/> />
{/key} {/key}

View file

@ -33,7 +33,7 @@
PaginationTypes, PaginationTypes,
RawRestBodyTypes, RawRestBodyTypes,
RestBodyTypes as bodyTypes, RestBodyTypes as bodyTypes,
SchemaTypeOptions, SchemaTypeOptionsExpanded,
} from "constants/backend" } from "constants/backend"
import JSONPreview from "components/integration/JSONPreview.svelte" import JSONPreview from "components/integration/JSONPreview.svelte"
import AccessLevelSelect from "components/integration/AccessLevelSelect.svelte" import AccessLevelSelect from "components/integration/AccessLevelSelect.svelte"
@ -97,9 +97,7 @@
$: schemaReadOnly = !responseSuccess $: schemaReadOnly = !responseSuccess
$: variablesReadOnly = !responseSuccess $: variablesReadOnly = !responseSuccess
$: showVariablesTab = shouldShowVariables(dynamicVariables, variablesReadOnly) $: showVariablesTab = shouldShowVariables(dynamicVariables, variablesReadOnly)
$: hasSchema = $: hasSchema = Object.keys(schema || {}).length !== 0
Object.keys(schema || {}).length !== 0 ||
Object.keys(query?.schema || {}).length !== 0
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs) $: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
@ -161,7 +159,7 @@
newQuery.fields.queryString = queryString newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders) newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders)
newQuery.schema = restUtils.fieldsToSchema(schema) newQuery.schema = schema
return newQuery return newQuery
} }
@ -231,6 +229,14 @@
notifications.info("Request did not return any data") notifications.info("Request did not return any data")
} else { } else {
response.info = response.info || { code: 200 } response.info = response.info || { code: 200 }
// if existing schema, copy over what it is
if (schema) {
for (let [name, field] of Object.entries(schema)) {
if (response.schema[name]) {
response.schema[name] = field
}
}
}
schema = response.schema schema = response.schema
notifications.success("Request sent successfully") notifications.success("Request sent successfully")
} }
@ -386,6 +392,7 @@
onMount(async () => { onMount(async () => {
query = getSelectedQuery() query = getSelectedQuery()
schema = query.schema
try { try {
// Clear any unsaved changes to the datasource // Clear any unsaved changes to the datasource
@ -416,7 +423,6 @@
query.fields.path = `${datasource.config.url}/${path ? path : ""}` query.fields.path = `${datasource.config.url}/${path ? path : ""}`
} }
url = buildUrl(query.fields.path, breakQs) url = buildUrl(query.fields.path, breakQs)
schema = restUtils.schemaToFields(query.schema)
requestBindings = restUtils.queryParametersToKeyValue(query.parameters) requestBindings = restUtils.queryParametersToKeyValue(query.parameters)
authConfigId = getAuthConfigId() authConfigId = getAuthConfigId()
if (!query.fields.disabledHeaders) { if (!query.fields.disabledHeaders) {
@ -682,10 +688,11 @@
bind:object={schema} bind:object={schema}
name="schema" name="schema"
headings headings
options={SchemaTypeOptions} options={SchemaTypeOptionsExpanded}
menuItems={schemaMenuItems} menuItems={schemaMenuItems}
showMenu={!schemaReadOnly} showMenu={!schemaReadOnly}
readOnly={schemaReadOnly} readOnly={schemaReadOnly}
compare={(option, value) => option.type === value.type}
/> />
</Tab> </Tab>
{/if} {/if}

View file

@ -271,6 +271,11 @@ export const SchemaTypeOptions = [
{ label: "Datetime", value: "datetime" }, { label: "Datetime", value: "datetime" },
] ]
export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({
...el,
value: { type: el.value },
}))
export const RawRestBodyTypes = { export const RawRestBodyTypes = {
NONE: "none", NONE: "none",
FORM: "form", FORM: "form",

View file

@ -1,26 +1,6 @@
import { IntegrationTypes } from "constants/backend" import { IntegrationTypes } from "constants/backend"
import { findHBSBlocks } from "@budibase/string-templates" import { findHBSBlocks } from "@budibase/string-templates"
export function schemaToFields(schema) {
const response = {}
if (schema && typeof schema === "object") {
for (let [field, value] of Object.entries(schema)) {
response[field] = value?.type || "string"
}
}
return response
}
export function fieldsToSchema(fields) {
const response = {}
if (fields && typeof fields === "object") {
for (let [name, type] of Object.entries(fields)) {
response[name] = { name, type }
}
}
return response
}
export function breakQueryString(qs) { export function breakQueryString(qs) {
if (!qs) { if (!qs) {
return {} return {}
@ -184,10 +164,8 @@ export const parseToCsv = (headers, rows) => {
export default { export default {
breakQueryString, breakQueryString,
buildQueryString, buildQueryString,
fieldsToSchema,
flipHeaderState, flipHeaderState,
keyValueToQueryParameters, keyValueToQueryParameters,
parseToCsv, parseToCsv,
queryParametersToKeyValue, queryParametersToKeyValue,
schemaToFields,
} }

View file

@ -1,11 +1,27 @@
import { PlanType } from "@budibase/types" import { PlanType } from "@budibase/types"
export function getFormattedPlanName(userPlanType) { export function getFormattedPlanName(userPlanType) {
let planName = "Free" let planName
if (userPlanType === PlanType.PREMIUM_PLUS) { switch (userPlanType) {
planName = "Premium" case PlanType.PRO:
} else if (userPlanType === PlanType.ENTERPRISE_BASIC) { planName = "Pro"
planName = "Enterprise" break
case PlanType.TEAM:
planName = "Team"
break
case PlanType.PREMIUM:
case PlanType.PREMIUM_PLUS:
planName = "Premium"
break
case PlanType.BUSINESS:
planName = "Business"
break
case PlanType.ENTERPRISE_BASIC:
case PlanType.ENTERPRISE:
planName = "Enterprise"
break
default:
planName = "Free" // Default to "Free" if the type is not explicitly handled
} }
return `${planName} Plan` return `${planName} Plan`
} }

View file

@ -1,7 +1,7 @@
<script> <script>
import { get } from "svelte/store" import { get } from "svelte/store"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { store, selectedScreen, currentAsset } from "builderStore" import { store, selectedScreen, currentAsset, hoverStore } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
ProgressCircle, ProgressCircle,
@ -118,7 +118,7 @@
} else if (type === "select-component" && data.id) { } else if (type === "select-component" && data.id) {
$store.selectedComponentId = data.id $store.selectedComponentId = data.id
} else if (type === "hover-component") { } else if (type === "hover-component") {
store.actions.components.hover(data.id, false) hoverStore.actions.update(data.id, false)
} else if (type === "update-prop") { } else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value) await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "update-styles") { } else if (type === "update-styles") {

View file

@ -5,6 +5,7 @@
selectedComponentPath, selectedComponentPath,
selectedComponent, selectedComponent,
selectedScreen, selectedScreen,
hoverStore,
} from "builderStore" } from "builderStore"
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte" import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
@ -90,7 +91,7 @@
return findComponentPath($selectedComponent, component._id)?.length > 0 return findComponentPath($selectedComponent, component._id)?.length > 0
} }
const hover = store.actions.components.hover const hover = hoverStore.actions.update
</script> </script>
<ul> <ul>
@ -111,7 +112,7 @@
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => toggleNodeOpen(component._id)} on:iconClick={() => toggleNodeOpen(component._id)}
on:drop={onDrop} on:drop={onDrop}
hovering={$store.hoveredComponentId === component._id} hovering={$hoverStore.componentId === component._id}
on:mouseenter={() => hover(component._id)} on:mouseenter={() => hover(component._id)}
on:mouseleave={() => hover(null)} on:mouseleave={() => hover(null)}
text={getComponentText(component)} text={getComponentText(component)}

View file

@ -1,7 +1,12 @@
<script> <script>
import { notifications, Icon, Body } from "@budibase/bbui" import { notifications, Icon, Body } from "@budibase/bbui"
import { isActive, goto } from "@roxi/routify" import { isActive, goto } from "@roxi/routify"
import { store, selectedScreen, userSelectedResourceMap } from "builderStore" import {
store,
selectedScreen,
userSelectedResourceMap,
hoverStore,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import { dndStore, DropPosition } from "./dndStore.js" import { dndStore, DropPosition } from "./dndStore.js"
@ -36,7 +41,7 @@
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
const hover = store.actions.components.hover const hover = hoverStore.actions.update
</script> </script>
<div class="components"> <div class="components">
@ -60,7 +65,7 @@
icon="WebPage" icon="WebPage"
on:drop={onDrop} on:drop={onDrop}
on:click={() => ($store.selectedComponentId = screenComponentId)} on:click={() => ($store.selectedComponentId = screenComponentId)}
hovering={$store.hoveredComponentId === screenComponentId} hovering={$hoverStore.componentId === screenComponentId}
on:mouseenter={() => hover(screenComponentId)} on:mouseenter={() => hover(screenComponentId)}
on:mouseleave={() => hover(null)} on:mouseleave={() => hover(null)}
id="component-screen" id="component-screen"
@ -79,7 +84,7 @@
: "VisibilityOff"} : "VisibilityOff"}
on:drop={onDrop} on:drop={onDrop}
on:click={() => ($store.selectedComponentId = navComponentId)} on:click={() => ($store.selectedComponentId = navComponentId)}
hovering={$store.hoveredComponentId === navComponentId} hovering={$hoverStore.componentId === navComponentId}
on:mouseenter={() => hover(navComponentId)} on:mouseenter={() => hover(navComponentId)}
on:mouseleave={() => hover(null)} on:mouseleave={() => hover(null)}
id="component-nav" id="component-nav"

View file

@ -15,9 +15,9 @@
<Content showMobileNav> <Content showMobileNav>
<SideNav slot="side-nav"> <SideNav slot="side-nav">
<SideNavItem <SideNavItem
text="Automation History" text="Automations"
url={$url("./automation-history")} url={$url("./automations")}
active={$isActive("./automation-history")} active={$isActive("./automations")}
/> />
<SideNavItem <SideNavItem
text="Backups" text="Backups"

View file

@ -8,6 +8,8 @@
Body, Body,
Heading, Heading,
Divider, Divider,
Toggle,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte" import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte"
import StatusRenderer from "./_components/StatusRenderer.svelte" import StatusRenderer from "./_components/StatusRenderer.svelte"
@ -16,7 +18,7 @@
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte" import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import { auth, licensing, admin } from "stores/portal" import { auth, licensing, admin, apps } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal" import Portal from "svelte-portal"
@ -35,9 +37,13 @@
let timeRange = null let timeRange = null
let loaded = false let loaded = false
$: app = $apps.find(app => app.devId === $store.appId?.includes(app.appId))
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
$: isCloud = $admin.cloud
$: chainAutomations = app?.automations?.chainAutomations ?? !isCloud
const timeOptions = [ const timeOptions = [
{ value: "90-d", label: "Past 90 days" }, { value: "90-d", label: "Past 90 days" },
@ -124,6 +130,18 @@
sidePanel.open() sidePanel.open()
} }
async function save({ detail }) {
try {
await apps.update($store.appId, {
automations: {
chainAutomations: detail,
},
})
} catch (error) {
notifications.error("Error updating automation chaining setting")
}
}
onMount(async () => { onMount(async () => {
await automationStore.actions.fetch() await automationStore.actions.fetch()
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@ -150,11 +168,30 @@
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading>Automation History</Heading> <Heading>Automations</Heading>
<Body>View the automations your app has executed</Body> <Body size="S">See your automation history and edit advanced settings</Body>
</Layout> </Layout>
<Divider /> <Divider />
<Layout gap="XS" noPadding>
<Heading size="XS">Chain automations</Heading>
<Body size="S">Allow automations to trigger from other automations</Body>
<div class="setting-spacing">
<Toggle
text={"Enable chaining"}
on:change={e => {
save(e)
}}
value={chainAutomations}
/>
</div>
</Layout>
<Divider />
<Layout gap="XS" noPadding>
<Heading size="XS">History</Heading>
<Body size="S">Free plan stores up to 1 day of automation history</Body>
</Layout>
<div class="controls"> <div class="controls">
<div class="search"> <div class="search">
<div class="select"> <div class="select">
@ -237,6 +274,9 @@
{/if} {/if}
<style> <style>
.setting-spacing {
padding-top: var(--spacing-s);
}
.controls { .controls {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -1,5 +1,5 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
$redirect("../settings/automation-history") $redirect("../settings/automations")
</script> </script>

View file

@ -89,8 +89,8 @@ export function createQueriesStore() {
// Assume all the fields are strings and create a basic schema from the // Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server // unique fields returned by the server
const schema = {} const schema = {}
for (let [field, type] of Object.entries(result.schemaFields)) { for (let [field, metadata] of Object.entries(result.schema)) {
schema[field] = type || "string" schema[field] = metadata || { type: "string" }
} }
return { ...result, schema, rows: result.rows || [] } return { ...result, schema, rows: result.rows || [] }
} }

View file

@ -573,6 +573,7 @@
"description": "A configurable data list that attaches to your backend tables.", "description": "A configurable data list that attaches to your backend tables.",
"icon": "JourneyData", "icon": "JourneyData",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"requiredAncestors": ["dataprovider"],
"hasChildren": true, "hasChildren": true,
"size": { "size": {
"width": 400, "width": 400,
@ -710,12 +711,10 @@
], ],
"context": [ "context": [
{ {
"type": "schema", "type": "schema"
"scope": "local"
}, },
{ {
"type": "static", "type": "static",
"scope": "local",
"values": [ "values": [
{ {
"label": "Row index", "label": "Row index",
@ -1565,6 +1564,7 @@
"name": "Bar Chart", "name": "Bar Chart",
"description": "Bar chart", "description": "Bar chart",
"icon": "GraphBarVertical", "icon": "GraphBarVertical",
"requiredAncestors": ["dataprovider"],
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 400
@ -1727,6 +1727,7 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1880,6 +1881,7 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2045,6 +2047,7 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2174,6 +2177,7 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2303,6 +2307,7 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -3964,6 +3969,12 @@
"key": "allowManualEntry", "key": "allowManualEntry",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "Auto confirm",
"key": "autoConfirm",
"defaultValue": false
},
{ {
"type": "boolean", "type": "boolean",
"label": "Play sound on scan", "label": "Play sound on scan",
@ -4076,6 +4087,7 @@
"width": 400, "width": 400,
"height": 320 "height": 320
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -4631,6 +4643,7 @@
"name": "Table", "name": "Table",
"icon": "Table", "icon": "Table",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"requiredAncestors": ["dataprovider"],
"hasChildren": true, "hasChildren": true,
"showEmptyState": false, "showEmptyState": false,
"size": { "size": {
@ -4721,6 +4734,7 @@
"name": "Date Range", "name": "Date Range",
"icon": "Calendar", "icon": "Calendar",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["dataprovider"],
"hasChildren": false, "hasChildren": false,
"size": { "size": {
"width": 200, "width": 200,
@ -4828,6 +4842,7 @@
"width": 100, "width": 100,
"height": 35 "height": 35
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -5602,38 +5617,7 @@
} }
] ]
} }
], ]
"context": {
"type": "static",
"suffix": "provider",
"values": [
{
"label": "Rows",
"key": "rows",
"type": "array"
},
{
"label": "Extra Info",
"key": "info",
"type": "string"
},
{
"label": "Rows Length",
"key": "rowsLength",
"type": "number"
},
{
"label": "Schema",
"key": "schema",
"type": "object"
},
{
"label": "Page Number",
"key": "pageNumber",
"type": "number"
}
]
}
}, },
"cardsblock": { "cardsblock": {
"block": true, "block": true,
@ -6036,8 +6020,7 @@
}, },
{ {
"type": "schema", "type": "schema",
"suffix": "repeater", "suffix": "repeater"
"scope": "local"
} }
] ]
}, },
@ -6183,10 +6166,6 @@
"type": "form", "type": "form",
"suffix": "form" "suffix": "form"
}, },
{
"type": "schema",
"suffix": "repeater"
},
{ {
"type": "static", "type": "static",
"suffix": "form", "suffix": "form",
@ -6503,23 +6482,6 @@
"suffix": "repeater" "suffix": "repeater"
} }
}, },
"grid": {
"name": "Grid",
"icon": "ViewGrid",
"hasChildren": true,
"settings": [
{
"type": "number",
"key": "cols",
"label": "Columns"
},
{
"type": "number",
"key": "rows",
"label": "Rows"
}
]
},
"gridblock": { "gridblock": {
"name": "Grid Block", "name": "Grid Block",
"icon": "Table", "icon": "Table",

View file

@ -9,7 +9,7 @@
</script> </script>
<script> <script>
import { getContext, setContext, onMount } from "svelte" import { getContext, setContext, onMount, onDestroy } from "svelte"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { import {
enrichProps, enrichProps,
@ -30,15 +30,6 @@
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte" import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte" import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
import { BudibasePrefix } from "../stores/components.js" import { BudibasePrefix } from "../stores/components.js"
import {
decodeJSBinding,
findHBSBlocks,
isJSBinding,
} from "@budibase/string-templates"
import {
getActionContextKey,
getActionDependentContextKeys,
} from "../utils/buttonActions.js"
export let instance = {} export let instance = {}
export let isLayout = false export let isLayout = false
@ -90,6 +81,7 @@
// Keep track of stringified representations of context and instance // Keep track of stringified representations of context and instance
// to avoid enriching bindings as much as possible // to avoid enriching bindings as much as possible
let lastContextKey
let lastInstanceKey let lastInstanceKey
// Visibility flag used by conditional UI // Visibility flag used by conditional UI
@ -106,13 +98,6 @@
// We clear these whenever a new instance is received. // We clear these whenever a new instance is received.
let ephemeralStyles let ephemeralStyles
// Single string of all HBS blocks, used to check if we use a certain binding
// or not
let bindingString = ""
// List of context keys which we use inside bindings
let knownContextKeyMap = {}
// Set up initial state for each new component instance // Set up initial state for each new component instance
$: initialise(instance) $: initialise(instance)
@ -170,6 +155,9 @@
hasMissingRequiredSettings) hasMissingRequiredSettings)
$: emptyState = empty && showEmptyState $: emptyState = empty && showEmptyState
// Enrich component settings
$: enrichComponentSettings($context, settingsDefinitionMap)
// Evaluate conditional UI settings and store any component setting changes // Evaluate conditional UI settings and store any component setting changes
// which need to be made // which need to be made
$: evaluateConditions(conditions) $: evaluateConditions(conditions)
@ -218,7 +206,6 @@
errorState, errorState,
parent: id, parent: id,
ancestors: [...($component?.ancestors ?? []), instance._component], ancestors: [...($component?.ancestors ?? []), instance._component],
path: [...($component?.path ?? []), id],
}) })
const initialise = (instance, force = false) => { const initialise = (instance, force = false) => {
@ -227,8 +214,7 @@
} }
// Ensure we're processing a new instance // Ensure we're processing a new instance
const stringifiedInstance = JSON.stringify(instance) const instanceKey = Helpers.hashString(JSON.stringify(instance))
const instanceKey = Helpers.hashString(stringifiedInstance)
if (instanceKey === lastInstanceKey && !force) { if (instanceKey === lastInstanceKey && !force) {
return return
} else { } else {
@ -288,54 +274,13 @@
return missing return missing
}) })
// When considering bindings we can ignore children, so we remove that
// before storing the reference stringified version
const noChildren = JSON.stringify({ ...instance, _children: null })
const bindings = findHBSBlocks(noChildren).map(binding => {
let sanitizedBinding = binding.replace(/\\"/g, '"')
if (isJSBinding(sanitizedBinding)) {
return decodeJSBinding(sanitizedBinding)
} else {
return sanitizedBinding
}
})
// The known context key map is built up at runtime, as changes to keys are
// encountered. We manually seed this to the required action keys as these
// are not encountered at runtime and so need computed in advance.
knownContextKeyMap = generateActionKeyMap(instance, settingsDefinition)
bindingString = bindings.join(" ")
// Run any migrations // Run any migrations
runMigrations(instance, settingsDefinition) runMigrations(instance, settingsDefinition)
// Force an initial enrichment of the new settings // Force an initial enrichment of the new settings
enrichComponentSettings(get(context), settingsDefinitionMap) enrichComponentSettings(get(context), settingsDefinitionMap, {
} force: true,
// Extracts a map of all context keys which are required by action settings
// to provide the functions to evaluate at runtime. This needs done manually
// as the action definitions themselves do not specify bindings for action
// keys, meaning we cannot do this while doing the other normal bindings.
const generateActionKeyMap = (instance, settingsDefinition) => {
let map = {}
settingsDefinition.forEach(setting => {
if (setting.type === "event") {
instance[setting.key]?.forEach(action => {
// We depend on the actual action key
const actionKey = getActionContextKey(action)
if (actionKey) {
map[actionKey] = true
}
// We also depend on any manually declared context keys
getActionDependentContextKeys(action)?.forEach(key => {
map[key] = true
})
})
}
}) })
return map
} }
const runMigrations = (instance, settingsDefinition) => { const runMigrations = (instance, settingsDefinition) => {
@ -436,7 +381,17 @@
} }
// Enriches any string component props using handlebars // Enriches any string component props using handlebars
const enrichComponentSettings = (context, settingsDefinitionMap) => { const enrichComponentSettings = (
context,
settingsDefinitionMap,
options = { force: false }
) => {
const contextChanged = context.key !== lastContextKey
if (!contextChanged && !options?.force) {
return
}
lastContextKey = context.key
// Record the timestamp so we can reference it after enrichment // Record the timestamp so we can reference it after enrichment
latestUpdateTime = Date.now() latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime const enrichmentTime = latestUpdateTime
@ -551,46 +506,31 @@
}) })
} }
const handleContextChange = key => {
// Check if we already know if this key is used
let used = knownContextKeyMap[key]
// If we don't know, check and cache
if (used == null) {
used = bindingString.indexOf(`[${key}]`) !== -1
knownContextKeyMap[key] = used
}
// Enrich settings if we use this key
if (used) {
enrichComponentSettings($context, settingsDefinitionMap)
}
}
// Register an unregister component instance
onMount(() => { onMount(() => {
if ($appStore.isDevApp) { if (
if (!componentStore.actions.isComponentRegistered(id)) { $appStore.isDevApp &&
componentStore.actions.registerInstance(id, { !componentStore.actions.isComponentRegistered(id)
component: instance._component, ) {
getSettings: () => cachedSettings, componentStore.actions.registerInstance(id, {
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), component: instance._component,
getDataContext: () => get(context), getSettings: () => cachedSettings,
reload: () => initialise(instance, true), getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
setEphemeralStyles: styles => (ephemeralStyles = styles), getDataContext: () => get(context),
state: store, reload: () => initialise(instance, true),
}) setEphemeralStyles: styles => (ephemeralStyles = styles),
} state: store,
return () => { })
if (componentStore.actions.isComponentRegistered(id)) {
componentStore.actions.unregisterInstance(id)
}
}
} }
}) })
// Observe changes to context onDestroy(() => {
onMount(() => context.actions.observeChanges(handleContextChange)) if (
$appStore.isDevApp &&
componentStore.actions.isComponentRegistered(id)
) {
componentStore.actions.unregisterInstance(id)
}
})
</script> </script>
{#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden} {#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden}

View file

@ -71,7 +71,7 @@
datasource: dataSource || {}, datasource: dataSource || {},
schema, schema,
rowsLength: $fetch.rows.length, rowsLength: $fetch.rows.length,
pageNumber: $fetch.pageNumber + 1,
// Undocumented properties. These aren't supposed to be used in builder // Undocumented properties. These aren't supposed to be used in builder
// bindings, but are used internally by other components // bindings, but are used internally by other components
id: $component?.id, id: $component?.id,

View file

@ -1,5 +1,6 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Icon } from "@budibase/bbui"
const component = getContext("component") const component = getContext("component")
const { builderStore, componentStore } = getContext("sdk") const { builderStore, componentStore } = getContext("sdk")
@ -9,7 +10,15 @@
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<div class="component-placeholder"> <div class="component-placeholder">
{$component.name || definition?.name || "Component"} <Icon name="Help" color="var(--spectrum-global-color-blue-600)" />
<span
class="spectrum-Link"
on:click={() => {
builderStore.actions.requestAddComponent()
}}
>
Add components inside your {definition?.name || $component.type}
</span>
</div> </div>
{/if} {/if}
@ -23,4 +32,14 @@
font-size: var(--font-size-s); font-size: var(--font-size-s);
gap: var(--spacing-s); gap: var(--spacing-s);
} }
/* Common styles for all error states to use */
.component-placeholder :global(mark) {
background-color: var(--spectrum-global-color-gray-400);
padding: 0 4px;
border-radius: 2px;
}
.component-placeholder :global(.spectrum-Link) {
cursor: pointer;
}
</style> </style>

View file

@ -19,36 +19,7 @@
export let onRowClick = null export let onRowClick = null
export let buttons = null export let buttons = null
const context = getContext("context") // parses columns to fix older formats
const component = getContext("component")
const {
styleable,
API,
builderStore,
notificationStore,
enrichButtonActions,
ActionTypes,
createContextStore,
Provider,
} = getContext("sdk")
let grid
$: columnWhitelist = parsedColumns
?.filter(col => col.active)
?.map(col => col.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons)
$: parsedColumns = getParsedColumns(columns)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => grid?.getContext()?.rows.actions.refreshData(),
metadata: { dataSource: table },
},
]
// Parses columns to fix older formats
const getParsedColumns = columns => { const getParsedColumns = columns => {
// If the first element has an active key all elements should be in the new format // If the first element has an active key all elements should be in the new format
if (columns?.length && columns[0]?.active !== undefined) { if (columns?.length && columns[0]?.active !== undefined) {
@ -62,6 +33,28 @@
})) }))
} }
$: parsedColumns = getParsedColumns(columns)
const context = getContext("context")
const component = getContext("component")
const {
styleable,
API,
builderStore,
notificationStore,
enrichButtonActions,
ActionTypes,
createContextStore,
} = getContext("sdk")
let grid
$: columnWhitelist = parsedColumns
?.filter(col => col.active)
?.map(col => col.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons)
const getSchemaOverrides = columns => { const getSchemaOverrides = columns => {
let overrides = {} let overrides = {}
columns?.forEach(column => { columns?.forEach(column => {
@ -85,6 +78,11 @@
const id = get(component).id const id = get(component).id
const gridContext = createContextStore(context) const gridContext = createContextStore(context)
gridContext.actions.provideData(id, row) gridContext.actions.provideData(id, row)
gridContext.actions.provideAction(
id,
ActionTypes.RefreshDatasource,
() => grid?.getContext()?.rows.actions.refreshData()
)
const fn = enrichButtonActions(settings.onClick, get(gridContext)) const fn = enrichButtonActions(settings.onClick, get(gridContext))
return await fn?.({ row }) return await fn?.({ row })
}, },
@ -96,31 +94,29 @@
use:styleable={$component.styles} use:styleable={$component.styles}
class:in-builder={$builderStore.inBuilder} class:in-builder={$builderStore.inBuilder}
> >
<Provider {actions}> <Grid
<Grid bind:this={grid}
bind:this={grid} datasource={table}
datasource={table} {API}
{API} {stripeRows}
{stripeRows} {initialFilter}
{initialFilter} {initialSortColumn}
{initialSortColumn} {initialSortOrder}
{initialSortOrder} {fixedRowHeight}
{fixedRowHeight} {columnWhitelist}
{columnWhitelist} {schemaOverrides}
{schemaOverrides} canAddRows={allowAddRows}
canAddRows={allowAddRows} canEditRows={allowEditRows}
canEditRows={allowEditRows} canDeleteRows={allowDeleteRows}
canDeleteRows={allowDeleteRows} canEditColumns={false}
canEditColumns={false} canExpandRows={false}
canExpandRows={false} canSaveSchema={false}
canSaveSchema={false} showControls={false}
showControls={false} notifySuccess={notificationStore.actions.success}
notifySuccess={notificationStore.actions.success} notifyError={notificationStore.actions.error}
notifyError={notificationStore.actions.error} buttons={enrichedButtons}
buttons={enrichedButtons} on:rowclick={e => onRowClick?.({ row: e.detail })}
on:rowclick={e => onRowClick?.({ row: e.detail })} />
/>
</Provider>
</div> </div>
<style> <style>

View file

@ -2,7 +2,6 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import Placeholder from "./Placeholder.svelte" import Placeholder from "./Placeholder.svelte"
import Container from "./Container.svelte" import Container from "./Container.svelte"
import { ContextScopes } from "constants"
export let dataProvider export let dataProvider
export let noRowsMessage export let noRowsMessage
@ -10,7 +9,6 @@
export let hAlign export let hAlign
export let vAlign export let vAlign
export let gap export let gap
export let scope = ContextScopes.Local
const { Provider } = getContext("sdk") const { Provider } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -24,7 +22,7 @@
<Placeholder /> <Placeholder />
{:else if rows.length > 0} {:else if rows.length > 0}
{#each rows as row, index} {#each rows as row, index}
<Provider data={{ ...row, index }} {scope}> <Provider data={{ ...row, index }}>
<slot /> <slot />
</Provider> </Provider>
{/each} {/each}

View file

@ -1,6 +1,5 @@
<script> <script>
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { Helpers } from "@budibase/bbui"
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { builderStore } from "stores" import { builderStore } from "stores"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
@ -42,7 +41,7 @@
let schema let schema
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep) $: enrichedSteps = enrichSteps(steps, schema, $component.id)
$: updateCurrentStep(enrichedSteps, $builderStore, $component) $: updateCurrentStep(enrichedSteps, $builderStore, $component)
const updateCurrentStep = (steps, builderStore, component) => { const updateCurrentStep = (steps, builderStore, component) => {
@ -116,7 +115,6 @@
dataSource, dataSource,
}) })
return { return {
_stepId: Helpers.uuid(),
fields: getDefaultFields(fields || [], schema), fields: getDefaultFields(fields || [], schema),
title: title ?? defaultProps.title, title: title ?? defaultProps.title,
desc, desc,
@ -144,7 +142,7 @@
}, },
}} }}
> >
{#each enrichedSteps as step, stepIdx (step._stepId)} {#each enrichedSteps as step, stepIdx}
<BlockComponent <BlockComponent
type="formstep" type="formstep"
props={{ step: stepIdx + 1, _instanceName: `Step ${stepIdx + 1}` }} props={{ step: stepIdx + 1, _instanceName: `Step ${stepIdx + 1}` }}
@ -188,13 +186,12 @@
</BlockComponent> </BlockComponent>
</BlockComponent> </BlockComponent>
<BlockComponent type="text" props={{ text: step.desc }} order={1} /> <BlockComponent type="text" props={{ text: step.desc }} order={1} />
<BlockComponent type="container" order={2}> <BlockComponent type="container" order={2}>
<div <div
class="form-block fields" class="form-block fields"
class:mobile={$context.device.mobile} class:mobile={$context.device.mobile}
> >
{#each step.fields as field, fieldIdx (`${field.field || field.name}_${fieldIdx}`)} {#each step.fields as field, fieldIdx (`${field.field || field.name}_${stepIdx}_${fieldIdx}`)}
{#if getComponentForField(field)} {#if getComponentForField(field)}
<BlockComponent <BlockComponent
type={getComponentForField(field)} type={getComponentForField(field)}

View file

@ -231,7 +231,6 @@
paginate, paginate,
limit: rowCount, limit: rowCount,
}} }}
context="provider"
order={1} order={1}
> >
<BlockComponent <BlockComponent

View file

@ -10,7 +10,6 @@
export let noRowsMessage export let noRowsMessage
const component = getContext("component") const component = getContext("component")
const { ContextScopes } = getContext("sdk")
$: providerId = `${$component.id}-provider` $: providerId = `${$component.id}-provider`
$: dataProvider = `{{ literal ${safe(providerId)} }}` $: dataProvider = `{{ literal ${safe(providerId)} }}`
@ -56,7 +55,6 @@
noRowsMessage: noRowsMessage || "We couldn't find a row to display", noRowsMessage: noRowsMessage || "We couldn't find a row to display",
direction: "column", direction: "column",
hAlign: "center", hAlign: "center",
scope: ContextScopes.Global,
}} }}
> >
<slot /> <slot />

View file

@ -14,11 +14,13 @@
export let value export let value
export let disabled = false export let disabled = false
export let allowManualEntry = false export let allowManualEntry = false
export let autoConfirm = false
export let scanButtonText = "Scan code" export let scanButtonText = "Scan code"
export let beepOnScan = false export let beepOnScan = false
export let beepFrequency = 2637 export let beepFrequency = 2637
export let customFrequency = 1046 export let customFrequency = 1046
export let preferredCamera = "environment" export let preferredCamera = "environment"
export let validator
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -41,6 +43,9 @@
beep() beep()
} }
dispatch("change", decodedText) dispatch("change", decodedText)
if (autoConfirm && !validator?.(decodedText)) {
camModal?.hide()
}
} }
} }
@ -127,7 +132,11 @@
<div class="scanner-video-wrapper"> <div class="scanner-video-wrapper">
{#if value && !manualMode} {#if value && !manualMode}
<div class="scanner-value field-display"> <div class="scanner-value field-display">
<StatusLight positive /> {#if validator?.(value)}
<StatusLight negative />
{:else}
<StatusLight positive />
{/if}
{value} {value}
</div> </div>
{/if} {/if}
@ -183,11 +192,16 @@
</div> </div>
{#if cameraEnabled === true} {#if cameraEnabled === true}
<div class="code-wrap"> <div class="code-wrap">
{#if value} {#if value && !validator?.(value)}
<div class="scanner-value"> <div class="scanner-value">
<StatusLight positive /> <StatusLight positive />
{value} {value}
</div> </div>
{:else if value && validator?.(value)}
<div class="scanner-value">
<StatusLight negative />
{value}
</div>
{:else} {:else}
<div class="scanner-value"> <div class="scanner-value">
<StatusLight neutral /> <StatusLight neutral />

View file

@ -11,6 +11,7 @@
export let defaultValue = "" export let defaultValue = ""
export let onChange export let onChange
export let allowManualEntry export let allowManualEntry
export let autoConfirm
export let scanButtonText export let scanButtonText
export let beepOnScan export let beepOnScan
export let beepFrequency export let beepFrequency
@ -49,11 +50,13 @@
on:change={handleUpdate} on:change={handleUpdate}
disabled={fieldState.disabled || fieldState.readonly} disabled={fieldState.disabled || fieldState.readonly}
{allowManualEntry} {allowManualEntry}
{autoConfirm}
scanButtonText={scanText} scanButtonText={scanText}
{beepOnScan} {beepOnScan}
{beepFrequency} {beepFrequency}
{customFrequency} {customFrequency}
{preferredCamera} {preferredCamera}
validator={fieldState.validator}
/> />
{/if} {/if}
</Field> </Field>

View file

@ -21,7 +21,6 @@
export let editAutoColumns = false export let editAutoColumns = false
const context = getContext("context") const context = getContext("context")
const component = getContext("component")
const { API, fetchDatasourceSchema } = getContext("sdk") const { API, fetchDatasourceSchema } = getContext("sdk")
const getInitialFormStep = () => { const getInitialFormStep = () => {
@ -39,47 +38,28 @@
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: schemaKey = generateSchemaKey(schema) $: schemaKey = generateSchemaKey(schema)
$: initialValues = getInitialValues( $: initialValues = getInitialValues(actionType, dataSource, $context)
actionType,
dataSource,
$component.path,
$context
)
$: resetKey = Helpers.hashString( $: resetKey = Helpers.hashString(
schemaKey + JSON.stringify(initialValues) + disabled + readonly schemaKey + JSON.stringify(initialValues) + disabled + readonly
) )
// Returns the closes data context which isn't a built in context // Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, path, context) => { const getInitialValues = (type, dataSource, context) => {
// Only inherit values for update forms // Only inherit values for update forms
if (type !== "Update") { if (type !== "Update") {
return {} return {}
} }
// Only inherit values for forms targeting internal tables // Only inherit values for forms targeting internal tables
const dsType = dataSource?.type if (!dataSource?.tableId) {
if (dsType !== "table" && dsType !== "viewV2") {
return {} return {}
} }
// Look up the component tree and find something that is provided by an // Don't inherit values representing built in contexts
// ancestor that matches our datasource. This is for backwards compatibility if (["user", "url"].includes(context.closestComponentId)) {
// as previously we could use the "closest" context. return {}
for (let id of path.reverse().slice(1)) {
// Check for matching view datasource
if (
dataSource.type === "viewV2" &&
context[id]?._viewId === dataSource.id
) {
return context[id]
}
// Check for matching table datasource
if (
dataSource.type === "table" &&
context[id]?.tableId === dataSource.tableId
) {
return context[id]
}
} }
return {} // Always inherit the closest datasource
const closestContext = context[`${context.closestComponentId}`] || {}
return closestContext || {}
} }
// Fetches the form schema from this form's dataSource // Fetches the form schema from this form's dataSource

View file

@ -108,8 +108,16 @@
} }
} }
$: forceFetchRows(filter)
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) $: 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)
}
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
const allRowsFetched = const allRowsFetched =
$fetch.loaded && $fetch.loaded &&
@ -228,7 +236,6 @@
bind:searchTerm bind:searchTerm
loading={$fetch.loading} loading={$fetch.loading}
bind:open bind:open
customPopoverMaxHeight={400}
/> />
{/if} {/if}
</Field> </Field>

View file

@ -1,23 +1,20 @@
<script> <script>
import { getContext, setContext, onDestroy } from "svelte" import { getContext, setContext, onDestroy } from "svelte"
import { dataSourceStore, createContextStore } from "stores" import { dataSourceStore, createContextStore } from "stores"
import { ActionTypes, ContextScopes } from "constants" import { ActionTypes } from "constants"
import { generate } from "shortid" import { generate } from "shortid"
export let data export let data
export let actions export let actions
export let key export let key
export let scope = ContextScopes.Global
let context = getContext("context") // Clone and create new data context for this component tree
const context = getContext("context")
const component = getContext("component") const component = getContext("component")
const providerKey = key || $component.id const newContext = createContextStore(context)
setContext("context", newContext)
// Create a new layer of context if we are only locally scoped const providerKey = key || $component.id
if (scope === ContextScopes.Local) {
context = createContextStore(context)
setContext("context", context)
}
// Generate a permanent unique ID for this component and use it to register // Generate a permanent unique ID for this component and use it to register
// any datasource actions // any datasource actions
@ -33,7 +30,7 @@
const provideData = newData => { const provideData = newData => {
const dataKey = JSON.stringify(newData) const dataKey = JSON.stringify(newData)
if (dataKey !== lastDataKey) { if (dataKey !== lastDataKey) {
context.actions.provideData(providerKey, newData, scope) newContext.actions.provideData(providerKey, newData)
lastDataKey = dataKey lastDataKey = dataKey
} }
} }
@ -43,7 +40,7 @@
if (actionsKey !== lastActionsKey) { if (actionsKey !== lastActionsKey) {
lastActionsKey = actionsKey lastActionsKey = actionsKey
newActions?.forEach(({ type, callback, metadata }) => { newActions?.forEach(({ type, callback, metadata }) => {
context.actions.provideAction(providerKey, type, callback, scope) newContext.actions.provideAction(providerKey, type, callback)
// Register any "refresh datasource" actions with a singleton store // Register any "refresh datasource" actions with a singleton store
// so we can easily refresh data at all levels for any datasource // so we can easily refresh data at all levels for any datasource

View file

@ -12,10 +12,5 @@ export const ActionTypes = {
ScrollTo: "ScrollTo", ScrollTo: "ScrollTo",
} }
export const ContextScopes = {
Local: "local",
Global: "global",
}
export const DNDPlaceholderID = "dnd-placeholder" export const DNDPlaceholderID = "dnd-placeholder"
export const ScreenslotType = "screenslot" export const ScreenslotType = "screenslot"

View file

@ -23,7 +23,7 @@ import { getAction } from "utils/getAction"
import Provider from "components/context/Provider.svelte" import Provider from "components/context/Provider.svelte"
import Block from "components/Block.svelte" import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { ActionTypes, ContextScopes } from "./constants" import { ActionTypes } from "./constants"
import { fetchDatasourceSchema } from "./utils/schema.js" import { fetchDatasourceSchema } from "./utils/schema.js"
import { getAPIKey } from "./utils/api.js" import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js" import { enrichButtonActions } from "./utils/buttonActions.js"
@ -54,7 +54,6 @@ export default {
linkable, linkable,
getAction, getAction,
fetchDatasourceSchema, fetchDatasourceSchema,
ContextScopes,
getAPIKey, getAPIKey,
enrichButtonActions, enrichButtonActions,
processStringSync, processStringSync,

View file

@ -1,98 +1,59 @@
import { writable, derived } from "svelte/store" import { writable, derived } from "svelte/store"
import { ContextScopes } from "constants" import { Helpers } from "@budibase/bbui"
export const createContextStore = parentContext => { export const createContextStore = oldContext => {
const context = writable({}) const newContext = writable({})
let observers = [] const contexts = oldContext ? [oldContext, newContext] : [newContext]
// Derive the total context state at this point in the tree
const contexts = parentContext ? [parentContext, context] : [context]
const totalContext = derived(contexts, $contexts => { const totalContext = derived(contexts, $contexts => {
return $contexts.reduce((total, context) => ({ ...total, ...context }), {}) // The key is the serialized representation of context
let key = ""
for (let i = 0; i < $contexts.length - 1; i++) {
key += $contexts[i].key
}
key = Helpers.hashString(
key + JSON.stringify($contexts[$contexts.length - 1])
)
// Reduce global state
const reducer = (total, context) => ({ ...total, ...context })
const context = $contexts.reduce(reducer, {})
return {
...context,
key,
}
}) })
// Subscribe to updates in the parent context, so that we can proxy on any // Adds a data context layer to the tree
// change messages to our own subscribers const provideData = (providerId, data) => {
if (parentContext) {
parentContext.actions.observeChanges(key => {
broadcastChange(key)
})
}
// Provide some data in context
const provideData = (providerId, data, scope = ContextScopes.Global) => {
if (!providerId || data === undefined) { if (!providerId || data === undefined) {
return return
} }
newContext.update(state => {
state[providerId] = data
// Proxy message up the chain if we have a parent and are providing global // Keep track of the closest component ID so we can later hydrate a "data" prop.
// context // This is only required for legacy bindings that used "data" rather than a
if (scope === ContextScopes.Global && parentContext) { // component ID.
parentContext.actions.provideData(providerId, data, scope) state.closestComponentId = providerId
}
// Otherwise this is either the context root, or we're providing a local return state
// context override, so we need to update the local context instead })
else {
context.update(state => {
state[providerId] = data
return state
})
broadcastChange(providerId)
}
} }
// Provides some action in context // Adds an action context layer to the tree
const provideAction = ( const provideAction = (providerId, actionType, callback) => {
providerId,
actionType,
callback,
scope = ContextScopes.Global
) => {
if (!providerId || !actionType) { if (!providerId || !actionType) {
return return
} }
newContext.update(state => {
// Proxy message up the chain if we have a parent and are providing global state[`${providerId}_${actionType}`] = callback
// context return state
if (scope === ContextScopes.Global && parentContext) { })
parentContext.actions.provideAction(
providerId,
actionType,
callback,
scope
)
}
// Otherwise this is either the context root, or we're providing a local
// context override, so we need to update the local context instead
else {
const key = `${providerId}_${actionType}`
context.update(state => {
state[key] = callback
return state
})
broadcastChange(key)
}
}
const observeChanges = callback => {
observers.push(callback)
return () => {
observers = observers.filter(cb => cb !== callback)
}
}
const broadcastChange = key => {
observers.forEach(cb => cb(key))
} }
return { return {
subscribe: totalContext.subscribe, subscribe: totalContext.subscribe,
actions: { actions: { provideData, provideAction },
provideData,
provideAction,
observeChanges,
},
} }
} }

View file

@ -17,54 +17,6 @@ import { ActionTypes } from "constants"
import { enrichDataBindings } from "./enrichDataBinding" import { enrichDataBindings } from "./enrichDataBinding"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
// Default action handler, which extracts an action from context that was
// provided by another component and executes it with all action parameters
const contextActionHandler = async (action, context) => {
const key = getActionContextKey(action)
const fn = context[key]
if (fn) {
return await fn(action.parameters)
}
}
// Generates the context key, which is the key that this action depends on in
// context to provide the function it will run. This is broken out as a util
// because we reuse this inside the core Component.svelte file to determine
// what the required action context keys are for all action settings.
export const getActionContextKey = action => {
const type = action?.["##eventHandlerType"]
const key = (componentId, type) => `${componentId}_${type}`
switch (type) {
case "Scroll To Field":
return key(action.parameters.componentId, ActionTypes.ScrollTo)
case "Update Field Value":
return key(action.parameters.componentId, ActionTypes.UpdateFieldValue)
case "Validate Form":
return key(action.parameters.componentId, ActionTypes.ValidateForm)
case "Refresh Data Provider":
return key(action.parameters.componentId, ActionTypes.RefreshDatasource)
case "Clear Form":
return key(action.parameters.componentId, ActionTypes.ClearForm)
case "Change Form Step":
return key(action.parameters.componentId, ActionTypes.ChangeFormStep)
default:
return null
}
}
// If button actions depend on context, they must declare which keys they need
export const getActionDependentContextKeys = action => {
const type = action?.["##eventHandlerType"]
switch (type) {
case "Save Row":
case "Duplicate Row":
if (action.parameters?.providerId) {
return [action.parameters.providerId]
}
}
return []
}
const saveRowHandler = async (action, context) => { const saveRowHandler = async (action, context) => {
const { fields, providerId, tableId, notificationOverride } = const { fields, providerId, tableId, notificationOverride } =
action.parameters action.parameters
@ -80,21 +32,20 @@ const saveRowHandler = async (action, context) => {
} }
} }
if (tableId) { if (tableId) {
if (tableId.startsWith("view")) { payload.tableId = tableId
payload._viewId = tableId
} else {
payload.tableId = tableId
}
} }
try { try {
const row = await API.saveRow(payload) const row = await API.saveRow(payload)
if (!notificationOverride) { if (!notificationOverride) {
notificationStore.actions.success("Row saved") notificationStore.actions.success("Row saved")
} }
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, { await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true, invalidateRelationships: true,
}) })
return { row } return { row }
} catch (error) { } catch (error) {
// Abort next actions // Abort next actions
@ -113,11 +64,7 @@ const duplicateRowHandler = async (action, context) => {
} }
} }
if (tableId) { if (tableId) {
if (tableId.startsWith("view")) { payload.tableId = tableId
payload._viewId = tableId
} else {
payload.tableId = tableId
}
} }
delete payload._id delete payload._id
delete payload._rev delete payload._rev
@ -126,10 +73,12 @@ const duplicateRowHandler = async (action, context) => {
if (!notificationOverride) { if (!notificationOverride) {
notificationStore.actions.success("Row saved") notificationStore.actions.success("Row saved")
} }
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, { await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true, invalidateRelationships: true,
}) })
return { row } return { row }
} catch (error) { } catch (error) {
// Abort next actions // Abort next actions
@ -241,6 +190,17 @@ const navigationHandler = action => {
routeStore.actions.navigate(url, peek, externalNewTab) routeStore.actions.navigate(url, peek, externalNewTab)
} }
const scrollHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ScrollTo,
{
field: action.parameters.field,
}
)
}
const queryExecutionHandler = async action => { const queryExecutionHandler = async action => {
const { datasourceId, queryId, queryParams, notificationOverride } = const { datasourceId, queryId, queryParams, notificationOverride } =
action.parameters action.parameters
@ -276,6 +236,47 @@ const queryExecutionHandler = async action => {
} }
} }
const executeActionHandler = async (
context,
componentId,
actionType,
params
) => {
const fn = context[`${componentId}_${actionType}`]
if (fn) {
return await fn(params)
}
}
const updateFieldValueHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.UpdateFieldValue,
{
type: action.parameters.type,
field: action.parameters.field,
value: action.parameters.value,
}
)
}
const validateFormHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ValidateForm
)
}
const refreshDataProviderHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.RefreshDatasource
)
}
const logoutHandler = async action => { const logoutHandler = async action => {
await authStore.actions.logOut() await authStore.actions.logOut()
let redirectUrl = "/builder/auth/login" let redirectUrl = "/builder/auth/login"
@ -292,6 +293,23 @@ const logoutHandler = async action => {
} }
} }
const clearFormHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ClearForm
)
}
const changeFormStepHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ChangeFormStep,
action.parameters
)
}
const closeScreenModalHandler = action => { const closeScreenModalHandler = action => {
let url let url
if (action?.parameters) { if (action?.parameters) {
@ -399,10 +417,16 @@ const handlerMap = {
["Duplicate Row"]: duplicateRowHandler, ["Duplicate Row"]: duplicateRowHandler,
["Delete Row"]: deleteRowHandler, ["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler, ["Navigate To"]: navigationHandler,
["Scroll To Field"]: scrollHandler,
["Execute Query"]: queryExecutionHandler, ["Execute Query"]: queryExecutionHandler,
["Trigger Automation"]: triggerAutomationHandler, ["Trigger Automation"]: triggerAutomationHandler,
["Validate Form"]: validateFormHandler,
["Update Field Value"]: updateFieldValueHandler,
["Refresh Data Provider"]: refreshDataProviderHandler,
["Log Out"]: logoutHandler, ["Log Out"]: logoutHandler,
["Clear Form"]: clearFormHandler,
["Close Screen Modal"]: closeScreenModalHandler, ["Close Screen Modal"]: closeScreenModalHandler,
["Change Form Step"]: changeFormStepHandler,
["Update State"]: updateStateHandler, ["Update State"]: updateStateHandler,
["Upload File to S3"]: s3UploadHandler, ["Upload File to S3"]: s3UploadHandler,
["Export Data"]: exportDataHandler, ["Export Data"]: exportDataHandler,
@ -437,12 +461,7 @@ export const enrichButtonActions = (actions, context) => {
return actions return actions
} }
// Get handlers for each action. If no bespoke handler is configured, fall const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
// back to simply executing this action from context.
const handlers = actions.map(def => {
return handlerMap[def["##eventHandlerType"]] || contextActionHandler
})
return async eventContext => { return async eventContext => {
// Button context is built up as actions are executed. // Button context is built up as actions are executed.
// Inherit any previous button context which may have come from actions // Inherit any previous button context which may have come from actions

View file

@ -23,6 +23,16 @@ export const propsAreSame = (a, b) => {
* Data bindings are enriched, and button actions are enriched. * Data bindings are enriched, and button actions are enriched.
*/ */
export const enrichProps = (props, context, settingsDefinitionMap) => { export const enrichProps = (props, context, settingsDefinitionMap) => {
// Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires
const totalContext = {
...context,
// This is only required for legacy bindings that used "data" rather than a
// component ID.
data: context[context.closestComponentId],
}
// We want to exclude any button actions from enrichment at this stage. // We want to exclude any button actions from enrichment at this stage.
// Extract top level button action settings. // Extract top level button action settings.
let normalProps = { ...props } let normalProps = { ...props }
@ -39,13 +49,13 @@ export const enrichProps = (props, context, settingsDefinitionMap) => {
let rawConditions = normalProps._conditions let rawConditions = normalProps._conditions
// Enrich all props except button actions // Enrich all props except button actions
let enrichedProps = enrichDataBindings(normalProps, context) let enrichedProps = enrichDataBindings(normalProps, totalContext)
// Enrich button actions. // Enrich button actions.
// Actions are enriched into a function at this stage, but actual data // Actions are enriched into a function at this stage, but actual data
// binding enrichment is done dynamically at runtime. // binding enrichment is done dynamically at runtime.
Object.keys(actionProps).forEach(prop => { Object.keys(actionProps).forEach(prop => {
enrichedProps[prop] = enrichButtonActions(actionProps[prop], context) enrichedProps[prop] = enrichButtonActions(actionProps[prop], totalContext)
}) })
// Conditions // Conditions
@ -56,7 +66,7 @@ export const enrichProps = (props, context, settingsDefinitionMap) => {
// action // action
condition.settingValue = enrichButtonActions( condition.settingValue = enrichButtonActions(
rawConditions[idx].settingValue, rawConditions[idx].settingValue,
context totalContext
) )
// Since we can't compare functions, we need to assume that conditions // Since we can't compare functions, we need to assume that conditions

View file

@ -19,12 +19,11 @@ export const buildRowEndpoints = API => ({
* @param suppressErrors whether or not to suppress error notifications * @param suppressErrors whether or not to suppress error notifications
*/ */
saveRow: async (row, suppressErrors = false) => { saveRow: async (row, suppressErrors = false) => {
const resourceId = row?._viewId || row?.tableId if (!row?.tableId) {
if (!resourceId) {
return return
} }
return await API.post({ return await API.post({
url: `/api/${resourceId}/rows`, url: `/api/${row._viewId || row.tableId}/rows`,
body: row, body: row,
suppressErrors, suppressErrors,
}) })
@ -36,12 +35,11 @@ export const buildRowEndpoints = API => ({
* @param suppressErrors whether or not to suppress error notifications * @param suppressErrors whether or not to suppress error notifications
*/ */
patchRow: async (row, suppressErrors = false) => { patchRow: async (row, suppressErrors = false) => {
const resourceId = row?._viewId || row?.tableId if (!row?.tableId && !row?._viewId) {
if (!resourceId) {
return return
} }
return await API.patch({ return await API.patch({
url: `/api/${resourceId}/rows`, url: `/api/${row._viewId || row.tableId}/rows`,
body: row, body: row,
suppressErrors, suppressErrors,
}) })

@ -1 +1 @@
Subproject commit ce7722ed4474718596b465dcfd49bef36cab2e42 Subproject commit eb9565f568cfef14b336b14eee753119acfdd43b

View file

@ -1,15 +1,21 @@
import { generateQueryID } from "../../../db/utils" import { generateQueryID } from "../../../db/utils"
import { BaseQueryVerbs, FieldTypes } from "../../../constants" import { BaseQueryVerbs } from "../../../constants"
import { Thread, ThreadType } from "../../../threads" import { Thread, ThreadType } from "../../../threads"
import { save as saveDatasource } from "../datasource" import { save as saveDatasource } from "../datasource"
import { RestImporter } from "./import" import { RestImporter } from "./import"
import { invalidateDynamicVariables } from "../../../threads/utils" import { invalidateDynamicVariables } from "../../../threads/utils"
import env from "../../../environment" import env from "../../../environment"
import { quotas } from "@budibase/pro"
import { events, context, utils, constants } from "@budibase/backend-core" import { events, context, utils, constants } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { QueryEvent } from "../../../threads/definitions" import { QueryEvent, QueryResponse } from "../../../threads/definitions"
import { ConfigType, Query, UserCtx, SessionCookie } from "@budibase/types" import {
ConfigType,
Query,
UserCtx,
SessionCookie,
QuerySchema,
FieldType,
} from "@budibase/types"
import { ValidQueryNameRegex } from "@budibase/shared-core" import { ValidQueryNameRegex } from "@budibase/shared-core"
const Runner = new Thread(ThreadType.QUERY, { const Runner = new Thread(ThreadType.QUERY, {
@ -162,39 +168,43 @@ export async function preview(ctx: UserCtx) {
}, },
} }
const { rows, keys, info, extra } = (await Runner.run(inputs)) as any const { rows, keys, info, extra } = await Runner.run<QueryResponse>(inputs)
const schemaFields: any = {} const previewSchema: Record<string, QuerySchema> = {}
const makeQuerySchema = (type: FieldType, name: string): QuerySchema => ({
type,
name,
})
if (rows?.length > 0) { if (rows?.length > 0) {
for (let key of [...new Set(keys)] as string[]) { for (let key of [...new Set(keys)] as string[]) {
const field = rows[0][key] const field = rows[0][key]
let type = typeof field, let type = typeof field,
fieldType = FieldTypes.STRING fieldMetadata = makeQuerySchema(FieldType.STRING, key)
if (field) if (field)
switch (type) { switch (type) {
case "boolean": case "boolean":
schemaFields[key] = FieldTypes.BOOLEAN fieldMetadata = makeQuerySchema(FieldType.BOOLEAN, key)
break break
case "object": case "object":
if (field instanceof Date) { if (field instanceof Date) {
fieldType = FieldTypes.DATETIME fieldMetadata = makeQuerySchema(FieldType.DATETIME, key)
} else if (Array.isArray(field)) { } else if (Array.isArray(field)) {
fieldType = FieldTypes.ARRAY fieldMetadata = makeQuerySchema(FieldType.ARRAY, key)
} else { } else {
fieldType = FieldTypes.JSON fieldMetadata = makeQuerySchema(FieldType.JSON, key)
} }
break break
case "number": case "number":
fieldType = FieldTypes.NUMBER fieldMetadata = makeQuerySchema(FieldType.NUMBER, key)
break break
} }
schemaFields[key] = fieldType previewSchema[key] = fieldMetadata
} }
} }
// if existing schema, update to include any previous schema keys // if existing schema, update to include any previous schema keys
if (existingSchema) { if (existingSchema) {
for (let key of Object.keys(schemaFields)) { for (let key of Object.keys(previewSchema)) {
if (existingSchema[key]?.type) { if (existingSchema[key]) {
schemaFields[key] = existingSchema[key].type previewSchema[key] = existingSchema[key]
} }
} }
} }
@ -203,7 +213,7 @@ export async function preview(ctx: UserCtx) {
await events.query.previewed(datasource, query) await events.query.previewed(datasource, query)
ctx.body = { ctx.body = {
rows, rows,
schemaFields, schema: previewSchema,
info, info,
extra, extra,
} }
@ -257,7 +267,9 @@ async function execute(
schema: query.schema, schema: query.schema,
} }
const { rows, pagination, extra, info } = (await Runner.run(inputs)) as any const { rows, pagination, extra, info } = await Runner.run<QueryResponse>(
inputs
)
// remove the raw from execution incase transformer being used to hide data // remove the raw from execution incase transformer being used to hide data
if (extra?.raw) { if (extra?.raw) {
delete extra.raw delete extra.raw

View file

@ -235,9 +235,9 @@ describe("/queries", () => {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
// these responses come from the mock // these responses come from the mock
expect(res.body.schemaFields).toEqual({ expect(res.body.schema).toEqual({
a: "string", a: { type: "string", name: "a" },
b: "number", b: { type: "number", name: "b" },
}) })
expect(res.body.rows.length).toEqual(1) expect(res.body.rows.length).toEqual(1)
expect(events.query.previewed).toBeCalledTimes(1) expect(events.query.previewed).toBeCalledTimes(1)
@ -300,10 +300,10 @@ describe("/queries", () => {
queryString: "test={{ variable2 }}", queryString: "test={{ variable2 }}",
}) })
// these responses come from the mock // these responses come from the mock
expect(res.body.schemaFields).toEqual({ expect(res.body.schema).toEqual({
opts: "json", opts: { type: "json", name: "opts" },
url: "string", url: { type: "string", name: "url" },
value: "string", value: { type: "string", name: "value" },
}) })
expect(res.body.rows[0].url).toEqual("http://www.google.com?test=1") expect(res.body.rows[0].url).toEqual("http://www.google.com?test=1")
}) })
@ -314,10 +314,10 @@ describe("/queries", () => {
path: "www.google.com", path: "www.google.com",
queryString: "test={{ variable3 }}", queryString: "test={{ variable3 }}",
}) })
expect(res.body.schemaFields).toEqual({ expect(res.body.schema).toEqual({
opts: "json", opts: { type: "json", name: "opts" },
url: "string", url: { type: "string", name: "url" },
value: "string", value: { type: "string", name: "value" },
}) })
expect(res.body.rows[0].url).toContain("doctype%20html") expect(res.body.rows[0].url).toContain("doctype%20html")
}) })
@ -337,10 +337,10 @@ describe("/queries", () => {
path: "www.failonce.com", path: "www.failonce.com",
queryString: "test={{ variable3 }}", queryString: "test={{ variable3 }}",
}) })
expect(res.body.schemaFields).toEqual({ expect(res.body.schema).toEqual({
fails: "number", fails: { type: "number", name: "fails" },
opts: "json", opts: { type: "json", name: "opts" },
url: "string", url: { type: "string", name: "url" },
}) })
expect(res.body.rows[0].fails).toEqual(1) expect(res.body.rows[0].fails).toEqual(1)
}) })

View file

@ -60,6 +60,7 @@ const environment = {
PLUGINS_DIR: process.env.PLUGINS_DIR || "/plugins", PLUGINS_DIR: process.env.PLUGINS_DIR || "/plugins",
OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB, MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
// flags // flags
ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS, ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS,
DISABLE_THREADING: process.env.DISABLE_THREADING, DISABLE_THREADING: process.env.DISABLE_THREADING,

View file

@ -1,18 +1,11 @@
import { rowEmission, tableEmission } from "./utils" import { rowEmission, tableEmission } from "./utils"
import mainEmitter from "./index" import mainEmitter from "./index"
import env from "../environment" import env from "../environment"
import { Table, Row } from "@budibase/types" import { Table, Row, DocumentType, App } from "@budibase/types"
import { context } from "@budibase/backend-core"
// max number of automations that can chain on top of each other const MAX_AUTOMATIONS_ALLOWED = 5
// TODO: in future make this configurable at the automation level
const MAX_AUTOMATION_CHAIN = env.SELF_HOSTED ? 5 : 0
/**
* Special emitter which takes the count of automation runs which have occurred and blocks an
* automation from running if it has reached the maximum number of chained automations runs.
* This essentially "fakes" the normal emitter to add some functionality in-between to stop automations
* from getting stuck endlessly chaining.
*/
class AutomationEmitter { class AutomationEmitter {
chainCount: number chainCount: number
metadata: { automationChainCount: number } metadata: { automationChainCount: number }
@ -24,7 +17,23 @@ class AutomationEmitter {
} }
} }
emitRow(eventName: string, appId: string, row: Row, table?: Table) { async getMaxAutomationChain() {
const db = context.getAppDB()
const appMetadata = await db.get<App>(DocumentType.APP_METADATA)
let chainAutomations = appMetadata?.automations?.chainAutomations
if (chainAutomations === true) {
return MAX_AUTOMATIONS_ALLOWED
} else if (chainAutomations === undefined && env.SELF_HOSTED) {
return MAX_AUTOMATIONS_ALLOWED
} else {
return 0
}
}
async emitRow(eventName: string, appId: string, row: Row, table?: Table) {
let MAX_AUTOMATION_CHAIN = await this.getMaxAutomationChain()
// don't emit even if we've reached max automation chain // don't emit even if we've reached max automation chain
if (this.chainCount >= MAX_AUTOMATION_CHAIN) { if (this.chainCount >= MAX_AUTOMATION_CHAIN) {
return return
@ -39,9 +48,11 @@ class AutomationEmitter {
}) })
} }
emitTable(eventName: string, appId: string, table?: Table) { async emitTable(eventName: string, appId: string, table?: Table) {
let MAX_AUTOMATION_CHAIN = await this.getMaxAutomationChain()
// don't emit even if we've reached max automation chain // don't emit even if we've reached max automation chain
if (this.chainCount > MAX_AUTOMATION_CHAIN) { if (this.chainCount >= MAX_AUTOMATION_CHAIN) {
return return
} }

View file

@ -376,8 +376,8 @@ export function checkExternalTables(
errors[name] = "Table must have a primary key." errors[name] = "Table must have a primary key."
} }
const schemaFields = Object.keys(table.schema) const columnNames = Object.keys(table.schema)
if (schemaFields.find(f => invalidColumns.includes(f))) { if (columnNames.find(f => invalidColumns.includes(f))) {
errors[name] = "Table contains invalid columns." errors[name] = "Table contains invalid columns."
} }
} }

View file

@ -34,7 +34,7 @@ const checkAuthorized = async (
const isCreatorApi = permType === PermissionType.CREATOR const isCreatorApi = permType === PermissionType.CREATOR
const isBuilderApi = permType === PermissionType.BUILDER const isBuilderApi = permType === PermissionType.BUILDER
const isGlobalBuilder = users.isGlobalBuilder(ctx.user) const isGlobalBuilder = users.isGlobalBuilder(ctx.user)
const isCreator = users.isCreator(ctx.user) const isCreator = await users.isCreator(ctx.user)
const isBuilder = appId const isBuilder = appId
? users.isBuilder(ctx.user, appId) ? users.isBuilder(ctx.user, appId)
: users.hasBuilderPermissions(ctx.user) : users.hasBuilderPermissions(ctx.user)

View file

@ -3,6 +3,27 @@ import { processStringSync } from "@budibase/string-templates"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { getQueryParams, isProdAppID } from "../../../db/utils" import { getQueryParams, isProdAppID } from "../../../db/utils"
import { BaseQueryVerbs } from "../../../constants" import { BaseQueryVerbs } from "../../../constants"
import { Query, QuerySchema } from "@budibase/types"
function updateSchema(query: Query): Query {
if (!query.schema) {
return query
}
const schema: Record<string, QuerySchema> = {}
for (let key of Object.keys(query.schema)) {
if (typeof query.schema[key] === "string") {
schema[key] = { type: query.schema[key] as string, name: key }
} else {
schema[key] = query.schema[key] as QuerySchema
}
}
query.schema = schema
return query
}
function updateSchemas(queries: Query[]): Query[] {
return queries.map(query => updateSchema(query))
}
// simple function to append "readable" to all read queries // simple function to append "readable" to all read queries
function enrichQueries(input: any) { function enrichQueries(input: any) {
@ -25,7 +46,7 @@ export async function find(queryId: string) {
delete query.fields delete query.fields
delete query.parameters delete query.parameters
} }
return query return updateSchema(query)
} }
export async function fetch(opts: { enrich: boolean } = { enrich: true }) { export async function fetch(opts: { enrich: boolean } = { enrich: true }) {
@ -37,12 +58,11 @@ export async function fetch(opts: { enrich: boolean } = { enrich: true }) {
}) })
) )
const queries = body.rows.map((row: any) => row.doc) let queries = body.rows.map((row: any) => row.doc)
if (opts.enrich) { if (opts.enrich) {
return enrichQueries(queries) queries = await enrichQueries(queries)
} else {
return queries
} }
return updateSchemas(queries)
} }
export async function enrichContext( export async function enrichContext(

View file

@ -84,7 +84,7 @@ describe("syncGlobalUsers", () => {
await syncGlobalUsers() await syncGlobalUsers()
const metadata = await rawUserMetadata() const metadata = await rawUserMetadata()
expect(metadata).toHaveLength(3) expect(metadata).toHaveLength(2)
expect(metadata).toContainEqual( expect(metadata).toContainEqual(
expect.objectContaining({ expect.objectContaining({
_id: db.generateUserMetadataID(user1._id!), _id: db.generateUserMetadataID(user1._id!),
@ -121,7 +121,7 @@ describe("syncGlobalUsers", () => {
await syncGlobalUsers() await syncGlobalUsers()
const metadata = await rawUserMetadata() const metadata = await rawUserMetadata()
expect(metadata).toHaveLength(0) expect(metadata).toHaveLength(1) //ADMIN user created in test bootstrap still in the application
}) })
}) })
}) })

View file

@ -278,6 +278,9 @@ class TestConfiguration {
if (params) { if (params) {
request.params = params request.params = params
} }
request.throw = (status: number, message: string) => {
throw new Error(`Error ${status} - ${message}`)
}
return this.doInContext(appId, async () => { return this.doInContext(appId, async () => {
await controlFunc(request) await controlFunc(request)
return request.body return request.body

View file

@ -1,3 +1,5 @@
import { QuerySchema, Row } from "@budibase/types"
export type WorkerCallback = (error: any, response?: any) => void export type WorkerCallback = (error: any, response?: any) => void
export interface QueryEvent { export interface QueryEvent {
@ -11,7 +13,15 @@ export interface QueryEvent {
queryId: string queryId: string
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>
ctx?: any ctx?: any
schema?: Record<string, { name?: string; type: string }> schema?: Record<string, QuerySchema | string>
}
export interface QueryResponse {
rows: Row[]
keys: string[]
info: any
extra: any
pagination: any
} }
export interface QueryVariable { export interface QueryVariable {

View file

@ -74,7 +74,7 @@ export class Thread {
) )
} }
run(job: AutomationJob | QueryEvent) { run<T>(job: AutomationJob | QueryEvent): Promise<T> {
const timeout = this.timeoutMs const timeout = this.timeoutMs
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function fire(worker: any) { function fire(worker: any) {

View file

@ -1,7 +1,12 @@
import { default as threadUtils } from "./utils" import { default as threadUtils } from "./utils"
threadUtils.threadSetup() threadUtils.threadSetup()
import { WorkerCallback, QueryEvent, QueryVariable } from "./definitions" import {
WorkerCallback,
QueryEvent,
QueryVariable,
QueryResponse,
} from "./definitions"
import ScriptRunner from "../utilities/scriptRunner" import ScriptRunner from "../utilities/scriptRunner"
import { getIntegration } from "../integrations" import { getIntegration } from "../integrations"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
@ -9,7 +14,7 @@ import { context, cache, auth } from "@budibase/backend-core"
import { getGlobalIDFromUserMetadataID } from "../db/utils" import { getGlobalIDFromUserMetadataID } from "../db/utils"
import sdk from "../sdk" import sdk from "../sdk"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { SourceName, Query } from "@budibase/types" import { Query } from "@budibase/types"
import { isSQL } from "../integrations/utils" import { isSQL } from "../integrations/utils"
import { interpolateSQL } from "../integrations/queries/sql" import { interpolateSQL } from "../integrations/queries/sql"
@ -53,7 +58,7 @@ class QueryRunner {
this.hasDynamicVariables = false this.hasDynamicVariables = false
} }
async execute(): Promise<any> { async execute(): Promise<QueryResponse> {
let { datasource, fields, queryVerb, transformer, schema } = this let { datasource, fields, queryVerb, transformer, schema } = this
let datasourceClone = cloneDeep(datasource) let datasourceClone = cloneDeep(datasource)
let fieldsClone = cloneDeep(fields) let fieldsClone = cloneDeep(fields)

View file

@ -48,6 +48,9 @@ async function checkResponse(
let error let error
try { try {
error = await response.json() error = await response.json()
if (!error.message) {
error = JSON.stringify(error)
}
} catch (err) { } catch (err) {
error = await response.text() error = await response.text()
} }

View file

@ -70,7 +70,7 @@ export function hasAppCreatorPermissions(user?: User | ContextUser): boolean {
return _.flow( return _.flow(
_.get("roles"), _.get("roles"),
_.values, _.values,
_.find(x => x === "CREATOR"), _.find(x => ["CREATOR", "ADMIN"].includes(x)),
x => !!x x => !!x
)(user) )(user)
} }

View file

@ -137,7 +137,7 @@
"n" "n"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{ after [1, 2, 3] 1}} -> [3]", "example": "{{ after ['a', 'b', 'c', 'd'] 2}} -> ['c', 'd']",
"description": "<p>Returns all of the items in an array after the specified index. Opposite of <a href=\"#before\">before</a>.</p>\n" "description": "<p>Returns all of the items in an array after the specified index. Opposite of <a href=\"#before\">before</a>.</p>\n"
}, },
"arrayify": { "arrayify": {
@ -154,7 +154,7 @@
"n" "n"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{ before [1, 2, 3] 2}} -> [1, 2]", "example": "{{ before ['a', 'b', 'c', 'd'] 3}} -> ['a', 'b']",
"description": "<p>Return all of the items in the collection before the specified count. Opposite of <a href=\"#after\">after</a>.</p>\n" "description": "<p>Return all of the items in the collection before the specified count. Opposite of <a href=\"#after\">after</a>.</p>\n"
}, },
"eachIndex": { "eachIndex": {
@ -182,7 +182,7 @@
"n" "n"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{first [1, 2, 3, 4] 2}} -> [1, 2]", "example": "{{first [1, 2, 3, 4] 2}} -> 1,2",
"description": "<p>Returns the first item, or first <code>n</code> items of an array.</p>\n" "description": "<p>Returns the first item, or first <code>n</code> items of an array.</p>\n"
}, },
"forEach": { "forEach": {
@ -200,7 +200,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#inArray [1, 2, 3] 2}} 2 exists {{else}} 2 does not exist {{/inArray}} -> 2 exists", "example": "{{#inArray [1, 2, 3] 2}} 2 exists {{else}} 2 does not exist {{/inArray}} -> ' 2 exists '",
"description": "<p>Block helper that renders the block if an array has the given <code>value</code>. Optionally specify an inverse block to render when the array does not have the given value.</p>\n" "description": "<p>Block helper that renders the block if an array has the given <code>value</code>. Optionally specify an inverse block to render when the array does not have the given value.</p>\n"
}, },
"isArray": { "isArray": {
@ -226,7 +226,7 @@
"separator" "separator"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{join [1, 2, 3]}} -> '1, 2, 3'", "example": "{{join [1, 2, 3]}} -> 1, 2, 3",
"description": "<p>Join all elements of array into a string, optionally using a given separator.</p>\n" "description": "<p>Join all elements of array into a string, optionally using a given separator.</p>\n"
}, },
"equalsLength": { "equalsLength": {
@ -236,7 +236,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{equalsLength '[1,2,3]' 3}} -> true", "example": "{{equalsLength [1, 2, 3] 3}} -> true",
"description": "<p>Returns true if the the length of the given <code>value</code> is equal to the given <code>length</code>. Can be used as a block or inline helper.</p>\n" "description": "<p>Returns true if the the length of the given <code>value</code> is equal to the given <code>length</code>. Can be used as a block or inline helper.</p>\n"
}, },
"last": { "last": {
@ -253,7 +253,7 @@
"value" "value"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{length '[1, 2, 3]'}} -> 3", "example": "{{length [1, 2, 3]}} -> 3",
"description": "<p>Returns the length of the given string or array.</p>\n" "description": "<p>Returns the length of the given string or array.</p>\n"
}, },
"lengthEqual": { "lengthEqual": {
@ -263,7 +263,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{equalsLength '[1,2,3]' 3}} -> true", "example": "{{equalsLength [1, 2, 3] 3}} -> true",
"description": "<p>Returns true if the the length of the given <code>value</code> is equal to the given <code>length</code>. Can be used as a block or inline helper.</p>\n" "description": "<p>Returns true if the the length of the given <code>value</code> is equal to the given <code>length</code>. Can be used as a block or inline helper.</p>\n"
}, },
"map": { "map": {
@ -299,7 +299,7 @@
"provided" "provided"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#some [1, 'b', 3] isString}} string found {{else}} No string found {{/some}} -> string found", "example": "{{#some [1, \"b\", 3] isString}} string found {{else}} No string found {{/some}} -> ' string found '",
"description": "<p>Block helper that returns the block if the callback returns true for some value in the given array.</p>\n" "description": "<p>Block helper that returns the block if the callback returns true for some value in the given array.</p>\n"
}, },
"sort": { "sort": {
@ -317,7 +317,7 @@
"props" "props"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{ sortBy [{a: 'zzz'}, {a: 'aaa'}] 'a' }} -> [{'a':'aaa'}, {'a':'zzz'}]", "example": "{{ sortBy [{'a': 'zzz'}, {'a': 'aaa'}] 'a' }} -> [{'a':'aaa'},{'a':'zzz'}]",
"description": "<p>Sort an <code>array</code>. If an array of objects is passed, you may optionally pass a <code>key</code> to sort on as the second argument. You may alternatively pass a sorting function as the second argument.</p>\n" "description": "<p>Sort an <code>array</code>. If an array of objects is passed, you may optionally pass a <code>key</code> to sort on as the second argument. You may alternatively pass a sorting function as the second argument.</p>\n"
}, },
"withAfter": { "withAfter": {
@ -347,7 +347,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{ withFirst [1, 2, 3] }} {{this}} {{/withFirst}}", "example": "{{#withFirst [1, 2, 3] }}{{this}}{{/withFirst}} -> 1",
"description": "<p>Use the first item in a collection inside a handlebars block expression. Opposite of <a href=\"#withLast\">withLast</a>.</p>\n" "description": "<p>Use the first item in a collection inside a handlebars block expression. Opposite of <a href=\"#withLast\">withLast</a>.</p>\n"
}, },
"withGroup": { "withGroup": {
@ -357,7 +357,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#withGroup [1, 2, 3, 4] 2}} {{#each this}} {{.}} {{each}} <br> {{/withGroup}} -> 1,2<br> 3,4<br>", "example": "{{#withGroup [1, 2, 3, 4] 2}}{{#each this}}{{.}}{{/each}}<br>{{/withGroup}} -> 12<br>34<br>",
"description": "<p>Block helper that groups array elements by given group <code>size</code>.</p>\n" "description": "<p>Block helper that groups array elements by given group <code>size</code>.</p>\n"
}, },
"withLast": { "withLast": {
@ -367,7 +367,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#withLast [1, 2, 3, 4]}} {{this}} {{/withLast}} -> 4", "example": "{{#withLast [1, 2, 3, 4]}}{{this}}{{/withLast}} -> 4",
"description": "<p>Use the last item or <code>n</code> items in an array as context inside a block. Opposite of <a href=\"#withFirst\">withFirst</a>.</p>\n" "description": "<p>Use the last item or <code>n</code> items in an array as context inside a block. Opposite of <a href=\"#withFirst\">withFirst</a>.</p>\n"
}, },
"withSort": { "withSort": {
@ -377,7 +377,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#withSort ['b', 'a', 'c']}} {{this}} {{/withSort}} -> abc", "example": "{{#withSort ['b', 'a', 'c']}}{{this}}{{/withSort}} -> abc",
"description": "<p>Block helper that sorts a collection and exposes the sorted collection as context inside the block.</p>\n" "description": "<p>Block helper that sorts a collection and exposes the sorted collection as context inside the block.</p>\n"
}, },
"unique": { "unique": {
@ -386,7 +386,7 @@
"options" "options"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{#each (unique ['a', 'a', 'c', 'b', 'e', 'e']) }} {{.}} {{/each}} -> acbe", "example": "{{#each (unique ['a', 'a', 'c', 'b', 'e', 'e']) }}{{.}}{{/each}} -> acbe",
"description": "<p>Block helper that return an array with all duplicate values removed. Best used along with a <a href=\"#each\">each</a> helper.</p>\n" "description": "<p>Block helper that return an array with all duplicate values removed. Best used along with a <a href=\"#each\">each</a> helper.</p>\n"
} }
}, },
@ -396,7 +396,7 @@
"number" "number"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ bytes 1386 }} -> 1.4Kb", "example": "{{ bytes 1386 1 }} -> 1.4 kB",
"description": "<p>Format a number to it&#39;s equivalent in bytes. If a string is passed, it&#39;s length will be formatted and returned. <strong>Examples:</strong> - <code>&#39;foo&#39; =&gt; 3 B</code> - <code>13661855 =&gt; 13.66 MB</code> - <code>825399 =&gt; 825.39 kB</code> - <code>1396 =&gt; 1.4 kB</code></p>\n" "description": "<p>Format a number to it&#39;s equivalent in bytes. If a string is passed, it&#39;s length will be formatted and returned. <strong>Examples:</strong> - <code>&#39;foo&#39; =&gt; 3 B</code> - <code>13661855 =&gt; 13.66 MB</code> - <code>825399 =&gt; 825.39 kB</code> - <code>1396 =&gt; 1.4 kB</code></p>\n"
}, },
"addCommas": { "addCommas": {
@ -430,7 +430,7 @@
"fractionDigits" "fractionDigits"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{ toExponential 10123 2 }} -> 101e+4", "example": "{{ toExponential 10123 2 }} -> 1.01e+4",
"description": "<p>Returns a string representing the given number in exponential notation.</p>\n" "description": "<p>Returns a string representing the given number in exponential notation.</p>\n"
}, },
"toFixed": { "toFixed": {
@ -472,7 +472,7 @@
"str" "str"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ encodeURI 'https://myurl?Hello There' }} -> https://myurl?Hello%20There", "example": "{{ encodeURI 'https://myurl?Hello There' }} -> https%3A%2F%2Fmyurl%3FHello%20There",
"description": "<p>Encodes a Uniform Resource Identifier (URI) component by replacing each instance of certain characters by one, two, three, or four escape sequences representing the UTF-8 encoding of the character.</p>\n" "description": "<p>Encodes a Uniform Resource Identifier (URI) component by replacing each instance of certain characters by one, two, three, or four escape sequences representing the UTF-8 encoding of the character.</p>\n"
}, },
"escape": { "escape": {
@ -480,7 +480,7 @@
"str" "str"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ escape 'https://myurl?Hello+There' }} -> https://myurl?Hello%20There", "example": "{{ escape 'https://myurl?Hello+There' }} -> https%3A%2F%2Fmyurl%3FHello%2BThere",
"description": "<p>Escape the given string by replacing characters with escape sequences. Useful for allowing the string to be used in a URL, etc.</p>\n" "description": "<p>Escape the given string by replacing characters with escape sequences. Useful for allowing the string to be used in a URL, etc.</p>\n"
}, },
"decodeURI": { "decodeURI": {
@ -488,7 +488,7 @@
"str" "str"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ decodeURI 'https://myurl?Hello%20There' }} -> https://myurl?=Hello There", "example": "{{ decodeURI 'https://myurl?Hello%20There' }} -> https://myurl?Hello There",
"description": "<p>Decode a Uniform Resource Identifier (URI) component.</p>\n" "description": "<p>Decode a Uniform Resource Identifier (URI) component.</p>\n"
}, },
"urlResolve": { "urlResolve": {
@ -513,7 +513,7 @@
"url" "url"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ stripQueryString 'https://myurl/api/test?foo=bar' }} -> 'https://myurl/api/test'", "example": "{{ stripQuerystring 'https://myurl/api/test?foo=bar' }} -> 'https://myurl/api/test'",
"description": "<p>Strip the query string from the given <code>url</code>.</p>\n" "description": "<p>Strip the query string from the given <code>url</code>.</p>\n"
}, },
"stripProtocol": { "stripProtocol": {
@ -521,7 +521,7 @@
"str" "str"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ stripProtocol 'https://myurl/api/test' }} -> 'myurl/api/test'", "example": "{{ stripProtocol 'https://myurl/api/test' }} -> '//myurl/api/test'",
"description": "<p>Strip protocol from a <code>url</code>. Useful for displaying media that may have an &#39;http&#39; protocol on secure connections.</p>\n" "description": "<p>Strip protocol from a <code>url</code>. Useful for displaying media that may have an &#39;http&#39; protocol on secure connections.</p>\n"
} }
}, },
@ -573,7 +573,7 @@
"string" "string"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{ chop ' ABC '}} -> 'ABC'", "example": "{{ chop ' ABC '}} -> ABC",
"description": "<p>Like trim, but removes both extraneous whitespace <strong>and non-word characters</strong> from the beginning and end of a string.</p>\n" "description": "<p>Like trim, but removes both extraneous whitespace <strong>and non-word characters</strong> from the beginning and end of a string.</p>\n"
}, },
"dashcase": { "dashcase": {
@ -606,7 +606,7 @@
"length" "length"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{ellipsis 'foo bar baz', 7}} -> foo bar…", "example": "{{ellipsis 'foo bar baz' 7}} -> foo bar…",
"description": "<p>Truncates a string to the specified <code>length</code>, and appends it with an elipsis, <code>…</code>.</p>\n" "description": "<p>Truncates a string to the specified <code>length</code>, and appends it with an elipsis, <code>…</code>.</p>\n"
}, },
"hyphenate": { "hyphenate": {
@ -675,14 +675,6 @@
"example": "{{prepend 'bar' 'foo-'}} -> foo-bar", "example": "{{prepend 'bar' 'foo-'}} -> foo-bar",
"description": "<p>Prepends the given <code>string</code> with the specified <code>prefix</code>.</p>\n" "description": "<p>Prepends the given <code>string</code> with the specified <code>prefix</code>.</p>\n"
}, },
"raw": {
"args": [
"options"
],
"numArgs": 1,
"example": "{{{{#raw}}}} {{foo}} {{{{/raw}}}} -> {{foo}}",
"description": "<p>Render a block without processing mustache templates inside the block.</p>\n"
},
"remove": { "remove": {
"args": [ "args": [
"str", "str",
@ -698,7 +690,7 @@
"substring" "substring"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{remove 'a b a b a b' 'a'}} -> b a b a b", "example": "{{removeFirst 'a b a b a b' 'a'}} -> ' b a b a b'",
"description": "<p>Remove the first occurrence of <code>substring</code> from the given <code>str</code>.</p>\n" "description": "<p>Remove the first occurrence of <code>substring</code> from the given <code>str</code>.</p>\n"
}, },
"replace": { "replace": {
@ -718,7 +710,7 @@
"b" "b"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{replace 'a b a b a b' 'a' 'z'}} -> z b a b a b", "example": "{{replaceFirst 'a b a b a b' 'a' 'z'}} -> z b a b a b",
"description": "<p>Replace the first occurrence of substring <code>a</code> with substring <code>b</code>.</p>\n" "description": "<p>Replace the first occurrence of substring <code>a</code> with substring <code>b</code>.</p>\n"
}, },
"sentence": { "sentence": {
@ -752,7 +744,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#startsWith 'Goodbye' 'Hello, world!'}} Yep {{else}} Nope {{/startsWith}} -> Nope", "example": "{{#startsWith 'Goodbye' 'Hello, world!'}}Yep{{else}}Nope{{/startsWith}} -> Nope",
"description": "<p>Tests whether a string begins with the given prefix.</p>\n" "description": "<p>Tests whether a string begins with the given prefix.</p>\n"
}, },
"titleize": { "titleize": {
@ -760,7 +752,7 @@
"str" "str"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{#titleize 'this is title case' }} -> This Is Title Case", "example": "{{titleize 'this is title case' }} -> This Is Title Case",
"description": "<p>Title case the given string.</p>\n" "description": "<p>Title case the given string.</p>\n"
}, },
"trim": { "trim": {
@ -784,7 +776,7 @@
"string" "string"
], ],
"numArgs": 1, "numArgs": 1,
"example": "{{trimRight ' ABC ' }} -> ' ABC '", "example": "{{trimRight ' ABC ' }} -> ' ABC'",
"description": "<p>Removes extraneous whitespace from the end of a string.</p>\n" "description": "<p>Removes extraneous whitespace from the end of a string.</p>\n"
}, },
"truncate": { "truncate": {
@ -804,7 +796,7 @@
"suffix" "suffix"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{truncateWords 'foo bar baz' 1 }} -> foo", "example": "{{truncateWords 'foo bar baz' 1 }} -> foo",
"description": "<p>Truncate a string to have the specified number of words. Also see <a href=\"#truncate\">truncate</a>.</p>\n" "description": "<p>Truncate a string to have the specified number of words. Also see <a href=\"#truncate\">truncate</a>.</p>\n"
}, },
"upcase": { "upcase": {
@ -844,7 +836,7 @@
"options" "options"
], ],
"numArgs": 4, "numArgs": 4,
"example": "{{compare 10 '<' 5 }} -> true", "example": "{{compare 10 '<' 5 }} -> false",
"description": "<p>Render a block when a comparison of the first and third arguments returns true. The second argument is the [arithemetic operator][operators] to use. You may also optionally specify an inverse block to render when falsy.</p>\n" "description": "<p>Render a block when a comparison of the first and third arguments returns true. The second argument is the [arithemetic operator][operators] to use. You may also optionally specify an inverse block to render when falsy.</p>\n"
}, },
"contains": { "contains": {
@ -874,7 +866,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#eq 3 3}} equal{{else}} not equal{{/eq}} -> equal", "example": "{{#eq 3 3}}equal{{else}}not equal{{/eq}} -> equal",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n"
}, },
"gt": { "gt": {
@ -884,7 +876,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#gt 4 3}} greater than{{else}} not greater than{{/gt}} -> greater than", "example": "{{#gt 4 3}} greater than{{else}} not greater than{{/gt}} -> ' greater than'",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>greater than</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>greater than</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n"
}, },
"gte": { "gte": {
@ -894,7 +886,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#gte 4 3}} greater than or equal{{else}} not greater than{{/gte}} -> greater than or equal", "example": "{{#gte 4 3}} greater than or equal{{else}} not greater than{{/gte}} -> ' greater than or equal'",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>greater than or equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>greater than or equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n"
}, },
"has": { "has": {
@ -904,7 +896,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#has 'foobar' 'foo'}} has it{{else}} doesn't{{/has}} -> has it", "example": "{{#has 'foobar' 'foo'}}has it{{else}}doesn't{{/has}} -> has it",
"description": "<p>Block helper that renders a block if <code>value</code> has <code>pattern</code>. If an inverse block is specified it will be rendered when falsy.</p>\n" "description": "<p>Block helper that renders a block if <code>value</code> has <code>pattern</code>. If an inverse block is specified it will be rendered when falsy.</p>\n"
}, },
"isFalsey": { "isFalsey": {
@ -931,7 +923,7 @@
"options" "options"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{#ifEven 2}} even {{else}} odd {{/ifEven}} -> even", "example": "{{#ifEven 2}} even {{else}} odd {{/ifEven}} -> ' even '",
"description": "<p>Return true if the given value is an even number.</p>\n" "description": "<p>Return true if the given value is an even number.</p>\n"
}, },
"ifNth": { "ifNth": {
@ -941,8 +933,8 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#ifNth 10 2}} remainder {{else}} no remainder {{/ifNth}} -> remainder", "example": "{{#ifNth 2 10}}remainder{{else}}no remainder{{/ifNth}} -> remainder",
"description": "<p>Conditionally renders a block if the remainder is zero when <code>a</code> operand is divided by <code>b</code>. If an inverse block is specified it will be rendered when the remainder is <strong>not zero</strong>.</p>\n" "description": "<p>Conditionally renders a block if the remainder is zero when <code>b</code> operand is divided by <code>a</code>. If an inverse block is specified it will be rendered when the remainder is <strong>not zero</strong>.</p>\n"
}, },
"ifOdd": { "ifOdd": {
"args": [ "args": [
@ -950,7 +942,7 @@
"options" "options"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{#ifOdd 3}} odd {{else}} even {{/ifOdd}} -> odd", "example": "{{#ifOdd 3}}odd{{else}}even{{/ifOdd}} -> odd",
"description": "<p>Block helper that renders a block if <code>value</code> is <strong>an odd number</strong>. If an inverse block is specified it will be rendered when falsy.</p>\n" "description": "<p>Block helper that renders a block if <code>value</code> is <strong>an odd number</strong>. If an inverse block is specified it will be rendered when falsy.</p>\n"
}, },
"is": { "is": {
@ -960,7 +952,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#is 3 3}} is {{else}} is not {{/is}} -> is", "example": "{{#is 3 3}} is {{else}} is not {{/is}} -> ' is '",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. Similar to <a href=\"#eq\">eq</a> but does not do strict equality.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. Similar to <a href=\"#eq\">eq</a> but does not do strict equality.</p>\n"
}, },
"isnt": { "isnt": {
@ -970,7 +962,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#isnt 3 3}} isnt {{else}} is {{/isnt}} -> is", "example": "{{#isnt 3 3}} isnt {{else}} is {{/isnt}} -> ' is '",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>not equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. Similar to <a href=\"#unlesseq\">unlessEq</a> but does not use strict equality for comparisons.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>not equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. Similar to <a href=\"#unlesseq\">unlessEq</a> but does not use strict equality for comparisons.</p>\n"
}, },
"lt": { "lt": {
@ -979,7 +971,7 @@
"options" "options"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{#lt 2 3}} less than {{else}} more than or equal {{/lt}} -> less than", "example": "{{#lt 2 3}} less than {{else}} more than or equal {{/lt}} -> ' less than '",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>less than</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>less than</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n"
}, },
"lte": { "lte": {
@ -989,7 +981,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#lte 2 3}} less than or equal {{else}} more than {{/lte}} -> less than or equal", "example": "{{#lte 2 3}} less than or equal {{else}} more than {{/lte}} -> ' less than or equal '",
"description": "<p>Block helper that renders a block if <code>a</code> is <strong>less than or equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n" "description": "<p>Block helper that renders a block if <code>a</code> is <strong>less than or equal to</strong> <code>b</code>. If an inverse block is specified it will be rendered when falsy. You may optionally use the <code>compare=&#39;&#39;</code> hash argument for the second value.</p>\n"
}, },
"neither": { "neither": {
@ -999,7 +991,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#neither null null}} both falsey {{else}} both not falsey {{/neither}} -> both falsey", "example": "{{#neither null null}}both falsey{{else}}both not falsey{{/neither}} -> both falsey",
"description": "<p>Block helper that renders a block if <strong>neither of</strong> the given values are truthy. If an inverse block is specified it will be rendered when falsy.</p>\n" "description": "<p>Block helper that renders a block if <strong>neither of</strong> the given values are truthy. If an inverse block is specified it will be rendered when falsy.</p>\n"
}, },
"not": { "not": {
@ -1008,7 +1000,7 @@
"options" "options"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{#not undefined }} falsey {{else}} not falsey {{/not}} -> falsey", "example": "{{#not undefined }}falsey{{else}}not falsey{{/not}} -> falsey",
"description": "<p>Returns true if <code>val</code> is falsey. Works as a block or inline helper.</p>\n" "description": "<p>Returns true if <code>val</code> is falsey. Works as a block or inline helper.</p>\n"
}, },
"or": { "or": {
@ -1017,7 +1009,7 @@
"options" "options"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{#or 1 2 undefined }} at least one truthy {{else}} all falsey {{/or}} -> at least one truthy", "example": "{{#or 1 2 undefined }} at least one truthy {{else}} all falsey {{/or}} -> ' at least one truthy '",
"description": "<p>Block helper that renders a block if <strong>any of</strong> the given values is truthy. If an inverse block is specified it will be rendered when falsy.</p>\n" "description": "<p>Block helper that renders a block if <strong>any of</strong> the given values is truthy. If an inverse block is specified it will be rendered when falsy.</p>\n"
}, },
"unlessEq": { "unlessEq": {
@ -1027,7 +1019,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#unlessEq 2 1 }} not equal {{else}} equal {{/unlessEq}} -> not equal", "example": "{{#unlessEq 2 1 }} not equal {{else}} equal {{/unlessEq}} -> ' not equal '",
"description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is equal to <code>b</code></strong>.</p>\n" "description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is equal to <code>b</code></strong>.</p>\n"
}, },
"unlessGt": { "unlessGt": {
@ -1037,7 +1029,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#unlessGt 20 1 }} not greater than {{else}} greater than {{/unlessGt}} -> greater than", "example": "{{#unlessGt 20 1 }} not greater than {{else}} greater than {{/unlessGt}} -> ' greater than '",
"description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is greater than <code>b</code></strong>.</p>\n" "description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is greater than <code>b</code></strong>.</p>\n"
}, },
"unlessLt": { "unlessLt": {
@ -1047,7 +1039,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#unlessLt 20 1 }} greater than or equal {{else}} less than {{/unlessLt}} -> greater than or equal", "example": "{{#unlessLt 20 1 }}greater than or equal{{else}}less than{{/unlessLt}} -> greater than or equal",
"description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is less than <code>b</code></strong>.</p>\n" "description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is less than <code>b</code></strong>.</p>\n"
}, },
"unlessGteq": { "unlessGteq": {
@ -1057,7 +1049,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#unlessGteq 20 1 }} less than {{else}} greater than or equal to {{/unlessGteq}} -> greater than or equal to", "example": "{{#unlessGteq 20 1 }} less than {{else}}greater than or equal to{{/unlessGteq}} -> greater than or equal to",
"description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is greater than or equal to <code>b</code></strong>.</p>\n" "description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is greater than or equal to <code>b</code></strong>.</p>\n"
}, },
"unlessLteq": { "unlessLteq": {
@ -1067,7 +1059,7 @@
"options" "options"
], ],
"numArgs": 3, "numArgs": 3,
"example": "{{#unlessLteq 20 1 }} greater than {{else}} less than or equal to {{/unlessLteq}} -> greater than", "example": "{{#unlessLteq 20 1 }} greater than {{else}} less than or equal to {{/unlessLteq}} -> ' greater than '",
"description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is less than or equal to <code>b</code></strong>.</p>\n" "description": "<p>Block helper that always renders the inverse block <strong>unless <code>a</code> is less than or equal to <code>b</code></strong>.</p>\n"
} }
}, },
@ -1204,7 +1196,7 @@
"durationType" "durationType"
], ],
"numArgs": 2, "numArgs": 2,
"example": "{{duration timeLeft \"seconds\"}} -> a few seconds", "example": "{{duration 8 \"seconds\"}} -> a few seconds",
"description": "<p>Produce a humanized duration left/until given an amount of time and the type of time measurement.</p>\n" "description": "<p>Produce a humanized duration left/until given an amount of time and the type of time measurement.</p>\n"
} }
} }

View file

@ -25,7 +25,7 @@
"manifest": "node ./scripts/gen-collection-info.js" "manifest": "node ./scripts/gen-collection-info.js"
}, },
"dependencies": { "dependencies": {
"@budibase/handlebars-helpers": "^0.12.0", "@budibase/handlebars-helpers": "^0.13.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",

View file

@ -36,7 +36,7 @@ const ADDED_HELPERS = {
duration: { duration: {
args: ["time", "durationType"], args: ["time", "durationType"],
numArgs: 2, numArgs: 2,
example: '{{duration timeLeft "seconds"}} -> a few seconds', example: '{{duration 8 "seconds"}} -> a few seconds',
description: description:
"Produce a humanized duration left/until given an amount of time and the type of time measurement.", "Produce a humanized duration left/until given an amount of time and the type of time measurement.",
}, },
@ -118,6 +118,8 @@ function getCommentInfo(file, func) {
return docs return docs
} }
const excludeFunctions = { string: ["raw"] }
/** /**
* This script is very specific to purpose, parsing the handlebars-helpers files to attempt to get information about them. * This script is very specific to purpose, parsing the handlebars-helpers files to attempt to get information about them.
*/ */
@ -136,7 +138,8 @@ function run() {
// skip built in functions and ones seen already // skip built in functions and ones seen already
if ( if (
HelperFunctionBuiltin.indexOf(name) !== -1 || HelperFunctionBuiltin.indexOf(name) !== -1 ||
foundNames.indexOf(name) !== -1 foundNames.indexOf(name) !== -1 ||
excludeFunctions[collection]?.includes(name)
) { ) {
continue continue
} }

View file

@ -61,10 +61,10 @@ describe("test the array helpers", () => {
}) })
it("should allow use of the before helper", async () => { it("should allow use of the before helper", async () => {
const output = await processString("{{before array 2}}", { const output = await processString("{{before array 3}}", {
array, array,
}) })
expect(output).toBe("hi,person,how") expect(output).toBe("hi,person")
}) })
it("should allow use of the filter helper", async () => { it("should allow use of the filter helper", async () => {

View file

@ -0,0 +1,96 @@
jest.mock("@budibase/handlebars-helpers/lib/math", () => {
const actual = jest.requireActual("@budibase/handlebars-helpers/lib/math")
return {
...actual,
random: () => 10,
}
})
jest.mock("@budibase/handlebars-helpers/lib/uuid", () => {
const actual = jest.requireActual("@budibase/handlebars-helpers/lib/uuid")
return {
...actual,
uuid: () => "f34ebc66-93bd-4f7c-b79b-92b5569138bc",
}
})
const fs = require("fs")
const { processString } = require("../src/index.cjs")
const tk = require("timekeeper")
tk.freeze("2021-01-21T12:00:00")
const manifest = JSON.parse(
fs.readFileSync(require.resolve("../manifest.json"), "utf8")
)
const collections = Object.keys(manifest)
const examples = collections.reduce((acc, collection) => {
const functions = Object.keys(manifest[collection]).filter(
fnc => manifest[collection][fnc].example
)
if (functions.length) {
acc[collection] = functions
}
return acc
}, {})
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // $& means the whole matched string
}
function tryParseJson(str) {
if (typeof str !== "string") {
return
}
try {
return JSON.parse(str.replace(/\'/g, '"'))
} catch (e) {
return
}
}
describe("manifest", () => {
describe("examples are valid", () => {
describe.each(Object.keys(examples))("%s", collection => {
it.each(examples[collection])("%s", async func => {
const example = manifest[collection][func].example
let [hbs, js] = example.split("->").map(x => x.trim())
const context = {
double: i => i * 2,
isString: x => typeof x === "string",
}
const arrays = hbs.match(/\[[^/\]]+\]/)
arrays?.forEach((arrayString, i) => {
hbs = hbs.replace(new RegExp(escapeRegExp(arrayString)), `array${i}`)
context[`array${i}`] = JSON.parse(arrayString.replace(/\'/g, '"'))
})
if (js === undefined) {
// The function has no return value
return
}
let result = await processString(hbs, context)
// Trim 's
js = js.replace(/^\'|\'$/g, "")
if ((parsedExpected = tryParseJson(js))) {
if (Array.isArray(parsedExpected)) {
if (typeof parsedExpected[0] === "object") {
js = JSON.stringify(parsedExpected)
} else {
js = parsedExpected.join(",")
}
}
}
result = result.replace(/&nbsp;/g, " ")
expect(result).toEqual(js)
})
})
})
})

View file

@ -23,6 +23,7 @@ export interface App extends Document {
automationErrors?: AppMetadataErrors automationErrors?: AppMetadataErrors
icon?: AppIcon icon?: AppIcon
features?: AppFeatures features?: AppFeatures
automations?: AutomationSettings
} }
export interface AppInstance { export interface AppInstance {
@ -68,3 +69,7 @@ export interface AppFeatures {
componentValidation?: boolean componentValidation?: boolean
disableUserMetadata?: boolean disableUserMetadata?: boolean
} }
export interface AutomationSettings {
chainAutomations?: boolean
}

View file

@ -1,12 +1,17 @@
import { Document } from "../document" import { Document } from "../document"
export interface QuerySchema {
name?: string
type: string
}
export interface Query extends Document { export interface Query extends Document {
datasourceId: string datasourceId: string
name: string name: string
parameters: QueryParameter[] parameters: QueryParameter[]
fields: RestQueryFields | any fields: RestQueryFields | any
transformer: string | null transformer: string | null
schema: Record<string, { name?: string; type: string }> schema: Record<string, QuerySchema | string>
readable: boolean readable: boolean
queryVerb: string queryVerb: string
} }

View file

@ -91,6 +91,9 @@ export async function getSelf(ctx: any) {
id: userId, id: userId,
} }
// Adjust creators quotas (prevents wrong creators count if user has changed the plan)
await groups.adjustGroupCreatorsQuotas()
// get the main body of the user // get the main body of the user
const user = await userSdk.db.getUser(userId) const user = await userSdk.db.getUser(userId)
ctx.body = await groups.enrichUserRolesFromGroups(user) ctx.body = await groups.enrichUserRolesFromGroups(user)

View file

@ -55,6 +55,7 @@ const environment = {
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY, ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
/** /**
* Mock the email service in use - links to ethereal hosted emails are logged instead. * Mock the email service in use - links to ethereal hosted emails are logged instead.
*/ */

View file

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
yarn build --scope @budibase/server --scope @budibase/worker yarn build --scope @budibase/server --scope @budibase/worker
version=$(./scripts/getCurrentVersion.sh) version=$(./scripts/getCurrentVersion.sh)
docker build -f hosting/single/Dockerfile -t budibase:latest --build-arg BUDIBASE_VERSION=$version . docker build -f hosting/single/Dockerfile -t budibase:latest --build-arg BUDIBASE_VERSION=$version --build-arg TARGETBUILD=single .

View file

@ -2031,10 +2031,10 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/handlebars-helpers@^0.12.0": "@budibase/handlebars-helpers@^0.13.0":
version "0.12.0" version "0.13.0"
resolved "https://registry.yarnpkg.com/@budibase/handlebars-helpers/-/handlebars-helpers-0.12.0.tgz#dcc4ba8d796a611474e3495b1142c56b470ca67d" resolved "https://registry.yarnpkg.com/@budibase/handlebars-helpers/-/handlebars-helpers-0.13.0.tgz#224333d14e3900b7dacf48286af1e624a9fd62ea"
integrity sha512-JjGboau7KMdrVSO8gGJzgo1ACSeD4BxN46vidIx9hvdrEXy+v1x2bfQZMaq/c7Dv+V1vyq7c006XwxR1bpfARg== integrity sha512-g8+sFrMNxsIDnK+MmdUICTVGr6ReUFtnPp9hJX0VZwz1pN3Ynolpk/Qbu6rEWAvoU1sEqY1mXr9uo/+kEfeGbQ==
dependencies: dependencies:
get-object "^0.2.0" get-object "^0.2.0"
get-value "^3.0.1" get-value "^3.0.1"
@ -5557,9 +5557,9 @@
integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg== integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg==
"@types/node@>=8.1.0": "@types/node@>=8.1.0":
version "20.11.2" version "20.11.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.2.tgz#39cea3fe02fbbc2f80ed283e94e1d24f2d3856fb" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.6.tgz#6adf4241460e28be53836529c033a41985f85b6e"
integrity sha512-cZShBaVa+UO1LjWWBPmWRR4+/eY/JR/UIEcDlVsw3okjWEu+rB7/mH6X3B/L+qJVHDLjk9QW/y2upp9wp1yDXA== integrity sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
@ -9497,9 +9497,9 @@ dotenv@8.6.0, dotenv@^8.2.0:
integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==
dotenv@^16.3.1: dotenv@^16.3.1:
version "16.3.1" version "16.4.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.0.tgz#ac21c3fcaad2e7832a1cd0c0e4e8e52225ecda0e"
integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== integrity sha512-WvImr5kpN5NGNn7KaDjJnLTh5rDVLZiDf/YLA8T1ZEZEBZNEDOE+mnkS0PVjPax8ZxBP5zC5SLMB3/9VV5de9g==
dotenv@~10.0.0: dotenv@~10.0.0:
version "10.0.0" version "10.0.0"
@ -17426,11 +17426,12 @@ postgres-interval@^1.1.0:
xtend "^4.0.0" xtend "^4.0.0"
posthog-js@^1.13.4: posthog-js@^1.13.4:
version "1.100.0" version "1.101.0"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.100.0.tgz#687b9a6e4ed226aa6572f4040b418ea0c8b3d353" resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.101.0.tgz#00e0fc6e164addd52b1738f087996bb0d6685943"
integrity sha512-r2XZEiHQ9mBK7D1G9k57I8uYZ2kZTAJ0OCX6K/OOdCWN8jKPhw3h5F9No5weilP6eVAn+hrsy7NvPV7SCX7gMg== integrity sha512-mzwYSSWr9FdEMDeVpc+diLfc85+10r/LgELGtsW/HaYk+0du/GEql6szpqG8YXMMgb2dE4dnj0JICZFIJd7K3w==
dependencies: dependencies:
fflate "^0.4.1" fflate "^0.4.1"
preact "^10.19.3"
posthog-js@^1.36.0: posthog-js@^1.36.0:
version "1.96.1" version "1.96.1"
@ -17676,6 +17677,11 @@ pprof-format@^2.0.7:
resolved "https://registry.yarnpkg.com/pprof-format/-/pprof-format-2.0.7.tgz#526e4361f8b37d16b2ec4bb0696b5292de5046a4" resolved "https://registry.yarnpkg.com/pprof-format/-/pprof-format-2.0.7.tgz#526e4361f8b37d16b2ec4bb0696b5292de5046a4"
integrity sha512-1qWaGAzwMpaXJP9opRa23nPnt2Egi7RMNoNBptEE/XwHbcn4fC2b/4U4bKc5arkGkIh2ZabpF2bEb+c5GNHEKA== integrity sha512-1qWaGAzwMpaXJP9opRa23nPnt2Egi7RMNoNBptEE/XwHbcn4fC2b/4U4bKc5arkGkIh2ZabpF2bEb+c5GNHEKA==
preact@^10.19.3:
version "10.19.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.3.tgz#7a7107ed2598a60676c943709ea3efb8aaafa899"
integrity sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==
precinct@^8.1.0: precinct@^8.1.0:
version "8.3.1" version "8.3.1"
resolved "https://registry.yarnpkg.com/precinct/-/precinct-8.3.1.tgz#94b99b623df144eed1ce40e0801c86078466f0dc" resolved "https://registry.yarnpkg.com/precinct/-/precinct-8.3.1.tgz#94b99b623df144eed1ce40e0801c86078466f0dc"