Merge branch 'master' into master

This commit is contained in:
Michael Drury 2023-11-21 12:15:42 +00:00 committed by GitHub
commit 2b37152157
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
265 changed files with 4090 additions and 2708 deletions

View File

@ -19,6 +19,7 @@
"bundle.js" "bundle.js"
], ],
"extends": ["eslint:recommended"], "extends": ["eslint:recommended"],
"plugins": ["import", "eslint-plugin-local-rules"],
"overrides": [ "overrides": [
{ {
"files": ["**/*.svelte"], "files": ["**/*.svelte"],
@ -30,7 +31,6 @@
"sourceType": "module", "sourceType": "module",
"allowImportExportEverywhere": true "allowImportExportEverywhere": true
} }
}, },
{ {
"files": ["**/*.ts"], "files": ["**/*.ts"],
@ -42,13 +42,22 @@
"no-case-declarations": "off", "no-case-declarations": "off",
"no-useless-escape": "off", "no-useless-escape": "off",
"no-undef": "off", "no-undef": "off",
"no-prototype-builtins": "off" "no-prototype-builtins": "off",
"local-rules/no-budibase-imports": "error"
} }
} }
], ],
"rules": { "rules": {
"no-self-assign": "off", "no-self-assign": "off",
"no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_", "destructuredArrayIgnorePattern": "^_" }] "no-unused-vars": [
"error",
{
"varsIgnorePattern": "^_",
"argsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}
],
"import/no-relative-packages": "error"
}, },
"globals": { "globals": {
"GeolocationPositionError": true "GeolocationPositionError": true

View File

@ -12,6 +12,13 @@ on:
- master - master
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
workflow_call:
inputs:
run_as_oss:
type: boolean
required: false
description: Force running checks as if it was an OSS contributor
default: false
env: env:
BRANCH: ${{ github.event.pull_request.head.ref }} BRANCH: ${{ github.event.pull_request.head.ref }}
@ -19,50 +26,41 @@ env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
NX_BASE_BRANCH: origin/${{ github.base_ref }} NX_BASE_BRANCH: origin/${{ github.base_ref }}
USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' }} USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' }}
IS_OSS_CONTRIBUTOR: ${{ inputs.run_as_oss == true || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase') }}
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- run: yarn lint - run: yarn lint
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
# Run build all the projects # Run build all the projects
@ -81,24 +79,18 @@ jobs:
test-libraries: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Test - name: Test
run: | run: |
@ -116,24 +108,18 @@ jobs:
test-worker: test-worker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Test worker - name: Test worker
run: | run: |
@ -152,24 +138,18 @@ jobs:
test-server: test-server:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Test server - name: Test server
run: | run: |
@ -200,7 +180,7 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Test - name: Test
run: | run: |
@ -213,24 +193,23 @@ jobs:
integration-test: integration-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Build packages - name: Build packages
run: yarn build --scope @budibase/server --scope @budibase/worker run: yarn build --scope @budibase/server --scope @budibase/worker
- name: Build backend-core for OSS contributor (required for pro)
if: ${{ env.IS_OSS_CONTRIBUTOR == 'true' }}
run: yarn build --scope @budibase/backend-core
- name: Run tests - name: Run tests
run: | run: |
cd qa-core cd qa-core

View File

@ -0,0 +1,35 @@
name: OSS contributor checks
on:
workflow_dispatch:
schedule:
- cron: "0 8,16 * * 1-5" # on weekdays at 8am and 4pm
jobs:
run-checks:
name: Publish server and worker docker images
uses: ./.github/workflows/budibase_ci.yml
with:
run_as_oss: true
secrets: inherit
notify-error:
needs: ["run-checks"]
if: ${{ failure() }}
name: Notify error
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set commit SHA
id: set_sha
run: echo "::set-output name=sha::$(git rev-parse --short ${{ github.sha }})"
- name: Notify error
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.OSS_CHECKS_WEBHOOK_URL }}
embed-title: 🚨 OSS checks failed in master
embed-url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
Git sha: `${{ steps.set_sha.outputs.sha }}`

View File

@ -1,48 +0,0 @@
name: Budibase Deploy Production
on:
workflow_dispatch:
inputs:
version:
description: Budibase release version. For example - 1.0.0
required: false
jobs:
release:
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: |
if [ -z "${{ github.event.inputs.version }}" ]; then
release_version=$(cat lerna.json | jq -r '.version')
else
release_version=${{ github.event.inputs.version }}
fi
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-prod-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -7,6 +7,7 @@ on:
jobs: jobs:
release: release:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3

View File

@ -1,178 +0,0 @@
name: Budibase Release
concurrency:
group: release
cancel-in-progress: false
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
# Exclude all pre-releases
- "!*[0-9]+.[0-9]+.[0-9]+-*"
env:
# Posthog token used by ui at build time
POSTHOG_TOKEN: phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs:
release-images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
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
- uses: actions/setup-node@v1
with:
node-version: 18.x
cache: yarn
- run: yarn install --frozen-lockfile
- name: Update versions
run: ./scripts/updateVersions.sh
- run: yarn lint
- 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. I tend to use 'GitHub Actions Bot' with no email by default
git config --global user.name "Budibase 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
- name: "Get Current tag"
id: currenttag
run: |
version=$(./scripts/getCurrentVersion.sh)
echo "Using tag $version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Docker login
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
- name: Build worker docker
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
file: ./packages/worker/Dockerfile.v2
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
env:
IMAGE_NAME: budibase/worker
IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }}
- name: Build server docker
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
file: ./packages/server/Dockerfile.v2
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
env:
IMAGE_NAME: budibase/apps
IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }}
- name: Build proxy docker
uses: docker/build-push-action@v5
with:
context: ./hosting/proxy
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
file: ./hosting/proxy/Dockerfile
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
env:
IMAGE_NAME: budibase/proxy
IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
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
- 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
# 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-master --app-version "$RELEASE_VERSION" --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: ${{ env.RELEASE_VERSION }}"
git push
trigger-deploy-to-qa-env:
needs: [release-helm-chart]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: peter-evans/repository-dispatch@v2
with:
repository: budibase/budibase-deploys
event-type: budicloud-qa-deploy
token: ${{ secrets.GH_ACCESS_TOKEN }}
client-payload: |-
{
"VERSION": "${{ github.ref_name }}",
"REF_NAME": "${{ github.ref_name}}"
}

View File

@ -1,125 +0,0 @@
name: Budibase Release Selfhost
on:
workflow_dispatch:
jobs:
release:
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 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: Use Node.js 18.x
uses: actions/setup-node@v1
with:
node-version: 18.x
- 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
- name: Tag and release Docker images (Self Host)
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
release_tag=${{ env.RELEASE_VERSION }}
# Pull apps and worker images
docker pull budibase/apps:$release_tag
docker pull budibase/worker:$release_tag
docker pull budibase/proxy:$release_tag
# Tag apps and worker images
docker tag budibase/apps:$release_tag budibase/apps:$SELFHOST_TAG
docker tag budibase/worker:$release_tag budibase/worker:$SELFHOST_TAG
docker tag budibase/proxy:$release_tag budibase/proxy:$SELFHOST_TAG
# Push images
docker push budibase/apps:$SELFHOST_TAG
docker push budibase/worker:$SELFHOST_TAG
docker push budibase/proxy:$SELFHOST_TAG
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
SELFHOST_TAG: latest
- name: Bootstrap and build (CLI)
run: |
yarn
yarn build
- name: Build OpenAPI spec
run: |
pushd packages/server
yarn
yarn specs
popd
- 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 "$RELEASE_VERSION" --app-version "$RELEASE_VERSION" --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: ${{ env.RELEASE_VERSION }}"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Perform Github Release
uses: softprops/action-gh-release@v1
with:
name: ${{ env.RELEASE_VERSION }}
tag_name: ${{ env.RELEASE_VERSION }}
generate_release_notes: true
files: |
packages/cli/build/cli-win.exe
packages/cli/build/cli-linux
packages/cli/build/cli-macos
packages/server/specs/openapi.yaml
packages/server/specs/openapi.json
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Self Host Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Self Host."
embed-title: ${{ env.RELEASE_VERSION }}

View File

@ -1,86 +0,0 @@
name: Deploy Budibase Single Container Image to DockerHub
on:
workflow_dispatch:
env:
CI: true
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
REGISTRY_URL: registry.hub.docker.com
jobs:
build:
name: "build"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Maximize build space
uses: easimon/maximize-build-space@master
with:
root-reserve-mb: 30000
swap-size-mb: 1024
remove-android: "true"
remove-dotnet: "true"
- 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
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Get the latest release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo $release_version
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Budibase service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile.v2
env:
BUDIBASE_VERSION: ${{ env.RELEASE_VERSION }}
- name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64
build-args: |
TARGETBUILD=aas
BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile.v2
env:
BUDIBASE_VERSION: ${{ env.RELEASE_VERSION }}

View File

@ -45,8 +45,8 @@ jobs:
BUMP_TYPE=${BUMP_TYPE_INPUT:-"patch"} BUMP_TYPE=${BUMP_TYPE_INPUT:-"patch"}
./versionCommit.sh $BUMP_TYPE ./versionCommit.sh $BUMP_TYPE
cd ..
new_version=$(./getCurrentVersion.sh) new_version=$(./scripts/getCurrentVersion.sh)
echo "version=$new_version" >> $GITHUB_OUTPUT echo "version=$new_version" >> $GITHUB_OUTPUT
trigger-release: trigger-release:

View File

@ -1,13 +1,11 @@
node_modules node_modules
dist dist
*.spec.js
packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte
packages/server/builder packages/server/builder
packages/server/coverage packages/server/coverage
packages/worker/coverage
packages/backend-core/coverage
packages/server/client packages/server/client
packages/server/src/definitions/openapi.ts packages/server/src/definitions/openapi.ts
packages/worker/coverage
packages/backend-core/coverage
packages/builder/.routify packages/builder/.routify
packages/sdk/sdk packages/sdk/sdk
packages/pro/coverage packages/pro/coverage

View File

@ -46,11 +46,9 @@ spec:
image: minio/minio image: minio/minio
imagePullPolicy: "" imagePullPolicy: ""
livenessProbe: livenessProbe:
exec: httpGet:
command: path: /minio/health/live
- curl port: 9000
- -f
- http://localhost:9000/minio/health/live
failureThreshold: 3 failureThreshold: 3
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 20 timeoutSeconds: 20

View File

