Merge branch 'master' into BUDI-7580/account_portal_submodule

This commit is contained in:
Adria Navarro 2023-12-04 09:30:00 +01:00
commit 9c9f45436f
56 changed files with 1280 additions and 263 deletions

View File

@ -99,11 +99,6 @@ jobs:
else else
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
fi fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
name: codecov-umbrella
verbose: true
test-worker: test-worker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -129,12 +124,6 @@ jobs:
yarn test --scope=@budibase/worker yarn test --scope=@budibase/worker
fi fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
test-server: test-server:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -159,12 +148,6 @@ jobs:
yarn test --scope=@budibase/server yarn test --scope=@budibase/server
fi fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
test-pro: test-pro:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'

View File

@ -10,6 +10,7 @@ jobs:
steps: steps:
- uses: actions/stale@v8 - uses: actions/stale@v8
with: with:
days-before-stale: 330
operations-per-run: 1 operations-per-run: 1
# stale rules for PRs # stale rules for PRs
days-before-pr-stale: 7 days-before-pr-stale: 7

3
CODEOWNERS Normal file
View File

@ -0,0 +1,3 @@
/packages/server @Budibase/backend
/packages/worker @Budibase/backend
/packages/backend-core @Budibase/backend

View File

@ -7,8 +7,8 @@ metadata:
kubernetes.io/ingress.class: alb kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/success-codes: 200,301 alb.ingress.kubernetes.io/success-codes: '200'
alb.ingress.kubernetes.io/healthcheck-path: / alb.ingress.kubernetes.io/healthcheck-path: '/health'
{{- if .Values.ingress.certificateArn }} {{- if .Values.ingress.certificateArn }}
alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}' alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]' alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'

View File

@ -26,27 +26,48 @@ if [[ "${TARGETBUILD}" = "aas" ]]; then
sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini
elif [[ "${TARGETBUILD}" = "single" ]]; then elif [[ "${TARGETBUILD}" = "single" ]]; then
# In the single image build, the Dockerfile specifies /data as a volume
# mount, so we use that for all persistent data.
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
elif [[ -n $KUBERNETES_SERVICE_HOST ]]; then elif [[ -n $KUBERNETES_SERVICE_HOST ]]; then
# In Kubernetes the directory /opt/couchdb/data has a persistent volume # In Kubernetes the directory /opt/couchdb/data has a persistent volume
# mount for storing database data. # mount for storing database data.
sed -i "s#DATA_DIR#/opt/couchdb/data#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/opt/couchdb/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/opt/couchdb/data#g" /opt/couchdb/etc/local.ini
# We remove the database_dir and view_index_dir settings from the local.ini
# in Kubernetes because it will default to /opt/couchdb/data which is what
# our Helm chart was using prior to us switching to using our own CouchDB
# image.
sed -i "s#^database_dir.*\$##g" /opt/couchdb/etc/local.ini
sed -i "s#^view_index_dir.*\$##g" /opt/couchdb/etc/local.ini
# We remove the -name setting from the vm.args file in Kubernetes because
# it will default to the pod FQDN, which is what's required for clustering
# to work.
sed -i "s/^-name .*$//g" /opt/couchdb/etc/vm.args sed -i "s/^-name .*$//g" /opt/couchdb/etc/vm.args
else else
# For all other builds, we use /data for persistent data.
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
fi fi
# Start Clouseau. Budibase won't function correctly without Clouseau running, it
# powers the search API endpoints which are used to do all sorts, including
# populating app grids.
/opt/clouseau/bin/clouseau > /dev/stdout 2>&1 & /opt/clouseau/bin/clouseau > /dev/stdout 2>&1 &
# Start CouchDB.
/docker-entrypoint.sh /opt/couchdb/bin/couchdb & /docker-entrypoint.sh /opt/couchdb/bin/couchdb &
# Wati 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
echo 'Waiting for CouchDB to start...'; echo 'Waiting for CouchDB to start...';
sleep 5; sleep 5;
done done
# CouchDB needs the `_users` and `_replicator` databases to exist before it will
# function correctly, so we create them here.
curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_users curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_users
curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_replicator curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_replicator
sleep infinity sleep infinity

View File

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

View File

@ -72,7 +72,7 @@
"@types/tar-fs": "2.0.1", "@types/tar-fs": "2.0.1",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"chance": "1.1.8", "chance": "1.1.8",
"ioredis-mock": "8.7.0", "ioredis-mock": "8.9.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-node": "29.7.0", "jest-environment-node": "29.7.0",
"jest-serial-runner": "1.2.1", "jest-serial-runner": "1.2.1",

View File

