Merge branch 'master' of github.com:Budibase/budibase into fix/budi-7433-google-sheets-validation-wont-let-you-import-any-sheets-if

This commit is contained in:
Sam Rose 2023-10-16 16:05:45 +01:00
commit 37fe91e488
53 changed files with 589 additions and 521 deletions

View File

@ -10,7 +10,6 @@ on:
push: push:
branches: branches:
- master - master
- develop
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@ -262,11 +261,7 @@ jobs:
branch="${{ github.base_ref || github.ref_name }}" branch="${{ github.base_ref || github.ref_name }}"
echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})" echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})"
if [[ $branch == "master" ]]; then base_commit=$(git rev-parse origin/master)
base_commit=$(git rev-parse origin/master)
elif [[ $branch == "develop" ]]; then
base_commit=$(git rev-parse origin/develop)
fi
if [[ ! -z $base_commit ]]; then if [[ ! -z $base_commit ]]; then
echo "target_branch=$branch" echo "target_branch=$branch"

View File

@ -4,7 +4,13 @@ on:
pull_request: pull_request:
types: [closed] types: [closed]
branches: branches:
- develop - master
workflow_dispatch:
inputs:
BRANCH:
type: string
description: Which featurebranch branch to destroy?
required: true
jobs: jobs:
release: release:
@ -13,8 +19,8 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main - uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.head_ref }} PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.ref }} PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys
event: featurebranch-qa-close event: featurebranch-qa-close

View File

@ -3,7 +3,6 @@ name: deploy-featurebranch
on: on:
pull_request: pull_request:
branches: branches:
- develop
- master - master
jobs: jobs:

View File

@ -1,41 +0,0 @@
name: "deploy-preprod"
on:
workflow_dispatch:
workflow_call:
jobs:
deploy-to-legacy-preprod-env:
runs-on: ubuntu-latest
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Get the latest budibase release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- uses: passeidireto/trigger-external-workflow-action@main
env:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with:
repository: budibase/budibase-deploys
event: budicloud-preprod-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -1,124 +0,0 @@
name: Budibase Prerelease
concurrency:
group: release-prerelease
cancel-in-progress: false
on:
push:
tags:
- "*-alpha.*"
workflow_dispatch:
env:
# Posthog token used by ui at build time
# disable unless needed for testing
# POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
FEATURE_PREVIEW_URL: https://budirelease.live
jobs:
release-images:
runs-on: ubuntu-latest
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not develop
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/develop; then
echo "Tag is not in develop"
exit 1
fi
- uses: actions/setup-node@v1
with:
node-version: 18.x
- run: yarn install --frozen-lockfile
- name: Update versions
run: ./scripts/updateVersions.sh
- run: yarn build
- run: yarn build:sdk
- name: Publish budibase packages to NPM
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
git submodule foreach git commit -a -m 'Release process'
git commit -a -m 'Release process'
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release:develop
- name: Build/release Docker images
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker:develop
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
release-helm-chart:
needs: [release-images]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Helm
uses: azure/setup-helm@v1
id: helm-install
# due to helm repo index issue: https://github.com/helm/helm/issues/7363
# we need to create new package in a different dir, merge the index and move the package back
- name: Build and release helm chart
run: |
git config user.name "Budibase Helm Bot"
git config user.email "<>"
git reset --hard
git fetch
mkdir sync
echo "Packaging chart to sync dir"
helm package charts/budibase --version 0.0.0-develop --app-version develop --destination sync
echo "Packaging successful"
git checkout gh-pages
echo "Indexing helm repo"
helm repo index --merge docs/index.yaml sync
mv -f sync/* docs
rm -rf sync
echo "Pushing new helm release"
git add -A
git commit -m "Helm Release: develop"
git push
trigger-deploy-to-qa-env:
needs: [release-helm-chart]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Get the current budibase release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- uses: passeidireto/trigger-external-workflow-action@main
env:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with:
repository: budibase/budibase-deploys
event: budicloud-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -110,19 +110,13 @@ jobs:
git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}" git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}"
git push git push
deploy-to-legacy-preprod-env:
needs: [release-images]
uses: ./.github/workflows/deploy-preprod.yml
secrets: inherit
# Trigger deploy to new EKS preprod environment trigger-deploy-to-qa-env:
trigger-deploy-to-preprod-env:
needs: [release-helm-chart] needs: [release-helm-chart]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Get the current budibase release version
- name: Get the latest budibase release version
id: version id: version
run: | run: |
release_version=$(cat lerna.json | jq -r '.version') release_version=$(cat lerna.json | jq -r '.version')
@ -133,5 +127,5 @@ jobs:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }} PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys
event: budicloud-preprod-deploy event: budicloud-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }} github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -2,7 +2,7 @@ name: Close stale issues and PRs # https://github.com/actions/stale
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: '*/30 * * * *' # Every 30 mins - cron: "*/30 * * * *" # Every 30 mins
jobs: jobs:
stale: stale:
@ -10,20 +10,37 @@ jobs:
steps: steps:
- uses: actions/stale@v8 - uses: actions/stale@v8
with: with:
# stale rules operations-per-run: 1
days-before-stale: 60 # stale rules for PRs
days-before-pr-stale: 7 days-before-pr-stale: 7
stale-issue-label: stale stale-issue-label: stale
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for 60 days."
# close rules
# days after being marked as stale to close
days-before-close: 30
close-issue-label: closed-stale
close-issue-message: This issue has been automatically closed it has not had any activity in 90 days."
days-before-pr-close: 7
# exemptions
exempt-pr-labels: pinned,security,roadmap exempt-pr-labels: pinned,security,roadmap
days-before-pr-close: 7
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for high priority bugs
days-before-stale: 30
only-issue-labels: bug,High priority
stale-issue-label: warn
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for medium priority bugs
days-before-stale: 90
only-issue-labels: bug,Medium priority
stale-issue-label: warn
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for all bugs
days-before-stale: 180
stale-issue-label: stale
only-issue-labels: bug
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for six months."
days-before-close: 30

View File

@ -1,42 +0,0 @@
name: Tag prerelease
concurrency:
group: tag-prerelease
cancel-in-progress: false
on:
push:
branches:
- develop
paths:
- ".aws/**"
- ".github/**"
- "charts/**"
- "packages/**"
- "scripts/**"
- "package.json"
- "yarn.lock"
workflow_dispatch:
jobs:
tag-prerelease:
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not develop
if: github.ref != 'refs/heads/develop'
run: |
echo "Ref is not develop, you must run this job from develop."
exit 1
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: cd scripts && yarn
- name: Tag prerelease
run: |
cd scripts
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
./versionCommit.sh prerelease

View File

@ -4,17 +4,6 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
on: on:
push:
branches:
- master
paths:
- ".aws/**"
- ".github/**"
- "charts/**"
- "packages/**"
- "scripts/**"
- "package.json"
- "yarn.lock"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
versioning: versioning:

View File