@ -0,0 +1,21 @@
module.exports = {
"no-budibase-imports": {
create: function (context) {
return {
ImportDeclaration(node) {
const importPath = node.source.value
if (
/^@budibase\/[^/]+\/.*$/.test(importPath) &&
importPath !== "@budibase/backend-core/tests"
) {
context.report({
node,
message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests.`,
})
}
},
}
},
},
}

View File

@ -2,8 +2,8 @@
echo ${TARGETBUILD} > /buildtarget.txt echo ${TARGETBUILD} > /buildtarget.txt
if [[ "${TARGETBUILD}" = "aas" ]]; then if [[ "${TARGETBUILD}" = "aas" ]]; then
# Azure AppService uses /home for persisent data & SSH on port 2222 # Azure AppService uses /home for persistent data & SSH on port 2222
DATA_DIR=/home DATA_DIR="${DATA_DIR:-/home}"
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true WEBSITES_ENABLE_APP_SERVICE_STORAGE=true
mkdir -p $DATA_DIR/{search,minio,couch} mkdir -p $DATA_DIR/{search,minio,couch}
mkdir -p $DATA_DIR/couch/{dbs,views} mkdir -p $DATA_DIR/couch/{dbs,views}

View File

@ -2,16 +2,18 @@ server {
listen 443 ssl default_server; listen 443 ssl default_server;
listen [::]:443 ssl default_server; listen [::]:443 ssl default_server;
server_name _; server_name _;
ssl_certificate /etc/letsencrypt/live/CUSTOM_DOMAIN/fullchain.pem; error_log /dev/stderr warn;
ssl_certificate_key /etc/letsencrypt/live/CUSTOM_DOMAIN/privkey.pem; access_log /dev/stdout main;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 1000m; client_max_body_size 1000m;
ignore_invalid_headers off; ignore_invalid_headers off;
proxy_buffering off; proxy_buffering off;
# port_in_redirect off; # port_in_redirect off;
ssl_certificate /etc/letsencrypt/live/CUSTOM_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/CUSTOM_DOMAIN/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location ^~ /.well-known/acme-challenge/ { location ^~ /.well-known/acme-challenge/ {
default_type "text/plain"; default_type "text/plain";
root /var/www/html; root /var/www/html;
@ -47,6 +49,24 @@ server {
rewrite ^/worker/(.*)$ /$1 break; rewrite ^/worker/(.*)$ /$1 break;
} }
location /api/backups/ {
# calls to export apps are limited
limit_req zone=ratelimit burst=20 nodelay;
# 1800s timeout for app export requests
proxy_read_timeout 1800s;
proxy_connect_timeout 1800s;
proxy_send_timeout 1800s;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:4001;
}
location /api/ { location /api/ {
# calls to the API are rate limited with bursting # calls to the API are rate limited with bursting
limit_req zone=ratelimit burst=20 nodelay; limit_req zone=ratelimit burst=20 nodelay;
@ -70,18 +90,49 @@ server {
rewrite ^/db/(.*)$ /$1 break; rewrite ^/db/(.*)$ /$1 break;
} }
location /socket/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_pass http://127.0.0.1:4001;
}
location / { location / {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 300; proxy_connect_timeout 300;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; proxy_set_header Connection "";
chunked_transfer_encoding off; chunked_transfer_encoding off;
proxy_pass http://127.0.0.1:9000; proxy_pass http://127.0.0.1:9000;
} }
location /files/signed/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# IMPORTANT: Signed urls will inspect the host header of the request.
# Normally a signed url will need to be generated with a specified client host in mind.
# To support dynamic hosts, e.g. some unknown self-hosted installation url,
# use a predefined host header. The host 'minio-service' is also used at the time of url signing.
proxy_set_header Host minio-service;
proxy_connect_timeout 300;
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
proxy_pass http://127.0.0.1:9000;
rewrite ^/files/signed/(.*)$ /$1 break;
}
client_header_timeout 60; client_header_timeout 60;
client_body_timeout 60; client_body_timeout 60;
keepalive_timeout 60; keepalive_timeout 60;

View File

@ -2,8 +2,8 @@
echo ${TARGETBUILD} > /buildtarget.txt echo ${TARGETBUILD} > /buildtarget.txt
if [[ "${TARGETBUILD}" = "aas" ]]; then if [[ "${TARGETBUILD}" = "aas" ]]; then
# Azure AppService uses /home for persisent data & SSH on port 2222 # Azure AppService uses /home for persistent data & SSH on port 2222
DATA_DIR=/home DATA_DIR="${DATA_DIR:-/home}"
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true WEBSITES_ENABLE_APP_SERVICE_STORAGE=true
mkdir -p $DATA_DIR/{search,minio,couch} mkdir -p $DATA_DIR/{search,minio,couch}
mkdir -p $DATA_DIR/couch/{dbs,views} mkdir -p $DATA_DIR/couch/{dbs,views}

View File

@ -22,7 +22,7 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
# Azure App Service customisations # Azure App Service customisations
if [[ "${TARGETBUILD}" = "aas" ]]; then if [[ "${TARGETBUILD}" = "aas" ]]; then
DATA_DIR=/home DATA_DIR="${DATA_DIR:-/home}"
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true WEBSITES_ENABLE_APP_SERVICE_STORAGE=true
/etc/init.d/ssh start /etc/init.d/ssh start
else else

View File

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

View File

@ -2,11 +2,17 @@
"name": "root", "name": "root",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@babel/core": "^7.22.5",
"@babel/eslint-parser": "^7.22.5",
"@babel/preset-env": "^7.22.5",
"@esbuild-plugins/tsconfig-paths": "^0.1.2", "@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@typescript-eslint/parser": "6.7.2", "@typescript-eslint/parser": "6.7.2",
"esbuild": "^0.18.17", "esbuild": "^0.18.17",
"esbuild-node-externals": "^1.8.0", "esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0", "eslint": "^8.44.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-local-rules": "^2.0.0",
"eslint-plugin-svelte": "^2.32.2",
"husky": "^8.0.3", "husky": "^8.0.3",
"kill-port": "^1.6.1", "kill-port": "^1.6.1",
"lerna": "7.1.1", "lerna": "7.1.1",
@ -17,12 +23,8 @@
"prettier": "2.8.8", "prettier": "2.8.8",
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"svelte": "3.49.0", "svelte": "3.49.0",
"typescript": "5.2.2", "svelte-eslint-parser": "^0.32.0",
"@babel/core": "^7.22.5", "typescript": "5.2.2"
"@babel/eslint-parser": "^7.22.5",
"@babel/preset-env": "^7.22.5",
"eslint-plugin-svelte": "^2.32.2",
"svelte-eslint-parser": "^0.32.0"
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js", "preinstall": "node scripts/syncProPackage.js",

View File

@ -19,7 +19,7 @@ async function populateFromDB(appId: string) {
return doWithDB( return doWithDB(
appId, appId,
(db: Database) => { (db: Database) => {
return db.get(DocumentType.APP_METADATA) return db.get<App>(DocumentType.APP_METADATA)
}, },
{ skip_setup: true } { skip_setup: true }
) )

View File

@ -1,6 +1,6 @@
const BaseCache = require("./base") import BaseCache from "./base"
const GENERIC = new BaseCache.default() const GENERIC = new BaseCache()
export enum CacheKey { export enum CacheKey {
CHECKLIST = "checklist", CHECKLIST = "checklist",
@ -19,6 +19,7 @@ export enum TTL {
} }
function performExport(funcName: string) { function performExport(funcName: string) {
// @ts-ignore
return (...args: any) => GENERIC[funcName](...args) return (...args: any) => GENERIC[funcName](...args)
} }

View File

@ -2,4 +2,6 @@ export * as generic from "./generic"
export * as user from "./user" export * as user from "./user"
export * as app from "./appMetadata" export * as app from "./appMetadata"
export * as writethrough from "./writethrough" export * as writethrough from "./writethrough"
export * as invite from "./invite"
export * as passwordReset from "./passwordReset"
export * from "./generic" export * from "./generic"

View File

@ -0,0 +1,86 @@
import * as utils from "../utils"
import { Duration, DurationType } from "../utils"
import env from "../environment"
import { getTenantId } from "../context"
import * as redis from "../redis/init"
const TTL_SECONDS = Duration.fromDays(7).toSeconds()
interface Invite {
email: string
info: any
}
interface InviteWithCode extends Invite {
code: string
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param code The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateCode(code: string, value: Invite) {
const client = await redis.getInviteClient()
await client.store(code, value, TTL_SECONDS)
}
/**
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param email the email address which the code is being sent to (for use later).
* @param info Information to be carried along with the invitation.
* @return returns the code that was stored to redis.
*/
export async function createCode(email: string, info: any): Promise<string> {
const code = utils.newid()
const client = await redis.getInviteClient()
await client.store(code, { email, info }, TTL_SECONDS)
return code
}
/**
* Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param code the invite code that was provided as part of the link.
* @return If the code is valid then an email address will be returned.
*/
export async function getCode(code: string): Promise<Invite> {
const client = await redis.getInviteClient()
const value = (await client.get(code)) as Invite | undefined
if (!value) {
throw "Invitation is not valid or has expired, please request a new one."
}
return value
}
export async function deleteCode(code: string) {
const client = await redis.getInviteClient()
await client.delete(code)
}
/**
Get all currently available user invitations for the current tenant.
**/
export async function getInviteCodes(): Promise<InviteWithCode[]> {
const client = await redis.getInviteClient()
const invites: { key: string; value: Invite }[] = await client.scan()
const results: InviteWithCode[] = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
if (!env.MULTI_TENANCY) {
return results
}
const tenantId = getTenantId()
return results.filter(invite => tenantId === invite.info.tenantId)
}
export async function getExistingInvites(
emails: string[]
): Promise<InviteWithCode[]> {
return (await getInviteCodes()).filter(invite =>
emails.includes(invite.email)
)
}

View File

@ -0,0 +1,38 @@
import * as redis from "../redis/init"
import * as utils from "../utils"
import { Duration, DurationType } from "../utils"
const TTL_SECONDS = Duration.fromHours(1).toSeconds()
interface PasswordReset {
userId: string
info: any
}
/**
* Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link).
* @param userId the ID of the user which is to be reset.
* @param info Info about the user/the reset process.
* @return returns the code that was stored to redis.
*/
export async function createCode(userId: string, info: any): Promise<string> {
const code = utils.newid()
const client = await redis.getPasswordResetClient()
await client.store(code, { userId, info }, TTL_SECONDS)
return code
}
/**
* Given a reset code this will lookup to redis, check if the code is valid.
* @param code The code provided via the email link.
* @return returns the user ID if it is found
*/
export async function getCode(code: string): Promise<PasswordReset> {
const client = await redis.getPasswordResetClient()
const value = (await client.get(code)) as PasswordReset | undefined
if (!value) {
throw "Provided information is not valid, cannot reset password - please try again."
}
return value
}

View File

@ -28,7 +28,7 @@ export enum ViewName {
APP_BACKUP_BY_TRIGGER = "by_trigger", APP_BACKUP_BY_TRIGGER = "by_trigger",
} }
export const DeprecatedViews = { export const DeprecatedViews: Record<string, string[]> = {
[ViewName.USER_BY_EMAIL]: [ [ViewName.USER_BY_EMAIL]: [
// removed due to inaccuracy in view doc filter logic // removed due to inaccuracy in view doc filter logic
"by_email", "by_email",

View File

@ -4,7 +4,7 @@ import { ContextMap } from "./types"
export default class Context { export default class Context {
static storage = new AsyncLocalStorage<ContextMap>() static storage = new AsyncLocalStorage<ContextMap>()
static run(context: ContextMap, func: any) { static run<T>(context: ContextMap, func: () => T) {
return Context.storage.run(context, () => func()) return Context.storage.run(context, () => func())
} }

View File

@ -98,17 +98,17 @@ function updateContext(updates: ContextMap): ContextMap {
return context return context
} }
async function newContext(updates: ContextMap, task: any) { async function newContext<T>(updates: ContextMap, task: () => T) {
// see if there already is a context setup // see if there already is a context setup
let context: ContextMap = updateContext(updates) let context: ContextMap = updateContext(updates)
return Context.run(context, task) return Context.run(context, task)
} }
export async function doInAutomationContext(params: { export async function doInAutomationContext<T>(params: {
appId: string appId: string
automationId: string automationId: string
task: any task: () => T
}): Promise<any> { }): Promise<T> {
const tenantId = getTenantIDFromAppID(params.appId) const tenantId = getTenantIDFromAppID(params.appId)
return newContext( return newContext(
{ {
@ -144,10 +144,10 @@ export async function doInTenant<T>(
return newContext(updates, task) return newContext(updates, task)
} }
export async function doInAppContext( export async function doInAppContext<T>(
appId: string | null, appId: string | null,
task: any task: () => T
): Promise<any> { ): Promise<T> {
if (!appId && !env.isTest()) { if (!appId && !env.isTest()) {
throw new Error("appId is required") throw new Error("appId is required")
} }
@ -165,10 +165,10 @@ export async function doInAppContext(
return newContext(updates, task) return newContext(updates, task)
} }
export async function doInIdentityContext( export async function doInIdentityContext<T>(
identity: IdentityContext, identity: IdentityContext,
task: any task: () => T
): Promise<any> { ): Promise<T> {
if (!identity) { if (!identity) {
throw new Error("identity is required") throw new Error("identity is required")
} }
@ -276,6 +276,9 @@ export function getAuditLogsDB(): Database {
*/ */
export function getAppDB(opts?: any): Database { export function getAppDB(opts?: any): Database {
const appId = getAppId() const appId = getAppId()
if (!appId) {
throw new Error("Unable to retrieve app DB - no app ID.")
}
return getDB(appId, opts) return getDB(appId, opts)
} }

View File

@ -10,6 +10,7 @@ import {
DatabaseDeleteIndexOpts, DatabaseDeleteIndexOpts,
Document, Document,
isDocument, isDocument,
RowResponse,
} from "@budibase/types" } from "@budibase/types"
import { getCouchInfo } from "./connections" import { getCouchInfo } from "./connections"
import { directCouchUrlCall } from "./utils" import { directCouchUrlCall } from "./utils"
@ -48,10 +49,7 @@ export class DatabaseImpl implements Database {
private readonly couchInfo = getCouchInfo() private readonly couchInfo = getCouchInfo()
constructor(dbName?: string, opts?: DatabaseOpts, connection?: string) { constructor(dbName: string, opts?: DatabaseOpts, connection?: string) {
if (dbName == null) {
throw new Error("Database name cannot be undefined.")
}
this.name = dbName this.name = dbName
this.pouchOpts = opts || {} this.pouchOpts = opts || {}
if (connection) { if (connection) {
@ -112,7 +110,7 @@ export class DatabaseImpl implements Database {
} }
} }
async get<T>(id?: string): Promise<T | any> { async get<T extends Document>(id?: string): Promise<T> {
const db = await this.checkSetup() const db = await this.checkSetup()
if (!id) { if (!id) {
throw new Error("Unable to get doc without a valid _id.") throw new Error("Unable to get doc without a valid _id.")
@ -120,6 +118,35 @@ export class DatabaseImpl implements Database {
return this.updateOutput(() => db.get(id)) return this.updateOutput(() => db.get(id))
} }
async getMultiple<T extends Document>(
ids: string[],
opts?: { allowMissing?: boolean }
): Promise<T[]> {
// get unique
ids = [...new Set(ids)]
const response = await this.allDocs<T>({
keys: ids,
include_docs: true,
})
const rowUnavailable = (row: RowResponse<T>) => {
// row is deleted - key lookup can return this
if (row.doc == null || ("deleted" in row.value && row.value.deleted)) {
return true
}
return row.error === "not_found"
}
const rows = response.rows.filter(row => !rowUnavailable(row))
const someMissing = rows.length !== response.rows.length
// some were filtered out - means some missing
if (!opts?.allowMissing && someMissing) {
const missing = response.rows.filter(row => rowUnavailable(row))
const missingIds = missing.map(row => row.key).join(", ")
throw new Error(`Unable to get documents: ${missingIds}`)
}
return rows.map(row => row.doc!)
}
async remove(idOrDoc: string | Document, rev?: string) { async remove(idOrDoc: string | Document, rev?: string) {
const db = await this.checkSetup() const db = await this.checkSetup()
let _id: string let _id: string
@ -175,12 +202,14 @@ export class DatabaseImpl implements Database {
return this.updateOutput(() => db.bulk({ docs: documents })) return this.updateOutput(() => db.bulk({ docs: documents }))
} }
async allDocs<T>(params: DatabaseQueryOpts): Promise<AllDocsResponse<T>> { async allDocs<T extends Document>(
params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> {
const db = await this.checkSetup() const db = await this.checkSetup()
return this.updateOutput(() => db.list(params)) return this.updateOutput(() => db.list(params))
} }
async query<T>( async query<T extends Document>(
viewName: string, viewName: string,
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {

View File

@ -1,10 +1,7 @@
import env from "../environment"
import { directCouchQuery, DatabaseImpl } from "./couch" import { directCouchQuery, DatabaseImpl } from "./couch"
import { CouchFindOptions, Database } from "@budibase/types" import { CouchFindOptions, Database, DatabaseOpts } from "@budibase/types"
const dbList = new Set() export function getDB(dbName: string, opts?: DatabaseOpts): Database {
export function getDB(dbName?: string, opts?: any): Database {
return new DatabaseImpl(dbName, opts) return new DatabaseImpl(dbName, opts)
} }
@ -14,7 +11,7 @@ export function getDB(dbName?: string, opts?: any): Database {
export async function doWithDB<T>( export async function doWithDB<T>(
dbName: string, dbName: string,
cb: (db: Database) => Promise<T>, cb: (db: Database) => Promise<T>,
opts = {} opts?: DatabaseOpts
) { ) {
const db = getDB(dbName, opts) const db = getDB(dbName, opts)
// need this to be async so that we can correctly close DB after all // need this to be async so that we can correctly close DB after all
@ -22,13 +19,6 @@ export async function doWithDB<T>(
return await cb(db) return await cb(db)
} }
export function allDbs() {
if (!env.isTest()) {
throw new Error("Cannot be used outside test environment.")
}
return [...dbList]
}
export async function directCouchAllDbs(queryString?: string) { export async function directCouchAllDbs(queryString?: string) {
let couchPath = "/_all_dbs" let couchPath = "/_all_dbs"
if (queryString) { if (queryString) {

View File

@ -5,7 +5,6 @@ const { getDB } = require("../db")
describe("db", () => { describe("db", () => {
describe("getDB", () => { describe("getDB", () => {
it("returns a db", async () => { it("returns a db", async () => {
const dbName = structures.db.id() const dbName = structures.db.id()
const db = getDB(dbName) const db = getDB(dbName)
expect(db).toBeDefined() expect(db).toBeDefined()

View File

@ -7,12 +7,19 @@ import {
} from "../constants" } from "../constants"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import { doWithDB } from "./" import { doWithDB } from "./"
import { AllDocsResponse, Database, DatabaseQueryOpts } from "@budibase/types" import {
AllDocsResponse,
Database,
DatabaseQueryOpts,
Document,
DesignDocument,
DBView,
} from "@budibase/types"
import env from "../environment" import env from "../environment"
const DESIGN_DB = "_design/database" const DESIGN_DB = "_design/database"
function DesignDoc() { function DesignDoc(): DesignDocument {
return { return {
_id: DESIGN_DB, _id: DESIGN_DB,
// view collation information, read before writing any complex views: // view collation information, read before writing any complex views:
@ -21,20 +28,14 @@ function DesignDoc() {
} }
} }
interface DesignDocument {
views: any
}
async function removeDeprecated(db: Database, viewName: ViewName) { async function removeDeprecated(db: Database, viewName: ViewName) {
// @ts-ignore
if (!DeprecatedViews[viewName]) { if (!DeprecatedViews[viewName]) {
return return
} }
try { try {
const designDoc = await db.get<DesignDocument>(DESIGN_DB) const designDoc = await db.get<DesignDocument>(DESIGN_DB)
// @ts-ignore
for (let deprecatedNames of DeprecatedViews[viewName]) { for (let deprecatedNames of DeprecatedViews[viewName]) {
delete designDoc.views[deprecatedNames] delete designDoc.views?.[deprecatedNames]
} }
await db.put(designDoc) await db.put(designDoc)
} catch (err) { } catch (err) {
@ -43,18 +44,18 @@ async function removeDeprecated(db: Database, viewName: ViewName) {
} }
export async function createView( export async function createView(
db: any, db: Database,
viewJs: string, viewJs: string,
viewName: string viewName: string
): Promise<void> { ): Promise<void> {
let designDoc let designDoc
try { try {
designDoc = (await db.get(DESIGN_DB)) as DesignDocument designDoc = await db.get<DesignDocument>(DESIGN_DB)
} catch (err) { } catch (err) {
// no design doc, make one // no design doc, make one
designDoc = DesignDoc() designDoc = DesignDoc()
} }
const view = { const view: DBView = {
map: viewJs, map: viewJs,
} }
designDoc.views = { designDoc.views = {
@ -109,7 +110,7 @@ export interface QueryViewOptions {
arrayResponse?: boolean arrayResponse?: boolean
} }
export async function queryViewRaw<T>( export async function queryViewRaw<T extends Document>(
viewName: ViewName, viewName: ViewName,
params: DatabaseQueryOpts, params: DatabaseQueryOpts,
db: Database, db: Database,
@ -137,18 +138,16 @@ export async function queryViewRaw<T>(
} }
} }
export const queryView = async <T>( export const queryView = async <T extends Document>(
viewName: ViewName, viewName: ViewName,
params: DatabaseQueryOpts, params: DatabaseQueryOpts,
db: Database, db: Database,
createFunc: any, createFunc: any,
opts?: QueryViewOptions opts?: QueryViewOptions
): Promise<T[] | T | undefined> => { ): Promise<T[] | T> => {
const response = await queryViewRaw<T>(viewName, params, db, createFunc, opts) const response = await queryViewRaw<T>(viewName, params, db, createFunc, opts)
const rows = response.rows const rows = response.rows
const docs = rows.map((row: any) => const docs = rows.map(row => (params.include_docs ? row.doc! : row.value))
params.include_docs ? row.doc : row.value
)
// if arrayResponse has been requested, always return array regardless of length // if arrayResponse has been requested, always return array regardless of length
if (opts?.arrayResponse) { if (opts?.arrayResponse) {
@ -198,11 +197,11 @@ export const createPlatformUserView = async () => {
await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE) await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE)
} }
export const queryPlatformView = async <T>( export const queryPlatformView = async <T extends Document>(
viewName: ViewName, viewName: ViewName,
params: DatabaseQueryOpts, params: DatabaseQueryOpts,
opts?: QueryViewOptions opts?: QueryViewOptions
): Promise<T[] | T | undefined> => { ): Promise<T[] | T> => {
const CreateFuncByName: any = { const CreateFuncByName: any = {
[ViewName.ACCOUNT_BY_EMAIL]: createPlatformAccountEmailView, [ViewName.ACCOUNT_BY_EMAIL]: createPlatformAccountEmailView,
[ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView, [ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView,
@ -220,7 +219,7 @@ const CreateFuncByName: any = {
[ViewName.USER_BY_APP]: createUserAppView, [ViewName.USER_BY_APP]: createUserAppView,
} }
export const queryGlobalView = async <T>( export const queryGlobalView = async <T extends Document>(
viewName: ViewName, viewName: ViewName,
params: DatabaseQueryOpts, params: DatabaseQueryOpts,
db?: Database, db?: Database,
@ -231,10 +230,10 @@ export const queryGlobalView = async <T>(
db = getGlobalDB() db = getGlobalDB()
} }
const createFn = CreateFuncByName[viewName] const createFn = CreateFuncByName[viewName]
return queryView(viewName, params, db!, createFn, opts) return queryView<T>(viewName, params, db!, createFn, opts)
} }
export async function queryGlobalViewRaw<T>( export async function queryGlobalViewRaw<T extends Document>(
viewName: ViewName, viewName: ViewName,
params: DatabaseQueryOpts, params: DatabaseQueryOpts,
opts?: QueryViewOptions opts?: QueryViewOptions

View File

@ -30,7 +30,9 @@ export * as timers from "./timers"
export { default as env } from "./environment" export { default as env } from "./environment"
export * as blacklist from "./blacklist" export * as blacklist from "./blacklist"
export * as docUpdates from "./docUpdates" export * as docUpdates from "./docUpdates"
export * from "./utils/Duration"
export { SearchParams } from "./db" export { SearchParams } from "./db"
export * as docIds from "./docIds"
// Add context to tenancy for backwards compatibility // Add context to tenancy for backwards compatibility
// only do this for external usages to prevent internal // only do this for external usages to prevent internal
// circular dependencies // circular dependencies
@ -49,6 +51,7 @@ export * from "./constants"
// expose package init function // expose package init function
import * as db from "./db" import * as db from "./db"
export const init = (opts: any = {}) => { export const init = (opts: any = {}) => {
db.init(opts.db) db.init(opts.db)
} }

View File

@ -36,7 +36,7 @@ class InMemoryQueue {
* @param opts This is not used by the in memory queue as there is no real use * @param opts This is not used by the in memory queue as there is no real use
* case when in memory, but is the same API as Bull * case when in memory, but is the same API as Bull
*/ */
constructor(name: string, opts = null) { constructor(name: string, opts?: any) {
this._name = name this._name = name
this._opts = opts this._opts = opts
this._messages = [] this._messages = []

View File

@ -2,11 +2,17 @@ import env from "../environment"
import { getRedisOptions } from "../redis/utils" import { getRedisOptions } from "../redis/utils"
import { JobQueue } from "./constants" import { JobQueue } from "./constants"
import InMemoryQueue from "./inMemoryQueue" import InMemoryQueue from "./inMemoryQueue"
import BullQueue from "bull" import BullQueue, { QueueOptions } from "bull"
import { addListeners, StalledFn } from "./listeners" import { addListeners, StalledFn } from "./listeners"
import { Duration } from "../utils"
import * as timers from "../timers" import * as timers from "../timers"
const CLEANUP_PERIOD_MS = 60 * 1000 // the queue lock is held for 5 minutes
const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
// queue lock is refreshed every 30 seconds
const QUEUE_LOCK_RENEW_INTERNAL_MS = Duration.fromSeconds(30).toMs()
// cleanup the queue every 60 seconds
const CLEANUP_PERIOD_MS = Duration.fromSeconds(60).toMs()
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = [] let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
let cleanupInterval: NodeJS.Timeout let cleanupInterval: NodeJS.Timeout
@ -20,8 +26,15 @@ export function createQueue<T>(
jobQueue: JobQueue, jobQueue: JobQueue,
opts: { removeStalledCb?: StalledFn } = {} opts: { removeStalledCb?: StalledFn } = {}
): BullQueue.Queue<T> { ): BullQueue.Queue<T> {
const { opts: redisOpts, redisProtocolUrl } = getRedisOptions() const redisOpts = getRedisOptions()
const queueConfig: any = redisProtocolUrl || { redis: redisOpts } const queueConfig: QueueOptions = {
redis: redisOpts,
settings: {
maxStalledCount: 0,
lockDuration: QUEUE_LOCK_MS,
lockRenewTime: QUEUE_LOCK_RENEW_INTERNAL_MS,
},
}
let queue: any let queue: any
if (!env.isTest()) { if (!env.isTest()) {
queue = new BullQueue(jobQueue, queueConfig) queue = new BullQueue(jobQueue, queueConfig)

View File

@ -7,15 +7,19 @@ let userClient: Client,
cacheClient: Client, cacheClient: Client,
writethroughClient: Client, writethroughClient: Client,
lockClient: Client, lockClient: Client,
socketClient: Client socketClient: Client,
inviteClient: Client,
passwordResetClient: Client
async function init() { export async function init() {
userClient = await new Client(utils.Databases.USER_CACHE).init() userClient = await new Client(utils.Databases.USER_CACHE).init()
sessionClient = await new Client(utils.Databases.SESSIONS).init() sessionClient = await new Client(utils.Databases.SESSIONS).init()
appClient = await new Client(utils.Databases.APP_METADATA).init() appClient = await new Client(utils.Databases.APP_METADATA).init()
cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init() cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init()
lockClient = await new Client(utils.Databases.LOCKS).init() lockClient = await new Client(utils.Databases.LOCKS).init()
writethroughClient = await new Client(utils.Databases.WRITE_THROUGH).init() writethroughClient = await new Client(utils.Databases.WRITE_THROUGH).init()
inviteClient = await new Client(utils.Databases.INVITATIONS).init()
passwordResetClient = await new Client(utils.Databases.PW_RESETS).init()
socketClient = await new Client( socketClient = await new Client(
utils.Databases.SOCKET_IO, utils.Databases.SOCKET_IO,
utils.SelectableDatabase.SOCKET_IO utils.SelectableDatabase.SOCKET_IO
@ -29,6 +33,8 @@ export async function shutdown() {
if (cacheClient) await cacheClient.finish() if (cacheClient) await cacheClient.finish()
if (writethroughClient) await writethroughClient.finish() if (writethroughClient) await writethroughClient.finish()
if (lockClient) await lockClient.finish() if (lockClient) await lockClient.finish()
if (inviteClient) await inviteClient.finish()
if (passwordResetClient) await passwordResetClient.finish()
if (socketClient) await socketClient.finish() if (socketClient) await socketClient.finish()
} }
@ -84,3 +90,17 @@ export async function getSocketClient() {
} }
return socketClient return socketClient
} }
export async function getInviteClient() {
if (!inviteClient) {
await init()
}
return inviteClient
}
export async function getPasswordResetClient() {
if (!passwordResetClient) {
await init()
}
return passwordResetClient
}

View File

@ -16,6 +16,7 @@ import {
getRedisOptions, getRedisOptions,
SEPARATOR, SEPARATOR,
SelectableDatabase, SelectableDatabase,
getRedisConnectionDetails,
} from "./utils" } from "./utils"
import * as timers from "../timers" import * as timers from "../timers"
@ -27,7 +28,6 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
// for testing just generate the client once // for testing just generate the client once
let CLOSED = false let CLOSED = false
let CLIENTS: { [key: number]: any } = {} let CLIENTS: { [key: number]: any } = {}
0
let CONNECTED = false let CONNECTED = false
// mock redis always connected // mock redis always connected
@ -91,12 +91,11 @@ function init(selectDb = DEFAULT_SELECT_DB) {
if (client) { if (client) {
client.disconnect() client.disconnect()
} }
const { redisProtocolUrl, opts, host, port } = getRedisOptions() const { host, port } = getRedisConnectionDetails()
const opts = getRedisOptions()
if (CLUSTERED) { if (CLUSTERED) {
client = new RedisCore.Cluster([{ host, port }], opts) client = new RedisCore.Cluster([{ host, port }], opts)
} else if (redisProtocolUrl) {
client = new RedisCore(redisProtocolUrl)
} else { } else {
client = new RedisCore(opts) client = new RedisCore(opts)
} }

View File

@ -3,6 +3,7 @@ import { getLockClient } from "./init"
import { LockOptions, LockType } from "@budibase/types" import { LockOptions, LockType } from "@budibase/types"
import * as context from "../context" import * as context from "../context"
import env from "../environment" import env from "../environment"
import { logWarn } from "../logging"
async function getClient( async function getClient(
type: LockType, type: LockType,
@ -116,7 +117,7 @@ export async function doWithLock<T>(
const result = await task() const result = await task()
return { executed: true, result } return { executed: true, result }
} catch (e: any) { } catch (e: any) {
console.warn("lock error") logWarn(`lock type: ${opts.type} error`, e)
// lock limit exceeded // lock limit exceeded
if (e.name === "LockError") { if (e.name === "LockError") {
if (opts.type === LockType.TRY_ONCE) { if (opts.type === LockType.TRY_ONCE) {
@ -124,11 +125,9 @@ export async function doWithLock<T>(
// due to retry count (0) exceeded // due to retry count (0) exceeded
return { executed: false } return { executed: false }
} else { } else {
console.error(e)
throw e throw e
} }
} else { } else {
console.error(e)
throw e throw e
} }
} finally { } finally {

View File

@ -1,4 +1,5 @@
import env from "../environment" import env from "../environment"
import * as Redis from "ioredis"
const SLOT_REFRESH_MS = 2000 const SLOT_REFRESH_MS = 2000
const CONNECT_TIMEOUT_MS = 10000 const CONNECT_TIMEOUT_MS = 10000
@ -42,7 +43,7 @@ export enum Databases {
export enum SelectableDatabase { export enum SelectableDatabase {
DEFAULT = 0, DEFAULT = 0,
SOCKET_IO = 1, SOCKET_IO = 1,
UNUSED_1 = 2, RATE_LIMITING = 2,
UNUSED_2 = 3, UNUSED_2 = 3,
UNUSED_3 = 4, UNUSED_3 = 4,
UNUSED_4 = 5, UNUSED_4 = 5,
@ -58,7 +59,7 @@ export enum SelectableDatabase {
UNUSED_14 = 15, UNUSED_14 = 15,
} }
export function getRedisOptions() { export function getRedisConnectionDetails() {
let password = env.REDIS_PASSWORD let password = env.REDIS_PASSWORD
let url: string[] | string = env.REDIS_URL.split("//") let url: string[] | string = env.REDIS_URL.split("//")
// get rid of the protocol // get rid of the protocol
@ -74,28 +75,36 @@ export function getRedisOptions() {
} }
const [host, port] = url.split(":") const [host, port] = url.split(":")
let redisProtocolUrl const portNumber = parseInt(port)
return {
// fully qualified redis URL host,
if (/rediss?:\/\//.test(env.REDIS_URL)) { password,
redisProtocolUrl = env.REDIS_URL // assume default port for redis if invalid found
port: isNaN(portNumber) ? 6379 : portNumber,
} }
}
const opts: any = { export function getRedisOptions() {
const { host, password, port } = getRedisConnectionDetails()
let redisOpts: Redis.RedisOptions = {
connectTimeout: CONNECT_TIMEOUT_MS, connectTimeout: CONNECT_TIMEOUT_MS,
port: port,
host,
password,
} }
let opts: Redis.ClusterOptions | Redis.RedisOptions = redisOpts
if (env.REDIS_CLUSTERED) { if (env.REDIS_CLUSTERED) {
opts.redisOptions = {} opts = {
opts.redisOptions.tls = {} connectTimeout: CONNECT_TIMEOUT_MS,
opts.redisOptions.password = password redisOptions: {
opts.slotsRefreshTimeout = SLOT_REFRESH_MS ...redisOpts,
opts.dnsLookup = (address: string, callback: any) => callback(null, address) tls: {},
} else { },
opts.host = host slotsRefreshTimeout: SLOT_REFRESH_MS,
opts.port = port dnsLookup: (address: string, callback: any) => callback(null, address),
opts.password = password } as Redis.ClusterOptions
} }
return { opts, host, port: parseInt(port), redisProtocolUrl } return opts
} }
export function addDbPrefix(db: string, key: string) { export function addDbPrefix(db: string, key: string) {

View File

@ -303,7 +303,7 @@ export class UserDB {
static async bulkCreate( static async bulkCreate(
newUsersRequested: User[], newUsersRequested: User[],
groups: string[] groups?: string[]
): Promise<BulkUserCreated> { ): Promise<BulkUserCreated> {
const tenantId = getTenantId() const tenantId = getTenantId()
@ -328,7 +328,7 @@ export class UserDB {
}) })
continue continue
} }
newUser.userGroups = groups newUser.userGroups = groups || []
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) { if (isCreator(newUser)) {
newCreators.push(newUser) newCreators.push(newUser)
@ -413,15 +413,13 @@ export class UserDB {
} }
// Get users and delete // Get users and delete
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({ const allDocsResponse = await db.allDocs<User>({
include_docs: true, include_docs: true,
keys: userIds, keys: userIds,
}) })
const usersToDelete: User[] = allDocsResponse.rows.map( const usersToDelete = allDocsResponse.rows.map(user => {
(user: RowResponse<User>) => { return user.doc!
return user.doc })
}
)
// Delete from DB // Delete from DB
const toDelete = usersToDelete.map(user => ({ const toDelete = usersToDelete.map(user => ({

View File

@ -6,6 +6,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import * as dbUtils from "../db" import * as dbUtils from "../db"
import { ViewName } from "../constants" import { ViewName } from "../constants"
import { getExistingInvites } from "../cache/invite"
/** /**
* Apply a system-wide search on emails: * Apply a system-wide search on emails:
@ -26,6 +27,9 @@ export async function searchExistingEmails(emails: string[]) {
const existingAccounts = await getExistingAccounts(emails) const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email)) matchedEmails.push(...existingAccounts.map(account => account.email))
const invitedEmails = await getExistingInvites(emails)
matchedEmails.push(...invitedEmails.map(invite => invite.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))] return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
} }

View File

@ -151,7 +151,7 @@ export const searchGlobalUsersByApp = async (
include_docs: true, include_docs: true,
}) })
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
let response = await queryGlobalView(ViewName.USER_BY_APP, params) let response = await queryGlobalView<User>(ViewName.USER_BY_APP, params)
if (!response) { if (!response) {
response = [] response = []

View File

@ -0,0 +1,52 @@
export enum DurationType {
MILLISECONDS = "milliseconds",
SECONDS = "seconds",
MINUTES = "minutes",
HOURS = "hours",
DAYS = "days",
}
const conversion: Record<DurationType, number> = {
milliseconds: 1,
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
}
export class Duration {
static convert(from: DurationType, to: DurationType, duration: number) {
const milliseconds = duration * conversion[from]
return milliseconds / conversion[to]
}
static from(from: DurationType, duration: number) {
return {
to: (to: DurationType) => {
return Duration.convert(from, to, duration)
},
toMs: () => {
return Duration.convert(from, DurationType.MILLISECONDS, duration)
},
toSeconds: () => {
return Duration.convert(from, DurationType.SECONDS, duration)
},
}
}
static fromSeconds(duration: number) {
return Duration.from(DurationType.SECONDS, duration)
}
static fromMinutes(duration: number) {
return Duration.from(DurationType.MINUTES, duration)
}
static fromHours(duration: number) {
return Duration.from(DurationType.HOURS, duration)
}
static fromDays(duration: number) {
return Duration.from(DurationType.DAYS, duration)
}
}

View File

@ -1,3 +1,4 @@
export * from "./hashing" export * from "./hashing"
export * from "./utils" export * from "./utils"
export * from "./stringUtils" export * from "./stringUtils"
export * from "./Duration"

View File

@ -0,0 +1,19 @@
import { Duration, DurationType } from "../Duration"
describe("duration", () => {
it("should convert minutes to milliseconds", () => {
expect(Duration.fromMinutes(5).toMs()).toBe(300000)
})
it("should convert seconds to milliseconds", () => {
expect(Duration.fromSeconds(30).toMs()).toBe(30000)
})
it("should convert days to milliseconds", () => {
expect(Duration.fromDays(1).toMs()).toBe(86400000)
})
it("should convert minutes to days", () => {
expect(Duration.fromMinutes(1440).to(DurationType.DAYS)).toBe(1)
})
})

View File

@ -188,4 +188,17 @@ describe("utils", () => {
expectResult(false) expectResult(false)
}) })
}) })
describe("hasCircularStructure", () => {
it("should detect a circular structure", () => {
const a: any = { b: "b" }
const b = { a }
a.b = b
expect(utils.hasCircularStructure(b)).toBe(true)
})
it("should allow none circular structures", () => {
expect(utils.hasCircularStructure({ a: "b" })).toBe(false)
})
})
}) })

View File

@ -237,3 +237,17 @@ export function timeout(timeMs: number) {
export function isAudited(event: Event) { export function isAudited(event: Event) {
return !!AuditedEventFriendlyName[event] return !!AuditedEventFriendlyName[event]
} }
export function hasCircularStructure(json: any) {
if (typeof json !== "object") {
return false
}
try {
JSON.stringify(json)
} catch (err) {
if (err instanceof Error && err?.message.includes("circular structure")) {
return true
}
}
return false
}

View File

@ -1,5 +1,5 @@
const _ = require('lodash/fp') const _ = require("lodash/fp")
const {structures} = require("../../../tests") const { structures } = require("../../../tests")
jest.mock("../../../src/context") jest.mock("../../../src/context")
jest.mock("../../../src/db") jest.mock("../../../src/db")
@ -7,10 +7,9 @@ jest.mock("../../../src/db")
const context = require("../../../src/context") const context = require("../../../src/context")
const db = require("../../../src/db") const db = require("../../../src/db")
const {getCreatorCount} = require('../../../src/users/users') const { getCreatorCount } = require("../../../src/users/users")
describe("Users", () => { describe("Users", () => {
let getGlobalDBMock let getGlobalDBMock
let getGlobalUserParamsMock let getGlobalUserParamsMock
let paginationMock let paginationMock
@ -26,26 +25,26 @@ describe("Users", () => {
it("Retrieves the number of creators", async () => { it("Retrieves the number of creators", async () => {
const getUsers = (offset, limit, creators = false) => { const getUsers = (offset, limit, creators = false) => {
const range = _.range(offset, limit) const range = _.range(offset, limit)
const opts = creators ? {builder: {global: true}} : undefined const opts = creators ? { builder: { global: true } } : undefined
return range.map(() => structures.users.user(opts)) return range.map(() => structures.users.user(opts))
} }
const page1Data = getUsers(0, 8) const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true) const page2Data = getUsers(8, 12, true)
getGlobalDBMock.mockImplementation(() => ({ getGlobalDBMock.mockImplementation(() => ({
name : "fake-db", name: "fake-db",
allDocs: () => ({ allDocs: () => ({
rows: [...page1Data, ...page2Data] rows: [...page1Data, ...page2Data],
}) }),
})) }))
paginationMock.mockImplementationOnce(() => ({ paginationMock.mockImplementationOnce(() => ({
data: page1Data, data: page1Data,
hasNextPage: true, hasNextPage: true,
nextPage: "1" nextPage: "1",
})) }))
paginationMock.mockImplementation(() => ({ paginationMock.mockImplementation(() => ({
data: page2Data, data: page2Data,
hasNextPage: false, hasNextPage: false,
nextPage: undefined nextPage: undefined,
})) }))
const creatorsCount = await getCreatorCount() const creatorsCount = await getCreatorCount()
expect(creatorsCount).toBe(4) expect(creatorsCount).toBe(4)

View File

@ -12,7 +12,7 @@ import { generator } from "./generator"
import { tenant } from "." import { tenant } from "."
export const newEmail = () => { export const newEmail = () => {
return `${uuid()}@test.com` return `${uuid()}@example.com`
} }
export const user = (userProps?: Partial<Omit<User, "userId">>): User => { export const user = (userProps?: Partial<Omit<User, "userId">>): User => {

View File

@ -10,6 +10,7 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let size = "M" export let size = "M"
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -18,6 +19,6 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Checkbox {error} {disabled} {text} {value} {size} on:change={onChange} /> <Checkbox {error} {disabled} {text} {value} {size} on:change={onChange} />
</Field> </Field>

View File

@ -11,6 +11,7 @@
export let error = null export let error = null
export let placeholder = "Choose an option or type" export let placeholder = "Choose an option or type"
export let options = [] export let options = []
export let helpText = null
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
@ -27,7 +28,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Combobox <Combobox
{error} {error}
{disabled} {disabled}

View File

@ -4,10 +4,10 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = false export let value = false
export let error = null
export let id = null export let id = null
export let text = null export let text = null
export let disabled = false export let disabled = false
export let readonly = false
export let size export let size
export let indeterminate = false export let indeterminate = false
@ -21,9 +21,9 @@
<label <label
class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}" class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}"
class:is-invalid={!!error}
class:checked={value} class:checked={value}
class:is-indeterminate={indeterminate} class:is-indeterminate={indeterminate}
class:readonly
> >
<input <input
checked={value} checked={value}
@ -68,4 +68,7 @@
.spectrum-Checkbox-input { .spectrum-Checkbox-input {
opacity: 0; opacity: 0;
} }
.readonly {
pointer-events: none;
}
</style> </style>

View File

@ -6,8 +6,8 @@
export let direction = "vertical" export let direction = "vertical"
export let value = [] export let value = []
export let options = [] export let options = []
export let error = null
export let disabled = false export let disabled = false
export let readonly = false
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
@ -33,7 +33,7 @@
<div <div
title={getOptionLabel(option)} title={getOptionLabel(option)}
class="spectrum-Checkbox spectrum-FieldGroup-item" class="spectrum-Checkbox spectrum-FieldGroup-item"
class:is-invalid={!!error} class:readonly
> >
<label <label
class="spectrum-Checkbox spectrum-Checkbox--sizeM spectrum-FieldGroup-item" class="spectrum-Checkbox spectrum-Checkbox--sizeM spectrum-FieldGroup-item"
@ -66,4 +66,7 @@
.spectrum-Checkbox-input { .spectrum-Checkbox-input {
opacity: 0; opacity: 0;
} }
.readonly {
pointer-events: none;
}
</style> </style>

View File

@ -10,7 +10,6 @@
export let placeholder = "Choose an option or type" export let placeholder = "Choose an option or type"
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let error = null
export let options = [] export let options = []
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
@ -39,12 +38,10 @@
<div <div
class="spectrum-InputGroup" class="spectrum-InputGroup"
class:is-focused={open || focus} class:is-focused={open || focus}
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
> >
<div <div
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={open || focus} class:is-focused={open || focus}
> >

View File

@ -9,7 +9,7 @@
export let id = null export let id = null
export let disabled = false export let disabled = false
export let error = null export let readonly = false
export let enableTime = true export let enableTime = true
export let value = null export let value = null
export let placeholder = null export let placeholder = null
@ -186,8 +186,7 @@
> >
<div <div
id={flatpickrId} id={flatpickrId}
class:is-disabled={disabled} class:is-disabled={disabled || readonly}
class:is-invalid={!!error}
class="flatpickr spectrum-InputGroup spectrum-Datepicker" class="flatpickr spectrum-InputGroup spectrum-Datepicker"
class:is-focused={open} class:is-focused={open}
aria-readonly="false" aria-readonly="false"
@ -198,19 +197,10 @@
on:click={flatpickr?.open} on:click={flatpickr?.open}
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-invalid={!!error}
> >
{#if !!error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input <input
{disabled} {disabled}
{readonly}
data-input data-input
type="text" type="text"
class="spectrum-Textfield-input spectrum-InputGroup-input" class="spectrum-Textfield-input spectrum-InputGroup-input"
@ -225,7 +215,6 @@
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button" class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1" tabindex="-1"
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-invalid={!!error}
on:click={flatpickr?.open} on:click={flatpickr?.open}
> >
<svg <svg

View File

@ -22,7 +22,6 @@
export let handleFileTooLarge = null export let handleFileTooLarge = null
export let handleTooManyFiles = null export let handleTooManyFiles = null
export let gallery = true export let gallery = true
export let error = null
export let fileTags = [] export let fileTags = []
export let maximum = null export let maximum = null
export let extensions = "*" export let extensions = "*"
@ -222,7 +221,6 @@
{#if showDropzone} {#if showDropzone}
<div <div
class="spectrum-Dropzone" class="spectrum-Dropzone"
class:is-invalid={!!error}
class:disabled class:disabled
role="region" role="region"
tabindex="0" tabindex="0"
@ -351,9 +349,6 @@
.spectrum-Dropzone { .spectrum-Dropzone {
user-select: none; user-select: none;
} }
.spectrum-Dropzone.is-invalid {
border-color: var(--spectrum-global-color-red-400);
}
input[type="file"] { input[type="file"] {
display: none; display: none;
} }

View File

@ -14,7 +14,6 @@
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let updateOnChange = true export let updateOnChange = true
export let error = null
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
@ -111,27 +110,12 @@
} }
</script> </script>
<div <div class="spectrum-InputGroup" class:is-disabled={disabled}>
class="spectrum-InputGroup"
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div <div
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={focus} class:is-focused={focus}
> >
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input <input
{id} {id}
on:click on:click

View File

@ -6,7 +6,6 @@
export let id = null export let id = null
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let error = null
export let options = [] export let options = []
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
@ -84,7 +83,6 @@
<Picker <Picker
on:loadMore on:loadMore
{id} {id}
{error}
{disabled} {disabled}
{readonly} {readonly}
{fieldText} {fieldText}

View File

@ -14,7 +14,6 @@
export let id = null export let id = null
export let disabled = false export let disabled = false
export let error = null
export let fieldText = "" export let fieldText = ""
export let fieldIcon = "" export let fieldIcon = ""
export let fieldColour = "" export let fieldColour = ""
@ -113,7 +112,6 @@
class="spectrum-Picker spectrum-Picker--sizeM" class="spectrum-Picker spectrum-Picker--sizeM"
class:spectrum-Picker--quiet={quiet} class:spectrum-Picker--quiet={quiet}
{disabled} {disabled}
class:is-invalid={!!error}
class:is-open={open} class:is-open={open}
aria-haspopup="listbox" aria-haspopup="listbox"
on:click={onClick} on:click={onClick}
@ -142,16 +140,6 @@
> >
{fieldText} {fieldText}
</span> </span>
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Picker-validationIcon"
focusable="false"
aria-hidden="true"
aria-label="Folder"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<svg <svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon"
focusable="false" focusable="false"

View File

@ -16,7 +16,6 @@
export let id = null export let id = null
export let placeholder = "Choose an option or type" export let placeholder = "Choose an option or type"
export let disabled = false export let disabled = false
export let error = null
export let secondaryOptions = [] export let secondaryOptions = []
export let primaryOptions = [] export let primaryOptions = []
export let secondaryFieldText = "" export let secondaryFieldText = ""
@ -105,14 +104,9 @@
} }
</script> </script>
<div <div class="spectrum-InputGroup" class:is-disabled={disabled}>
class="spectrum-InputGroup"
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div <div
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={focus} class:is-focused={focus}
class:is-full-width={!secondaryOptions.length} class:is-full-width={!secondaryOptions.length}

View File

@ -6,8 +6,8 @@
export let direction = "vertical" export let direction = "vertical"
export let value = null export let value = null
export let options = [] export let options = []
export let error = null
export let disabled = false export let disabled = false
export let readonly = false
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionTitle = option => option export let getOptionTitle = option => option
@ -39,7 +39,7 @@
<div <div
title={getOptionTitle(option)} title={getOptionTitle(option)}
class="spectrum-Radio spectrum-FieldGroup-item spectrum-Radio--emphasized" class="spectrum-Radio spectrum-FieldGroup-item spectrum-Radio--emphasized"
class:is-invalid={!!error} class:readonly
> >
<input <input
on:change={onChange} on:change={onChange}
@ -62,4 +62,7 @@
.spectrum-Radio-input { .spectrum-Radio-input {
opacity: 0; opacity: 0;
} }
.readonly {
pointer-events: none;
}
</style> </style>

View File

@ -4,14 +4,14 @@
export let value = "" export let value = ""
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let error = null export let readonly = false
export let height = null export let height = null
export let id = null export let id = null
export let fullScreenOffset = null export let fullScreenOffset = null
export let easyMDEOptions = null export let easyMDEOptions = null
</script> </script>
<div class:error> <div>
<MarkdownEditor <MarkdownEditor
{value} {value}
{placeholder} {placeholder}
@ -20,23 +20,10 @@
{fullScreenOffset} {fullScreenOffset}
{disabled} {disabled}
{easyMDEOptions} {easyMDEOptions}
{readonly}
on:change on:change
/> />
</div> </div>
<style> <style>
.error :global(.EasyMDEContainer .editor-toolbar) {
border-top-color: var(--spectrum-semantic-negative-color-default);
border-left-color: var(--spectrum-semantic-negative-color-default);
border-right-color: var(--spectrum-semantic-negative-color-default);
}
.error :global(.EasyMDEContainer .CodeMirror) {
border-bottom-color: var(--spectrum-semantic-negative-color-default);
border-left-color: var(--spectrum-semantic-negative-color-default);
border-right-color: var(--spectrum-semantic-negative-color-default);
}
.error :global(.EasyMDEContainer .editor-preview-side) {
border-bottom-color: var(--spectrum-semantic-negative-color-default);
border-right-color: var(--spectrum-semantic-negative-color-default);
}
</style> </style>

View File

@ -6,7 +6,6 @@
export let id = null export let id = null
export let placeholder = "Choose an option" export let placeholder = "Choose an option"
export let disabled = false export let disabled = false
export let error = null
export let options = [] export let options = []
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
@ -71,7 +70,6 @@
on:loadMore on:loadMore
{quiet} {quiet}
{id} {id}
{error}
{disabled} {disabled}
{readonly} {readonly}
{fieldText} {fieldText}

View File

@ -7,7 +7,6 @@
export let value = null export let value = null
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let error = null
export let id = null export let id = null
export let readonly = false export let readonly = false
export let updateOnChange = true export let updateOnChange = true
@ -98,20 +97,9 @@
<div <div
class="spectrum-Stepper" class="spectrum-Stepper"
class:spectrum-Stepper--quiet={quiet} class:spectrum-Stepper--quiet={quiet}
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={focus} class:is-focused={focus}
> >
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<div class="spectrum-Textfield spectrum-Stepper-textfield"> <div class="spectrum-Textfield spectrum-Stepper-textfield">
<input <input
{disabled} {disabled}

View File

@ -5,7 +5,7 @@
export let value = "" export let value = ""
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let error = null export let readonly = false
export let id = null export let id = null
export let height = null export let height = null
export let minHeight = null export let minHeight = null
@ -40,20 +40,9 @@
<div <div
style={`${heightString}${minHeightString}`} style={`${heightString}${minHeightString}`}
class="spectrum-Textfield spectrum-Textfield--multiline" class="spectrum-Textfield spectrum-Textfield--multiline"
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={focus} class:is-focused={focus}
> >
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM
spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<!-- prettier-ignore --> <!-- prettier-ignore -->
<textarea <textarea
bind:this={textarea} bind:this={textarea}
@ -61,6 +50,7 @@
class="spectrum-Textfield-input" class="spectrum-Textfield-input"
style={align ? `text-align: ${align}` : ""} style={align ? `text-align: ${align}` : ""}
{disabled} {disabled}
{readonly}
{id} {id}
on:focus={() => (focus = true)} on:focus={() => (focus = true)}
on:blur={onChange} on:blur={onChange}

View File

@ -6,7 +6,6 @@
export let placeholder = null export let placeholder = null
export let type = "text" export let type = "text"
export let disabled = false export let disabled = false
export let error = null
export let id = null export let id = null
export let readonly = false export let readonly = false
export let updateOnChange = true export let updateOnChange = true
@ -78,19 +77,9 @@
<div <div
class="spectrum-Textfield" class="spectrum-Textfield"
class:spectrum-Textfield--quiet={quiet} class:spectrum-Textfield--quiet={quiet}
class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={focus} class:is-focused={focus}
> >
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input <input
bind:this={field} bind:this={field}
{disabled} {disabled}

View File

@ -7,6 +7,7 @@
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let disabled = false export let disabled = false
export let readonly = false
export let error = null export let error = null
export let enableTime = true export let enableTime = true
export let timeOnly = false export let timeOnly = false
@ -15,6 +16,7 @@
export let appendTo = undefined export let appendTo = undefined
export let ignoreTimezones = false export let ignoreTimezones = false
export let range = false export let range = false
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -29,10 +31,11 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<DatePicker <DatePicker
{error} {error}
{disabled} {disabled}
{readonly}
{value} {value}
{placeholder} {placeholder}
{enableTime} {enableTime}

View File

@ -17,6 +17,7 @@
export let fileTags = [] export let fileTags = []
export let maximum = undefined export let maximum = undefined
export let compact = false export let compact = false
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -25,7 +26,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<CoreDropzone <CoreDropzone
{error} {error}
{disabled} {disabled}

View File

@ -16,6 +16,7 @@
export let autofocus export let autofocus
export let variables export let variables
export let showModal export let showModal
export let helpText = null
export let environmentVariablesEnabled export let environmentVariablesEnabled
export let handleUpgradePanel export let handleUpgradePanel
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -25,7 +26,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<EnvDropdown <EnvDropdown
{updateOnChange} {updateOnChange}
{error} {error}

View File

@ -1,11 +1,13 @@
<script> <script>
import "@spectrum-css/fieldlabel/dist/index-vars.css" import "@spectrum-css/fieldlabel/dist/index-vars.css"
import FieldLabel from "./FieldLabel.svelte" import FieldLabel from "./FieldLabel.svelte"
import Icon from "../Icon/Icon.svelte"
export let id = null export let id = null
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error = null
export let helpText = null
export let tooltip = "" export let tooltip = ""
</script> </script>
@ -17,6 +19,10 @@
<slot /> <slot />
{#if error} {#if error}
<div class="error">{error}</div> <div class="error">{error}</div>
{:else if helpText}
<div class="helpText">
<Icon name="HelpOutline" /> <span>{helpText}</span>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -39,4 +45,21 @@
font-size: var(--spectrum-global-dimension-font-size-75); font-size: var(--spectrum-global-dimension-font-size-75);
margin-top: var(--spectrum-global-dimension-size-75); margin-top: var(--spectrum-global-dimension-size-75);
} }
.helpText {
display: flex;
margin-top: var(--spectrum-global-dimension-size-75);
align-items: center;
}
.helpText :global(svg) {
width: 14px;
color: var(--grey-5);
margin-right: 6px;
}
.helpText span {
color: var(--grey-7);
font-size: var(--spectrum-global-dimension-font-size-75);
}
</style> </style>

View File

@ -14,6 +14,7 @@
export let title = null export let title = null
export let value = null export let value = null
export let tooltip = null export let tooltip = null
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -22,7 +23,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error} {tooltip}> <Field {helpText} {label} {labelPosition} {error} {tooltip}>
<CoreFile <CoreFile
{error} {error}
{disabled} {disabled}

View File

@ -15,6 +15,7 @@
export let quiet = false export let quiet = false
export let autofocus export let autofocus
export let autocomplete export let autocomplete
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -23,7 +24,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<TextField <TextField
{updateOnChange} {updateOnChange}
{error} {error}

View File

@ -15,6 +15,7 @@
export let updateOnChange = true export let updateOnChange = true
export let quiet = false export let quiet = false
export let autofocus export let autofocus
export let helpText = null
export let options = [] export let options = []
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -29,7 +30,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<InputDropdown <InputDropdown
{updateOnChange} {updateOnChange}
{error} {error}

View File

@ -18,6 +18,7 @@
export let autocomplete = false export let autocomplete = false
export let searchTerm = null export let searchTerm = null
export let customPopoverHeight export let customPopoverHeight
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -26,7 +27,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Multiselect <Multiselect
{error} {error}
{disabled} {disabled}

View File

@ -26,6 +26,7 @@
export let secondaryOptions = [] export let secondaryOptions = []
export let searchTerm export let searchTerm
export let showClearIcon = true export let showClearIcon = true
export let helpText = null
let primaryLabel let primaryLabel
let secondaryLabel let secondaryLabel
@ -93,7 +94,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<PickerDropdown <PickerDropdown
{searchTerm} {searchTerm}
{autocomplete} {autocomplete}

View File

@ -13,6 +13,7 @@
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionTitle = option => extractProperty(option, "label") export let getOptionTitle = option => extractProperty(option, "label")
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -27,7 +28,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<RadioGroup <RadioGroup
{error} {error}
{disabled} {disabled}

View File

@ -13,6 +13,7 @@
export let id = null export let id = null
export let fullScreenOffset = null export let fullScreenOffset = null
export let easyMDEOptions = null export let easyMDEOptions = null
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -21,7 +22,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<RichTextField <RichTextField
{error} {error}
{disabled} {disabled}

View File

@ -11,6 +11,7 @@
export let updateOnChange = true export let updateOnChange = true
export let quiet = false export let quiet = false
export let inputRef export let inputRef
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -19,7 +20,7 @@
} }
</script> </script>
<Field {label} {labelPosition}> <Field {helpText} {label} {labelPosition}>
<Search <Search
{updateOnChange} {updateOnChange}
{disabled} {disabled}

View File

@ -26,6 +26,7 @@
export let align export let align
export let footer = null export let footer = null
export let tag = null export let tag = null
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -40,7 +41,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error} {tooltip}> <Field {helpText} {label} {labelPosition} {error} {tooltip}>
<Select <Select
{quiet} {quiet}
{error} {error}

View File

@ -11,6 +11,7 @@
export let step = 1 export let step = 1
export let disabled = false export let disabled = false
export let error = null export let error = null
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -19,6 +20,6 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Slider {disabled} {value} {min} {max} {step} on:change={onChange} /> <Slider {disabled} {value} {min} {max} {step} on:change={onChange} />
</Field> </Field>

View File

@ -15,6 +15,7 @@
export let min = null export let min = null
export let max = null export let max = null
export let step = 1 export let step = 1
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -23,7 +24,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Stepper <Stepper
{updateOnChange} {updateOnChange}
{error} {error}

View File

@ -12,6 +12,7 @@
export let getCaretPosition = null export let getCaretPosition = null
export let height = null export let height = null
export let minHeight = null export let minHeight = null
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -20,7 +21,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<TextArea <TextArea
bind:getCaretPosition bind:getCaretPosition
{error} {error}

View File

@ -9,6 +9,7 @@
export let text = null export let text = null
export let disabled = false export let disabled = false
export let error = null export let error = null
export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -17,6 +18,6 @@
} }
</script> </script>
<Field {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} /> <Switch {error} {disabled} {text} {value} on:change={onChange} />
</Field> </Field>

View File

@ -8,6 +8,7 @@
export let id = null export let id = null
export let fullScreenOffset = 0 export let fullScreenOffset = 0
export let disabled = false export let disabled = false
export let readonly = false
export let easyMDEOptions export let easyMDEOptions
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -19,6 +20,9 @@
// control // control
$: checkValue(value) $: checkValue(value)
$: mde?.codemirror.on("change", debouncedUpdate) $: mde?.codemirror.on("change", debouncedUpdate)
$: if (readonly || disabled) {
mde?.togglePreview()
}
const checkValue = val => { const checkValue = val => {
if (mde && val !== latestValue) { if (mde && val !== latestValue) {
@ -54,6 +58,7 @@
easyMDEOptions={{ easyMDEOptions={{
initialValue: value, initialValue: value,
placeholder, placeholder,
toolbar: disabled || readonly ? false : undefined,
...easyMDEOptions, ...easyMDEOptions,
}} }}
/> />

View File

@ -48,15 +48,14 @@
<UndoRedoControl store={automationHistoryStore} /> <UndoRedoControl store={automationHistoryStore} />
</div> </div>
<div class="controls"> <div class="controls">
<div class="buttons"> <div
on:click={() => {
testDataModal.show()
}}
class="buttons"
>
<Icon hoverable size="M" name="Play" /> <Icon hoverable size="M" name="Play" />
<div <div>Run test</div>
on:click={() => {
testDataModal.show()
}}
>
Run test
</div>
</div> </div>
<div class="buttons"> <div class="buttons">
<Icon <Icon

View File

@ -13,13 +13,13 @@
export let idx export let idx
export let addLooping export let addLooping
export let deleteStep export let deleteStep
export let enableNaming = true
let validRegex = /^[A-Za-z0-9_\s]+$/ let validRegex = /^[A-Za-z0-9_\s]+$/
let typing = false let typing = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: stepNames = $selectedAutomation.definition.stepNames $: stepNames = $selectedAutomation?.definition.stepNames
$: automationName = stepNames?.[block.id] || block?.name || "" $: automationName = stepNames?.[block.id] || block?.name || ""
$: automationNameError = getAutomationNameError(automationName) $: automationNameError = getAutomationNameError(automationName)
$: status = updateStatus(testResult, isTrigger) $: status = updateStatus(testResult, isTrigger)
@ -32,7 +32,7 @@
)?.[0] )?.[0]
} }
} }
$: loopBlock = $selectedAutomation.definition.steps.find( $: loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block?.id x => x.blockToLoop === block?.id
) )
@ -126,24 +126,33 @@
<Body size="XS"><b>Step {idx}</b></Body> <Body size="XS"><b>Step {idx}</b></Body>
</div> </div>
{/if} {/if}
<input
placeholder="Enter some text" {#if enableNaming}
name="name" <input
autocomplete="off" class="input-text"
value={automationName} disabled={!enableNaming}
on:input={e => { placeholder="Enter some text"
automationName = e.target.value.trim() name="name"
}} autocomplete="off"
on:click={startTyping} value={automationName}
on:blur={async () => { on:input={e => {
typing = false automationName = e.target.value.trim()
if (automationNameError) { }}
automationName = stepNames[block.id] || block?.name on:click={startTyping}
} else { on:blur={async () => {
await saveName() typing = false
} if (automationNameError) {
}} automationName = stepNames[block.id] || block?.name
/> } else {
await saveName()
}
}}
/>
{:else}
<div class="input-text">
{automationName}
</div>
{/if}
</div> </div>
</div> </div>
<div class="blockTitle"> <div class="blockTitle">
@ -178,9 +187,11 @@
<Icon on:click={addLooping} hoverable name="RotateCW" /> <Icon on:click={addLooping} hoverable name="RotateCW" />
</AbsTooltip> </AbsTooltip>
{/if} {/if}
<AbsTooltip type="negative" text="Delete step"> {#if !isHeaderTrigger}
<Icon on:click={deleteStep} hoverable name="DeleteOutline" /> <AbsTooltip type="negative" text="Delete step">
</AbsTooltip> <Icon on:click={deleteStep} hoverable name="DeleteOutline" />
</AbsTooltip>
{/if}
{/if} {/if}
{#if !showTestStatus} {#if !showTestStatus}
<Icon <Icon
@ -244,18 +255,21 @@
display: none; display: none;
} }
input { input {
font-family: var(--font-sans);
color: var(--ink); color: var(--ink);
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
font-size: var(--spectrum-alias-font-size-default);
width: 230px; width: 230px;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.input-text {
font-size: var(--spectrum-alias-font-size-default);
font-family: var(--font-sans);
text-overflow: ellipsis;
}
input:focus { input:focus {
outline: none; outline: none;
} }

View File

@ -48,6 +48,7 @@
<div class="block" style={width ? `width: ${width}` : ""}> <div class="block" style={width ? `width: ${width}` : ""}>
{#if block.stepId !== ActionStepID.LOOP} {#if block.stepId !== ActionStepID.LOOP}
<FlowItemHeader <FlowItemHeader
enableNaming={false}
open={!!openBlocks[block.id]} open={!!openBlocks[block.id]}
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])} on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
isTrigger={idx === 0} isTrigger={idx === 0}

View File

@ -27,7 +27,7 @@
$: if (value?.queryId == null) value = { queryId: "" } $: if (value?.queryId == null) value = { queryId: "" }
</script> </script>
<div class="schema-fields"> <div class="schema-field">
<Label>Query</Label> <Label>Query</Label>
<div class="field-width"> <div class="field-width">
<Select <Select
@ -41,8 +41,8 @@
</div> </div>
{#if parameters.length} {#if parameters.length}
<div class="schema-fields"> {#each parameters as field}
{#each parameters as field} <div class="schema-field">
<Label>{field.name}</Label> <Label>{field.name}</Label>
<div class="field-width"> <div class="field-width">
<DrawerBindableInput <DrawerBindableInput
@ -56,8 +56,8 @@
updateOnChange={false} updateOnChange={false}
/> />
</div> </div>
{/each} </div>
</div> {/each}
{/if} {/if}
<style> <style>
@ -65,7 +65,7 @@
width: 320px; width: 320px;
} }
.schema-fields { .schema-field {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -76,7 +76,7 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.schema-fields :global(label) { .schema-field :global(label) {
text-transform: capitalize; text-transform: capitalize;
} }
</style> </style>

View File

@ -114,10 +114,10 @@
</div> </div>
{#if schemaFields.length} {#if schemaFields.length}
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
<div class="schema-fields"> {#if !schema.autocolumn && schema.type !== "attachment"}
<Label>{field}</Label> <div class="schema-fields">
<div class="field-width"> <Label>{field}</Label>
{#if !schema.autocolumn && schema.type !== "attachment"} <div class="field-width">
{#if isTestModal} {#if isTestModal}
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}
@ -151,20 +151,20 @@
/> />
</DrawerBindableSlot> </DrawerBindableSlot>
{/if} {/if}
{/if}
{#if isUpdateRow && schema.type === "link"} {#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field"> <div class="checkbox-field">
<Checkbox <Checkbox
value={meta.fields?.[field]?.clearRelationships} value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"} text={"Clear relationships if empty?"}
size={"S"} size={"S"}
on:change={e => onChangeSetting(e, field)} on:change={e => onChangeSetting(e, field)}
/> />
</div> </div>
{/if} {/if}
</div>
</div> </div>
</div> {/if}
{/each} {/each}
{/if} {/if}

View File

@ -67,6 +67,7 @@
bind:linkedRows={value[field]} bind:linkedRows={value[field]}
{schema} {schema}
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false}
/> />
{:else if schema.type === "string" || schema.type === "number"} {:else if schema.type === "string" || schema.type === "number"}
<svelte:component <svelte:component

View File

@ -44,6 +44,8 @@
const NUMBER_TYPE = FIELDS.NUMBER.type const NUMBER_TYPE = FIELDS.NUMBER.type
const JSON_TYPE = FIELDS.JSON.type const JSON_TYPE = FIELDS.JSON.type
const DATE_TYPE = FIELDS.DATETIME.type const DATE_TYPE = FIELDS.DATETIME.type
const USER_TYPE = FIELDS.USER.subtype
const USERS_TYPE = FIELDS.USERS.subtype
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
@ -287,6 +289,14 @@
if (saveColumn.type !== LINK_TYPE) { if (saveColumn.type !== LINK_TYPE) {
delete saveColumn.fieldName delete saveColumn.fieldName
} }
if (isUsersColumn(saveColumn)) {
if (saveColumn.subtype === USER_TYPE) {
saveColumn.relationshipType = RelationshipType.ONE_TO_MANY
} else if (saveColumn.subtype === USERS_TYPE) {
saveColumn.relationshipType = RelationshipType.MANY_TO_MANY
}
}
try { try {
await tables.saveField({ await tables.saveField({
originalName, originalName,

View File

@ -8,7 +8,8 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import download from "downloadjs" import download from "downloadjs"
import { API } from "api" import { API } from "api"
import { Constants, LuceneUtils } from "@budibase/frontend-core" import { LuceneUtils } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { ROW_EXPORT_FORMATS } from "constants/backend" import { ROW_EXPORT_FORMATS } from "constants/backend"
export let view export let view
@ -32,6 +33,8 @@
}, },
] ]
$: appliedFilters = filters?.filter(filter => !filter.onEmptyFilter)
$: options = FORMATS.filter(format => { $: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) { if (formats && !formats.includes(format.key)) {
return false return false
@ -46,23 +49,20 @@
exportFormat = Array.isArray(options) ? options[0]?.key : [] exportFormat = Array.isArray(options) ? options[0]?.key : []
} }
$: luceneFilter = LuceneUtils.buildLuceneQuery(filters) $: luceneFilter = LuceneUtils.buildLuceneQuery(appliedFilters)
$: exportOpDisplay = buildExportOpDisplay(sorting, filterDisplay, filters) $: exportOpDisplay = buildExportOpDisplay(
sorting,
filterDisplay,
appliedFilters
)
const buildFilterLookup = () => { filterLookup = utils.filterValueToLabel()
return Object.keys(Constants.OperatorOptions).reduce((acc, key) => {
const op = Constants.OperatorOptions[key]
acc[op.value] = op.label
return acc
}, {})
}
filterLookup = buildFilterLookup()
const filterDisplay = () => { const filterDisplay = () => {
if (!filters) { if (!appliedFilters) {
return [] return []
} }
return filters.map(filter => { return appliedFilters.map(filter => {
let newFieldName = filter.field + "" let newFieldName = filter.field + ""
const parts = newFieldName.split(":") const parts = newFieldName.split(":")
parts.shift() parts.shift()
@ -77,7 +77,7 @@
const buildExportOpDisplay = (sorting, filterDisplay) => { const buildExportOpDisplay = (sorting, filterDisplay) => {
let filterDisplayConfig = filterDisplay() let filterDisplayConfig = filterDisplay()
if (sorting) { if (sorting?.sortColumn) {
filterDisplayConfig = [ filterDisplayConfig = [
...filterDisplayConfig, ...filterDisplayConfig,
{ {
@ -132,7 +132,7 @@
format: exportFormat, format: exportFormat,
}) })
downloadWithBlob(data, `export.${exportFormat}`) downloadWithBlob(data, `export.${exportFormat}`)
} else if (filters || sorting) { } else if (appliedFilters || sorting) {
let response let response
try { try {
response = await API.exportRows({ response = await API.exportRows({
@ -163,29 +163,33 @@
title="Export Data" title="Export Data"
confirmText="Export" confirmText="Export"
onConfirm={exportRows} onConfirm={exportRows}
size={filters?.length || sorting ? "M" : "S"} size={appliedFilters?.length || sorting ? "M" : "S"}
> >
{#if selectedRows?.length} {#if selectedRows?.length}
<Body size="S"> <Body size="S">
<strong>{selectedRows?.length}</strong> <span data-testid="exporting-n-rows">
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported`} <strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported`}
</span>
</Body> </Body>
{:else if filters || (sorting?.sortOrder && sorting?.sortColumn)} {:else if appliedFilters?.length || (sorting?.sortOrder && sorting?.sortColumn)}
<Body size="S"> <Body size="S">
{#if !filters} {#if !appliedFilters}
Exporting <strong>all</strong> rows <span data-testid="exporting-rows">
Exporting <strong>all</strong> rows
</span>
{:else} {:else}
Filters applied <span data-testid="filters-applied">Filters applied</span>
{/if} {/if}
</Body> </Body>
<div class="table-wrap"> <div class="table-wrap" data-testid="export-config-table">
<Table <Table
schema={displaySchema} schema={displaySchema}
data={exportOpDisplay} data={exportOpDisplay}
{filters} {appliedFilters}
loading={false} loading={false}
rowCount={filters?.length + 1} rowCount={appliedFilters?.length + 1}
disableSorting={true} disableSorting={true}
allowSelectRows={false} allowSelectRows={false}
allowEditRows={false} allowEditRows={false}
@ -196,18 +200,21 @@
</div> </div>
{:else} {:else}
<Body size="S"> <Body size="S">
Exporting <strong>all</strong> rows <span data-testid="export-all-rows">
Exporting <strong>all</strong> rows
</span>
</Body> </Body>
{/if} {/if}
<span data-testid="format-select">
<Select <Select
label="Format" label="Format"
bind:value={exportFormat} bind:value={exportFormat}
{options} {options}
placeholder={null} placeholder={null}
getOptionLabel={x => x.name} getOptionLabel={x => x.name}
getOptionValue={x => x.key} getOptionValue={x => x.key}
/> />
</span>
</ModalContent> </ModalContent>
<style> <style>

View File

@ -0,0 +1,240 @@
import { it, expect, describe, vi } from "vitest"
import { render, screen } from "@testing-library/svelte"
import "@testing-library/jest-dom"
import ExportModal from "./ExportModal.svelte"
import { utils } from "@budibase/shared-core"
const labelLookup = utils.filterValueToLabel()
const rowText = filter => {
let readableField = filter.field.split(":")[1]
let rowLabel = labelLookup[filter.operator]
let value = Array.isArray(filter.value)
? JSON.stringify(filter.value)
: filter.value
return `${readableField}${rowLabel}${value}`.trim()
}
const defaultFilters = [
{
onEmptyFilter: "all",
},
]
vi.mock("svelte", async () => {
return {
getContext: () => {
return {
hide: vi.fn(),
cancel: vi.fn(),
}
},
createEventDispatcher: vi.fn(),
onDestroy: vi.fn(),
}
})
vi.mock("api", async () => {
return {
API: {
exportView: vi.fn(),
exportRows: vi.fn(),
},
}
})
describe("Export Modal", () => {
it("show default messaging with no export config specified", () => {
render(ExportModal, {
props: {},
})
expect(screen.getByTestId("export-all-rows")).toBeVisible()
expect(screen.getByTestId("export-all-rows")).toHaveTextContent(
"Exporting all rows"
)
expect(screen.queryByTestId("export-config-table")).toBe(null)
})
it("indicate that a filter is being applied to the export", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.getByTestId("filters-applied")).toBeVisible()
expect(screen.getByTestId("filters-applied").textContent).toBe(
"Filters applied"
)
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
let rowTextContent = rowText(propsCfg.filters[0])
//"CostLess than or equal to100"
expect(rows[0].textContent?.trim()).toEqual(rowTextContent)
})
it("Show only selected row messaging if rows are supplied", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
selectedRows: [
{
_id: "ro_ta_bb_expenses_57d5f6fe1b6640d8bb22b15f5eae62cd",
},
{
_id: "ro_ta_bb_expenses_99ce5760a53a430bab4349cd70335a07",
},
],
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeNull()
expect(screen.queryByTestId("filters-applied")).toBeNull()
expect(screen.queryByTestId("exporting-n-rows")).toBeVisible()
expect(screen.queryByTestId("exporting-n-rows").textContent).toEqual(
"2 rows will be exported"
)
})
it("Show only the configured sort when no filters are specified", () => {
const propsCfg = {
filters: [...defaultFilters],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeVisible()
const ele = screen.queryByTestId("export-config-table")
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
expect(rows[0].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("Display all currently configured filters and applied sort", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
{
id: "2ot-aB0gE",
field: "2:Expense Tags",
operator: "contains",
value: ["Equipment", "Services"],
valueType: "Value",
type: "array",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Payment Due",
sortOrder: "ascending",
},
}
render(ExportModal, {
props: propsCfg,
})
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(3)
let rowTextContent1 = rowText(propsCfg.filters[0])
expect(rows[0].textContent?.trim()).toEqual(rowTextContent1)
let rowTextContent2 = rowText(propsCfg.filters[1])
expect(rows[1].textContent?.trim()).toEqual(rowTextContent2)
expect(rows[2].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("show only the valid, configured download formats", () => {
const propsCfg = {
formats: ["badger", "json"],
}
render(ExportModal, {
props: propsCfg,
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("JSON")
})
it("Load the default format config when no explicit formats are configured", () => {
render(ExportModal, {
props: {},
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("CSV")
})
})

View File

@ -33,6 +33,10 @@
part1: PrettyRelationshipDefinitions.MANY, part1: PrettyRelationshipDefinitions.MANY,
part2: PrettyRelationshipDefinitions.ONE, part2: PrettyRelationshipDefinitions.ONE,
}, },
[RelationshipType.ONE_TO_MANY]: {
part1: PrettyRelationshipDefinitions.ONE,
part2: PrettyRelationshipDefinitions.MANY,
},
} }
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
@ -58,7 +62,7 @@
let fromPrimary, fromForeign, fromColumn, toColumn let fromPrimary, fromForeign, fromColumn, toColumn
let throughId, throughToKey, throughFromKey let throughId, throughToKey, throughFromKey
let isManyToMany, isManyToOne, relationshipType let relationshipType
let hasValidated = false let hasValidated = false
$: fromId = null $: fromId = null
@ -85,8 +89,9 @@
$: valid = $: valid =
getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType) getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType)
$: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY $: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
$: isManyToOne = relationshipType === RelationshipType.MANY_TO_ONE $: isManyToOne =
relationshipType === RelationshipType.MANY_TO_ONE ||
relationshipType === RelationshipType.ONE_TO_MANY
function getTable(id) { function getTable(id) {
return plusTables.find(table => table._id === id) return plusTables.find(table => table._id === id)
} }

View File

@ -53,7 +53,7 @@
selected={isViewActive(view, $isActive, $views, $viewsV2)} selected={isViewActive(view, $isActive, $views, $viewsV2)}
on:click={() => { on:click={() => {
if (view.version === 2) { if (view.version === 2) {
$goto(`./view/v2/${view.id}`) $goto(`./view/v2/${encodeURIComponent(view.id)}`)
} else { } else {
$goto(`./view/v1/${encodeURIComponent(name)}`) $goto(`./view/v1/${encodeURIComponent(name)}`)
} }

View File

@ -7,7 +7,7 @@
export let schema export let schema
export let linkedRows = [] export let linkedRows = []
export let useLabel = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let rows = [] let rows = []
@ -51,7 +51,7 @@
linkedIds = e.detail ? [e.detail] : [] linkedIds = e.detail ? [e.detail] : []
dispatch("change", linkedIds) dispatch("change", linkedIds)
}} }}
{label} label={useLabel ? label : null}
sort sort
/> />
{:else} {:else}

View File

@ -21,6 +21,7 @@
export let allowHelpers = true export let allowHelpers = true
export let updateOnChange = true export let updateOnChange = true
export let drawerLeft export let drawerLeft
export let disableBindings = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
@ -62,7 +63,7 @@
{placeholder} {placeholder}
{updateOnChange} {updateOnChange}
/> />
{#if !disabled} {#if !disabled && !disableBindings}
<div <div
class="icon" class="icon"
on:click={() => { on:click={() => {

View File

@ -21,7 +21,8 @@
$: schemaComponents = getContextProviderComponents( $: schemaComponents = getContextProviderComponents(
$currentAsset, $currentAsset,
$store.selectedComponentId, $store.selectedComponentId,
"schema" "schema",
{ includeSelf: nested }
) )
$: providerOptions = getProviderOptions(formComponents, schemaComponents) $: providerOptions = getProviderOptions(formComponents, schemaComponents)
$: schemaFields = getSchemaFields(parameters?.tableId) $: schemaFields = getSchemaFields(parameters?.tableId)

View File

@ -4,10 +4,15 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { store } from "builderStore" import { store } from "builderStore"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { getEventContextBindings } from "builderStore/dataBinding"
export let componentInstance
export let componentBindings export let componentBindings
export let bindings export let bindings
export let value export let value
export let key
export let nested
export let max
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -15,12 +20,18 @@
$: buttonList = sanitizeValue(value) || [] $: buttonList = sanitizeValue(value) || []
$: buttonCount = buttonList.length $: buttonCount = buttonList.length
$: eventContextBindings = getEventContextBindings({
componentInstance,
settingKey: key,
})
$: allBindings = [...bindings, ...eventContextBindings]
$: itemProps = { $: itemProps = {
componentBindings: componentBindings || [], componentBindings: componentBindings || [],
bindings, bindings: allBindings,
removeButton, removeButton,
canRemove: buttonCount > 1, nested,
} }
$: canAddButtons = max == null || buttonList.length < max
const sanitizeValue = val => { const sanitizeValue = val => {
return val?.map(button => { return val?.map(button => {
@ -86,11 +97,16 @@
focus={focusItem} focus={focusItem}
draggable={buttonCount > 1} draggable={buttonCount > 1}
/> />
<div class="list-footer" on:click={addButton}>
<div class="add-button">Add button</div>
</div>
{/if} {/if}
<div
class="list-footer"
class:disabled={!canAddButtons}
on:click={addButton}
class:empty={!buttonCount}
>
<div class="add-button">Add button</div>
</div>
</div> </div>
<style> <style>
@ -120,15 +136,21 @@
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)); var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
cursor: pointer; cursor: pointer;
} }
.list-footer.empty {
.add-button { border-radius: 4px;
margin: var(--spacing-s); }
.list-footer.disabled {
color: var(--spectrum-global-color-gray-500);
pointer-events: none;
} }
.list-footer:hover { .list-footer:hover {
background-color: var( background-color: var(
--spectrum-table-row-background-color-hover, --spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover) var(--spectrum-alias-highlight-hover)
); );
} }
.add-button {
margin: var(--spacing-s);
}
</style> </style>

View File

@ -9,11 +9,33 @@
export let bindings export let bindings
export let anchor export let anchor
export let removeButton export let removeButton
export let canRemove export let nested
$: readableText = isJSBinding(item.text) $: readableText = isJSBinding(item.text)
? "(JavaScript function)" ? "(JavaScript function)"
: runtimeToReadableBinding([...bindings, componentBindings], item.text) : runtimeToReadableBinding([...bindings, componentBindings], item.text)
// If this is a nested setting (for example inside a grid or form block) then
// we need to mark all the settings of the actual buttons as nested too, to
// allow us to reference context provided by the block.
// We will need to update this in future if the normal button component
// gets broken into multiple settings sections, as we assume a flat array.
const updatedNestedFlags = settings => {
if (!nested || !settings?.length) {
return settings
}
let newSettings = settings.map(setting => ({
...setting,
nested: true,
}))
// We need to prevent bindings for the button names because of how grid
// blocks work. This is an edge case but unavoidable.
let name = newSettings.find(x => x.key === "text")
if (name) {
name.disableBindings = true
}
return newSettings
}
</script> </script>
<div class="list-item-body"> <div class="list-item-body">
@ -24,12 +46,12 @@
{componentBindings} {componentBindings}
{bindings} {bindings}
on:change on:change
parseSettings={updatedNestedFlags}
/> />
<div class="field-label">{readableText || "Button"}</div> <div class="field-label">{readableText || "Button"}</div>
</div> </div>
<div class="list-item-right"> <div class="list-item-right">
<Icon <Icon
disabled={!canRemove}
size="S" size="S"
name="Close" name="Close"
hoverable hoverable

View File

@ -11,6 +11,7 @@
export let componentBindings export let componentBindings
export let bindings export let bindings
export let parseSettings export let parseSettings
export let disabled
const draggable = getContext("draggable") const draggable = getContext("draggable")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -1,26 +0,0 @@
<script>
import { DrawerContent, Drawer, Button, Icon } from "@budibase/bbui"
import ValidationDrawer from "components/design/settings/controls/ValidationEditor/ValidationDrawer.svelte"
export let column
export let type
let drawer
</script>
<Icon name="Settings" hoverable size="S" on:click={drawer.show} />
<Drawer bind:this={drawer} title="Field Validation">
<svelte:fragment slot="description">
"{column.name}" field validation
</svelte:fragment>
<Button cta slot="buttons" on:click={drawer.hide}>Save</Button>
<DrawerContent slot="body">
<div class="container">
<ValidationDrawer
slot="body"
bind:rules={column.validation}
fieldName={column.name}
{type}
/>
</div>
</DrawerContent>
</Drawer>

Some files were not shown because too many files have changed in this diff Show More