@ -260,12 +260,12 @@ export async function listAllObjects(bucketName: string, path: string) {
} }
/** /**
* Generate a presigned url with a default TTL of 1 hour * Generate a presigned url with a default TTL of 36 hours
*/ */
export function getPresignedUrl( export function getPresignedUrl(
bucketName: string, bucketName: string,
key: string, key: string,
durationSeconds: number = 3600 durationSeconds: number = 129600
) { ) {
const objectStore = ObjectStore(bucketName, { presigning: true }) const objectStore = ObjectStore(bucketName, { presigning: true })
const params = { const params = {

View File

@ -2,8 +2,9 @@ import Redlock from "redlock"
import { getLockClient } from "./init" import { getLockClient } from "./init"
import { LockOptions, LockType } from "@budibase/types" import { LockOptions, LockType } from "@budibase/types"
import * as context from "../context" import * as context from "../context"
import env from "../environment"
import { logWarn } from "../logging" import { logWarn } from "../logging"
import { utils } from "@budibase/shared-core"
import { Duration } from "../utils"
async function getClient( async function getClient(
type: LockType, type: LockType,
@ -12,9 +13,7 @@ async function getClient(
if (type === LockType.CUSTOM) { if (type === LockType.CUSTOM) {
return newRedlock(opts) return newRedlock(opts)
} }
if (env.isTest() && type !== LockType.TRY_ONCE) {
return newRedlock(OPTIONS.TEST)
}
switch (type) { switch (type) {
case LockType.TRY_ONCE: { case LockType.TRY_ONCE: {
return newRedlock(OPTIONS.TRY_ONCE) return newRedlock(OPTIONS.TRY_ONCE)
@ -28,13 +27,16 @@ async function getClient(
case LockType.DELAY_500: { case LockType.DELAY_500: {
return newRedlock(OPTIONS.DELAY_500) return newRedlock(OPTIONS.DELAY_500)
} }
case LockType.AUTO_EXTEND: {
return newRedlock(OPTIONS.AUTO_EXTEND)
}
default: { default: {
throw new Error(`Could not get redlock client: ${type}`) throw utils.unreachable(type)
} }
} }
} }
const OPTIONS = { const OPTIONS: Record<keyof typeof LockType, Redlock.Options> = {
TRY_ONCE: { TRY_ONCE: {
// immediately throws an error if the lock is already held // immediately throws an error if the lock is already held
retryCount: 0, retryCount: 0,
@ -42,11 +44,6 @@ const OPTIONS = {
TRY_TWICE: { TRY_TWICE: {
retryCount: 1, retryCount: 1,
}, },
TEST: {
// higher retry count in unit tests
// due to high contention.
retryCount: 100,
},
DEFAULT: { DEFAULT: {
// the expected clock drift; for more details // the expected clock drift; for more details
// see http://redis.io/topics/distlock // see http://redis.io/topics/distlock
@ -67,10 +64,14 @@ const OPTIONS = {
DELAY_500: { DELAY_500: {
retryDelay: 500, retryDelay: 500,
}, },
CUSTOM: {},
AUTO_EXTEND: {
retryCount: -1,
},
} }
export async function newRedlock(opts: Redlock.Options = {}) { export async function newRedlock(opts: Redlock.Options = {}) {
let options = { ...OPTIONS.DEFAULT, ...opts } const options = { ...OPTIONS.DEFAULT, ...opts }
const redisWrapper = await getLockClient() const redisWrapper = await getLockClient()
const client = redisWrapper.getClient() const client = redisWrapper.getClient()
return new Redlock([client], options) return new Redlock([client], options)
@ -100,17 +101,36 @@ function getLockName(opts: LockOptions) {
return name return name
} }
export const AUTO_EXTEND_POLLING_MS = Duration.fromSeconds(10).toMs()
export async function doWithLock<T>( export async function doWithLock<T>(
opts: LockOptions, opts: LockOptions,
task: () => Promise<T> task: () => Promise<T>
): Promise<RedlockExecution<T>> { ): Promise<RedlockExecution<T>> {
const redlock = await getClient(opts.type, opts.customOptions) const redlock = await getClient(opts.type, opts.customOptions)
let lock let lock: Redlock.Lock | undefined
let timeout: NodeJS.Timeout | undefined
try { try {
const name = getLockName(opts) const name = getLockName(opts)
const ttl =
opts.type === LockType.AUTO_EXTEND ? AUTO_EXTEND_POLLING_MS : opts.ttl
// create the lock // create the lock
lock = await redlock.lock(name, opts.ttl) lock = await redlock.lock(name, ttl)
if (opts.type === LockType.AUTO_EXTEND) {
// We keep extending the lock while the task is running
const extendInIntervals = (): void => {
timeout = setTimeout(async () => {
lock = await lock!.extend(ttl, () => opts.onExtend && opts.onExtend())
extendInIntervals()
}, ttl / 2)
}
extendInIntervals()
}
// perform locked task // perform locked task
// need to await to ensure completion before unlocking // need to await to ensure completion before unlocking
@ -131,8 +151,7 @@ export async function doWithLock<T>(
throw e throw e
} }
} finally { } finally {
if (lock) { clearTimeout(timeout)
await lock.unlock() await lock?.unlock()
}
} }
} }

View File

@ -0,0 +1,105 @@
import { LockName, LockType, LockOptions } from "@budibase/types"
import { AUTO_EXTEND_POLLING_MS, doWithLock } from "../redlockImpl"
import { DBTestConfiguration, generator } from "../../../tests"
describe("redlockImpl", () => {
beforeEach(() => {
jest.useFakeTimers()
})
describe("doWithLock", () => {
const config = new DBTestConfiguration()
const lockTtl = AUTO_EXTEND_POLLING_MS
function runLockWithExecutionTime({
opts,
task,
executionTimeMs,
}: {
opts: LockOptions
task: () => Promise<string>
executionTimeMs: number
}) {
return config.doInTenant(() =>
doWithLock(opts, async () => {
// Run in multiple intervals until hitting the expected time
const interval = lockTtl / 10
for (let i = executionTimeMs; i > 0; i -= interval) {
await jest.advanceTimersByTimeAsync(interval)
}
return task()
})
)
}
it.each(Object.values(LockType))(
"should return the task value and release the lock",
async (lockType: LockType) => {
const expectedResult = generator.guid()
const mockTask = jest.fn().mockResolvedValue(expectedResult)
const opts: LockOptions = {
name: LockName.PERSIST_WRITETHROUGH,
type: lockType,
ttl: lockTtl,
}
const result = await runLockWithExecutionTime({
opts,
task: mockTask,
executionTimeMs: 0,
})
expect(result.executed).toBe(true)
expect(result.executed && result.result).toBe(expectedResult)
expect(mockTask).toHaveBeenCalledTimes(1)
}
)
it("should extend when type is autoextend", async () => {
const expectedResult = generator.guid()
const mockTask = jest.fn().mockResolvedValue(expectedResult)
const mockOnExtend = jest.fn()
const opts: LockOptions = {
name: LockName.PERSIST_WRITETHROUGH,
type: LockType.AUTO_EXTEND,
onExtend: mockOnExtend,
}
const result = await runLockWithExecutionTime({
opts,
task: mockTask,
executionTimeMs: lockTtl * 2.5,
})
expect(result.executed).toBe(true)
expect(result.executed && result.result).toBe(expectedResult)
expect(mockTask).toHaveBeenCalledTimes(1)
expect(mockOnExtend).toHaveBeenCalledTimes(5)
})
it.each(Object.values(LockType).filter(t => t !== LockType.AUTO_EXTEND))(
"should timeout when type is %s",
async (lockType: LockType) => {
const mockTask = jest.fn().mockResolvedValue("mockResult")
const opts: LockOptions = {
name: LockName.PERSIST_WRITETHROUGH,
type: lockType,
ttl: lockTtl,
}
await expect(
runLockWithExecutionTime({
opts,
task: mockTask,
executionTimeMs: lockTtl * 2,
})
).rejects.toThrowError(
`Unable to fully release the lock on resource \"lock:${config.tenantId}_persist_writethrough\".`
)
}
)
})
})

View File

@ -18,6 +18,7 @@
checked={value} checked={value}
{disabled} {disabled}
on:change={onChange} on:change={onChange}
on:click
{id} {id}
type="checkbox" type="checkbox"
class="spectrum-Switch-input" class="spectrum-Switch-input"

View File

@ -20,7 +20,7 @@
let focus = false let focus = false
const updateValue = newValue => { const updateValue = newValue => {
if (readonly) { if (readonly || disabled) {
return return
} }
if (type === "number") { if (type === "number") {
@ -31,14 +31,14 @@
} }
const onFocus = () => { const onFocus = () => {
if (readonly) { if (readonly || disabled) {
return return
} }
focus = true focus = true
} }
const onBlur = event => { const onBlur = event => {
if (readonly) { if (readonly || disabled) {
return return
} }
focus = false focus = false
@ -46,14 +46,14 @@
} }
const onInput = event => { const onInput = event => {
if (readonly || !updateOnChange) { if (readonly || !updateOnChange || disabled) {
return return
} }
updateValue(event.target.value) updateValue(event.target.value)
} }
const updateValueOnEnter = event => { const updateValueOnEnter = event => {
if (readonly) { if (readonly || disabled) {
return return
} }
if (event.key === "Enter") { if (event.key === "Enter") {
@ -69,6 +69,7 @@
} }
onMount(() => { onMount(() => {
if (disabled) return
focus = autofocus focus = autofocus
if (focus) field.focus() if (focus) field.focus()
}) })
@ -108,4 +109,16 @@
.spectrum-Textfield { .spectrum-Textfield {
width: 100%; width: 100%;
} }
input::placeholder {
color: var(--grey-7);
}
input:hover::placeholder {
color: var(--grey-7) !important;
}
input:focus::placeholder {
color: var(--grey-7) !important;
}
</style> </style>

View File

@ -19,5 +19,5 @@
</script> </script>
<Field {helpText} {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} /> <Switch {error} {disabled} {text} {value} on:change={onChange} on:click />
</Field> </Field>

View File

@ -1,4 +1,5 @@
import { store } from "./index" import { store } from "./index"
import { get } from "svelte/store"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { import {
decodeJSBinding, decodeJSBinding,
@ -238,6 +239,10 @@ export const makeComponentUnique = component => {
} }
export const getComponentText = component => { export const getComponentText = component => {
if (component == null) {
return ""
}
if (component?._instanceName) { if (component?._instanceName) {
return component._instanceName return component._instanceName
} }
@ -246,3 +251,16 @@ export const getComponentText = component => {
"component" "component"
return capitalise(type) return capitalise(type)
} }
export const getComponentName = component => {
if (component == null) {
return ""
}
const components = get(store)?.components || {}
const componentDefinition = components[component._component] || {}
const name =
componentDefinition.friendlyName || componentDefinition.name || ""
return name
}

View File

@ -29,6 +29,12 @@ const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g
const UpdateReferenceAction = {
ADD: "add",
DELETE: "delete",
MOVE: "move",
}
/** /**
* Gets all bindable data context fields and instance fields. * Gets all bindable data context fields and instance fields.
*/ */
@ -1226,3 +1232,81 @@ export const runtimeToReadableBinding = (
"readableBinding" "readableBinding"
) )
} }
/**
* Used to update binding references for automation or action steps
*
* @param obj - The object to be updated
* @param originalIndex - The original index of the step being moved. Not applicable to add/delete.
* @param modifiedIndex - The new index of the step being modified
* @param action - Used to determine if a step is being added, deleted or moved
* @param label - The binding text that describes the steps
*/
export const updateReferencesInObject = ({
obj,
modifiedIndex,
action,
label,
originalIndex,
}) => {
const stepIndexRegex = new RegExp(`{{\\s*${label}\\.(\\d+)\\.`, "g")
const updateActionStep = (str, index, replaceWith) =>
str.replace(`{{ ${label}.${index}.`, `{{ ${label}.${replaceWith}.`)
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = stepIndexRegex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
if (
action === UpdateReferenceAction.ADD &&
referencedStep >= modifiedIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep + 1
)
} else if (
action === UpdateReferenceAction.DELETE &&
referencedStep > modifiedIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep - 1
)
} else if (action === UpdateReferenceAction.MOVE) {
if (referencedStep === originalIndex) {
obj[key] = updateActionStep(obj[key], referencedStep, modifiedIndex)
} else if (
modifiedIndex <= referencedStep &&
modifiedIndex < originalIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep + 1
)
} else if (
modifiedIndex >= referencedStep &&
modifiedIndex > originalIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep - 1
)
}
}
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
updateReferencesInObject({
obj: obj[key],
modifiedIndex,
action,
label,
originalIndex,
})
}
}
}

View File

@ -4,7 +4,7 @@ import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users" import { getUserStore } from "./store/users"
import { getDeploymentStore } from "./store/deployments" import { getDeploymentStore } from "./store/deployments"
import { derived, writable, get } from "svelte/store" import { derived, get } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" 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"
@ -146,5 +146,3 @@ export const userSelectedResourceMap = derived(userStore, $userStore => {
export const isOnlyUser = derived(userStore, $userStore => { export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2 return $userStore.length < 2
}) })
export const screensHeight = writable("210px")

View File

@ -4,6 +4,7 @@ import { cloneDeep } from "lodash/fp"
import { generate } from "shortid" import { generate } from "shortid"
import { selectedAutomation } from "builderStore" import { selectedAutomation } from "builderStore"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { updateReferencesInObject } from "builderStore/dataBinding"
const initialAutomationState = { const initialAutomationState = {
automations: [], automations: [],
@ -22,34 +23,14 @@ export const getAutomationStore = () => {
return store return store
} }
const updateReferencesInObject = (obj, modifiedIndex, action) => {
const regex = /{{\s*steps\.(\d+)\./g
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = regex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
if (action === "add" && referencedStep >= modifiedIndex) {
obj[key] = obj[key].replace(
`{{ steps.${referencedStep}.`,
`{{ steps.${referencedStep + 1}.`
)
} else if (action === "delete" && referencedStep > modifiedIndex) {
obj[key] = obj[key].replace(
`{{ steps.${referencedStep}.`,
`{{ steps.${referencedStep - 1}.`
)
}
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
updateReferencesInObject(obj[key], modifiedIndex, action)
}
}
}
const updateStepReferences = (steps, modifiedIndex, action) => { const updateStepReferences = (steps, modifiedIndex, action) => {
steps.forEach(step => { steps.forEach(step => {
updateReferencesInObject(step.inputs, modifiedIndex, action) updateReferencesInObject({
obj: step.inputs,
modifiedIndex,
action,
label: "steps",
})
}) })
} }

View File

@ -2,6 +2,7 @@ import { expect, describe, it, vi } from "vitest"
import { import {
runtimeToReadableBinding, runtimeToReadableBinding,
readableToRuntimeBinding, readableToRuntimeBinding,
updateReferencesInObject,
} from "../dataBinding" } from "../dataBinding"
vi.mock("@budibase/frontend-core") vi.mock("@budibase/frontend-core")
@ -84,3 +85,461 @@ describe("readableToRuntimeBinding", () => {
).toEqual(`Hello {{ [user].[firstName] }}! The count is {{ count }}.`) ).toEqual(`Hello {{ [user].[firstName] }}! The count is {{ count }}.`)
}) })
}) })
describe("updateReferencesInObject", () => {
it("should increment steps in sequence on 'add'", () => {
let obj = [
{
id: "a0",
parameters: {
text: "Alpha",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.1.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.1.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.3.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.4.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 0,
action: "add",
label: "actions",
})
expect(obj).toEqual([
{
id: "a0",
parameters: {
text: "Alpha",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.2.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.2.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.4.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.5.row }}",
},
},
])
})
it("should decrement steps in sequence on 'delete'", () => {
let obj = [
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.1.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.3.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.4.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "delete",
label: "actions",
})
expect(obj).toEqual([
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.1.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.2.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
])
})
it("should handle on 'move' to a lower index", () => {
let obj = [
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.0.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.0.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.2.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "move",
label: "actions",
originalIndex: 4,
})
expect(obj).toEqual([
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.0.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.4.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.0.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.3.row }}",
},
},
])
})
it("should handle on 'move' to a higher index", () => {
let obj = [
{
id: "b2",
parameters: {
text: "Banana {{ actions.0.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.0.row }}",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.2.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "move",
label: "actions",
originalIndex: 0,
})
expect(obj).toEqual([
{
id: "b2",
parameters: {
text: "Banana {{ actions.2.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.2.row }}",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.1.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
])
})
it("should handle on 'move' of action being referenced, dragged to a higher index", () => {
let obj = [
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.1.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "move",
label: "actions",
originalIndex: 1,
})
expect(obj).toEqual([
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.2.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
])
})
it("should handle on 'move' of action being referenced, dragged to a lower index", () => {
let obj = [
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.4.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
]
updateReferencesInObject({
obj,
modifiedIndex: 0,
action: "move",
label: "actions",
originalIndex: 4,
})
expect(obj).toEqual([
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.0.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
])
})
})

