1
0
Fork 0
mirror of synced 2024-07-19 13:15:49 +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 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

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

@ -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 = () => {
@ -1415,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

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

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

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

@ -3969,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",

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

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

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

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

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

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

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

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