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:
commit
37fe91e488
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -3,7 +3,6 @@ name: deploy-featurebranch
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
|
||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
@ -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 }}
|
|
||||||
|
|
|
@ -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 }}
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.11.32",
|
"version": "2.11.34",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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`,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}`}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}`}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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}`)
|
||||||
|
|
|
@ -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==
|
||||||
|
|
Loading…
Reference in New Issue