View File

@ -64,7 +64,7 @@
</span> </span>
{:else if schema.type === "link"} {:else if schema.type === "link"}
<LinkedRowSelector <LinkedRowSelector
bind:linkedRows={value[field]} linkedRows={value[field]}
{schema} {schema}
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}

View File

@ -22,7 +22,7 @@
<Select <Select
on:change={onChange} on:change={onChange}
bind:value bind:value
options={filteredTables.filter(table => table._id !== TableNames.USERS)} options={filteredTables}
getOptionLabel={table => table.name} getOptionLabel={table => table.name}
getOptionValue={table => table._id} getOptionValue={table => table._id}
/> />

View File

@ -70,7 +70,12 @@
options={meta.constraints.inclusion} options={meta.constraints.inclusion}
/> />
{:else if type === "link"} {:else if type === "link"}
<LinkedRowSelector {error} bind:linkedRows={value} schema={meta} /> <LinkedRowSelector
{error}
linkedRows={value}
schema={meta}
on:change={e => (value = e.detail)}
/>
{:else if type === "longform"} {:else if type === "longform"}
{#if meta.useRichText} {#if meta.useRichText}
<RichTextField {error} {label} height="150px" bind:value /> <RichTextField {error} {label} height="150px" bind:value />

View File

@ -56,12 +56,12 @@
/> />
{:else} {:else}
<Multiselect <Multiselect
bind:value={linkedIds} value={linkedIds}
{label} {label}
options={rows} options={rows}
getOptionLabel={getPrettyName} getOptionLabel={getPrettyName}
getOptionValue={row => row._id} getOptionValue={row => row._id}
sort sort
on:change={() => dispatch("change", linkedIds)} on:change
/> />
{/if} {/if}

View File

@ -1,10 +1,11 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { AbsTooltip, Icon } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
export let icon export let icon
export let iconTooltip
export let withArrow = false export let withArrow = false
export let withActions = true export let withActions = true
export let indentLevel = 0 export let indentLevel = 0
@ -77,7 +78,11 @@
{style} {style}
{draggable} {draggable}
> >
<div class="nav-item-content" bind:this={contentRef}> <div
class="nav-item-content"
bind:this={contentRef}
class:right={rightAlignIcon}
>
{#if withArrow} {#if withArrow}
<div <div
class:opened class:opened
@ -98,7 +103,9 @@
</div> </div>
{:else if icon} {:else if icon}
<div class="icon" class:right={rightAlignIcon}> <div class="icon" class:right={rightAlignIcon}>
<AbsTooltip type="info" position="right" text={iconTooltip}>
<Icon color={iconColor} size="S" name={icon} /> <Icon color={iconColor} size="S" name={icon} />
</AbsTooltip>
</div> </div>
{/if} {/if}
<div class="text" title={showTooltip ? text : null}> <div class="text" title={showTooltip ? text : null}>
@ -166,6 +173,11 @@
width: max-content; width: max-content;
position: relative; position: relative;
padding-left: var(--spacing-l); padding-left: var(--spacing-l);
box-sizing: border-box;
}
.nav-item-content.right {
width: 100%;
} }
/* Needed to fully display the actions icon */ /* Needed to fully display the actions icon */
@ -264,6 +276,7 @@
} }
.right { .right {
margin-left: auto;
order: 10; order: 10;
} }
</style> </style>

