1
0
Fork 0
mirror of synced 2024-08-05 05:11:43 +12:00

Merge branch 'master' into dependabot/npm_and_yarn/ejs-3.1.10

This commit is contained in:
Michael Drury 2024-05-07 12:20:56 +01:00 committed by GitHub
commit 0f27f9609c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 280 additions and 181 deletions

View file

@ -1,4 +1,5 @@
{ {
"root": true,
"env": { "env": {
"browser": true, "browser": true,
"es6": true, "es6": true,

View file

@ -12,4 +12,5 @@ packages/pro/coverage
packages/account-portal/packages/ui/build packages/account-portal/packages/ui/build
packages/account-portal/packages/ui/.routify packages/account-portal/packages/ui/.routify
packages/account-portal/packages/server/build packages/account-portal/packages/server/build
packages/account-portal/packages/server/coverage
**/*.ivm.bundle.js **/*.ivm.bundle.js

View file

@ -70,10 +70,10 @@ sed -i "s#COUCHDB_ERLANG_COOKIE#${COUCHDB_ERLANG_COOKIE}#g" /opt/clouseau/clouse
/opt/clouseau/bin/clouseau > /dev/stdout 2>&1 & /opt/clouseau/bin/clouseau > /dev/stdout 2>&1 &
# Start CouchDB. # Start CouchDB.
/docker-entrypoint.sh /opt/couchdb/bin/couchdb & /docker-entrypoint.sh /opt/couchdb/bin/couchdb > /dev/stdout 2>&1 &
# Start SQS. # Start SQS. Use 127.0.0.1 instead of localhost to avoid IPv6 issues.
/opt/sqs/sqs --server "http://localhost:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 & /opt/sqs/sqs --server "http://127.0.0.1:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 > /dev/stdout 2>&1 &
# Wait for CouchDB to start up. # Wait for CouchDB to start up.
while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do

View file

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

View file

@ -1,22 +1,25 @@
// These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [ const ignoredClasses = [
".download-js-link", ".download-js-link",
".spectrum-Menu", ".spectrum-Menu",
".date-time-popover", ".date-time-popover",
] ]
// These class names will only trigger a callback when clicked if the registered
// component is not nested inside them. For example, clicking inside a modal
// will not close the modal, or clicking inside a popover will not close the
// popover.
const conditionallyIgnoredClasses = [ const conditionallyIgnoredClasses = [
".spectrum-Underlay", ".spectrum-Underlay",
".drawer-wrapper", ".drawer-wrapper",
".spectrum-Popover", ".spectrum-Popover",
] ]
let clickHandlers = [] let clickHandlers = []
let candidateTarget
/** // Processes a "click outside" event and invokes callbacks if our source element
* Handle a body click event // is valid
*/
const handleClick = event => { const handleClick = event => {
// Treat right clicks (context menu events) as normal clicks
const eventType = event.type === "contextmenu" ? "click" : event.type
// Ignore click if this is an ignored class // Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) { if (event.target.closest('[data-ignore-click-outside="true"]')) {
return return
@ -29,11 +32,6 @@ const handleClick = event => {
// Process handlers // Process handlers
clickHandlers.forEach(handler => { clickHandlers.forEach(handler => {
// Check that we're the right kind of click event
if (handler.allowedType && eventType !== handler.allowedType) {
return
}
// Check that the click isn't inside the target // Check that the click isn't inside the target
if (handler.element.contains(event.target)) { if (handler.element.contains(event.target)) {
return return
@ -51,17 +49,43 @@ const handleClick = event => {
handler.callback?.(event) handler.callback?.(event)
}) })
} }
document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("mousedown", handleClick, true) // On mouse up we only trigger a "click outside" callback if we targetted the
document.documentElement.addEventListener("contextmenu", handleClick, true) // same element that we did on mouse down. This fixes all sorts of issues where
// we get annoying callbacks firing when we drag to select text.
const handleMouseUp = e => {
if (candidateTarget === e.target) {
handleClick(e)
}
candidateTarget = null
}
// On mouse down we store which element was targetted for comparison later
const handleMouseDown = e => {
// Only handle the primary mouse button here.
// We handle context menu (right click) events in another handler.
if (e.button !== 0) {
return
}
candidateTarget = e.target
// Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener
document.removeEventListener("mouseup", handleMouseUp)
document.addEventListener("mouseup", handleMouseUp, true)
}
// Global singleton listeners for our events
document.addEventListener("mousedown", handleMouseDown)
document.addEventListener("contextmenu", handleClick)
/** /**
* Adds or updates a click handler * Adds or updates a click handler
*/ */
const updateHandler = (id, element, anchor, callback, allowedType) => { const updateHandler = (id, element, anchor, callback) => {
let existingHandler = clickHandlers.find(x => x.id === id) let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) { if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback, allowedType }) clickHandlers.push({ id, element, anchor, callback })
} else { } else {
existingHandler.callback = callback existingHandler.callback = callback
} }
@ -88,8 +112,7 @@ export default (element, opts) => {
const callback = const callback =
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null) newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
const anchor = newOpts?.anchor || element const anchor = newOpts?.anchor || element
const allowedType = newOpts?.allowedType || "click" updateHandler(id, element, anchor, callback)
updateHandler(id, element, anchor, callback, allowedType)
} }
update(opts) update(opts)
return { return {

View file

@ -4,6 +4,9 @@
import dayjs from "dayjs" import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte" import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import isoWeek from "dayjs/plugin/isoWeek"
dayjs.extend(isoWeek)
export let value export let value
@ -43,7 +46,7 @@
return [] return []
} }
let monthEnd = monthStart.endOf("month") let monthEnd = monthStart.endOf("month")
let calendarStart = monthStart.startOf("week") let calendarStart = monthStart.startOf("isoWeek")
const numWeeks = Math.ceil((monthEnd.diff(calendarStart, "day") + 1) / 7) const numWeeks = Math.ceil((monthEnd.diff(calendarStart, "day") + 1) / 7)
let mondays = [] let mondays = []

View file

@ -1,8 +1,10 @@
<script> <script>
import { Body, Label, Input } from "@budibase/bbui" import { Body, Label } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings
onMount(() => { onMount(() => {
if (!parameters.confirm) { if (!parameters.confirm) {
@ -15,11 +17,18 @@
<Body size="S">Enter the message you wish to display to the user.</Body> <Body size="S">Enter the message you wish to display to the user.</Body>
<div class="params"> <div class="params">
<Label small>Title</Label> <Label small>Title</Label>
<Input placeholder="Prompt User" bind:value={parameters.customTitleText} /> <DrawerBindableInput
placeholder="Title"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<Label small>Message</Label> <Label small>Message</Label>
<Input <DrawerBindableInput
placeholder="Are you sure you want to continue?" placeholder="Are you sure you want to continue?"
bind:value={parameters.confirmText} value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/> />
</div> </div>
</div> </div>

View file

@ -21,26 +21,24 @@
const currentStep = derived(multiStepStore, state => state.currentStep) const currentStep = derived(multiStepStore, state => state.currentStep)
const componentType = "@budibase/standard-components/multistepformblockstep" const componentType = "@budibase/standard-components/multistepformblockstep"
setContext("multi-step-form-block", multiStepStore)
let cachedValue let cachedValue
let cachedInstance = {} let cachedInstance = {}
$: if (!isEqual(cachedValue, value)) { $: if (!isEqual(cachedValue, value)) {
cachedValue = value cachedValue = value
} }
$: if (!isEqual(componentInstance, cachedInstance)) { $: if (!isEqual(componentInstance, cachedInstance)) {
cachedInstance = componentInstance cachedInstance = componentInstance
} }
setContext("multi-step-form-block", multiStepStore)
$: stepCount = cachedValue?.length || 0 $: stepCount = cachedValue?.length || 0
$: updateStore(stepCount) $: updateStore(stepCount)
$: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance) $: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance)
$: emitCurrentStep($currentStep) $: emitCurrentStep($currentStep)
$: stepLabel = getStepLabel($multiStepStore) $: stepLabel = getStepLabel($multiStepStore)
$: stepDef = getDefinition(stepLabel) $: stepDef = getDefinition(stepLabel)
$: stepSettings = cachedValue?.[$currentStep] || {} $: savedInstance = cachedValue?.[$currentStep] || {}
$: defaults = Utils.buildMultiStepFormBlockDefaultProps({ $: defaults = Utils.buildMultiStepFormBlockDefaultProps({
_id: cachedInstance._id, _id: cachedInstance._id,
stepCount: $multiStepStore.stepCount, stepCount: $multiStepStore.stepCount,
@ -48,14 +46,16 @@
actionType: cachedInstance.actionType, actionType: cachedInstance.actionType,
dataSource: cachedInstance.dataSource, dataSource: cachedInstance.dataSource,
}) })
// For backwards compatibility we need to sometimes manually set base
// properties like _id and _component as we didn't used to save these
$: stepInstance = { $: stepInstance = {
_id: Helpers.uuid(), _id: savedInstance._id || Helpers.uuid(),
_component: componentType, _component: savedInstance._component || componentType,
_instanceName: `Step ${currentStep + 1}`, _instanceName: `Step ${currentStep + 1}`,
title: stepSettings.title ?? defaults?.title, title: savedInstance.title ?? defaults?.title,
buttons: stepSettings.buttons || defaults?.buttons, buttons: savedInstance.buttons || defaults?.buttons,
fields: stepSettings.fields, fields: savedInstance.fields,
desc: stepSettings.desc, desc: savedInstance.desc,
// Needed for field configuration // Needed for field configuration
dataSource, dataSource,
@ -92,7 +92,8 @@
} }
const addStep = () => { const addStep = () => {
value = value.toSpliced($currentStep + 1, 0, {}) const newInstance = componentStore.createInstance(componentType)
value = value.toSpliced($currentStep + 1, 0, newInstance)
dispatch("change", value) dispatch("change", value)
multiStepStore.update(state => ({ multiStepStore.update(state => ({
...state, ...state,

View file

@ -1,6 +1,6 @@
<script> <script>
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { ActionButton } from "@budibase/bbui" import { ActionButton, AbsTooltip } from "@budibase/bbui"
const multiStepStore = getContext("multi-step-form-block") const multiStepStore = getContext("multi-step-form-block")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -28,6 +28,7 @@
</div> </div>
{:else} {:else}
<div class="step-actions"> <div class="step-actions">
<AbsTooltip text="Previous step" noWrap>
<ActionButton <ActionButton
size="S" size="S"
secondary secondary
@ -36,8 +37,9 @@
on:click={() => { on:click={() => {
stepAction("previousStep") stepAction("previousStep")
}} }}
tooltip={"Previous step"}
/> />
</AbsTooltip>
<AbsTooltip text="Next step" noWrap>
<ActionButton <ActionButton
size="S" size="S"
secondary secondary
@ -46,8 +48,9 @@
on:click={() => { on:click={() => {
stepAction("nextStep") stepAction("nextStep")
}} }}
tooltip={"Next step"}
/> />
</AbsTooltip>
<AbsTooltip text="Remove step" noWrap>
<ActionButton <ActionButton
size="S" size="S"
secondary secondary
@ -56,8 +59,9 @@
on:click={() => { on:click={() => {
stepAction("removeStep") stepAction("removeStep")
}} }}
tooltip={"Remove step"}
/> />
</AbsTooltip>
<AbsTooltip text="Add step" noWrap>
<ActionButton <ActionButton
size="S" size="S"
secondary secondary
@ -65,8 +69,8 @@
on:click={() => { on:click={() => {
stepAction("addStep") stepAction("addStep")
}} }}
tooltip={"Add step"}
/> />
</AbsTooltip>
</div> </div>
{/if} {/if}

View file

@ -75,6 +75,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
return createComponent( return createComponent(
"@budibase/standard-components/labelfield", "@budibase/standard-components/labelfield",
{ {
_id: column.field,
_instanceName: column.field, _instanceName: column.field,
active: column.active, active: column.active,
field: column.field, field: column.field,

View file

@ -65,6 +65,7 @@ describe("getColumns", () => {
it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => { it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -73,6 +74,7 @@ describe("getColumns", () => {
label: "three label", label: "three label",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -81,6 +83,7 @@ describe("getColumns", () => {
label: "two label", label: "two label",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -91,6 +94,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -115,6 +119,7 @@ describe("getColumns", () => {
it("returns all columns, with non-hidden columns automatically selected", ctx => { it("returns all columns, with non-hidden columns automatically selected", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -123,6 +128,7 @@ describe("getColumns", () => {
label: "two", label: "two",
}, },
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -131,6 +137,7 @@ describe("getColumns", () => {
label: "three", label: "three",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -141,6 +148,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -173,6 +181,7 @@ describe("getColumns", () => {
it("returns all columns, including those missing from the initial data", ctx => { it("returns all columns, including those missing from the initial data", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -181,6 +190,7 @@ describe("getColumns", () => {
label: "three label", label: "three label",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -189,6 +199,7 @@ describe("getColumns", () => {
label: "two", label: "two",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -199,6 +210,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -228,6 +240,7 @@ describe("getColumns", () => {
it("returns all valid columns, excluding those that aren't valid for the schema", ctx => { it("returns all valid columns, excluding those that aren't valid for the schema", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -236,6 +249,7 @@ describe("getColumns", () => {
label: "three label", label: "three label",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -244,6 +258,7 @@ describe("getColumns", () => {
label: "two", label: "two",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -254,6 +269,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -318,6 +334,7 @@ describe("getColumns", () => {
beforeEach(ctx => { beforeEach(ctx => {
ctx.updateSortable([ ctx.updateSortable([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -326,6 +343,7 @@ describe("getColumns", () => {
label: "three", label: "three",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -334,6 +352,7 @@ describe("getColumns", () => {
label: "one", label: "one",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: false, active: false,
columnType: "foo", columnType: "foo",

View file

@ -1106,50 +1106,51 @@ export const getAllStateVariables = () => {
getAllAssets().forEach(asset => { getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => { findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._component) const settings = componentStore.getComponentSettings(component._component)
const nestedTypes = [
"buttonConfiguration",
"fieldConfiguration",
"stepConfiguration",
]
// Extracts all event settings from a component instance.
// Recurses into nested types to find all event-like settings at any
// depth.
const parseEventSettings = (settings, comp) => { const parseEventSettings = (settings, comp) => {
if (!settings?.length) {
return
}
// Extract top level event settings
settings settings
.filter(setting => setting.type === "event") .filter(setting => setting.type === "event")
.forEach(setting => { .forEach(setting => {
eventSettings.push(comp[setting.key]) eventSettings.push(comp[setting.key])
}) })
}
const parseComponentSettings = (settings, component) => { // Recurse into any nested instance types
// Parse the nested button configurations
settings settings
.filter(setting => setting.type === "buttonConfiguration") .filter(setting => nestedTypes.includes(setting.type))
.forEach(setting => { .forEach(setting => {
const buttonConfig = component[setting.key] const instances = comp[setting.key]
if (Array.isArray(instances) && instances.length) {
instances.forEach(instance => {
let type = instance?._component
if (Array.isArray(buttonConfig)) { // Backwards compatibility for multi-step from blocks which
buttonConfig.forEach(button => { // didn't set a proper component type previously.
const nestedSettings = componentStore.getComponentSettings( if (setting.type === "stepConfiguration" && !type) {
button._component type = "@budibase/standard-components/multistepformblockstep"
) }
parseEventSettings(nestedSettings, button)
// Parsed nested component instances inside this setting
const nestedSettings = componentStore.getComponentSettings(type)
parseEventSettings(nestedSettings, instance)
}) })
} }
}) })
}
parseEventSettings(settings, component) parseEventSettings(settings, component)
}
// Parse the base component settings
parseComponentSettings(settings, component)
// Parse step configuration
const stepSetting = settings.find(
setting => setting.type === "stepConfiguration"
)
const steps = stepSetting ? component[stepSetting.key] : []
const stepDefinition = componentStore.getComponentSettings(
"@budibase/standard-components/multistepformblockstep"
)
steps?.forEach(step => {
parseComponentSettings(stepDefinition, step)
})
}) })
}) })

View file

@ -324,10 +324,7 @@
<div <div
id="side-panel-container" id="side-panel-container"
class:open={$sidePanelStore.open} class:open={$sidePanelStore.open}
use:clickOutside={{ use:clickOutside={autoCloseSidePanel ? sidePanelStore.actions.close : null}
callback: autoCloseSidePanel ? sidePanelStore.actions.close : null,
allowedType: "mousedown",
}}
class:builder={$builderStore.inBuilder} class:builder={$builderStore.inBuilder}
> >
<div class="side-panel-header"> <div class="side-panel-header">

View file

@ -542,16 +542,22 @@ export const enrichButtonActions = (actions, context) => {
// then execute the rest of the actions in the chain // then execute the rest of the actions in the chain
const result = await callback() const result = await callback()
if (result !== false) { if (result !== false) {
// Generate a new total context to pass into the next enrichment // Generate a new total context for the next enrichment
buttonContext.push(result) buttonContext.push(result)
const newContext = { ...context, actions: buttonContext } const newContext = { ...context, actions: buttonContext }
// Enrich and call the next button action if there is more than one action remaining // Enrich and call the next button action if there is more
// than one action remaining
const next = enrichButtonActions( const next = enrichButtonActions(
actions.slice(i + 1), actions.slice(i + 1),
newContext newContext
) )
resolve(typeof next === "function" ? await next() : true) if (typeof next === "function") {
// Pass the event context back into the new action chain
resolve(await next(eventContext))
} else {
resolve(true)
}
} else { } else {
resolve(false) resolve(false)
} }

View file

@ -124,6 +124,7 @@
const fieldSchema = schemaFields.find(x => x.name === filter.field) const fieldSchema = schemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType
// Update external type based on field // Update external type based on field
filter.externalType = getSchema(filter)?.externalType filter.externalType = getSchema(filter)?.externalType

View file

@ -121,8 +121,14 @@
const onContextMenu = e => { const onContextMenu = e => {
e.preventDefault() e.preventDefault()
// The timeout allows time for clickoutside to close other open popvers
// before we show this one. Without the timeout, this popover closes again
// before it's even visible as clickoutside closes it.
setTimeout(() => {
ui.actions.blur() ui.actions.blur()
open = !open open = !open
}, 10)
} }
const sortAscending = () => { const sortAscending = () => {

View file

@ -18,7 +18,7 @@
focusedCellAPI, focusedCellAPI,
focusedRowId, focusedRowId,
notifications, notifications,
isDatasourcePlus, hasBudibaseIdentifiers,
} = getContext("grid") } = getContext("grid")
let anchor let anchor
@ -82,7 +82,7 @@
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Copy" icon="Copy"
disabled={isNewRow || !$focusedRow?._id || !$isDatasourcePlus} disabled={isNewRow || !$focusedRow?._id || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._id)} on:click={() => copyToClipboard($focusedRow?._id)}
on:click={menu.actions.close} on:click={menu.actions.close}
> >
@ -90,7 +90,7 @@
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Copy" icon="Copy"
disabled={isNewRow || !$focusedRow?._rev} disabled={isNewRow || !$focusedRow?._rev || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._rev)} on:click={() => copyToClipboard($focusedRow?._rev)}
on:click={menu.actions.close} on:click={menu.actions.close}
> >

View file

@ -75,14 +75,18 @@ export const deriveStores = context => {
} }
) )
const isDatasourcePlus = derived(datasource, $datasource => { const hasBudibaseIdentifiers = derived(datasource, $datasource => {
return ["table", "viewV2"].includes($datasource?.type) let type = $datasource?.type
if (type === "provider") {
type = $datasource.value?.datasource?.type
}
return ["table", "viewV2", "link"].includes(type)
}) })
return { return {
schema, schema,
enrichedSchema, enrichedSchema,
isDatasourcePlus, hasBudibaseIdentifiers,
} }
} }

View file

@ -17,6 +17,7 @@ export const createActions = context => {
const open = (cellId, e) => { const open = (cellId, e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation()
// Get DOM node for grid data wrapper to compute relative position to // Get DOM node for grid data wrapper to compute relative position to
const gridNode = document.getElementById(gridID) const gridNode = document.getElementById(gridID)

View file

@ -83,7 +83,7 @@ export const createActions = context => {
error, error,
notifications, notifications,
fetch, fetch,
isDatasourcePlus, hasBudibaseIdentifiers,
refreshing, refreshing,
} = context } = context
const instanceLoaded = writable(false) const instanceLoaded = writable(false)
@ -196,9 +196,16 @@ export const createActions = context => {
// Handles validation errors from the rows API and updates local validation // Handles validation errors from the rows API and updates local validation
// state, storing error messages against relevant cells // state, storing error messages against relevant cells
const handleValidationError = (rowId, error) => { const handleValidationError = (rowId, error) => {
let errorString
if (typeof error === "string") {
errorString = error
} else if (typeof error?.message === "string") {
errorString = error.message
}
// If the server doesn't reply with a valid error, assume that the source // If the server doesn't reply with a valid error, assume that the source
// of the error is the focused cell's column // of the error is the focused cell's column
if (!error?.json?.validationErrors && error?.message) { if (!error?.json?.validationErrors && errorString) {
const focusedColumn = get(focusedCellId)?.split("-")[1] const focusedColumn = get(focusedCellId)?.split("-")[1]
if (focusedColumn) { if (focusedColumn) {
error = { error = {
@ -261,7 +268,7 @@ export const createActions = context => {
focusedCellId.set(`${rowId}-${erroredColumns[0]}`) focusedCellId.set(`${rowId}-${erroredColumns[0]}`)
} }
} else { } else {
get(notifications).error(error?.message || "An unknown error occurred") get(notifications).error(errorString || "An unknown error occurred")
} }
} }
@ -458,14 +465,14 @@ export const createActions = context => {
} }
let rowsToAppend = [] let rowsToAppend = []
let newRow let newRow
const $isDatasourcePlus = get(isDatasourcePlus) const $hasBudibaseIdentifiers = get(hasBudibaseIdentifiers)
for (let i = 0; i < newRows.length; i++) { for (let i = 0; i < newRows.length; i++) {
newRow = newRows[i] newRow = newRows[i]
// Ensure we have a unique _id. // Ensure we have a unique _id.
// This means generating one for non DS+, overwriting any that may already // This means generating one for non DS+, overwriting any that may already
// exist as we cannot allow duplicates. // exist as we cannot allow duplicates.
if (!$isDatasourcePlus) { if (!$hasBudibaseIdentifiers) {
newRow._id = Helpers.uuid() newRow._id = Helpers.uuid()
} }
@ -510,7 +517,7 @@ export const createActions = context => {
const cleanRow = row => { const cleanRow = row => {
let clone = { ...row } let clone = { ...row }
delete clone.__idx delete clone.__idx
if (!get(isDatasourcePlus)) { if (!get(hasBudibaseIdentifiers)) {
delete clone._id delete clone._id
} }
return clone return clone

View file

@ -52,6 +52,8 @@ import {
FetchAppPackageResponse, FetchAppPackageResponse,
DuplicateAppRequest, DuplicateAppRequest,
DuplicateAppResponse, DuplicateAppResponse,
UpdateAppRequest,
UpdateAppResponse,
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -450,7 +452,7 @@ export async function create(ctx: UserCtx<CreateAppRequest, App>) {
// This endpoint currently operates as a PATCH rather than a PUT // This endpoint currently operates as a PATCH rather than a PUT
// Thus name and url fields are handled only if present // Thus name and url fields are handled only if present
export async function update( export async function update(
ctx: UserCtx<{ name?: string; url?: string }, App> ctx: UserCtx<UpdateAppRequest, UpdateAppResponse>
) { ) {
const apps = (await dbCore.getAllApps({ dev: true })) as App[] const apps = (await dbCore.getAllApps({ dev: true })) as App[]
// validation // validation

View file

@ -1,20 +1,11 @@
import { getRowParams } from "../../../db/utils" import { getRowParams } from "../../../db/utils"
import { import {
outputProcessing, outputProcessing,
processAutoColumn,
processFormulas, processFormulas,
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import { context, locks } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { import { Table, Row, FormulaType, FieldType } from "@budibase/types"
Table,
Row,
LockType,
LockName,
FormulaType,
FieldType,
} from "@budibase/types"
import * as linkRows from "../../../db/linkedRows" import * as linkRows from "../../../db/linkedRows"
import sdk from "../../../sdk"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -151,30 +142,7 @@ export async function finaliseRow(
// if another row has been written since processing this will // if another row has been written since processing this will
// handle the auto ID clash // handle the auto ID clash
if (oldTable && !isEqual(oldTable, table)) { if (oldTable && !isEqual(oldTable, table)) {
try {
await db.put(table) await db.put(table)
} catch (err: any) {
if (err.status === 409) {
// Some conflicts with the autocolumns occurred, we need to refetch the table and recalculate
await locks.doWithLock(
{
type: LockType.AUTO_EXTEND,
name: LockName.PROCESS_AUTO_COLUMNS,
resource: table._id,
},
async () => {
const latestTable = await sdk.tables.getTable(table._id!)
let response = processAutoColumn(null, latestTable, row, {
reprocessing: true,
})
await db.put(response.table)
row = response.row
}
)
} else {
throw err
}
}
} }
const response = await db.put(row) const response = await db.put(row)
// for response, calculate the formulas for the enriched row // for response, calculate the formulas for the enriched row

View file

@ -156,7 +156,7 @@ describe.each([
return expectSearch({ query }) return expectSearch({ query })
} }
describe("strings", () => { describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ await createTable({
name: { name: "name", type: FieldType.STRING }, name: { name: "name", type: FieldType.STRING },
@ -508,7 +508,7 @@ describe.each([
}) })
}) })
describe("array of strings", () => { describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ await createTable({
numbers: { numbers: {

View file

@ -145,6 +145,7 @@ describe("sdk >> rows >> internal", () => {
lastID: 1, lastID: 1,
}, },
}, },
_rev: expect.stringMatching("2-.*"),
}, },
row: { row: {
...row, ...row,
@ -189,7 +190,6 @@ describe("sdk >> rows >> internal", () => {
type: FieldType.AUTO, type: FieldType.AUTO,
subtype: AutoFieldSubType.AUTO_ID, subtype: AutoFieldSubType.AUTO_ID,
autocolumn: true, autocolumn: true,
lastID: 0,
}, },
}, },
}) })
@ -199,7 +199,7 @@ describe("sdk >> rows >> internal", () => {
await internalSdk.save(table._id!, row, config.getUser()._id) await internalSdk.save(table._id!, row, config.getUser()._id)
} }
await Promise.all( await Promise.all(
makeRows(10).map(row => makeRows(20).map(row =>
internalSdk.save(table._id!, row, config.getUser()._id) internalSdk.save(table._id!, row, config.getUser()._id)
) )
) )
@ -209,19 +209,21 @@ describe("sdk >> rows >> internal", () => {
}) })
const persistedRows = await config.getRows(table._id!) const persistedRows = await config.getRows(table._id!)
expect(persistedRows).toHaveLength(20) expect(persistedRows).toHaveLength(30)
expect(persistedRows).toEqual( expect(persistedRows).toEqual(
expect.arrayContaining( expect.arrayContaining(
Array.from({ length: 20 }).map((_, i) => Array.from({ length: 30 }).map((_, i) =>
expect.objectContaining({ id: i + 1 }) expect.objectContaining({ id: i + 1 })
) )
) )
) )
const persistedTable = await config.getTable(table._id) const persistedTable = await config.getTable(table._id)
expect((table.schema.id as AutoColumnFieldMetadata).lastID).toBe(0) expect(
(table.schema.id as AutoColumnFieldMetadata).lastID
).toBeUndefined()
expect((persistedTable.schema.id as AutoColumnFieldMetadata).lastID).toBe( expect((persistedTable.schema.id as AutoColumnFieldMetadata).lastID).toBe(
20 30
) )
}) })
}) })

View file

@ -5,6 +5,8 @@ import {
type FetchAppDefinitionResponse, type FetchAppDefinitionResponse,
type FetchAppPackageResponse, type FetchAppPackageResponse,
DuplicateAppResponse, DuplicateAppResponse,
UpdateAppRequest,
UpdateAppResponse,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
import { AppStatus } from "../../../db/utils" import { AppStatus } from "../../../db/utils"
@ -109,11 +111,11 @@ export class ApplicationAPI extends TestAPI {
update = async ( update = async (
appId: string, appId: string,
app: { name?: string; url?: string }, app: UpdateAppRequest,
expectations?: Expectations expectations?: Expectations
): Promise<App> => { ): Promise<UpdateAppResponse> => {
return await this._put<App>(`/api/applications/${appId}`, { return await this._put<App>(`/api/applications/${appId}`, {
fields: app, body: app,
expectations, expectations,
}) })
} }

View file

@ -1,6 +1,6 @@
import * as linkRows from "../../db/linkedRows" import * as linkRows from "../../db/linkedRows"
import { processFormulas, fixAutoColumnSubType } from "./utils" import { processFormulas, fixAutoColumnSubType } from "./utils"
import { objectStore, utils } from "@budibase/backend-core" import { context, objectStore, utils } from "@budibase/backend-core"
import { InternalTables } from "../../db/utils" import { InternalTables } from "../../db/utils"
import { TYPE_TRANSFORM_MAP } from "./map" import { TYPE_TRANSFORM_MAP } from "./map"
import { import {
@ -25,7 +25,44 @@ type AutoColumnProcessingOpts = {
noAutoRelationships?: boolean noAutoRelationships?: boolean
} }
const BASE_AUTO_ID = 1 // Returns the next auto ID for a column in a table. On success, the table will
// be updated which is why it gets returned. The nextID returned is guaranteed
// to be given only to you, and if you don't use it it's gone forever (a gap
// will be left in the auto ID sequence).
//
// This function can throw if it fails to generate an auto ID after so many
// attempts.
async function getNextAutoId(
table: Table,
column: string
): Promise<{ table: Table; nextID: number }> {
const db = context.getAppDB()
for (let attempt = 0; attempt < 5; attempt++) {
const schema = table.schema[column]
if (schema.type !== FieldType.NUMBER && schema.type !== FieldType.AUTO) {
throw new Error(`Column ${column} is not an auto column`)
}
schema.lastID = (schema.lastID || 0) + 1
try {
const resp = await db.put(table)
table._rev = resp.rev
return { table, nextID: schema.lastID }
} catch (e: any) {
if (e.status !== 409) {
throw e
}
// We wait for a random amount of time before retrying. The randomness
// makes it less likely for multiple requests modifying this table to
// collide.
await new Promise(resolve =>
setTimeout(resolve, Math.random() * 1.2 ** attempt * 1000)
)
table = await db.get(table._id)
}
}
throw new Error("Failed to generate an auto ID")
}
/** /**
* This will update any auto columns that are found on the row/table with the correct information based on * This will update any auto columns that are found on the row/table with the correct information based on
@ -37,7 +74,7 @@ const BASE_AUTO_ID = 1
* @returns The updated row and table, the table may need to be updated * @returns The updated row and table, the table may need to be updated
* for automatic ID purposes. * for automatic ID purposes.
*/ */
export function processAutoColumn( export async function processAutoColumn(
userId: string | null | undefined, userId: string | null | undefined,
table: Table, table: Table,
row: Row, row: Row,
@ -79,8 +116,9 @@ export function processAutoColumn(
break break
case AutoFieldSubType.AUTO_ID: case AutoFieldSubType.AUTO_ID:
if (creating) { if (creating) {
schema.lastID = !schema.lastID ? BASE_AUTO_ID : schema.lastID + 1 const { table: newTable, nextID } = await getNextAutoId(table, key)
row[key] = schema.lastID table = newTable
row[key] = nextID
} }
break break
} }

View file

@ -44,3 +44,6 @@ export interface PublishResponse {
status: string status: string
appUrl: string appUrl: string
} }
export interface UpdateAppRequest extends Partial<App> {}
export interface UpdateAppResponse extends App {}

View file

@ -21,7 +21,6 @@ export enum LockName {
PERSIST_WRITETHROUGH = "persist_writethrough", PERSIST_WRITETHROUGH = "persist_writethrough",
QUOTA_USAGE_EVENT = "quota_usage_event", QUOTA_USAGE_EVENT = "quota_usage_event",
APP_MIGRATION = "app_migrations", APP_MIGRATION = "app_migrations",
PROCESS_AUTO_COLUMNS = "process_auto_columns",
PROCESS_USER_INVITE = "process_user_invite", PROCESS_USER_INVITE = "process_user_invite",
} }