1
0
Fork 0
mirror of synced 2024-07-08 15:56:23 +12:00

Merge branch 'master' into fix/disable-save-button-relationshiop-empty

This commit is contained in:
Peter Clement 2024-01-25 12:25:09 +00:00 committed by GitHub
commit 74b06f20a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 542 additions and 224 deletions

View file

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

View file

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

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
async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage })
creators += page.data.filter(isCreator).length
const creatorsEval = await Promise.all(page.data.map(isCreator))
creators += creatorsEval.filter(creator => !!creator).length
if (page.hasNextPage) {
await iterate(page.nextPage)
}

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 env from "../environment"
import { getPlatformUser } from "./lookup"
@ -6,17 +6,48 @@ import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
import { BUILTIN_ROLE_IDS } from "../security/roles"
import * as context from "../context"
// extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin
export const isCreator = sdk.users.isCreator
export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions
export async function isCreator(user?: User | ContextUser) {
const isCreatorByUserDefinition = sdk.users.isCreator(user)
if (!isCreatorByUserDefinition && user) {
return await isCreatorByGroupMembership(user)
}
return isCreatorByUserDefinition
}
async function isCreatorByGroupMembership(user?: User | ContextUser) {
const userGroups = user?.userGroups || []
if (userGroups.length > 0) {
const db = context.getGlobalDB()
const groups: UserGroup[] = []
for (let groupId of userGroups) {
try {
const group = await db.get<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) {
// check budibase users in other tenants
if (env.MULTI_TENANCY) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -92,9 +92,6 @@ const INITIAL_FRONTEND_STATE = {
// Onboarding
onboarding: false,
tourNodes: null,
// UI state
hoveredComponentId: null,
}
export const getFrontendStore = () => {
@ -1415,18 +1412,6 @@ export const getFrontendStore = () => {
return state
})
},
hover: (componentId, notifyClient = true) => {
if (componentId === get(store).hoveredComponentId) {
return
}
store.update(state => {
state.hoveredComponentId = componentId
return state
})
if (notifyClient) {
store.actions.preview.sendEvent("hover-component", componentId)
}
},
},
links: {
save: async (url, title) => {

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

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

View file

@ -35,6 +35,7 @@
export let bindingDrawerLeft
export let allowHelpers = true
export let customButtonText = null
export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({
name,
@ -112,7 +113,12 @@
on:blur={changed}
/>
{#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}
<DrawerBindableInput
{bindings}

View file

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

View file

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

View file

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

View file

@ -1,26 +1,6 @@
import { IntegrationTypes } from "constants/backend"
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) {
if (!qs) {
return {}
@ -184,10 +164,8 @@ export const parseToCsv = (headers, rows) => {
export default {
breakQueryString,
buildQueryString,
fieldsToSchema,
flipHeaderState,
keyValueToQueryParameters,
parseToCsv,
queryParametersToKeyValue,
schemaToFields,
}

View file

@ -1,11 +1,27 @@
import { PlanType } from "@budibase/types"
export function getFormattedPlanName(userPlanType) {
let planName = "Free"
if (userPlanType === PlanType.PREMIUM_PLUS) {
planName = "Premium"
} else if (userPlanType === PlanType.ENTERPRISE_BASIC) {
planName = "Enterprise"
let planName
switch (userPlanType) {
case PlanType.PRO:
planName = "Pro"
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`
}

View file

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

View file

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

View file

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

View file

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

View file

@ -3969,6 +3969,12 @@
"key": "allowManualEntry",
"defaultValue": false
},
{
"type": "boolean",
"label": "Auto confirm",
"key": "autoConfirm",
"defaultValue": false
},
{
"type": "boolean",
"label": "Play sound on scan",

View file

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

View file

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

View file

@ -108,10 +108,13 @@
}
}
$: forceFetchRows(filter)
$: forceFetchRows(filter, fieldApi)
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const forceFetchRows = async () => {
if (!fieldApi) {
return
}
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
optionsObj = {}
fieldApi.setValue([])
@ -236,7 +239,6 @@
bind:searchTerm
loading={$fetch.loading}
bind:open
customPopoverMaxHeight={400}
/>
{/if}
</Field>

@ -1 +1 @@
Subproject commit ce7722ed4474718596b465dcfd49bef36cab2e42
Subproject commit 0e4c5f95bda6af126a5cddeef01b8cf551f236be

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,27 @@ import { processStringSync } from "@budibase/string-templates"
import { context } from "@budibase/backend-core"
import { getQueryParams, isProdAppID } from "../../../db/utils"
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
function enrichQueries(input: any) {
@ -25,7 +46,7 @@ export async function find(queryId: string) {
delete query.fields
delete query.parameters
}
return query
return updateSchema(query)
}
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) {
return enrichQueries(queries)
} else {
return queries
queries = await enrichQueries(queries)
}
return updateSchemas(queries)
}
export async function enrichContext(

View file

@ -84,7 +84,7 @@ describe("syncGlobalUsers", () => {
await syncGlobalUsers()
const metadata = await rawUserMetadata()
expect(metadata).toHaveLength(3)
expect(metadata).toHaveLength(2)
expect(metadata).toContainEqual(
expect.objectContaining({
_id: db.generateUserMetadataID(user1._id!),
@ -121,7 +121,7 @@ describe("syncGlobalUsers", () => {
await syncGlobalUsers()
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) {
request.params = params
}
request.throw = (status: number, message: string) => {
throw new Error(`Error ${status} - ${message}`)
}
return this.doInContext(appId, async () => {
await controlFunc(request)
return request.body

View file

@ -1,3 +1,5 @@
import { QuerySchema, Row } from "@budibase/types"
export type WorkerCallback = (error: any, response?: any) => void
export interface QueryEvent {
@ -11,7 +13,15 @@ export interface QueryEvent {
queryId: string
environmentVariables?: Record<string, string>
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 {

View file

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

View file

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

View file

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

View file

@ -137,7 +137,7 @@
"n"
],
"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"
},
"arrayify": {
@ -154,7 +154,7 @@
"n"
],
"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"
},
"eachIndex": {
@ -182,7 +182,7 @@
"n"
],
"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"
},
"forEach": {
@ -200,7 +200,7 @@
"options"
],
"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"
},
"isArray": {
@ -226,7 +226,7 @@
"separator"
],
"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"
},
"equalsLength": {
@ -236,7 +236,7 @@
"options"
],
"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"
},
"last": {
@ -253,7 +253,7 @@
"value"
],
"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"
},
"lengthEqual": {
@ -263,7 +263,7 @@
"options"
],
"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"
},
"map": {
@ -299,7 +299,7 @@
"provided"
],
"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"
},
"sort": {
@ -317,7 +317,7 @@
"props"
],
"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"
},
"withAfter": {
@ -347,7 +347,7 @@
"options"
],
"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"
},
"withGroup": {
@ -357,7 +357,7 @@
"options"
],
"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"
},
"withLast": {
@ -367,7 +367,7 @@
"options"
],
"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"
},
"withSort": {
@ -377,7 +377,7 @@
"options"
],
"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"
},
"unique": {
@ -386,7 +386,7 @@
"options"
],
"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"
}
},
@ -396,7 +396,7 @@
"number"
],
"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"
},
"addCommas": {
@ -430,7 +430,7 @@
"fractionDigits"
],
"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"
},
"toFixed": {
@ -472,7 +472,7 @@
"str"
],
"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"
},
"escape": {
@ -480,7 +480,7 @@
"str"
],
"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"
},
"decodeURI": {
@ -488,7 +488,7 @@
"str"
],
"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"
},
"urlResolve": {
@ -513,7 +513,7 @@
"url"
],
"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"
},
"stripProtocol": {
@ -521,7 +521,7 @@
"str"
],
"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"
}
},
@ -573,7 +573,7 @@
"string"
],
"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"
},
"dashcase": {
@ -606,7 +606,7 @@
"length"
],
"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"
},
"hyphenate": {
@ -675,14 +675,6 @@
"example": "{{prepend 'bar' 'foo-'}} -> foo-bar",
"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": {
"args": [
"str",
@ -698,7 +690,7 @@
"substring"
],
"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"
},
"replace": {
@ -718,7 +710,7 @@
"b"
],
"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"
},
"sentence": {
@ -752,7 +744,7 @@
"options"
],
"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"
},
"titleize": {
@ -760,7 +752,7 @@
"str"
],
"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"
},
"trim": {
@ -784,7 +776,7 @@
"string"
],
"numArgs": 1,
"example": "{{trimRight ' ABC ' }} -> ' ABC '",
"example": "{{trimRight ' ABC ' }} -> ' ABC'",
"description": "<p>Removes extraneous whitespace from the end of a string.</p>\n"
},
"truncate": {
@ -804,7 +796,7 @@
"suffix"
],
"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"
},
"upcase": {
@ -844,7 +836,7 @@
"options"
],
"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"
},
"contains": {
@ -874,7 +866,7 @@
"options"
],
"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"
},
"gt": {
@ -884,7 +876,7 @@
"options"
],
"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"
},
"gte": {
@ -894,7 +886,7 @@
"options"
],
"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"
},
"has": {
@ -904,7 +896,7 @@
"options"
],
"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"
},
"isFalsey": {
@ -931,7 +923,7 @@
"options"
],
"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"
},
"ifNth": {
@ -941,8 +933,8 @@
"options"
],
"numArgs": 3,
"example": "{{#ifNth 10 2}} 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"
"example": "{{#ifNth 2 10}}remainder{{else}}no remainder{{/ifNth}} -> remainder",
"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": {
"args": [
@ -950,7 +942,7 @@
"options"
],
"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"
},
"is": {
@ -960,7 +952,7 @@
"options"
],
"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"
},
"isnt": {
@ -970,7 +962,7 @@
"options"
],
"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"
},
"lt": {
@ -979,7 +971,7 @@
"options"
],
"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"
},
"lte": {
@ -989,7 +981,7 @@
"options"
],
"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"
},
"neither": {
@ -999,7 +991,7 @@
"options"
],
"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"
},
"not": {
@ -1008,7 +1000,7 @@
"options"
],
"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"
},
"or": {
@ -1017,7 +1009,7 @@
"options"
],
"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"
},
"unlessEq": {
@ -1027,7 +1019,7 @@
"options"
],
"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"
},
"unlessGt": {
@ -1037,7 +1029,7 @@
"options"
],
"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"
},
"unlessLt": {
@ -1047,7 +1039,7 @@
"options"
],
"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"
},
"unlessGteq": {
@ -1057,7 +1049,7 @@
"options"
],
"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"
},
"unlessLteq": {
@ -1067,7 +1059,7 @@
"options"
],
"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"
}
},
@ -1204,7 +1196,7 @@
"durationType"
],
"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"
}
}

View file

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

View file

@ -36,7 +36,7 @@ const ADDED_HELPERS = {
duration: {
args: ["time", "durationType"],
numArgs: 2,
example: '{{duration timeLeft "seconds"}} -> a few seconds',
example: '{{duration 8 "seconds"}} -> a few seconds',
description:
"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
}
const excludeFunctions = { string: ["raw"] }
/**
* 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
if (
HelperFunctionBuiltin.indexOf(name) !== -1 ||
foundNames.indexOf(name) !== -1
foundNames.indexOf(name) !== -1 ||
excludeFunctions[collection]?.includes(name)
) {
continue
}

View file

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

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

View file

@ -91,6 +91,9 @@ export async function getSelf(ctx: any) {
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
const user = await userSdk.db.getUser(userId)
ctx.body = await groups.enrichUserRolesFromGroups(user)

View file

@ -1,4 +1,4 @@
#!/bin/bash
yarn build --scope @budibase/server --scope @budibase/worker
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"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/handlebars-helpers@^0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@budibase/handlebars-helpers/-/handlebars-helpers-0.12.0.tgz#dcc4ba8d796a611474e3495b1142c56b470ca67d"
integrity sha512-JjGboau7KMdrVSO8gGJzgo1ACSeD4BxN46vidIx9hvdrEXy+v1x2bfQZMaq/c7Dv+V1vyq7c006XwxR1bpfARg==
"@budibase/handlebars-helpers@^0.13.0":
version "0.13.0"
resolved "https://registry.yarnpkg.com/@budibase/handlebars-helpers/-/handlebars-helpers-0.13.0.tgz#224333d14e3900b7dacf48286af1e624a9fd62ea"
integrity sha512-g8+sFrMNxsIDnK+MmdUICTVGr6ReUFtnPp9hJX0VZwz1pN3Ynolpk/Qbu6rEWAvoU1sEqY1mXr9uo/+kEfeGbQ==
dependencies:
get-object "^0.2.0"
get-value "^3.0.1"
@ -5557,9 +5557,9 @@
integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg==
"@types/node@>=8.1.0":
version "20.11.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.2.tgz#39cea3fe02fbbc2f80ed283e94e1d24f2d3856fb"
integrity sha512-cZShBaVa+UO1LjWWBPmWRR4+/eY/JR/UIEcDlVsw3okjWEu+rB7/mH6X3B/L+qJVHDLjk9QW/y2upp9wp1yDXA==
version "20.11.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.6.tgz#6adf4241460e28be53836529c033a41985f85b6e"
integrity sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==
dependencies:
undici-types "~5.26.4"
@ -9497,9 +9497,9 @@ dotenv@8.6.0, dotenv@^8.2.0:
integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==
dotenv@^16.3.1:
version "16.3.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
version "16.4.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.0.tgz#ac21c3fcaad2e7832a1cd0c0e4e8e52225ecda0e"
integrity sha512-WvImr5kpN5NGNn7KaDjJnLTh5rDVLZiDf/YLA8T1ZEZEBZNEDOE+mnkS0PVjPax8ZxBP5zC5SLMB3/9VV5de9g==
dotenv@~10.0.0:
version "10.0.0"
@ -17426,11 +17426,12 @@ postgres-interval@^1.1.0:
xtend "^4.0.0"
posthog-js@^1.13.4:
version "1.100.0"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.100.0.tgz#687b9a6e4ed226aa6572f4040b418ea0c8b3d353"
integrity sha512-r2XZEiHQ9mBK7D1G9k57I8uYZ2kZTAJ0OCX6K/OOdCWN8jKPhw3h5F9No5weilP6eVAn+hrsy7NvPV7SCX7gMg==
version "1.101.0"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.101.0.tgz#00e0fc6e164addd52b1738f087996bb0d6685943"
integrity sha512-mzwYSSWr9FdEMDeVpc+diLfc85+10r/LgELGtsW/HaYk+0du/GEql6szpqG8YXMMgb2dE4dnj0JICZFIJd7K3w==
dependencies:
fflate "^0.4.1"
preact "^10.19.3"
posthog-js@^1.36.0:
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"
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:
version "8.3.1"
resolved "https://registry.yarnpkg.com/precinct/-/precinct-8.3.1.tgz#94b99b623df144eed1ce40e0801c86078466f0dc"