@ -138,6 +138,8 @@ To develop the Budibase platform you'll need [Docker](https://www.docker.com/) a
`yarn setup` will check that all necessary components are installed and setup the repo for usage. `yarn setup` will check that all necessary components are installed and setup the repo for usage.
If you have access to the `@budibase/pro` submodule then please follow the Pro section of this guide before running the above command.
##### Manual method ##### Manual method
The following commands can be executed to manually get Budibase up and running (assuming Docker/Docker Compose has been installed). The following commands can be executed to manually get Budibase up and running (assuming Docker/Docker Compose has been installed).
@ -146,6 +148,8 @@ The following commands can be executed to manually get Budibase up and running (
`yarn build` will build all budibase packages. `yarn build` will build all budibase packages.
If you have access to the `@budibase/pro` submodule then please follow the Pro section of this guide before running the above commands.
#### 4. Running #### 4. Running
To run the budibase server and builder in dev mode (i.e. with live reloading): To run the budibase server and builder in dev mode (i.e. with live reloading):

View File

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

View File

@ -6,6 +6,7 @@ import {
AutomationStepIdArray, AutomationStepIdArray,
AutomationIOType, AutomationIOType,
AutomationCustomIOType, AutomationCustomIOType,
DatasourceFeature,
} from "@budibase/types" } from "@budibase/types"
import joi from "joi" import joi from "joi"
@ -67,9 +68,27 @@ function validateDatasource(schema: any) {
version: joi.string().optional(), version: joi.string().optional(),
schema: joi.object({ schema: joi.object({
docs: joi.string(), docs: joi.string(),
plus: joi.boolean().optional(),
isSQL: joi.boolean().optional(),
auth: joi
.object({
type: joi.string().required(),
})
.optional(),
features: joi
.object(
Object.fromEntries(
Object.values(DatasourceFeature).map(key => [
key,
joi.boolean().optional(),
])
)
)
.optional(),
relationships: joi.boolean().optional(),
description: joi.string().required(),
friendlyName: joi.string().required(), friendlyName: joi.string().required(),
type: joi.string().allow(...DATASOURCE_TYPES), type: joi.string().allow(...DATASOURCE_TYPES),
description: joi.string().required(),
datasource: joi.object().pattern(joi.string(), fieldValidator).required(), datasource: joi.object().pattern(joi.string(), fieldValidator).required(),
query: joi query: joi
.object() .object()

View File

@ -14,13 +14,14 @@ import {
} from "../db" } from "../db"
import { import {
BulkDocsResponse, BulkDocsResponse,
ContextUser,
SearchQuery,
SearchQueryOperators,
SearchUsersRequest, SearchUsersRequest,
User, User,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import { getGlobalDB } from "../context"
import * as context from "../context" import * as context from "../context"
import { user as userCache } from "../cache" import { getGlobalDB } from "../context"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -39,6 +40,31 @@ function removeUserPassword(users: User | User[]) {
return users return users
} }
export const isSupportedUserSearch = (query: SearchQuery) => {
const allowed = [
{ op: SearchQueryOperators.STRING, key: "email" },
{ op: SearchQueryOperators.EQUAL, key: "_id" },
]
for (let [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") {
return false
}
const fields = Object.keys(operation || {})
// this filter doesn't contain options - ignore
if (fields.length === 0) {
continue
}
const allowedOperation = allowed.find(
allow =>
allow.op === key && fields.length === 1 && fields[0] === allow.key
)
if (!allowedOperation) {
return false
}
}
return true
}
export const bulkGetGlobalUsersById = async ( export const bulkGetGlobalUsersById = async (
userIds: string[], userIds: string[],
opts?: GetOpts opts?: GetOpts
@ -211,8 +237,8 @@ export const searchGlobalUsersByEmail = async (
const PAGE_LIMIT = 8 const PAGE_LIMIT = 8
export const paginatedUsers = async ({ export const paginatedUsers = async ({
page, bookmark,
email, query,
appId, appId,
}: SearchUsersRequest = {}) => { }: SearchUsersRequest = {}) => {
const db = getGlobalDB() const db = getGlobalDB()
@ -222,18 +248,20 @@ export const paginatedUsers = async ({
limit: PAGE_LIMIT + 1, limit: PAGE_LIMIT + 1,
} }
// add a startkey if the page was specified (anchor) // add a startkey if the page was specified (anchor)
if (page) { if (bookmark) {
opts.startkey = page opts.startkey = bookmark
} }
// property specifies what to use for the page/anchor // property specifies what to use for the page/anchor
let userList: User[], let userList: User[],
property = "_id", property = "_id",
getKey getKey
if (appId) { if (query?.equal?._id) {
userList = [await getById(query.equal._id)]
} else if (appId) {
userList = await searchGlobalUsersByApp(appId, opts) userList = await searchGlobalUsersByApp(appId, opts)
getKey = (doc: any) => getGlobalUserByAppPage(appId, doc) getKey = (doc: any) => getGlobalUserByAppPage(appId, doc)
} else if (email) { } else if (query?.string?.email) {
userList = await searchGlobalUsersByEmail(email, opts) userList = await searchGlobalUsersByEmail(query?.string?.email, opts)
property = "email" property = "email"
} else { } else {
// no search, query allDocs // no search, query allDocs

View File

@ -13,6 +13,8 @@
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { RelationshipErrorChecker } from "./relationshipErrors" import { RelationshipErrorChecker } from "./relationshipErrors"
import { onMount } from "svelte" import { onMount } from "svelte"
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { PrettyRelationshipDefinitions } from "constants/backend"
export let save export let save
export let datasource export let datasource
@ -22,16 +24,21 @@
export let selectedFromTable export let selectedFromTable
export let close export let close
const relationshipTypes = [ let relationshipMap = {
{ [RelationshipType.MANY_TO_MANY]: {
label: "One to Many", part1: PrettyRelationshipDefinitions.MANY,
value: RelationshipType.MANY_TO_ONE, part2: PrettyRelationshipDefinitions.MANY,
}, },
{ [RelationshipType.MANY_TO_ONE]: {
label: "Many to Many", part1: PrettyRelationshipDefinitions.ONE,
value: RelationshipType.MANY_TO_MANY, part2: PrettyRelationshipDefinitions.MANY,
}, },
] }
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
let relationshipPart1 = PrettyRelationshipDefinitions.MANY
let relationshipPart2 = PrettyRelationshipDefinitions.ONE
let originalFromColumnName = toRelationship.name, let originalFromColumnName = toRelationship.name,
originalToColumnName = fromRelationship.name originalToColumnName = fromRelationship.name
@ -49,14 +56,32 @@
) )
let errors = {} let errors = {}
let fromPrimary, fromForeign, fromColumn, toColumn let fromPrimary, fromForeign, fromColumn, toColumn
let fromId, toId, throughId, throughToKey, throughFromKey
let throughId, throughToKey, throughFromKey
let isManyToMany, isManyToOne, relationshipType let isManyToMany, isManyToOne, relationshipType
let hasValidated = false let hasValidated = false
$: fromId = null
$: toId = null
$: tableOptions = plusTables.map(table => ({ $: tableOptions = plusTables.map(table => ({
label: table.name, label: table.name,
value: table._id, value: table._id,
name: table.name,
_id: table._id,
})) }))
$: {
// Determine the relationship type based on the selected values of both parts
relationshipType = Object.entries(relationshipMap).find(
([_, parts]) =>
parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2
)?.[0]
changed(() => {
hasValidated = false
})
}
$: valid = $: valid =
getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType) getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType)
$: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY $: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
@ -338,33 +363,34 @@
onConfirm={saveRelationship} onConfirm={saveRelationship}
disabled={!valid} disabled={!valid}
> >
<Select
label="Relationship type"
options={relationshipTypes}
bind:value={relationshipType}
bind:error={errors.relationshipType}
on:change={() =>
changed(() => {
hasValidated = false
})}
/>
<div class="headings"> <div class="headings">
<Detail>Tables</Detail> <Detail>Tables</Detail>
</div> </div>
{#if !selectedFromTable}
<Select <RelationshipSelector
label="Select from table" bind:relationshipPart1
options={tableOptions} bind:relationshipPart2
bind:value={fromId} bind:relationshipTableIdPrimary={fromId}
bind:error={errors.fromTable} bind:relationshipTableIdSecondary={toId}
on:change={e => {relationshipOpts1}
changed(() => { {relationshipOpts2}
const table = plusTables.find(tbl => tbl._id === e.detail) {tableOptions}
fromColumn = table?.name || "" {errors}
fromPrimary = table?.primary?.[0] primaryDisabled={selectedFromTable}
})} primaryTableChanged={e =>
/> changed(() => {
{/if} const table = plusTables.find(tbl => tbl._id === e.detail)
fromColumn = table?.name || ""
fromPrimary = table?.primary?.[0]
})}
secondaryTableChanged={e =>
changed(() => {
const table = plusTables.find(tbl => tbl._id === e.detail)
toColumn = table.name || ""
fromForeign = null
})}
/>
{#if isManyToOne && fromId} {#if isManyToOne && fromId}
<Select <Select
label={`Primary Key (${getTable(fromId).name})`} label={`Primary Key (${getTable(fromId).name})`}
@ -374,18 +400,6 @@
on:change={changed} on:change={changed}
/> />
{/if} {/if}
<Select
label={"Select to table"}
options={tableOptions}
bind:value={toId}
bind:error={errors.toTable}
on:change={e =>
changed(() => {
const table = plusTables.find(tbl => tbl._id === e.detail)
toColumn = table.name || ""
fromForeign = null
})}
/>
{#if isManyToMany} {#if isManyToMany}
<Select <Select
label={"Through"} label={"Through"}

View File

@ -6,11 +6,14 @@
export let relationshipTableIdPrimary export let relationshipTableIdPrimary
export let relationshipTableIdSecondary export let relationshipTableIdSecondary
export let editableColumn export let editableColumn
export let linkEditDisabled export let linkEditDisabled = false
export let tableOptions export let tableOptions
export let errors export let errors
export let relationshipOpts1 export let relationshipOpts1
export let relationshipOpts2 export let relationshipOpts2
export let primaryTableChanged
export let secondaryTableChanged
export let primaryDisabled = true
</script> </script>
<div class="relationship-container"> <div class="relationship-container">
@ -19,16 +22,19 @@
disabled={linkEditDisabled} disabled={linkEditDisabled}
bind:value={relationshipPart1} bind:value={relationshipPart1}
options={relationshipOpts1} options={relationshipOpts1}
bind:error={errors.relationshipType}
/> />
</div> </div>
<div class="relationship-label">in</div> <div class="relationship-label">in</div>
<div class="relationship-part"> <div class="relationship-part">
<Select <Select
disabled disabled={primaryDisabled}
options={tableOptions} options={tableOptions}
getOptionLabel={table => table.name} getOptionLabel={table => table.name}
getOptionValue={table => table._id} getOptionValue={table => table._id}
bind:value={relationshipTableIdPrimary} bind:value={relationshipTableIdPrimary}
on:change={primaryTableChanged}
bind:error={errors.fromTable}
/> />
</div> </div>
</div> </div>
@ -46,20 +52,24 @@
<Select <Select
disabled={linkEditDisabled} disabled={linkEditDisabled}
bind:value={relationshipTableIdSecondary} bind:value={relationshipTableIdSecondary}
bind:error={errors.toTable}
options={tableOptions.filter( options={tableOptions.filter(
table => table._id !== relationshipTableIdPrimary table => table._id !== relationshipTableIdPrimary
)} )}
getOptionLabel={table => table.name} getOptionLabel={table => table.name}
getOptionValue={table => table._id} getOptionValue={table => table._id}
on:change={secondaryTableChanged}
/> />
</div> </div>
</div> </div>
<Input {#if editableColumn}
disabled={linkEditDisabled} <Input
label={`Column name in other table`} disabled={linkEditDisabled}
bind:value={editableColumn.fieldName} label={`Column name in other table`}
error={errors.relatedName} bind:value={editableColumn.fieldName}
/> error={errors.relatedName}
/>
{/if}
<style> <style>
.relationship-container { .relationship-container {

View File

@ -1,91 +0,0 @@
<script>
import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnEditor/ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { getFields } from "helpers/searchFields"
export let componentInstance
export let value = []
export let allowCellEditing = true
export let subject = "Table"
const dispatch = createEventDispatcher()
let drawer
let boundValue
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchema($currentAsset, datasource)
$: options = allowCellEditing
? Object.keys(schema || {})
: enrichedSchemaFields?.map(field => field.name)
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
$: enrichedSchemaFields = getFields(Object.values(schema || {}), {
allowLinks: true,
})
const getSchema = (asset, datasource) => {
const schema = getSchemaForDatasource(asset, datasource).schema
// Don't show ID and rev in tables
if (schema) {
delete schema._id
delete schema._rev
}
return schema
}
const updateBoundValue = value => {
boundValue = cloneDeep(value)
}
const getValidColumns = (columns, options) => {
if (!Array.isArray(columns) || !columns.length) {
return []
}
// We need to account for legacy configs which would just be an array
// of strings
if (typeof columns[0] === "string") {
columns = columns.map(col => ({
name: col,
displayName: col,
}))
}
return columns.filter(column => {
return options.includes(column.name)
})
}
const open = () => {
updateBoundValue(sanitisedValue)
drawer.show()
}
const save = () => {
dispatch("change", getValidColumns(boundValue, options))
drawer.hide()
}
</script>
<ActionButton on:click={open}>Configure columns</ActionButton>
<Drawer bind:this={drawer} title="{subject} Columns">
<svelte:fragment slot="description">
Configure the columns in your {subject.toLowerCase()}.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<ColumnDrawer
slot="body"
bind:columns={boundValue}
{options}
{schema}
{allowCellEditing}
/>
</Drawer>

View File

@ -54,6 +54,7 @@
label="App export" label="App export"
on:change={e => { on:change={e => {
file = e.detail?.[0] file = e.detail?.[0]
encrypted = file?.name?.endsWith(".enc.tar.gz")
}} }}
/> />
<Toggle text="Encrypted" bind:value={encrypted} /> <Toggle text="Encrypted" bind:value={encrypted} />

View File

@ -62,7 +62,14 @@
</div> </div>
{/if} {/if}
<div class="truncate"> <div class="truncate">
<Body>{getSubtitle(datasource)}</Body> <Body>
{@const subtitle = getSubtitle(datasource)}
{#if subtitle}
{subtitle}
{:else}
{Object.values(datasource.config).join(" / ")}
{/if}
</Body>
</div> </div>
</div> </div>
<div class="right"> <div class="right">

View File

@ -13,7 +13,7 @@
import ExportAppModal from "components/start/ExportAppModal.svelte" import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte" import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId == $store.appId) $: filteredApps = $apps.filter(app => app.devId === $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -123,7 +123,10 @@
prevUserSearch = search prevUserSearch = search
try { try {
userPageInfo.loading() userPageInfo.loading()
await users.search({ userPage, email: search }) await users.search({
bookmark: userPage,
query: { string: { email: search } },
})
userPageInfo.fetched($users.hasNextPage, $users.nextPage) userPageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")

View File

@ -31,7 +31,10 @@
prevSearch = search prevSearch = search
try { try {
pageInfo.loading() pageInfo.loading()
await users.search({ page, email: search }) await users.search({
bookmark: page,
query: { string: { email: search } },
})
pageInfo.fetched($users.hasNextPage, $users.nextPage) pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")

View File

@ -130,6 +130,7 @@ export function createDatasourcesStore() {
config, config,
name: `${integration.friendlyName}${nameModifier}`, name: `${integration.friendlyName}${nameModifier}`,
plus: integration.plus && integration.name !== IntegrationTypes.REST, plus: integration.plus && integration.name !== IntegrationTypes.REST,
isSQL: integration.isSQL,
} }
if (await checkDatasourceValidity(integration, datasource)) { if (await checkDatasourceValidity(integration, datasource)) {

View File

@ -5609,6 +5609,21 @@
} }
] ]
}, },
{
"type": "event",
"label": "On row click",
"key": "onRowClick",
"context": [
{
"label": "Clicked row",
"key": "row"
}
],
"dependsOn": {
"setting": "allowEditRows",
"value": false
}
},
{ {
"type": "boolean", "type": "boolean",
"label": "Add rows", "label": "Add rows",

View File

@ -14,12 +14,14 @@
export let initialSortOrder = null export let initialSortOrder = null
export let fixedRowHeight = null export let fixedRowHeight = null
export let columns = null export let columns = null
export let onRowClick = null
const component = getContext("component") const component = getContext("component")
const { styleable, API, builderStore, notificationStore } = getContext("sdk") const { styleable, API, builderStore, notificationStore } = getContext("sdk")
$: columnWhitelist = columns?.map(col => col.name) $: columnWhitelist = columns?.map(col => col.name)
$: schemaOverrides = getSchemaOverrides(columns) $: schemaOverrides = getSchemaOverrides(columns)
$: handleRowClick = allowEditRows ? undefined : onRowClick
const getSchemaOverrides = columns => { const getSchemaOverrides = columns => {
let overrides = {} let overrides = {}
@ -56,6 +58,7 @@
showControls={false} showControls={false}
notifySuccess={notificationStore.actions.success} notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error} notifyError={notificationStore.actions.error}
on:rowclick={e => handleRowClick?.({ row: e.detail })}
/> />
</div> </div>

View File

@ -105,19 +105,25 @@
} }
} }
$: fetchRows(searchTerm, primaryDisplay) $: fetchRows(searchTerm, primaryDisplay, defaultValue)
const fetchRows = (searchTerm, primaryDisplay) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
const allRowsFetched = const allRowsFetched =
$fetch.loaded && $fetch.loaded &&
!Object.keys($fetch.query?.string || {}).length && !Object.keys($fetch.query?.string || {}).length &&
!$fetch.hasNextPage !$fetch.hasNextPage
// Don't request until we have the primary display // Don't request until we have the primary display or default value has been fetched
if (!allRowsFetched && primaryDisplay) { if (allRowsFetched || !primaryDisplay) {
fetch.update({ return
query: { string: { [primaryDisplay]: searchTerm } }, }
if (defaultVal && !optionsObj[defaultVal]) {
await fetch.update({
query: { equal: { _id: defaultVal } },
}) })
} }
await fetch.update({
query: { string: { [primaryDisplay]: searchTerm } },
})
} }
const flatten = values => { const flatten = values => {

View File

@ -10,24 +10,28 @@ export const buildUserEndpoints = API => ({
/** /**
* Gets a list of users in the current tenant. * Gets a list of users in the current tenant.
* @param {string} page The page to retrieve * @param {string} bookmark The page to retrieve
* @param {string} search The starts with string to search username/email by. * @param {object} query search filters for lookup by user (all operators not supported).
* @param {string} appId Facilitate app/role based user searching * @param {string} appId Facilitate app/role based user searching
* @param {boolean} paginated Allow the disabling of pagination * @param {boolean} paginate Allow the disabling of pagination
* @param {number} limit How many users to retrieve in a single search
*/ */
searchUsers: async ({ paginated, page, email, appId } = {}) => { searchUsers: async ({ paginate, bookmark, query, appId, limit } = {}) => {
const opts = {} const opts = {}
if (page) { if (bookmark) {
opts.page = page opts.bookmark = bookmark
} }
if (email) { if (query) {
opts.email = email opts.query = query
} }
if (appId) { if (appId) {
opts.appId = appId opts.appId = appId
} }
if (typeof paginated === "boolean") { if (typeof paginate === "boolean") {
opts.paginated = paginated opts.paginate = paginate
}
if (limit) {
opts.limit = limit
} }
return await API.post({ return await API.post({
url: `/api/global/users/search`, url: `/api/global/users/search`,

View File

@ -27,7 +27,7 @@
const email = Object.values(searchParams.query.string)[0] const email = Object.values(searchParams.query.string)[0]
const results = await API.searchUsers({ const results = await API.searchUsers({
email, query: { string: { email } },
}) })
// Mapping to the expected data within RelationshipCell // Mapping to the expected data within RelationshipCell

View File

@ -17,13 +17,24 @@
const { config, dispatch, selectedRows } = getContext("grid") const { config, dispatch, selectedRows } = getContext("grid")
const svelteDispatch = createEventDispatcher() const svelteDispatch = createEventDispatcher()
const select = () => { const select = e => {
e.stopPropagation()
svelteDispatch("select") svelteDispatch("select")
const id = row?._id const id = row?._id
if (id) { if (id) {
selectedRows.actions.toggleRow(id) selectedRows.actions.toggleRow(id)
} }
} }
const bulkDelete = e => {
e.stopPropagation()
dispatch("request-bulk-delete")
}
const expand = e => {
e.stopPropagation()
svelteDispatch("expand")
}
</script> </script>
<GridCell <GridCell
@ -56,7 +67,7 @@
{/if} {/if}
{/if} {/if}
{#if rowSelected && $config.canDeleteRows} {#if rowSelected && $config.canDeleteRows}
<div class="delete" on:click={() => dispatch("request-bulk-delete")}> <div class="delete" on:click={bulkDelete}>
<Icon <Icon
name="Delete" name="Delete"
size="S" size="S"
@ -65,12 +76,7 @@
</div> </div>
{:else} {:else}
<div class="expand" class:visible={$config.canExpandRows && expandable}> <div class="expand" class:visible={$config.canExpandRows && expandable}>
<Icon <Icon size="S" name="Maximize" hoverable on:click={expand} />
size="S"
name="Maximize"
hoverable
on:click={() => svelteDispatch("expand")}
/>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -35,7 +35,7 @@
</script> </script>
<div bind:this={body} class="grid-body"> <div bind:this={body} class="grid-body">
<GridScrollWrapper scrollHorizontally scrollVertically wheelInteractive> <GridScrollWrapper scrollHorizontally scrollVertically attachHandlers>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
<GridRow <GridRow
{row} {row}

View File

@ -17,6 +17,7 @@
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
contentLines, contentLines,
isDragging, isDragging,
dispatch,
} = getContext("grid") } = getContext("grid")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
@ -30,6 +31,7 @@
on:focus on:focus
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", row)}
> >
{#each $renderedColumns as column, columnIdx (column.name)} {#each $renderedColumns as column, columnIdx (column.name)}
{@const cellId = `${row._id}-${column.name}`} {@const cellId = `${row._id}-${column.name}`}

View File

@ -17,7 +17,11 @@
export let scrollVertically = false export let scrollVertically = false
export let scrollHorizontally = false export let scrollHorizontally = false
export let wheelInteractive = false export let attachHandlers = false
// Used for tracking touch events
let initialTouchX
let initialTouchY
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth) $: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth)
@ -27,17 +31,47 @@
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);` return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
} }
// Handles a wheel even and updates the scroll offsets // Handles a mouse wheel event and updates scroll state
const handleWheel = e => { const handleWheel = e => {
e.preventDefault() e.preventDefault()
debouncedHandleWheel(e.deltaX, e.deltaY, e.clientY) updateScroll(e.deltaX, e.deltaY, e.clientY)
// If a context menu was visible, hide it // If a context menu was visible, hide it
if ($menu.visible) { if ($menu.visible) {
menu.actions.close() menu.actions.close()
} }
} }
const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => {
// Handles touch start events
const handleTouchStart = e => {
if (!e.touches?.[0]) return
initialTouchX = e.touches[0].clientX
initialTouchY = e.touches[0].clientY
}
// Handles touch move events and updates scroll state
const handleTouchMove = e => {
if (!e.touches?.[0]) return
e.preventDefault()
// Compute delta from previous event, and update scroll
const deltaX = initialTouchX - e.touches[0].clientX
const deltaY = initialTouchY - e.touches[0].clientY
updateScroll(deltaX, deltaY)
// Store position to reference in next event
initialTouchX = e.touches[0].clientX
initialTouchY = e.touches[0].clientY
// If a context menu was visible, hide it
if ($menu.visible) {
menu.actions.close()
}
}
// Updates the scroll offset by a certain delta, and ensure scrolling
// stays within sensible bounds. Debounced for performance.
const updateScroll = domDebounce((deltaX, deltaY, clientY) => {
const { top, left } = $scroll const { top, left } = $scroll
// Calculate new scroll top // Calculate new scroll top
@ -55,15 +89,19 @@
}) })
// Hover row under cursor // Hover row under cursor
const y = clientY - $bounds.top + (newScrollTop % $rowHeight) if (clientY != null) {
const hoveredRow = $renderedRows[Math.floor(y / $rowHeight)] const y = clientY - $bounds.top + (newScrollTop % $rowHeight)
hoveredRowId.set(hoveredRow?._id) const hoveredRow = $renderedRows[Math.floor(y / $rowHeight)]
hoveredRowId.set(hoveredRow?._id)
}
}) })
</script> </script>
<div <div
class="outer" class="outer"
on:wheel={wheelInteractive ? handleWheel : null} on:wheel={attachHandlers ? handleWheel : null}
on:touchstart={attachHandlers ? handleTouchStart : null}
on:touchmove={attachHandlers ? handleTouchMove : null}
on:click|self={() => ($focusedCellId = null)} on:click|self={() => ($focusedCellId = null)}
> >
<div {style} class="inner"> <div {style} class="inner">

View File

@ -205,7 +205,7 @@
{/if} {/if}
</div> </div>
<div class="normal-columns" transition:fade|local={{ duration: 130 }}> <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally wheelInteractive> <GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row"> <div class="row">
{#each $renderedColumns as column, columnIdx} {#each $renderedColumns as column, columnIdx}
{@const cellId = `new-${column.name}`} {@const cellId = `new-${column.name}`}

View File

@ -64,7 +64,7 @@
</div> </div>
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}> <div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
<GridScrollWrapper scrollVertically wheelInteractive> <GridScrollWrapper scrollVertically attachHandlers>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]} {@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id} {@const rowHovered = $hoveredRowId === row._id}
@ -74,6 +74,7 @@
class="row" class="row"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", row)}
> >
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} /> <GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
{#if $stickyColumn} {#if $stickyColumn}

View File

@ -53,18 +53,27 @@
} }
} }
const getLocation = e => {
return {
y: e.touches?.[0]?.clientY ?? e.clientY,
x: e.touches?.[0]?.clientX ?? e.clientX,
}
}
// V scrollbar drag handlers // V scrollbar drag handlers
const startVDragging = e => { const startVDragging = e => {
e.preventDefault() e.preventDefault()
initialMouse = e.clientY initialMouse = getLocation(e).y
initialScroll = $scrollTop initialScroll = $scrollTop
document.addEventListener("mousemove", moveVDragging) document.addEventListener("mousemove", moveVDragging)
document.addEventListener("touchmove", moveVDragging)
document.addEventListener("mouseup", stopVDragging) document.addEventListener("mouseup", stopVDragging)
document.addEventListener("touchend", stopVDragging)
isDraggingV = true isDraggingV = true
closeMenu() closeMenu()
} }
const moveVDragging = domDebounce(e => { const moveVDragging = domDebounce(e => {
const delta = e.clientY - initialMouse const delta = getLocation(e).y - initialMouse
const weight = delta / availHeight const weight = delta / availHeight
const newScrollTop = initialScroll + weight * $maxScrollTop const newScrollTop = initialScroll + weight * $maxScrollTop
scroll.update(state => ({ scroll.update(state => ({
@ -74,22 +83,26 @@
}) })
const stopVDragging = () => { const stopVDragging = () => {
document.removeEventListener("mousemove", moveVDragging) document.removeEventListener("mousemove", moveVDragging)
document.removeEventListener("touchmove", moveVDragging)
document.removeEventListener("mouseup", stopVDragging) document.removeEventListener("mouseup", stopVDragging)
document.removeEventListener("touchend", stopVDragging)
isDraggingV = false isDraggingV = false
} }
// H scrollbar drag handlers // H scrollbar drag handlers
const startHDragging = e => { const startHDragging = e => {
e.preventDefault() e.preventDefault()
initialMouse = e.clientX initialMouse = getLocation(e).x
initialScroll = $scrollLeft initialScroll = $scrollLeft
document.addEventListener("mousemove", moveHDragging) document.addEventListener("mousemove", moveHDragging)
document.addEventListener("touchmove", moveHDragging)
document.addEventListener("mouseup", stopHDragging) document.addEventListener("mouseup", stopHDragging)
document.addEventListener("touchend", stopHDragging)
isDraggingH = true isDraggingH = true
closeMenu() closeMenu()
} }
const moveHDragging = domDebounce(e => { const moveHDragging = domDebounce(e => {
const delta = e.clientX - initialMouse const delta = getLocation(e).x - initialMouse
const weight = delta / availWidth const weight = delta / availWidth
const newScrollLeft = initialScroll + weight * $maxScrollLeft const newScrollLeft = initialScroll + weight * $maxScrollLeft
scroll.update(state => ({ scroll.update(state => ({
@ -99,7 +112,9 @@
}) })
const stopHDragging = () => { const stopHDragging = () => {
document.removeEventListener("mousemove", moveHDragging) document.removeEventListener("mousemove", moveHDragging)
document.removeEventListener("touchmove", moveHDragging)
document.removeEventListener("mouseup", stopHDragging) document.removeEventListener("mouseup", stopHDragging)
document.removeEventListener("touchend", stopHDragging)
isDraggingH = false isDraggingH = false
} }
</script> </script>
@ -109,6 +124,7 @@
class="v-scrollbar" class="v-scrollbar"
style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;" style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;"
on:mousedown={startVDragging} on:mousedown={startVDragging}
on:touchstart={startVDragging}
class:dragging={isDraggingV} class:dragging={isDraggingV}
/> />
{/if} {/if}
@ -117,6 +133,7 @@
class="h-scrollbar" class="h-scrollbar"
style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;" style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;"
on:mousedown={startHDragging} on:mousedown={startHDragging}
on:touchstart={startHDragging}
class:dragging={isDraggingH} class:dragging={isDraggingH}
/> />
{/if} {/if}

View File

@ -1,4 +1,5 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { Helpers } from "@budibase/bbui"
export const createStores = () => { export const createStores = () => {
const copiedCell = writable(null) const copiedCell = writable(null)
@ -12,7 +13,16 @@ export const createActions = context => {
const { copiedCell, focusedCellAPI } = context const { copiedCell, focusedCellAPI } = context
const copy = () => { const copy = () => {
copiedCell.set(get(focusedCellAPI)?.getValue()) const value = get(focusedCellAPI)?.getValue()
copiedCell.set(value)
// Also copy a stringified version to the clipboard
let stringified = ""
if (value != null && value !== "") {
// Only conditionally stringify to avoid redundant quotes around text
stringified = typeof value === "object" ? JSON.stringify(value) : value
}
Helpers.copyToClipboard(stringified)
} }
const paste = () => { const paste = () => {

View File

@ -1,6 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import DataFetch from "./DataFetch.js" import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { LuceneUtils } from "../utils"
export default class UserFetch extends DataFetch { export default class UserFetch extends DataFetch {
constructor(opts) { constructor(opts) {
@ -27,16 +28,25 @@ export default class UserFetch extends DataFetch {
} }
async getData() { async getData() {
const { limit, paginate } = this.options
const { cursor, query } = get(this.store) const { cursor, query } = get(this.store)
let finalQuery
// convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest } = query
if (!LuceneUtils.hasFilters(query) && rest.email) {
finalQuery = { string: { email: rest.email } }
} else {
finalQuery = rest
}
try { try {
// "query" normally contains a lucene query, but users uses a non-standard const opts = {
// search endpoint so we use query uniquely here bookmark: cursor,
const res = await this.API.searchUsers({ query: finalQuery,
page: cursor, appId: appId,
email: query.email, paginate: paginated || paginate,
appId: query.appId, limit,
paginated: query.paginated, }
}) const res = await this.API.searchUsers(opts)
return { return {
rows: res?.data || [], rows: res?.data || [],
hasNextPage: res?.hasNextPage || false, hasNextPage: res?.hasNextPage || false,

View File

@ -23,7 +23,10 @@ describe("/applications/:appId/import", () => {
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.message).toBe("app updated") const appPackage = await config.api.application.get(appId!)
expect(appPackage.navigation?.links?.length).toBe(2)
expect(expect(appPackage.navigation?.links?.[0].url).toBe("/blank"))
expect(expect(appPackage.navigation?.links?.[1].url).toBe("/derp"))
const screens = await config.api.screen.list() const screens = await config.api.screen.list()
expect(screens.length).toBe(2) expect(screens.length).toBe(2)
expect(screens[0].routing.route).toBe("/derp") expect(screens[0].routing.route).toBe("/derp")

View File

@ -16,6 +16,7 @@ import {
RelationshipType, RelationshipType,
Row, Row,
SaveTableRequest, SaveTableRequest,
SearchQueryOperators,
SortOrder, SortOrder,
SortType, SortType,
StaticQuotaName, StaticQuotaName,
@ -1141,7 +1142,9 @@ describe.each([
) )
const createViewResponse = await config.createView({ const createViewResponse = await config.createView({
query: [{ operator: "equal", field: "age", value: 40 }], query: [
{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 },
],
schema: viewSchema, schema: viewSchema,
}) })

View File

@ -3,6 +3,7 @@ import {
CreateViewRequest, CreateViewRequest,
FieldSchema, FieldSchema,
FieldType, FieldType,
SearchQueryOperators,
SortOrder, SortOrder,
SortType, SortType,
Table, Table,
@ -89,7 +90,13 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: generator.word(), primaryDisplay: generator.word(),
query: [{ operator: "equal", field: "field", value: "value" }], query: [
{
operator: SearchQueryOperators.EQUAL,
field: "field",
value: "value",
},
],
sort: { sort: {
field: "fieldToSort", field: "fieldToSort",
order: SortOrder.DESCENDING, order: SortOrder.DESCENDING,
@ -184,7 +191,13 @@ describe.each([
const tableId = table._id! const tableId = table._id!
await config.api.viewV2.update({ await config.api.viewV2.update({
...view, ...view,
query: [{ operator: "equal", field: "newField", value: "thatValue" }], query: [
{
operator: SearchQueryOperators.EQUAL,
field: "newField",
value: "thatValue",
},
],
}) })
expect((await config.api.table.get(tableId)).views).toEqual({ expect((await config.api.table.get(tableId)).views).toEqual({
@ -207,7 +220,7 @@ describe.each([
primaryDisplay: generator.word(), primaryDisplay: generator.word(),
query: [ query: [
{ {
operator: "equal", operator: SearchQueryOperators.EQUAL,
field: generator.word(), field: generator.word(),
value: generator.word(), value: generator.word(),
}, },
@ -279,7 +292,13 @@ describe.each([
{ {
...view, ...view,
tableId: generator.guid(), tableId: generator.guid(),
query: [{ operator: "equal", field: "newField", value: "thatValue" }], query: [
{
operator: SearchQueryOperators.EQUAL,
field: "newField",
value: "thatValue",
},
],
}, },
{ expectStatus: 404 } { expectStatus: 404 }
) )

View File

@ -4,6 +4,8 @@ import {
Document, Document,
Database, Database,
RowValue, RowValue,
DocumentType,
App,
} from "@budibase/types" } from "@budibase/types"
import backups from "../backups" import backups from "../backups"
@ -12,9 +14,39 @@ export type FileAttributes = {
path: string path: string
} }
async function getNewAppMetadata(
tempDb: Database,
appDb: Database
): Promise<App> {
// static doc denoting app information
const docId = DocumentType.APP_METADATA
try {
const [tempMetadata, appMetadata] = await Promise.all([
tempDb.get<App>(docId),
appDb.get<App>(docId),
])
return {
...appMetadata,
automationErrors: undefined,
theme: tempMetadata.theme,
customTheme: tempMetadata.customTheme,
features: tempMetadata.features,
icon: tempMetadata.icon,
navigation: tempMetadata.navigation,
type: tempMetadata.type,
version: tempMetadata.version,
}
} catch (err: any) {
throw new Error(
`Unable to retrieve app metadata for import - ${err.message}`
)
}
}
function mergeUpdateAndDeleteDocuments( function mergeUpdateAndDeleteDocuments(
updateDocs: Document[], updateDocs: Document[],
deleteDocs: Document[] deleteDocs: Document[],
metadata: App
) { ) {
// compress the documents to create and to delete (if same ID, then just update the rev) // compress the documents to create and to delete (if same ID, then just update the rev)
const finalToDelete = [] const finalToDelete = []
@ -26,7 +58,7 @@ function mergeUpdateAndDeleteDocuments(
finalToDelete.push(deleteDoc) finalToDelete.push(deleteDoc)
} }
} }
return [...updateDocs, ...finalToDelete] return [...updateDocs, ...finalToDelete, metadata]
} }
async function removeImportableDocuments(db: Database) { async function removeImportableDocuments(db: Database) {
@ -90,12 +122,15 @@ export async function updateWithExport(
await backups.importApp(devId, tempDb, template, { await backups.importApp(devId, tempDb, template, {
importObjStoreContents: false, importObjStoreContents: false,
}) })
const newMetadata = await getNewAppMetadata(tempDb, appDb)
// get the documents to copy // get the documents to copy
const toUpdate = await getImportableDocuments(tempDb) const toUpdate = await getImportableDocuments(tempDb)
// clear out the old documents // clear out the old documents
const toDelete = await removeImportableDocuments(appDb) const toDelete = await removeImportableDocuments(appDb)
// now bulk update documents - add new ones, delete old ones and update common ones // now bulk update documents - add new ones, delete old ones and update common ones
await appDb.bulkDocs(mergeUpdateAndDeleteDocuments(toUpdate, toDelete)) await appDb.bulkDocs(
mergeUpdateAndDeleteDocuments(toUpdate, toDelete, newMetadata)
)
} finally { } finally {
await tempDb.destroy() await tempDb.destroy()
} }

View File

@ -0,0 +1,18 @@
import { App } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
export class ApplicationAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
get = async (appId: string): Promise<App> => {
const result = await this.request
.get(`/api/applications/${appId}/appPackage`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return result.body.application as App
}
}

View File

@ -6,6 +6,7 @@ import { ViewV2API } from "./viewV2"
import { DatasourceAPI } from "./datasource" import { DatasourceAPI } from "./datasource"
import { LegacyViewAPI } from "./legacyView" import { LegacyViewAPI } from "./legacyView"
import { ScreenAPI } from "./screen" import { ScreenAPI } from "./screen"
import { ApplicationAPI } from "./application"
export default class API { export default class API {
table: TableAPI table: TableAPI
@ -15,6 +16,7 @@ export default class API {
permission: PermissionAPI permission: PermissionAPI
datasource: DatasourceAPI datasource: DatasourceAPI
screen: ScreenAPI screen: ScreenAPI
application: ApplicationAPI
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.table = new TableAPI(config) this.table = new TableAPI(config)
@ -24,5 +26,6 @@ export default class API {
this.permission = new PermissionAPI(config) this.permission = new PermissionAPI(config)
this.datasource = new DatasourceAPI(config) this.datasource = new DatasourceAPI(config)
this.screen = new ScreenAPI(config) this.screen = new ScreenAPI(config)
this.application = new ApplicationAPI(config)
} }
} }

View File

@ -1,12 +1,13 @@
import { import {
Datasource, Datasource,
FieldSubtype,
FieldType, FieldType,
SortDirection,
SortType,
SearchFilter, SearchFilter,
SearchQuery, SearchQuery,
SearchQueryFields, SearchQueryFields,
FieldSubtype, SearchQueryOperators,
SortDirection,
SortType,
} from "@budibase/types" } from "@budibase/types"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
import { deepGet } from "./helpers" import { deepGet } from "./helpers"
@ -273,22 +274,30 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
} }
// Process a string match (fails if the value does not start with the string) // Process a string match (fails if the value does not start with the string)
const stringMatch = match("string", (docValue: string, testValue: string) => { const stringMatch = match(
return ( SearchQueryOperators.STRING,
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) (docValue: string, testValue: string) => {
) return (
}) !docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
)
// Process a fuzzy match (treat the same as starts with when running locally) // Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (docValue: string, testValue: string) => { const fuzzyMatch = match(
return ( SearchQueryOperators.FUZZY,
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase()) (docValue: string, testValue: string) => {
) return (
}) !docValue ||
!docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
}
)
// Process a range match // Process a range match
const rangeMatch = match( const rangeMatch = match(
"range", SearchQueryOperators.RANGE,
( (
docValue: string | number | null, docValue: string | number | null,
testValue: { low: number; high: number } testValue: { low: number; high: number }
@ -304,7 +313,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process an equal match (fails if the value is different) // Process an equal match (fails if the value is different)
const equalMatch = match( const equalMatch = match(
"equal", SearchQueryOperators.EQUAL,
(docValue: any, testValue: string | null) => { (docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue !== testValue return testValue != null && testValue !== "" && docValue !== testValue
} }
@ -312,46 +321,58 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
// Process a not-equal match (fails if the value is the same) // Process a not-equal match (fails if the value is the same)
const notEqualMatch = match( const notEqualMatch = match(
"notEqual", SearchQueryOperators.NOT_EQUAL,
(docValue: any, testValue: string | null) => { (docValue: any, testValue: string | null) => {
return testValue != null && testValue !== "" && docValue === testValue return testValue != null && testValue !== "" && docValue === testValue
} }
) )
// Process an empty match (fails if the value is not empty) // Process an empty match (fails if the value is not empty)
const emptyMatch = match("empty", (docValue: string | null) => { const emptyMatch = match(
return docValue != null && docValue !== "" SearchQueryOperators.EMPTY,
}) (docValue: string | null) => {
return docValue != null && docValue !== ""
}
)
// Process a not-empty match (fails is the value is empty) // Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match("notEmpty", (docValue: string | null) => { const notEmptyMatch = match(
return docValue == null || docValue === "" SearchQueryOperators.NOT_EMPTY,
}) (docValue: string | null) => {
return docValue == null || docValue === ""
}
)
// Process an includes match (fails if the value is not included) // Process an includes match (fails if the value is not included)
const oneOf = match("oneOf", (docValue: any, testValue: any) => { const oneOf = match(
if (typeof testValue === "string") { SearchQueryOperators.ONE_OF,
testValue = testValue.split(",") (docValue: any, testValue: any) => {
if (typeof docValue === "number") { if (typeof testValue === "string") {
testValue = testValue.map((item: string) => parseFloat(item)) testValue = testValue.split(",")
if (typeof docValue === "number") {
testValue = testValue.map((item: string) => parseFloat(item))
}
} }
return !testValue?.includes(docValue)
} }
return !testValue?.includes(docValue) )
})
const containsAny = match("containsAny", (docValue: any, testValue: any) => { const containsAny = match(
return !docValue?.includes(...testValue) SearchQueryOperators.CONTAINS_ANY,
}) (docValue: any, testValue: any) => {
return !docValue?.includes(...testValue)
}
)
const contains = match( const contains = match(
"contains", SearchQueryOperators.CONTAINS,
(docValue: string | any[], testValue: any[]) => { (docValue: string | any[], testValue: any[]) => {
return !testValue?.every((item: any) => docValue?.includes(item)) return !testValue?.every((item: any) => docValue?.includes(item))
} }
) )
const notContains = match( const notContains = match(
"notContains", SearchQueryOperators.NOT_CONTAINS,
(docValue: string | any[], testValue: any[]) => { (docValue: string | any[], testValue: any[]) => {
return testValue?.every((item: any) => docValue?.includes(item)) return testValue?.every((item: any) => docValue?.includes(item))
} }
@ -433,7 +454,7 @@ export const hasFilters = (query?: SearchQuery) => {
if (skipped.includes(key) || typeof value !== "object") { if (skipped.includes(key) || typeof value !== "object") {
continue continue
} }
if (Object.keys(value).length !== 0) { if (Object.keys(value || {}).length !== 0) {
return true return true
} }
} }

View File

@ -14,5 +14,5 @@ export function isSQL(datasource: Datasource): boolean {
SourceName.MYSQL, SourceName.MYSQL,
SourceName.ORACLE, SourceName.ORACLE,
] ]
return SQL.indexOf(datasource.source) !== -1 return SQL.indexOf(datasource.source) !== -1 || datasource.isSQL === true
} }

View File

@ -10,43 +10,57 @@ export type SearchFilter = {
externalType?: string externalType?: string
} }
export enum SearchQueryOperators {
STRING = "string",
FUZZY = "fuzzy",
RANGE = "range",
EQUAL = "equal",
NOT_EQUAL = "notEqual",
EMPTY = "empty",
NOT_EMPTY = "notEmpty",
ONE_OF = "oneOf",
CONTAINS = "contains",
NOT_CONTAINS = "notContains",
CONTAINS_ANY = "containsAny",
}
export type SearchQuery = { export type SearchQuery = {
allOr?: boolean allOr?: boolean
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
string?: { [SearchQueryOperators.STRING]?: {
[key: string]: string [key: string]: string
} }
fuzzy?: { [SearchQueryOperators.FUZZY]?: {
[key: string]: string [key: string]: string
} }
range?: { [SearchQueryOperators.RANGE]?: {
[key: string]: { [key: string]: {
high: number | string high: number | string
low: number | string low: number | string
} }
} }
equal?: { [SearchQueryOperators.EQUAL]?: {
[key: string]: any [key: string]: any
} }
notEqual?: { [SearchQueryOperators.NOT_EQUAL]?: {
[key: string]: any [key: string]: any
} }
empty?: { [SearchQueryOperators.EMPTY]?: {
[key: string]: any [key: string]: any
} }
notEmpty?: { [SearchQueryOperators.NOT_EMPTY]?: {
[key: string]: any [key: string]: any
} }
oneOf?: { [SearchQueryOperators.ONE_OF]?: {
[key: string]: any[] [key: string]: any[]
} }
contains?: { [SearchQueryOperators.CONTAINS]?: {
[key: string]: any[] [key: string]: any[]
} }
notContains?: { [SearchQueryOperators.NOT_CONTAINS]?: {
[key: string]: any[] [key: string]: any[]
} }
containsAny?: { [SearchQueryOperators.CONTAINS_ANY]?: {
[key: string]: any[] [key: string]: any[]
} }
} }

View File

@ -1,4 +1,5 @@
import { User } from "../../documents" import { User } from "../../documents"
import { SearchQuery } from "./searchFilter"
export interface SaveUserResponse { export interface SaveUserResponse {
_id: string _id: string
@ -51,10 +52,10 @@ export interface InviteUsersResponse {
} }
export interface SearchUsersRequest { export interface SearchUsersRequest {
page?: string bookmark?: string
email?: string query?: SearchQuery
appId?: string appId?: string
paginated?: boolean paginate?: boolean
} }
export interface CreateAdminUserRequest { export interface CreateAdminUserRequest {

View File

@ -9,6 +9,7 @@ export interface Datasource extends Document {
// the config is defined by the schema // the config is defined by the schema
config?: Record<string, any> config?: Record<string, any>
plus?: boolean plus?: boolean
isSQL?: boolean
entities?: { entities?: {
[key: string]: Table [key: string]: Table
} }

View File

@ -140,6 +140,7 @@ export interface DatasourceConfig {
export interface Integration { export interface Integration {
docs: string docs: string
plus?: boolean plus?: boolean
isSQL?: boolean
auth?: { type: string } auth?: { type: string }
features?: Partial<Record<DatasourceFeature, boolean>> features?: Partial<Record<DatasourceFeature, boolean>>
relationships?: boolean relationships?: boolean

View File

@ -197,7 +197,12 @@ export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
export const search = async (ctx: Ctx<SearchUsersRequest>) => { export const search = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body const body = ctx.request.body
if (body.paginated === false) { // TODO: for now only one supported search key, string.email
if (body?.query && !userSdk.core.isSupportedUserSearch(body.query)) {
ctx.throw(501, "Can only search by string.email or equal._id")
}
if (body.paginate === false) {
await getAppUsers(ctx) await getAppUsers(ctx)
} else { } else {
const paginated = await userSdk.core.paginatedUsers(body) const paginated = await userSdk.core.paginatedUsers(body)

View File

@ -11,7 +11,7 @@ import { TestConfiguration } from "../../../../tests"
import { events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
// this test can 409 - retries reduce issues with this // this test can 409 - retries reduce issues with this
jest.retryTimes(2) jest.retryTimes(2, { logErrorsBeforeRetry: true })
jest.setTimeout(30000) jest.setTimeout(30000)
mocks.licenses.useScimIntegration() mocks.licenses.useScimIntegration()

View File

@ -544,6 +544,36 @@ describe("/api/global/users", () => {
}) })
}) })
describe("POST /api/global/users/search", () => {
it("should be able to search by email", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: { string: { email: user.email } },
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0].email).toBe(user.email)
})
it("should be able to search by _id", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: { equal: { _id: user._id } },
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0]._id).toBe(user._id)
})
it("should throw an error when unimplemented options used", async () => {
const user = await config.createUser()
await config.api.users.searchUsers(
{
query: { equal: { firstName: user.firstName } },
},
501
)
})
})
describe("DELETE /api/global/users/:userId", () => { describe("DELETE /api/global/users/:userId", () => {
it("should be able to destroy a basic user", async () => { it("should be able to destroy a basic user", async () => {
const user = await config.createUser() const user = await config.createUser()

View File

@ -4,6 +4,7 @@ import {
InviteUsersRequest, InviteUsersRequest,
User, User,
CreateAdminUserRequest, CreateAdminUserRequest,
SearchQuery,
} from "@budibase/types" } from "@budibase/types"
import structures from "../structures" import structures from "../structures"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
@ -133,6 +134,15 @@ export class UserAPI extends TestAPI {
.expect(status ? status : 200) .expect(status ? status : 200)
} }
searchUsers = ({ query }: { query?: SearchQuery }, status = 200) => {
return this.request
.post("/api/global/users/search")
.set(this.config.defaultHeaders())
.send({ query })
.expect("Content-Type", /json/)
.expect(status ? status : 200)
}
getUser = (userId: string, opts?: TestAPIOpts) => { getUser = (userId: string, opts?: TestAPIOpts) => {
return this.request return this.request
.get(`/api/global/users/${userId}`) .get(`/api/global/users/${userId}`)

View File

@ -21750,7 +21750,7 @@ vlq@^0.2.2:
resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
vm2@^3.9.19: vm2@^3.9.19, vm2@^3.9.8:
version "3.9.19" version "3.9.19"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a" resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a"
integrity sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg== integrity sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==