View File

@ -0,0 +1,119 @@
const getResizeActions = (
cssProperty,
mouseMoveEventProperty,
elementProperty,
initialValue,
setValue = () => {}
) => {
let element = null
const elementAction = node => {
element = node
if (initialValue != null) {
element.style[cssProperty] = `${initialValue}px`
}
return {
destroy() {
element = null
},
}
}
const dragHandleAction = node => {
let startProperty = null
let startPosition = null
const handleMouseMove = e => {
e.preventDefault() // Prevent highlighting while dragging
const change = e[mouseMoveEventProperty] - startPosition
element.style[cssProperty] = `${startProperty + change}px`
}
const handleMouseUp = e => {
e.preventDefault() // Prevent highlighting while dragging
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
element.style.removeProperty("transition") // remove temporary transition override
for (let item of document.getElementsByTagName("iframe")) {
item.style.removeProperty("pointer-events")
}
setValue(element[elementProperty])
}
const handleMouseDown = e => {
if (e.detail > 1) {
// e.detail is the number of rapid clicks, so e.detail = 2 is
// a double click. We want to prevent default behaviour in
// this case as it highlights nearby selectable elements, which
// then interferes with the resizing mousemove.
// Putting this on the double click handler doesn't seem to
// work, so it must go here.
e.preventDefault()
}
if (
e.target.hasAttribute("disabled") &&
e.target.getAttribute("disabled") !== "false"
) {
return
}
element.style.transition = `${cssProperty} 0ms` // temporarily override any height transitions
// iframes swallow mouseup events if your cursor ends up over it during a drag, so make them
// temporarily non-interactive
for (let item of document.getElementsByTagName("iframe")) {
item.style.pointerEvents = "none"
}
startProperty = element[elementProperty]
startPosition = e[mouseMoveEventProperty]
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
}
const handleDoubleClick = () => {
element.style.removeProperty(cssProperty)
}
node.addEventListener("mousedown", handleMouseDown)
node.addEventListener("dblclick", handleDoubleClick)
return {
destroy() {
node.removeEventListener("mousedown", handleMouseDown)
node.removeEventListener("dblclick", handleDoubleClick)
},
}
}
return [elementAction, dragHandleAction]
}
export const getVerticalResizeActions = (initialValue, setValue = () => {}) => {
return getResizeActions(
"height",
"pageY",
"clientHeight",
initialValue,
setValue
)
}
export const getHorizontalResizeActions = (
initialValue,
setValue = () => {}
) => {
return getResizeActions(
"width",
"pageX",
"clientWidth",
initialValue,
setValue
)
}

View File

@ -1,8 +1,9 @@
<script> <script>
import { Icon, Body } from "@budibase/bbui" import { AbsTooltip, Icon, Body } from "@budibase/bbui"
export let title export let title
export let icon export let icon
export let iconTooltip
export let showAddButton = false export let showAddButton = false
export let showBackButton = false export let showBackButton = false
export let showCloseButton = false export let showCloseButton = false
@ -36,7 +37,9 @@
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} /> <Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
{/if} {/if}
{#if icon} {#if icon}
<AbsTooltip type="info" text={iconTooltip}>
<Icon name={icon} /> <Icon name={icon} />
</AbsTooltip>
{/if} {/if}
<div class="title"> <div class="title">
{#if customTitleContent} {#if customTitleContent}
@ -68,6 +71,7 @@
<style> <style>
.panel { .panel {
min-width: 260px;
width: 260px; width: 260px;
flex: 0 0 260px; flex: 0 0 260px;
background: var(--background); background: var(--background);
@ -85,6 +89,7 @@
border-right: var(--border-light); border-right: var(--border-light);
} }
.panel.wide { .panel.wide {
min-width: 310px;
width: 310px; width: 310px;
flex: 0 0 310px; flex: 0 0 310px;
} }

View File

@ -15,6 +15,7 @@
getEventContextBindings, getEventContextBindings,
getActionBindings, getActionBindings,
makeStateBinding, makeStateBinding,
updateReferencesInObject,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -30,6 +31,7 @@
let actionQuery let actionQuery
let selectedAction = actions?.length ? actions[0] : null let selectedAction = actions?.length ? actions[0] : null
let originalActionIndex
const setUpdateActions = actions => { const setUpdateActions = actions => {
return actions return actions
@ -115,6 +117,14 @@
if (isSelected) { if (isSelected) {
selectedAction = actions?.length ? actions[0] : null selectedAction = actions?.length ? actions[0] : null
} }
// Update action binding references
updateReferencesInObject({
obj: actions,
modifiedIndex: index,
action: "delete",
label: "actions",
})
} }
const toggleActionList = () => { const toggleActionList = () => {
@ -137,6 +147,7 @@
const selectAction = action => () => { const selectAction = action => () => {
selectedAction = action selectedAction = action
originalActionIndex = actions.findIndex(item => item.id === action.id)
} }
const onAddAction = actionType => { const onAddAction = actionType => {
@ -146,9 +157,29 @@
function handleDndConsider(e) { function handleDndConsider(e) {
actions = e.detail.items actions = e.detail.items
// set the initial index of the action being dragged
if (e.detail.info.trigger === "draggedEntered") {
originalActionIndex = actions.findIndex(
action => action.id === e.detail.info.id
)
}
} }
function handleDndFinalize(e) { function handleDndFinalize(e) {
actions = e.detail.items actions = e.detail.items
// Update action binding references
updateReferencesInObject({
obj: actions,
modifiedIndex: actions.findIndex(
action => action.id === e.detail.info.id
),
action: "move",
label: "actions",
originalIndex: originalActionIndex,
})
originalActionIndex = -1
} }
const getAllBindings = (actionBindings, eventContextBindings, actions) => { const getAllBindings = (actionBindings, eventContextBindings, actions) => {
@ -289,7 +320,7 @@
</Layout> </Layout>
<Layout noPadding> <Layout noPadding>
{#if selectedActionComponent && !showAvailableActions} {#if selectedActionComponent && !showAvailableActions}
{#key selectedAction.id} {#key (selectedAction.id, originalActionIndex)}
<div class="selected-action-container"> <div class="selected-action-container">
<svelte:component <svelte:component
this={selectedActionComponent} this={selectedActionComponent}

View File

@ -55,7 +55,10 @@
size="S" size="S"
name="Close" name="Close"
hoverable hoverable
on:click={() => removeButton(item._id)} on:click={e => {
e.stopPropagation()
removeButton(item._id)
}}
/> />
</div> </div>
</div> </div>

View File

@ -32,11 +32,14 @@
} }
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flipDurationMs = 150
let anchors = {} let anchors = {}
let draggableItems = [] let draggableItems = []
// Used for controlling cursor behaviour in order to limit drag behaviour
// to the drag handle
let inactive = true
const buildDraggable = items => { const buildDraggable = items => {
return items return items
.map(item => { .map(item => {
@ -64,6 +67,7 @@
} }
const handleFinalize = e => { const handleFinalize = e => {
inactive = true
updateRowOrder(e) updateRowOrder(e)
dispatch("change", serialiseUpdate()) dispatch("change", serialiseUpdate())
} }
@ -77,24 +81,36 @@
class="list-wrap" class="list-wrap"
use:dndzone={{ use:dndzone={{
items: draggableItems, items: draggableItems,
flipDurationMs,
dropTargetStyle: { outline: "none" }, dropTargetStyle: { outline: "none" },
dragDisabled: !draggable, dragDisabled: !draggable || inactive,
}} }}
on:finalize={handleFinalize} on:finalize={handleFinalize}
on:consider={updateRowOrder} on:consider={updateRowOrder}
> >
{#each draggableItems as draggable (draggable.id)} {#each draggableItems as draggableItem (draggableItem.id)}
<li <li
on:click={() => {
get(store).actions.select(draggableItem.id)
}}
on:mousedown={() => { on:mousedown={() => {
get(store).actions.select() get(store).actions.select()
}} }}
bind:this={anchors[draggable.id]} bind:this={anchors[draggableItem.id]}
class:highlighted={draggable.id === $store.selected} class:highlighted={draggableItem.id === $store.selected}
> >
<div class="left-content"> <div class="left-content">
{#if showHandle} {#if showHandle}
<div class="handle"> <div
class="handle"
aria-label="drag-handle"
style={!inactive ? "cursor:grabbing" : "cursor:grab"}
on:mousedown={() => {
inactive = false
}}
on:mouseup={() => {
inactive = true
}}
>
<DragHandle /> <DragHandle />
</div> </div>
{/if} {/if}
@ -102,8 +118,8 @@
<div class="right-content"> <div class="right-content">
<svelte:component <svelte:component
this={listType} this={listType}
anchor={anchors[draggable.item._id]} anchor={anchors[draggableItem.item._id]}
item={draggable.item} item={draggableItem.item}
{...listTypeProps} {...listTypeProps}
on:change={onItemChanged} on:change={onItemChanged}
/> />
@ -143,6 +159,7 @@
--spectrum-table-row-background-color-hover, --spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover) var(--spectrum-alias-highlight-hover)
); );
cursor: pointer;
} }
.list-wrap > li:first-child { .list-wrap > li:first-child {
border-top-left-radius: 4px; border-top-left-radius: 4px;
@ -165,6 +182,9 @@
display: flex; display: flex;
height: var(--spectrum-global-dimension-size-150); height: var(--spectrum-global-dimension-size-150);
} }
.handle:hover {
cursor: grab;
}
.handle :global(svg) { .handle :global(svg) {
fill: var(--spectrum-global-color-gray-500); fill: var(--spectrum-global-color-gray-500);
margin-right: var(--spacing-m); margin-right: var(--spacing-m);

View File

@ -156,7 +156,7 @@
<div class="field-configuration"> <div class="field-configuration">
<div class="toggle-all"> <div class="toggle-all">
<span /> <span>Fields</span>
<Toggle <Toggle
on:change={() => { on:change={() => {
let update = fieldList.map(field => ({ let update = fieldList.map(field => ({
@ -186,6 +186,9 @@
</div> </div>
<style> <style>
.field-configuration {
padding-top: 8px;
}
.field-configuration :global(.spectrum-ActionButton) { .field-configuration :global(.spectrum-ActionButton) {
width: 100%; width: 100%;
} }
@ -204,6 +207,5 @@
.toggle-all span { .toggle-all span {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
font-size: 12px; font-size: 12px;
margin-left: calc(var(--spacing-s) - 1px);
} }
</style> </style>

View File

@ -58,7 +58,15 @@
<div class="field-label">{readableText}</div> <div class="field-label">{readableText}</div>
</div> </div>
<div class="list-item-right"> <div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin /> <Toggle
on:change={onToggle(item)}
on:click={e => {
e.stopPropagation()
}}
text=""
value={item.active}
thin
/>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
<script> <script>
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { store, selectedComponent, selectedScreen } from "builderStore" import { store, selectedComponent, selectedScreen } from "builderStore"
import { getComponentText } from "builderStore/componentUtils" import { getComponentName } from "builderStore/componentUtils"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
@ -43,17 +43,25 @@
$: id = $selectedComponent?._id $: id = $selectedComponent?._id
$: id, (section = tabs[0]) $: id, (section = tabs[0])
$: componentName = getComponentName(componentInstance)
</script> </script>
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id} {#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft wide> <Panel
{title}
icon={componentDefinition?.icon}
iconTooltip={componentName}
borderLeft
wide
>
<span class="panel-title-content" slot="panel-title-content"> <span class="panel-title-content" slot="panel-title-content">
<input <input
class="input" class="input"
value={title} value={title}
{title} {title}
placeholder={getComponentText(componentInstance)} placeholder={componentName}
on:keypress={e => { on:keypress={e => {
if (e.key.toLowerCase() === "enter") { if (e.key.toLowerCase() === "enter") {
e.target.blur() e.target.blur()

View File

@ -25,6 +25,7 @@
<style> <style>
.app-panel { .app-panel {
min-width: 410px;
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;

View File

@ -12,6 +12,7 @@
import { import {
findComponentPath, findComponentPath,
getComponentText, getComponentText,
getComponentName,
} from "builderStore/componentUtils" } from "builderStore/componentUtils"
import { get } from "svelte/store" import { get } from "svelte/store"
import { dndStore } from "./dndStore" import { dndStore } from "./dndStore"
@ -110,6 +111,7 @@
on:drop={onDrop} on:drop={onDrop}
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
iconTooltip={getComponentName(component)}
withArrow={componentHasChildren(component)} withArrow={componentHasChildren(component)}
indentLevel={level} indentLevel={level}
selected={$store.selectedComponentId === component._id} selected={$store.selectedComponentId === component._id}

View File

@ -1,21 +1,55 @@
<script> <script>
import ScreenList from "./ScreenList/index.svelte" import ScreenList from "./ScreenList/index.svelte"
import ComponentList from "./ComponentList/index.svelte" import ComponentList from "./ComponentList/index.svelte"
import { getHorizontalResizeActions } from "components/common/resizable"
const [resizable, resizableHandle] = getHorizontalResizeActions()
</script> </script>
<div class="panel"> <div class="panel" use:resizable>
<div class="content">
<ScreenList /> <ScreenList />
<ComponentList /> <ComponentList />
</div>
<div class="divider">
<div class="dividerClickExtender" role="separator" use:resizableHandle />
</div>
</div> </div>
<style> <style>
.panel { .panel {
display: flex;
min-width: 270px;
width: 310px; width: 310px;
height: 100%; height: 100%;
border-right: var(--border-light); }
.content {
overflow: hidden;
flex-grow: 1;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--background); background: var(--background);
position: relative; position: relative;
} }
.divider {
position: relative;
height: 100%;
width: 2px;
background: var(--spectrum-global-color-gray-200);
transition: background 130ms ease-out;
}
.divider:hover {
background: var(--spectrum-global-color-gray-300);
cursor: row-resize;
}
.dividerClickExtender {
position: absolute;
cursor: col-resize;
height: 100%;
width: 12px;
}
</style> </style>

View File

@ -1,108 +1,50 @@
<script> <script>
import { Layout } from "@budibase/bbui" import { Layout } from "@budibase/bbui"
import { import { store, sortedScreens, userSelectedResourceMap } from "builderStore"
store,
sortedScreens,
userSelectedResourceMap,
screensHeight,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import RoleIndicator from "./RoleIndicator.svelte" import RoleIndicator from "./RoleIndicator.svelte"
import DropdownMenu from "./DropdownMenu.svelte" import DropdownMenu from "./DropdownMenu.svelte"
import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { getVerticalResizeActions } from "components/common/resizable"
import NavHeader from "components/common/NavHeader.svelte" import NavHeader from "components/common/NavHeader.svelte"
let search = false const [resizable, resizableHandle] = getVerticalResizeActions()
let resizing = false
let searchValue = ""
let container let searching = false
let searchValue = ""
let screensContainer let screensContainer
let scrolling = false let scrolling = false
let previousHeight = null
let dragOffset
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue) $: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const handleOpenSearch = async () => {
$: search ? openSearch() : closeSearch()
const openSearch = async () => {
screensContainer.scroll({ top: 0, behavior: "smooth" }) screensContainer.scroll({ top: 0, behavior: "smooth" })
previousHeight = $screensHeight
$screensHeight = "calc(100% + 1px)"
} }
const closeSearch = async () => { $: {
if (previousHeight) { if (searching) {
// Restore previous height and wait for animation handleOpenSearch()
$screensHeight = previousHeight
previousHeight = null
await sleep(300)
} }
} }
const getFilteredScreens = (screens, search) => { const getFilteredScreens = (screens, searchValue) => {
return screens.filter(screen => { return screens.filter(screen => {
return !search || screen.routing.route.includes(search) return !searchValue || screen.routing.route.includes(searchValue)
}) })
} }
const handleScroll = e => { const handleScroll = e => {
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
const startResizing = e => {
// Reset the height store to match the true height
$screensHeight = `${container.getBoundingClientRect().height}px`
// Store an offset to easily compute new height when moving the mouse
dragOffset = parseInt($screensHeight) - e.clientY
// Add event listeners
resizing = true
document.addEventListener("mousemove", resize)
document.addEventListener("mouseup", stopResizing)
}
const resize = e => {
// Prevent negative heights as this screws with layout
const newHeight = Math.max(0, e.clientY + dragOffset)
if (newHeight == null || isNaN(newHeight)) {
return
}
$screensHeight = `${newHeight}px`
}
const stopResizing = () => {
resizing = false
document.removeEventListener("mousemove", resize)
}
onMount(() => {
// Ensure we aren't stuck at 100% height from leaving while searching
if ($screensHeight == null || isNaN(parseInt($screensHeight))) {
$screensHeight = "210px"
}
})
</script> </script>
<svelte:window /> <div class="screens" class:searching use:resizable>
<div
class="screens"
class:search
class:resizing
style={`height:${$screensHeight};`}
bind:this={container}
>
<div class="header" class:scrolling> <div class="header" class:scrolling>
<NavHeader <NavHeader
title="Screens" title="Screens"
placeholder="Search for screens" placeholder="Search for screens"
bind:value={searchValue} bind:value={searchValue}
bind:search bind:search={searching}
onAdd={() => $goto("../new")} onAdd={() => $goto("../new")}
/> />
</div> </div>
@ -110,6 +52,7 @@
{#if filteredScreens?.length} {#if filteredScreens?.length}
{#each filteredScreens as screen (screen._id)} {#each filteredScreens as screen (screen._id)}
<NavItem <NavItem
scrollable
icon={screen.routing.homeScreen ? "Home" : null} icon={screen.routing.homeScreen ? "Home" : null}
indentLevel={0} indentLevel={0}
selected={$store.selectedScreenId === screen._id} selected={$store.selectedScreenId === screen._id}
@ -135,9 +78,11 @@
</div> </div>
<div <div
role="separator"
disabled={searching}
class="divider" class="divider"
on:mousedown={startResizing} class:disabled={searching}
on:dblclick={() => screensHeight.set("210px")} use:resizableHandle
/> />
</div> </div>
@ -148,14 +93,12 @@
min-height: 147px; min-height: 147px;
max-height: calc(100% - 147px); max-height: calc(100% - 147px);
position: relative; position: relative;
transition: height 300ms ease-out; transition: height 300ms ease-out, max-height 300ms ease-out;
height: 210px;
} }
.screens.search { .screens.searching {
max-height: none; max-height: 100%;
} height: 100% !important;
.screens.resizing {
user-select: none;
cursor: row-resize;
} }
.header { .header {
@ -177,9 +120,6 @@
overflow: auto; overflow: auto;
flex-grow: 1; flex-grow: 1;
} }
.screens.resizing .content {
pointer-events: none;
}
.screens :global(.nav-item) { .screens :global(.nav-item) {
padding-right: 8px !important; padding-right: 8px !important;
@ -217,4 +157,10 @@
.divider:hover:after { .divider:hover:after {
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
} }
.divider.disabled {
cursor: auto;
}
.divider.disabled:after {
background: var(--spectrum-global-color-gray-200);
}
</style> </style>

View File

@ -40,6 +40,7 @@
} }
.content { .content {
width: 100vw;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;

View File

@ -14,7 +14,7 @@
import PortalSideBar from "./_components/PortalSideBar.svelte" import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = $apps.length != null let loaded = !!$apps?.length
onMount(async () => { onMount(async () => {
try { try {

View File

@ -1,5 +1,6 @@
<script> <script>
import { import {
banner,
Heading, Heading,
Layout, Layout,
Button, Button,
@ -10,6 +11,7 @@
Notification, Notification,
Body, Body,
Search, Search,
BANNER_TYPES,
} from "@budibase/bbui" } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
@ -198,6 +200,20 @@
if (usersLimitLockAction) { if (usersLimitLockAction) {
usersLimitLockAction() usersLimitLockAction()
} }
if (!$admin.isDev) {
await banner.show({
messages: [
{
message:
"We've updated our pricing - see our website to learn more.",
type: BANNER_TYPES.NEUTRAL,
extraButtonText: "Learn More",
extraButtonAction: () =>
window.open("https://budibase.com/pricing"),
},
],
})
}
} catch (error) { } catch (error) {
notifications.error("Error getting init info") notifications.error("Error getting init info")
} }

View File

@ -8,7 +8,7 @@
x => x.value === users.getUserRole(row) x => x.value === users.getUserRole(row)
) )
$: value = role?.label || "Not available" $: value = role?.label || "Not available"
$: tooltip = role.subtitle || "" $: tooltip = role?.subtitle || ""
</script> </script>
<div on:click|stopPropagation title={tooltip}> <div on:click|stopPropagation title={tooltip}>

View File

@ -6056,18 +6056,6 @@
"options": ["Create", "Update", "View"], "options": ["Create", "Update", "View"],
"defaultValue": "Create" "defaultValue": "Create"
}, },
{
"type": "text",
"label": "Title",
"key": "title",
"nested": true
},
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"section": true, "section": true,
"dependsOn": { "dependsOn": {
@ -6075,7 +6063,7 @@
"value": "Create", "value": "Create",
"invert": true "invert": true
}, },
"name": "Row details", "name": "Row ID",
"info": "<a href='https://docs.budibase.com/docs/form-block' target='_blank'>How to pass a row ID using bindings</a>", "info": "<a href='https://docs.budibase.com/docs/form-block' target='_blank'>How to pass a row ID using bindings</a>",
"settings": [ "settings": [
{ {
@ -6095,8 +6083,20 @@
}, },
{ {
"section": true, "section": true,
"name": "Fields", "name": "Details",
"settings": [ "settings": [
{
"type": "text",
"label": "Title",
"key": "title",
"nested": true
},
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"type": "fieldConfiguration", "type": "fieldConfiguration",
"key": "fields", "key": "fields",

View File

@ -1,6 +1,6 @@
<script> <script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
@ -108,7 +108,7 @@
} }
} }
$: fetchRows(searchTerm, primaryDisplay, defaultValue) $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
const allRowsFetched = const allRowsFetched =
@ -124,10 +124,22 @@
query: { equal: { _id: defaultVal } }, query: { equal: { _id: defaultVal } },
}) })
} }
// Ensure we match all filters, rather than any
const baseFilter = (filter || []).filter(x => x.operator !== "allOr")
await fetch.update({ await fetch.update({
query: { string: { [primaryDisplay]: searchTerm } }, filter: [
...baseFilter,
{
// Use a big numeric prefix to avoid clashing with an existing filter
field: `999:${primaryDisplay}`,
operator: "string",
value: searchTerm,
},
],
}) })
} }
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
const flatten = values => { const flatten = values => {
if (!values) { if (!values) {

View File

@ -2152,7 +2152,7 @@
"/applications/{appId}/publish": { "/applications/{appId}/publish": {
"post": { "post": {
"operationId": "appPublish", "operationId": "appPublish",
"summary": "Unpublish an application", "summary": "Publish an application",
"tags": [ "tags": [
"applications" "applications"
], ],

View File

@ -1761,7 +1761,7 @@ paths:
"/applications/{appId}/publish": "/applications/{appId}/publish":
post: post:
operationId: appPublish operationId: appPublish
summary: Unpublish an application summary: Publish an application
tags: tags:
- applications - applications
parameters: parameters:

View File

@ -24,7 +24,7 @@ import AWS from "aws-sdk"
import fs from "fs" import fs from "fs"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
import { App, Ctx, ProcessAttachmentResponse, Upload } from "@budibase/types" import { App, Ctx, ProcessAttachmentResponse } from "@budibase/types"
const send = require("koa-send") const send = require("koa-send")
@ -212,7 +212,9 @@ export const serveBuilderPreview = async function (ctx: Ctx) {
if (!env.isJest()) { if (!env.isJest()) {
let appId = context.getAppId() let appId = context.getAppId()
const previewHbs = loadHandlebarsFile(`${__dirname}/preview.hbs`) const templateLoc = join(__dirname, "templates")
const previewLoc = fs.existsSync(templateLoc) ? templateLoc : __dirname
const previewHbs = loadHandlebarsFile(join(previewLoc, "preview.hbs"))
ctx.body = await processString(previewHbs, { ctx.body = await processString(previewHbs, {
clientLibPath: objectStore.clientLibraryUrl(appId!, appInfo.version), clientLibPath: objectStore.clientLibraryUrl(appId!, appInfo.version),
}) })

View File

@ -517,9 +517,24 @@ describe.each([
}) })
describe("patch", () => { describe("patch", () => {
let otherTable: Table
beforeAll(async () => { beforeAll(async () => {
const tableConfig = generateTableConfig() const tableConfig = generateTableConfig()
table = await createTable(tableConfig) table = await createTable(tableConfig)
const otherTableConfig = generateTableConfig()
// need a short name of table here - for relationship tests
otherTableConfig.name = "a"
otherTableConfig.schema.relationship = {
name: "relationship",
relationshipType: RelationshipType.ONE_TO_MANY,
type: FieldType.LINK,
tableId: table._id!,
fieldName: "relationship",
}
otherTable = await createTable(otherTableConfig)
// need to set the config back to the original table
config.table = table
}) })
it("should update only the fields that are supplied", async () => { it("should update only the fields that are supplied", async () => {
@ -615,6 +630,28 @@ describe.each([
expect(getResp.body.user1[0]._id).toEqual(user2._id) expect(getResp.body.user1[0]._id).toEqual(user2._id)
expect(getResp.body.user2[0]._id).toEqual(user2._id) expect(getResp.body.user2[0]._id).toEqual(user2._id)
}) })
it("should be able to update relationships when both columns are same name", async () => {
let row = await config.api.row.save(table._id!, {
name: "test",
description: "test",
})
let row2 = await config.api.row.save(otherTable._id!, {
name: "test",
description: "test",
relationship: [row._id],
})
row = (await config.api.row.get(table._id!, row._id!)).body
expect(row.relationship.length).toBe(1)
const resp = await config.api.row.patch(table._id!, {
_id: row._id!,
_rev: row._rev!,
tableId: row.tableId!,
name: "test2",
relationship: [row2._id],
})
expect(resp.relationship.length).toBe(1)
})
}) })
describe("destroy", () => { describe("destroy", () => {

View File

@ -251,9 +251,19 @@ class LinkController {
// find the docs that need to be deleted // find the docs that need to be deleted
let toDeleteDocs = thisFieldLinkDocs let toDeleteDocs = thisFieldLinkDocs
.filter(doc => { .filter(doc => {
let correctDoc = let correctDoc
doc.doc1.fieldName === fieldName ? doc.doc2 : doc.doc1 if (
return rowField.indexOf(correctDoc.rowId) === -1 doc.doc1.tableId === table._id! &&
doc.doc1.fieldName === fieldName
) {
correctDoc = doc.doc2
} else if (
doc.doc2.tableId === table._id! &&
doc.doc2.fieldName === fieldName
) {
correctDoc = doc.doc1
}
return correctDoc && rowField.indexOf(correctDoc.rowId) === -1
}) })
.map(doc => { .map(doc => {
return { ...doc, _deleted: true } return { ...doc, _deleted: true }

View File

@ -934,25 +934,43 @@ describe("postgres integrations", () => {
}, },
], ],
}) })
const m2oRel = {
[m2oFieldName]: [
{
_id: row._id,
},
],
}
expect(res.body[m2oFieldName]).toEqual([ expect(res.body[m2oFieldName]).toEqual([
{ {
...m2oRel,
...foreignRowsByType[RelationshipType.MANY_TO_ONE][0].row, ...foreignRowsByType[RelationshipType.MANY_TO_ONE][0].row,
[`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]: [`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]:
row.id, row.id,
}, },
{ {
...m2oRel,
...foreignRowsByType[RelationshipType.MANY_TO_ONE][1].row, ...foreignRowsByType[RelationshipType.MANY_TO_ONE][1].row,
[`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]: [`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]:
row.id, row.id,
}, },
{ {
...m2oRel,
...foreignRowsByType[RelationshipType.MANY_TO_ONE][2].row, ...foreignRowsByType[RelationshipType.MANY_TO_ONE][2].row,
[`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]: [`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]:
row.id, row.id,
}, },
]) ])
const o2mRel = {
[o2mFieldName]: [
{
_id: row._id,
},
],
}
expect(res.body[o2mFieldName]).toEqual([ expect(res.body[o2mFieldName]).toEqual([
{ {
...o2mRel,
...foreignRowsByType[RelationshipType.ONE_TO_MANY][0].row, ...foreignRowsByType[RelationshipType.ONE_TO_MANY][0].row,
_id: expect.any(String), _id: expect.any(String),
_rev: expect.any(String), _rev: expect.any(String),

View File

@ -133,9 +133,14 @@ export async function exportRows(
let result = await search({ tableId, query: requestQuery, sort, sortOrder }) let result = await search({ tableId, query: requestQuery, sort, sortOrder })
let rows: Row[] = [] let rows: Row[] = []
let headers
if (!tableName) {
throw new HTTPError("Could not find table name.", 400)
}
const schema = datasource.entities[tableName].schema
// Filter data to only specified columns if required // Filter data to only specified columns if required
if (columns && columns.length) { if (columns && columns.length) {
for (let i = 0; i < result.rows.length; i++) { for (let i = 0; i < result.rows.length; i++) {
rows[i] = {} rows[i] = {}
@ -143,22 +148,17 @@ export async function exportRows(
rows[i][column] = result.rows[i][column] rows[i][column] = result.rows[i][column]
} }
} }
headers = columns
} else { } else {
rows = result.rows rows = result.rows
} }
if (!tableName) {
throw new HTTPError("Could not find table name.", 400)
}
const schema = datasource.entities[tableName].schema
let exportRows = cleanExportRows(rows, schema, format, columns) let exportRows = cleanExportRows(rows, schema, format, columns)
let headers = Object.keys(schema)
let content: string let content: string
switch (format) { switch (format) {
case exporters.Format.CSV: case exporters.Format.CSV:
content = exporters.csv(headers, exportRows) content = exporters.csv(headers ?? Object.keys(schema), exportRows)
break break
case exporters.Format.JSON: case exporters.Format.JSON:
content = exporters.json(exportRows) content = exporters.json(exportRows)

View File

@ -110,7 +110,7 @@ export async function exportRows(
let rows: Row[] = [] let rows: Row[] = []
let schema = table.schema let schema = table.schema
let headers
// Filter data to only specified columns if required // Filter data to only specified columns if required
if (columns && columns.length) { if (columns && columns.length) {
for (let i = 0; i < result.length; i++) { for (let i = 0; i < result.length; i++) {
@ -119,6 +119,7 @@ export async function exportRows(
rows[i][column] = result[i][column] rows[i][column] = result[i][column]
} }
} }
headers = columns
} else { } else {
rows = result rows = result
} }
@ -127,7 +128,7 @@ export async function exportRows(
if (format === Format.CSV) { if (format === Format.CSV) {
return { return {
fileName: "export.csv", fileName: "export.csv",
content: csv(Object.keys(rows[0]), exportRows), content: csv(headers ?? Object.keys(rows[0]), exportRows),
} }
} else if (format === Format.JSON) { } else if (format === Format.JSON) {
return { return {

View File

@ -136,6 +136,8 @@ export async function save(
schema.main = true schema.main = true
} }
// add in the new table for relationship purposes
tables[tableToSave.name] = tableToSave
cleanupRelationships(tableToSave, tables, oldTable) cleanupRelationships(tableToSave, tables, oldTable)
const operation = tableId ? Operation.UPDATE_TABLE : Operation.CREATE_TABLE const operation = tableId ? Operation.UPDATE_TABLE : Operation.CREATE_TABLE

View File

@ -1,5 +1,6 @@
import { import {
Datasource, Datasource,
FieldType,
ManyToManyRelationshipFieldMetadata, ManyToManyRelationshipFieldMetadata,
ManyToOneRelationshipFieldMetadata, ManyToOneRelationshipFieldMetadata,
OneToManyRelationshipFieldMetadata, OneToManyRelationshipFieldMetadata,
@ -42,10 +43,13 @@ export function cleanupRelationships(
for (let [relatedKey, relatedSchema] of Object.entries( for (let [relatedKey, relatedSchema] of Object.entries(
relatedTable.schema relatedTable.schema
)) { )) {
if ( if (relatedSchema.type !== FieldType.LINK) {
relatedSchema.type === FieldTypes.LINK && continue
relatedSchema.fieldName === foreignKey }
) { // if they both have the same field name it will appear as if it needs to be removed,
// don't cleanup in this scenario
const sameFieldNameForBoth = relatedSchema.name === schema.name
if (relatedSchema.fieldName === foreignKey && !sameFieldNameForBoth) {
delete relatedTable.schema[relatedKey] delete relatedTable.schema[relatedKey]
} }
} }

View File

@ -18,7 +18,6 @@ jest.mock("../../../utilities/rowProcessor", () => ({
jest.mock("../../../api/controllers/view/exporters", () => ({ jest.mock("../../../api/controllers/view/exporters", () => ({
...jest.requireActual("../../../api/controllers/view/exporters"), ...jest.requireActual("../../../api/controllers/view/exporters"),
csv: jest.fn(),
Format: { Format: {
CSV: "csv", CSV: "csv",
}, },
@ -102,5 +101,32 @@ describe("external row sdk", () => {
new HTTPError("Could not find table name.", 400) new HTTPError("Could not find table name.", 400)
) )
}) })
it("should only export specified columns", async () => {
mockDatasourcesGet.mockImplementation(async () => ({
entities: {
tablename: {
schema: {
name: {},
age: {},
dob: {},
},
},
},
}))
const headers = ["name", "dob"]
const result = await exportRows({
tableId: "datasource__tablename",
format: Format.CSV,
query: {},
columns: headers,
})
expect(result).toEqual({
fileName: "export.csv",
content: `"name","dob"`,
})
})
}) })
}) })

View File

@ -315,7 +315,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
new Date(docValue).getTime() > new Date(testValue.high).getTime() new Date(docValue).getTime() > new Date(testValue.high).getTime()
) )
} }
throw "Cannot perform range filter - invalid type." return false
} }
) )

View File

@ -130,21 +130,13 @@ describe("runLuceneQuery", () => {
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2]) expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
}) })
it("should throw an error is an invalid doc value is passed into a range filter", async () => { it("should return return all docs if an invalid doc value is passed into a range filter", async () => {
const query = buildQuery("range", { const docs = [
order_date: {
low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z",
},
})
expect(() =>
runLuceneQuery(
[
{ {
order_id: 4, order_id: 4,
customer_id: 1758, customer_id: 1758,
order_status: 5, order_status: 5,
order_date: "INVALID", order_date: "{{ Binding.INVALID }}",
required_date: "2017-03-05T00:00:00.000Z", required_date: "2017-03-05T00:00:00.000Z",
shipped_date: "2017-03-03T00:00:00.000Z", shipped_date: "2017-03-03T00:00:00.000Z",
store_id: 2, store_id: 2,
@ -152,10 +144,14 @@ describe("runLuceneQuery", () => {
description: undefined, description: undefined,
label: "", label: "",
}, },
], ]
query const query = buildQuery("range", {
) order_date: {
).toThrowError("Cannot perform range filter - invalid type.") low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z",
},
})
expect(runLuceneQuery(docs, query)).toEqual(docs)
}) })
it("should return rows with matches on empty filter", () => { it("should return rows with matches on empty filter", () => {

View File

@ -10,6 +10,7 @@ export enum LockType {
DEFAULT = "default", DEFAULT = "default",
DELAY_500 = "delay_500", DELAY_500 = "delay_500",
CUSTOM = "custom", CUSTOM = "custom",
AUTO_EXTEND = "auto_extend",
} }
export enum LockName { export enum LockName {
@ -21,7 +22,7 @@ export enum LockName {
QUOTA_USAGE_EVENT = "quota_usage_event", QUOTA_USAGE_EVENT = "quota_usage_event",
} }
export interface LockOptions { export type LockOptions = {
/** /**
* The lock type determines which client to use * The lock type determines which client to use
*/ */
@ -35,10 +36,6 @@ export interface LockOptions {
* The name for the lock * The name for the lock
*/ */
name: LockName name: LockName
/**
* The ttl to auto-expire the lock if not unlocked manually
*/
ttl: number
/** /**
* The individual resource to lock. This is useful for locking around very specific identifiers, e.g. a document that is prone to conflicts * The individual resource to lock. This is useful for locking around very specific identifiers, e.g. a document that is prone to conflicts
*/ */
@ -47,4 +44,16 @@ export interface LockOptions {
* This is a system-wide lock - don't use tenancy in lock key * This is a system-wide lock - don't use tenancy in lock key
*/ */
systemLock?: boolean systemLock?: boolean
} } & (
| {
/**
* The ttl to auto-expire the lock if not unlocked manually
*/
ttl: number
type: Exclude<LockType, LockType.AUTO_EXTEND>
}
| {
type: LockType.AUTO_EXTEND
onExtend?: () => void
}
)

View File

@ -12970,16 +12970,16 @@ invert-kv@^2.0.0:
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02"
integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==
ioredis-mock@8.7.0: ioredis-mock@8.9.0:
version "8.7.0" version "8.9.0"
resolved "https://registry.yarnpkg.com/ioredis-mock/-/ioredis-mock-8.7.0.tgz#9877a85e0d233e1b49123d1c6e320df01e9a1d36" resolved "https://registry.yarnpkg.com/ioredis-mock/-/ioredis-mock-8.9.0.tgz#5d694c4b81d3835e4291e0b527f947e260981779"
integrity sha512-BJcSjkR3sIMKbH93fpFzwlWi/jl1kd5I3vLvGQxnJ/W/6bD2ksrxnyQN186ljAp3Foz4p1ivViDE3rZeKEAluA== integrity sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==
dependencies: dependencies:
"@ioredis/as-callback" "^3.0.0" "@ioredis/as-callback" "^3.0.0"
"@ioredis/commands" "^1.2.0" "@ioredis/commands" "^1.2.0"
fengari "^0.1.4" fengari "^0.1.4"
fengari-interop "^0.1.3" fengari-interop "^0.1.3"
semver "^7.3.8" semver "^7.5.4"
ioredis@5.3.2: ioredis@5.3.2:
version "5.3.2" version "5.3.2"