Merge remote-tracking branch 'origin/develop' into test/9339-sqlpostgres-row-api-test-suite

This commit is contained in:
adrinr 2023-02-06 09:12:13 +00:00
commit 9bb1a2fa18
177 changed files with 2777 additions and 1724 deletions

View File

@ -194,5 +194,5 @@ jobs:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }} PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys
event: deploy-develop-to-qa event: deploy-budibase-develop-to-qa
github_pat: ${{ secrets.GH_ACCESS_TOKEN }} github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v14.19.3

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.11.1

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
nodejs 14.19.3
python 3.11.1

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"svelte.svelte-vscode"
]
}

46
.vscode/settings.json vendored
View File

@ -1,22 +1,28 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": true "source.fixAll": true
}, },
"editor.defaultFormatter": "svelte.svelte-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"[json]": { "[json]": {
"editor.defaultFormatter": "vscode.json-language-features" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"debug.javascript.terminalOptions": { "debug.javascript.terminalOptions": {
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/packages/backend-core/node_modules/**", "${workspaceFolder}/packages/backend-core/node_modules/**",
"<node_internals>/**" "<node_internals>/**"
] ]
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[dockercompose]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
}
} }

View File

@ -14,6 +14,9 @@ metadata:
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]' alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
alb.ingress.kubernetes.io/certificate-arn: {{ .Values.ingress.certificateArn }} alb.ingress.kubernetes.io/certificate-arn: {{ .Values.ingress.certificateArn }}
{{- end }} {{- end }}
{{- if .Values.ingress.securityGroups }}
alb.ingress.kubernetes.io/security-groups: {{ .Values.ingress.securityGroups }}
{{- end }}
spec: spec:
rules: rules:
- http: - http:

View File

@ -76,7 +76,7 @@ affinity: {}
globals: globals:
appVersion: "latest" appVersion: "latest"
budibaseEnv: PRODUCTION budibaseEnv: PRODUCTION
tenantFeatureFlags: "*:LICENSING,*:USER_GROUPS" tenantFeatureFlags: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR"
enableAnalytics: "1" enableAnalytics: "1"
sentryDSN: "" sentryDSN: ""
posthogToken: "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU" posthogToken: "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU"

View File

@ -9,7 +9,6 @@ From opening a bug report to creating a pull request: every contribution is appr
- [Glossary of Terms](#glossary-of-terms) - [Glossary of Terms](#glossary-of-terms)
- [Contributing to Budibase](#contributing-to-budibase) - [Contributing to Budibase](#contributing-to-budibase)
## Not Sure Where to Start? ## Not Sure Where to Start?
Budibase is a low-code web application builder that creates svelte-based web applications. Budibase is a low-code web application builder that creates svelte-based web applications.
@ -22,7 +21,7 @@ Budibase is a monorepo managed by [lerna](https://github.com/lerna/lerna). Lerna
- **packages/server** - The budibase server. This [Koa](https://koajs.com/) app is responsible for serving the JS for the builder and budibase apps, as well as providing the API for interaction with the database and file system. - **packages/server** - The budibase server. This [Koa](https://koajs.com/) app is responsible for serving the JS for the builder and budibase apps, as well as providing the API for interaction with the database and file system.
- **packages/worker** - This [Koa](https://koajs.com/) app is responsible for providing global apis for managing your budibase installation. Authentication, Users, Email, Org and Auth configs are all provided by the worker. - **packages/worker** - This [Koa](https://koajs.com/) app is responsible for providing global apis for managing your budibase installation. Authentication, Users, Email, Org and Auth configs are all provided by the worker.
## Contributor License Agreement (CLA) ## Contributor License Agreement (CLA)
@ -45,7 +44,7 @@ A client represents a single budibase customer. Each budibase client will have 1
### App ### App
A client can have one or more budibase applications. Budibase applications would be things like "Developer Inventory Management" or "Goat Herder CRM". Think of a budibase application as a tree. A client can have one or more budibase applications. Budibase applications would be things like "Developer Inventory Management" or "Goat Herder CRM". Think of a budibase application as a tree.
### Database ### Database
@ -73,28 +72,55 @@ A component is the basic frontend building block of a budibase app.
### Component Library ### Component Library
Component libraries are collections of components as well as the definition of their props contained in a file called `components.json`. Component libraries are collections of components as well as the definition of their props contained in a file called `components.json`.
## Contributing to Budibase ## Contributing to Budibase
* Please maintain the existing code style. - Please maintain the existing code style.
* Please try to keep your commits small and focused. - Please try to keep your commits small and focused.
* Please write tests. - Please write tests.
* If the project diverges from your branch, please rebase instead of merging. This makes the commit graph easier to read. - If the project diverges from your branch, please rebase instead of merging. This makes the commit graph easier to read.
* Once your work is completed, please raise a PR against the `develop` branch with some information about what has changed and why. - Once your work is completed, please raise a PR against the `develop` branch with some information about what has changed and why.
### Getting Started For Contributors ### Getting Started For Contributors
#### 1. Prerequisites
NodeJS Version `14.x.x` #### 1. Prerequisites
*yarn -* `npm install -g yarn` - NodeJS version `14.x.x`
- Python version `3.x`
*jest* - `npm install -g jest` ### Using asdf (recommended)
Asdf is a package manager that allows managing multiple dependencies.
You can install them following any of the steps described below:
- Install using script (only for mac users):
`./scripts/install-contributor-dependencies.sh`
- Or, manually:
- Installation steps: https://asdf-vm.com/guide/getting-started.html
- asdf plugin add nodejs
- asdf plugin add python
- npm install -g yarn
### Using NVM and pyenv
- NVM:
- Install: https://github.com/nvm-sh/nvm#installing-and-updating
- Setup: `nvm use`
- Pyenv:
- Install: https://github.com/pyenv/pyenv#installation
- Setup: `pyenv install -v 3.7.2`
- _yarn -_ `npm install -g yarn`
#### 2. Clone this repository #### 2. Clone this repository
@ -102,7 +128,7 @@ NodeJS Version `14.x.x`
then `cd ` into your local copy. then `cd ` into your local copy.
#### 3. Install and Build #### 3. Install and Build
| **NOTE**: On Windows, all yarn commands must be executed on a bash shell (e.g. git bash) | **NOTE**: On Windows, all yarn commands must be executed on a bash shell (e.g. git bash)
@ -134,9 +160,9 @@ This will enable watch mode for both the builder app, server, client library and
#### 5. Debugging using VS Code #### 5. Debugging using VS Code
To debug the budibase server and worker a VS Code launch configuration has been provided. To debug the budibase server and worker a VS Code launch configuration has been provided.
Visit the debug window and select `Budibase Server` or `Budibase Worker` to debug the respective component. Visit the debug window and select `Budibase Server` or `Budibase Worker` to debug the respective component.
Alternatively to start both components simultaneously select `Start Budibase`. Alternatively to start both components simultaneously select `Start Budibase`.
In addition to the above, the remaining budibase components may be run in dev mode using: `yarn dev:noserver`. In addition to the above, the remaining budibase components may be run in dev mode using: `yarn dev:noserver`.
@ -156,11 +182,11 @@ For the backend we run [Redis](https://redis.io/), [CouchDB](https://couchdb.apa
When you are running locally, budibase stores data on disk using docker volumes. The volumes and the types of data associated with each are: When you are running locally, budibase stores data on disk using docker volumes. The volumes and the types of data associated with each are:
- `redis_data` - `redis_data`
- Sessions, email tokens - Sessions, email tokens
- `couchdb3_data` - `couchdb3_data`
- Global and app databases - Global and app databases
- `minio_data` - `minio_data`
- App manifest, budibase client, static assets - App manifest, budibase client, static assets
### Development Modes ### Development Modes
@ -172,34 +198,42 @@ A combination of environment variables controls the mode budibase runs in.
Yarn commands can be used to mimic the different modes as described in the sections below: Yarn commands can be used to mimic the different modes as described in the sections below:
#### Self Hosted #### Self Hosted
The default mode. A single tenant installation with no usage restrictions.
The default mode. A single tenant installation with no usage restrictions.
To enable this mode, use: To enable this mode, use:
``` ```
yarn mode:self yarn mode:self
``` ```
#### Cloud #### Cloud
The cloud mode, with account portal turned off.
The cloud mode, with account portal turned off.
To enable this mode, use: To enable this mode, use:
``` ```
yarn mode:cloud yarn mode:cloud
``` ```
#### Cloud & Account
The cloud mode, with account portal turned on. This is a replica of the mode that runs at https://budibase.app
#### Cloud & Account
The cloud mode, with account portal turned on. This is a replica of the mode that runs at https://budibase.app
To enable this mode, use: To enable this mode, use:
``` ```
yarn mode:account yarn mode:account
``` ```
### CI ### CI
An overview of the CI pipelines can be found [here](../.github/workflows/README.md)
An overview of the CI pipelines can be found [here](../.github/workflows/README.md)
### Pro ### Pro
@budibase/pro is the closed source package that supports licensed features in budibase. By default the package will be pulled from NPM and will not normally need to be touched in local development. If you require to update code inside the pro package it can be cloned to the same root level as budibase, e.g. @budibase/pro is the closed source package that supports licensed features in budibase. By default the package will be pulled from NPM and will not normally need to be touched in local development. If you require to update code inside the pro package it can be cloned to the same root level as budibase, e.g.
``` ```
. .
@ -207,13 +241,14 @@ yarn mode:account
|_ budibase-pro |_ budibase-pro
``` ```
Note that only budibase maintainers will be able to access the pro repo. Note that only budibase maintainers will be able to access the pro repo.
The `yarn bootstrap` command can be used to replace the NPM supplied dependency with the local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev. The `yarn bootstrap` command can be used to replace the NPM supplied dependency with the local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev.
### Troubleshooting ### Troubleshooting
Sometimes, things go wrong. This can be due to incompatible updates on the budibase platform. To clear down your development environment and start again follow **Step 6. Cleanup**, then proceed from **Step 3. Install and Build** in the setup guide above to create a fresh Budibase installation. Sometimes, things go wrong. This can be due to incompatible updates on the budibase platform. To clear down your development environment and start again follow **Step 6. Cleanup**, then proceed from **Step 3. Install and Build** in the setup guide above to create a fresh Budibase installation.
### Running tests ### Running tests
#### End-to-end Tests #### End-to-end Tests
@ -226,12 +261,11 @@ yarn test:e2e
Or if you are in the builder you can run `yarn cy:test`. Or if you are in the builder you can run `yarn cy:test`.
### Other Useful Information ### Other Useful Information
* The contributors are listed in [AUTHORS.md](https://github.com/Budibase/budibase/blob/master/.github/AUTHORS.md) (add yourself). - The contributors are listed in [AUTHORS.md](https://github.com/Budibase/budibase/blob/master/.github/AUTHORS.md) (add yourself).
* This project uses a modified version of the MPLv2 license, see [LICENSE](https://github.com/budibase/server/blob/master/LICENSE). - This project uses a modified version of the MPLv2 license, see [LICENSE](https://github.com/budibase/server/blob/master/LICENSE).
* We use the [C4 (Collective Code Construction Contract)](https://rfc.zeromq.org/spec:42/C4/) process for contributions. - We use the [C4 (Collective Code Construction Contract)](https://rfc.zeromq.org/spec:42/C4/) process for contributions.
Please read this if you are unfamiliar with it. Please read this if you are unfamiliar with it.

View File

@ -4,5 +4,5 @@ redis-server --requirepass $REDIS_PASSWORD > /dev/stdout 2>&1 &
/bbcouch-runner.sh & /bbcouch-runner.sh &
/minio/minio server ${DATA_DIR}/minio --console-address ":9001" > /dev/stdout 2>&1 & /minio/minio server ${DATA_DIR}/minio --console-address ":9001" > /dev/stdout 2>&1 &
echo "Test environment started..." echo "Budibase dependencies started..."
sleep infinity sleep infinity

View File

@ -53,20 +53,6 @@ services:
volumes: volumes:
- couchdb_data:/data - couchdb_data:/data
couch-init:
container_name: budi-couchdb-init-dev
image: curlimages/curl
environment:
PUT_CALL: "curl -u ${COUCH_DB_USER}:${COUCH_DB_PASSWORD} -X PUT couchdb-service:5984"
depends_on:
- couchdb-service
command:
[
"sh",
"-c",
"sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;",
]
redis-service: redis-service:
container_name: budi-redis-dev container_name: budi-redis-dev
restart: on-failure restart: on-failure

View File

@ -10,7 +10,7 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
[[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000 [[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000
[[ -z "${NODE_ENV}" ]] && export NODE_ENV=production [[ -z "${NODE_ENV}" ]] && export NODE_ENV=production
[[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU [[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
[[ -z "${TENANT_FEATURE_FLAGS}" ]] && export TENANT_FEATURE_FLAGS="*:LICENSING,*:USER_GROUPS" [[ -z "${TENANT_FEATURE_FLAGS}" ]] && export TENANT_FEATURE_FLAGS="*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR"
[[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app [[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app
[[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379 [[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379
[[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1 [[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1

View File

@ -1,5 +1,5 @@
{ {
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,7 +1,7 @@
import { Config } from "jest" import { Config } from "@jest/types"
const preset = require("ts-jest/jest-preset") const preset = require("ts-jest/jest-preset")
const testContainersSettings = { const baseConfig: Config.InitialProjectOptions = {
...preset, ...preset,
preset: "@trendyol/jest-testcontainers", preset: "@trendyol/jest-testcontainers",
setupFiles: ["./tests/jestEnv.ts"], setupFiles: ["./tests/jestEnv.ts"],
@ -13,23 +13,23 @@ const testContainersSettings = {
if (!process.env.CI) { if (!process.env.CI) {
// use sources when not in CI // use sources when not in CI
testContainersSettings.moduleNameMapper = { baseConfig.moduleNameMapper = {
"@budibase/types": "<rootDir>/../types/src", "@budibase/types": "<rootDir>/../types/src",
} }
} else { } else {
console.log("Running tests with compiled dependency sources") console.log("Running tests with compiled dependency sources")
} }
const config: Config = { const config: Config.InitialOptions = {
projects: [ projects: [
{ {
...testContainersSettings, ...baseConfig,
displayName: "sequential test", displayName: "sequential test",
testMatch: ["<rootDir>/**/*.seq.spec.[jt]s"], testMatch: ["<rootDir>/**/*.seq.spec.[jt]s"],
runner: "jest-serial-runner", runner: "jest-serial-runner",
}, },
{ {
...testContainersSettings, ...baseConfig,
testMatch: ["<rootDir>/**/!(*.seq).spec.[jt]s"], testMatch: ["<rootDir>/**/!(*.seq).spec.[jt]s"],
}, },
], ],

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -23,7 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.1", "@budibase/nano": "10.1.1",
"@budibase/types": "2.2.12-alpha.59", "@budibase/types": "2.2.27-alpha.0",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",

View File

@ -1,17 +1,17 @@
const{generator}=require("../../../tests") require("../../../tests")
const { Writethrough } = require("../writethrough") const { Writethrough } = require("../writethrough")
const { getDB } = require("../../db") const { getDB } = require("../../db")
const tk = require("timekeeper") const tk = require("timekeeper")
const { structures } = require("../../../tests")
const START_DATE = Date.now() const START_DATE = Date.now()
tk.freeze(START_DATE) tk.freeze(START_DATE)
const { newid } = require("../../newid")
const DELAY = 5000 const DELAY = 5000
const db = getDB(`db_${newid()}`) const db = getDB(structures.db.id())
const db2 = getDB(`db_${newid()}`) const db2 = getDB(structures.db.id())
const writethrough = new Writethrough(db, DELAY), writethrough2 = new Writethrough(db2, DELAY) const writethrough = new Writethrough(db, DELAY), writethrough2 = new Writethrough(db2, DELAY)
describe("writethrough", () => { describe("writethrough", () => {

View File

@ -1,19 +1,19 @@
require("../../../tests") require("../../../tests")
const { newid } = require("../../newid") const { structures } = require("../../../tests")
const { getDB } = require("../db") const { getDB } = require("../db")
describe("db", () => { describe("db", () => {
describe("getDB", () => { describe("getDB", () => {
it("returns a db", async () => { it("returns a db", async () => {
const dbName = `db_${newid()}` const dbName = structures.db.id()
const db = getDB(dbName) const db = getDB(dbName)
expect(db).toBeDefined() expect(db).toBeDefined()
expect(db.name).toBe(dbName) expect(db.name).toBe(dbName)
}) })
it("uses the custom put function", async () => { it("uses the custom put function", async () => {
const db = getDB(`db_${newid()}`) const db = getDB(structures.db.id())
let doc = { _id: "test" } let doc = { _id: "test" }
await db.put(doc) await db.put(doc)
doc = await db.get(doc._id) doc = await db.get(doc._id)

View File

@ -6,7 +6,7 @@ import * as tenancy from "../tenancy"
* The env var is formatted as: * The env var is formatted as:
* tenant1:feature1:feature2,tenant2:feature1 * tenant1:feature1:feature2,tenant2:feature1
*/ */
function getFeatureFlags() { export function buildFeatureFlags() {
if (!env.TENANT_FEATURE_FLAGS) { if (!env.TENANT_FEATURE_FLAGS) {
return return
} }
@ -27,8 +27,6 @@ function getFeatureFlags() {
return tenantFeatureFlags return tenantFeatureFlags
} }
const TENANT_FEATURE_FLAGS = getFeatureFlags()
export function isEnabled(featureFlag: string) { export function isEnabled(featureFlag: string) {
const tenantId = tenancy.getTenantId() const tenantId = tenancy.getTenantId()
const flags = getTenantFeatureFlags(tenantId) const flags = getTenantFeatureFlags(tenantId)
@ -36,18 +34,36 @@ export function isEnabled(featureFlag: string) {
} }
export function getTenantFeatureFlags(tenantId: string) { export function getTenantFeatureFlags(tenantId: string) {
const flags = [] let flags: string[] = []
const envFlags = buildFeatureFlags()
if (envFlags) {
const globalFlags = envFlags["*"]
const tenantFlags = envFlags[tenantId] || []
if (TENANT_FEATURE_FLAGS) { // Explicitly exclude tenants from global features if required.
const globalFlags = TENANT_FEATURE_FLAGS["*"] // Prefix the tenant flag with '!'
const tenantFlags = TENANT_FEATURE_FLAGS[tenantId] const tenantOverrides = tenantFlags.reduce(
(acc: string[], flag: string) => {
if (flag.startsWith("!")) {
let stripped = flag.substring(1)
acc.push(stripped)
}
return acc
},
[]
)
if (globalFlags) { if (globalFlags) {
flags.push(...globalFlags) flags.push(...globalFlags)
} }
if (tenantFlags) { if (tenantFlags.length) {
flags.push(...tenantFlags) flags.push(...tenantFlags)
} }
// Purge any tenant specific overrides
flags = flags.filter(flag => {
return tenantOverrides.indexOf(flag) == -1 && !flag.startsWith("!")
})
} }
return flags return flags
@ -57,4 +73,5 @@ export enum TenantFeatureFlag {
LICENSING = "LICENSING", LICENSING = "LICENSING",
GOOGLE_SHEETS = "GOOGLE_SHEETS", GOOGLE_SHEETS = "GOOGLE_SHEETS",
USER_GROUPS = "USER_GROUPS", USER_GROUPS = "USER_GROUPS",
ONBOARDING_TOUR = "ONBOARDING_TOUR",
} }

View File

@ -0,0 +1,85 @@
import {
TenantFeatureFlag,
buildFeatureFlags,
getTenantFeatureFlags,
} from "../"
import env from "../../environment"
const { ONBOARDING_TOUR, LICENSING, USER_GROUPS } = TenantFeatureFlag
describe("featureFlags", () => {
beforeEach(() => {
env._set("TENANT_FEATURE_FLAGS", "")
})
it("Should return no flags when the TENANT_FEATURE_FLAG is empty", async () => {
let features = buildFeatureFlags()
expect(features).toBeUndefined()
})
it("Should generate a map of global and named tenant feature flags from the env value", async () => {
env._set(
"TENANT_FEATURE_FLAGS",
`*:${ONBOARDING_TOUR},tenant1:!${ONBOARDING_TOUR},tenant2:${USER_GROUPS},tenant1:${LICENSING}`
)
const parsedFlags: Record<string, string[]> = {
"*": [ONBOARDING_TOUR],
tenant1: [`!${ONBOARDING_TOUR}`, LICENSING],
tenant2: [USER_GROUPS],
}
let features = buildFeatureFlags()
expect(features).toBeDefined()
expect(features).toEqual(parsedFlags)
})
it("Should add feature flag flag only to explicitly configured tenant", async () => {
env._set(
"TENANT_FEATURE_FLAGS",
`*:${LICENSING},*:${USER_GROUPS},tenant1:${ONBOARDING_TOUR}`
)
let tenant1Flags = getTenantFeatureFlags("tenant1")
let tenant2Flags = getTenantFeatureFlags("tenant2")
expect(tenant1Flags).toBeDefined()
expect(tenant1Flags).toEqual([LICENSING, USER_GROUPS, ONBOARDING_TOUR])
expect(tenant2Flags).toBeDefined()
expect(tenant2Flags).toEqual([LICENSING, USER_GROUPS])
})
})
it("Should exclude tenant1 from global feature flag", async () => {
env._set(
"TENANT_FEATURE_FLAGS",
`*:${LICENSING},*:${ONBOARDING_TOUR},tenant1:!${ONBOARDING_TOUR}`
)
let tenant1Flags = getTenantFeatureFlags("tenant1")
let tenant2Flags = getTenantFeatureFlags("tenant2")
expect(tenant1Flags).toBeDefined()
expect(tenant1Flags).toEqual([LICENSING])
expect(tenant2Flags).toBeDefined()
expect(tenant2Flags).toEqual([LICENSING, ONBOARDING_TOUR])
})
it("Should explicitly add flags to configured tenants only", async () => {
env._set(
"TENANT_FEATURE_FLAGS",
`tenant1:${ONBOARDING_TOUR},tenant1:${LICENSING},tenant2:${LICENSING}`
)
let tenant1Flags = getTenantFeatureFlags("tenant1")
let tenant2Flags = getTenantFeatureFlags("tenant2")
expect(tenant1Flags).toBeDefined()
expect(tenant1Flags).toEqual([ONBOARDING_TOUR, LICENSING])
expect(tenant2Flags).toBeDefined()
expect(tenant2Flags).toEqual([LICENSING])
})

View File

@ -64,7 +64,9 @@ const print = (fn: any, data: any[]) => {
message = message + ` [identityId=${identityId}]` message = message + ` [identityId=${identityId}]`
} }
// fn(message, data) if (!process.env.CI) {
fn(message, data)
}
} }
const logging = (ctx: any, next: any) => { const logging = (ctx: any, next: any) => {

View File

@ -6,8 +6,6 @@ const { DEFAULT_TENANT_ID } = require("../../../constants")
const { generateGlobalUserID } = require("../../../db/utils") const { generateGlobalUserID } = require("../../../db/utils")
const { newid } = require("../../../utils") const { newid } = require("../../../utils")
const { doWithGlobalDB, doInTenant } = require("../../../tenancy") const { doWithGlobalDB, doInTenant } = require("../../../tenancy")
const { default: environment } = require("../../../environment")
environment._set("MULTI_TENANCY", 'TRUE')
const done = jest.fn() const done = jest.fn()

View File

@ -2,9 +2,8 @@ require("../../../tests")
const { runMigrations, getMigrationsDoc } = require("../index") const { runMigrations, getMigrationsDoc } = require("../index")
const { getGlobalDBName, getDB } = require("../../db") const { getGlobalDBName, getDB } = require("../../db")
const { default: environment } = require("../../environment") const { structures, testEnv } = require("../../../tests")
const { newid } = require("../../newid") testEnv.multiTenant()
environment._set("MULTI_TENANCY", 'TRUE')
let db let db
@ -21,7 +20,7 @@ describe("migrations", () => {
let tenantId let tenantId
beforeEach(() => { beforeEach(() => {
tenantId = `tenant_${newid()}` tenantId = structures.tenant.id()
db = getDB(getGlobalDBName(tenantId)) db = getDB(getGlobalDBName(tenantId))
}) })

View File

@ -4,17 +4,12 @@ import * as events from "../../events"
import * as db from "../../db" import * as db from "../../db"
import { Header } from "../../constants" import { Header } from "../../constants"
import { doInTenant } from "../../context" import { doInTenant } from "../../context"
import environment from "../../environment"
import { newid } from "../../utils" import { newid } from "../../utils"
describe("utils", () => { describe("utils", () => {
describe("platformLogout", () => { describe("platformLogout", () => {
beforeEach(() => {
environment._set("MULTI_TENANCY", "TRUE")
})
it("should call platform logout", async () => { it("should call platform logout", async () => {
await doInTenant(`tenant-${newid()}`, async () => { await doInTenant(structures.tenant.id(), async () => {
const ctx = structures.koa.newContext() const ctx = structures.koa.newContext()
await utils.platformLogout({ ctx, userId: "test" }) await utils.platformLogout({ ctx, userId: "test" })
expect(events.auth.logout).toBeCalledTimes(1) expect(events.auth.logout).toBeCalledTimes(1)
@ -23,10 +18,6 @@ describe("utils", () => {
}) })
describe("getAppIdFromCtx", () => { describe("getAppIdFromCtx", () => {
beforeEach(() => {
environment._set("MULTI_TENANCY", undefined)
})
it("gets appId from header", async () => { it("gets appId from header", async () => {
const ctx = structures.koa.newContext() const ctx = structures.koa.newContext()
const expected = db.generateAppID() const expected = db.generateAppID()

View File

@ -0,0 +1,5 @@
import { newid } from "../../../src/newid"
export function id() {
return `db_${newid()}`
}

View File

@ -8,3 +8,5 @@ export * as apps from "./apps"
export * as koa from "./koa" export * as koa from "./koa"
export * as licenses from "./licenses" export * as licenses from "./licenses"
export * as plugins from "./plugins" export * as plugins from "./plugins"
export * as tenant from "./tenants"
export * as db from "./db"

View File

@ -0,0 +1,5 @@
import { newid } from "../../../src/newid"
export function id() {
return `tenant-${newid()}`
}

View File

@ -487,11 +487,6 @@
qs "^6.11.0" qs "^6.11.0"
tough-cookie "^4.1.2" tough-cookie "^4.1.2"
"@budibase/types@2.2.12-alpha.59":
version "2.2.12-alpha.59"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.2.12-alpha.59.tgz#41635c1e405acfa6162b5ca0f79f0c73f16bc764"
integrity sha512-cEcM0nnTEOEan9UYVspwcdgYgIbtY2zQTe1uDdwys+NFplMrbiwGyQbsafOx2IA0jCxmyqqYGmUAC0eF1napKQ==
"@cspotcode/source-map-support@^0.8.0": "@cspotcode/source-map-support@^0.8.0":
version "0.8.1" version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
@ -3126,9 +3121,9 @@ http-assert@^1.3.0:
http-errors "~1.8.0" http-errors "~1.8.0"
http-cache-semantics@^4.0.0: http-cache-semantics@^4.0.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
http-cookie-agent@^4.0.2: http-cookie-agent@^4.0.2:
version "4.0.2" version "4.0.2"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1", "@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/string-templates": "2.2.12-alpha.59", "@budibase/string-templates": "2.2.27-alpha.0",
"@spectrum-css/accordion": "3.0.24", "@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1", "@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1", "@spectrum-css/actiongroup": "1.0.1",

View File

@ -86,7 +86,7 @@
} }
.is-selected:not(.spectrum-ActionButton--emphasized):not(.spectrum-ActionButton--quiet) { .is-selected:not(.spectrum-ActionButton--emphasized):not(.spectrum-ActionButton--quiet) {
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-700); border-color: var(--spectrum-global-color-gray-500);
} }
.noPadding { .noPadding {
padding: 0; padding: 0;

View File

@ -1,11 +1,21 @@
export default function positionDropdown( export default function positionDropdown(element, opts) {
element, let resizeObserver
{ anchor, align, maxWidth, useAnchorWidth, offset = 5 } let latestOpts = opts
) {
const update = () => { // We need a static reference to this function so that we can properly
// clean up the scroll listener.
const scrollUpdate = () => {
updatePosition(latestOpts)
}
// Updates the position of the dropdown
const updatePosition = opts => {
const { anchor, align, maxWidth, useAnchorWidth, offset = 5 } = opts
if (!anchor) { if (!anchor) {
return return
} }
// Compute bounds
const anchorBounds = anchor.getBoundingClientRect() const anchorBounds = anchor.getBoundingClientRect()
const elementBounds = element.getBoundingClientRect() const elementBounds = element.getBoundingClientRect()
let styles = { let styles = {
@ -51,26 +61,47 @@ export default function positionDropdown(
}) })
} }
// The actual svelte action callback which creates observers on the relevant
// DOM elements
const update = newOpts => {
latestOpts = newOpts
// Cleanup old state
if (resizeObserver) {
resizeObserver.disconnect()
}
// Do nothing if no anchor
const { anchor } = newOpts
if (!anchor) {
return
}
// Observe both anchor and element and resize the popover as appropriate
resizeObserver = new ResizeObserver(() => updatePosition(newOpts))
resizeObserver.observe(anchor)
resizeObserver.observe(element)
resizeObserver.observe(document.body)
}
// Apply initial styles which don't need to change // Apply initial styles which don't need to change
element.style.position = "absolute" element.style.position = "absolute"
element.style.zIndex = "9999" element.style.zIndex = "9999"
// Observe both anchor and element and resize the popover as appropriate // Set up a scroll listener
const resizeObserver = new ResizeObserver(entries => { document.addEventListener("scroll", scrollUpdate, true)
entries.forEach(update)
})
if (anchor) {
resizeObserver.observe(anchor)
}
resizeObserver.observe(element)
resizeObserver.observe(document.body)
document.addEventListener("scroll", update, true) // Perform initial update
update(opts)
return { return {
update,
destroy() { destroy() {
resizeObserver.disconnect() // Cleanup
document.removeEventListener("scroll", update, true) if (resizeObserver) {
resizeObserver.disconnect()
}
document.removeEventListener("scroll", scrollUpdate, true)
}, },
} }
} }

View File

@ -76,13 +76,6 @@
} }
// If time only set date component to 2000-01-01 // If time only set date component to 2000-01-01
if (timeOnly) { if (timeOnly) {
// Classic flackpickr causing issues.
// When selecting a value for the first time for a "time only" field,
// the time is always offset by 1 hour for some reason (regardless of time
// zone) so we need to correct it.
if (!value && newValue) {
newValue = new Date(dates[0].getTime() + 60 * 60 * 1000).toISOString()
}
newValue = `2000-01-01T${newValue.split("T")[1]}` newValue = `2000-01-01T${newValue.split("T")[1]}`
} }
@ -113,7 +106,7 @@
const clearDateOnBackspace = event => { const clearDateOnBackspace = event => {
if (["Backspace", "Clear", "Delete"].includes(event.key)) { if (["Backspace", "Clear", "Delete"].includes(event.key)) {
dispatch("change", null) dispatch("change", "")
flatpickr.close() flatpickr.close()
} }
} }

View File

@ -11,14 +11,31 @@
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
export let sort = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => dispatch("change", e.target.value) const onChange = e => dispatch("change", e.target.value)
const getSortedOptions = (options, getLabel, sort) => {
if (!options?.length || !Array.isArray(options)) {
return []
}
if (!sort) {
return options
}
return [...options].sort((a, b) => {
const labelA = getLabel(a)
const labelB = getLabel(b)
return labelA > labelB ? 1 : -1
})
}
$: parsedOptions = getSortedOptions(options, getOptionLabel, sort)
</script> </script>
<div class={`spectrum-FieldGroup spectrum-FieldGroup--${direction}`}> <div class={`spectrum-FieldGroup spectrum-FieldGroup--${direction}`}>
{#if options && Array.isArray(options)} {#if parsedOptions && Array.isArray(parsedOptions)}
{#each options as option} {#each parsedOptions as option}
<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"

View File

@ -57,30 +57,28 @@
</script> </script>
{#if open} {#if open}
{#key anchor} <Portal {target}>
<Portal {target}> <div
<div tabindex="0"
tabindex="0" use:positionDropdown={{
use:positionDropdown={{ anchor,
anchor, align,
align, maxWidth,
maxWidth, useAnchorWidth,
useAnchorWidth, offset,
offset, }}
}} use:clickOutside={{
use:clickOutside={{ callback: dismissible ? handleOutsideClick : () => {},
callback: dismissible ? handleOutsideClick : () => {}, anchor,
anchor, }}
}} on:keydown={handleEscape}
on:keydown={handleEscape} class="spectrum-Popover is-open"
class="spectrum-Popover is-open" role="presentation"
role="presentation" transition:fly|local={{ y: -20, duration: 200 }}
transition:fly|local={{ y: -20, duration: 200 }} >
> <slot />
<slot /> </div>
</div> </Portal>
</Portal>
{/key}
{/if} {/if}
<style> <style>

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -58,10 +58,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.2.12-alpha.59", "@budibase/bbui": "2.2.27-alpha.0",
"@budibase/client": "2.2.12-alpha.59", "@budibase/client": "2.2.27-alpha.0",
"@budibase/frontend-core": "2.2.12-alpha.59", "@budibase/frontend-core": "2.2.27-alpha.0",
"@budibase/string-templates": "2.2.12-alpha.59", "@budibase/string-templates": "2.2.27-alpha.0",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",

View File

@ -509,21 +509,24 @@ const getSelectedRowsBindings = asset => {
return bindings return bindings
} }
export const makeStateBinding = key => {
return {
type: "context",
runtimeBinding: `${makePropSafe("state")}.${makePropSafe(key)}`,
readableBinding: `State.${key}`,
category: "State",
icon: "AutomatedSegment",
display: { name: key },
}
}
/** /**
* Gets all state bindings that are globally available. * Gets all state bindings that are globally available.
*/ */
const getStateBindings = () => { const getStateBindings = () => {
let bindings = [] let bindings = []
if (get(store).clientFeatures?.state) { if (get(store).clientFeatures?.state) {
const safeState = makePropSafe("state") bindings = getAllStateVariables().map(makeStateBinding)
bindings = getAllStateVariables().map(key => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(key)}`,
readableBinding: `State.${key}`,
category: "State",
icon: "AutomatedSegment",
display: { name: key },
}))
} }
return bindings return bindings
} }

View File

@ -74,8 +74,19 @@
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = block?.type === "TRIGGER" $: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
const onChange = Utils.sequential(async (e, key) => { const onChange = Utils.sequential(async (e, key) => {
if (e.detail?.tableId) {
const tableSchema = getSchemaForTable(e.detail.tableId, {
searchableSchema: true,
}).schema
if (isTestModal) {
testData.schema = tableSchema
} else {
block.inputs.schema = tableSchema
}
}
try { try {
if (isTestModal) { if (isTestModal) {
// Special case for webhook, as it requires a body, but the schema already brings back the body's contents // Special case for webhook, as it requires a body, but the schema already brings back the body's contents
@ -321,9 +332,17 @@
<RowSelector <RowSelector
{block} {block}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings} {bindings}
{isTestModal} {isTestModal}
{isUpdateRow}
/> />
{:else if value.customType === "webhookUrl"} {:else if value.customType === "webhookUrl"}
<WebhookDisplay <WebhookDisplay

View File

@ -1,6 +1,6 @@
<script> <script>
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { Select } from "@budibase/bbui" import { Select, Checkbox } from "@budibase/bbui"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
@ -10,9 +10,11 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let value export let value
export let meta
export let bindings export let bindings
export let block export let block
export let isTestModal export let isTestModal
export let isUpdateRow
$: parsedBindings = bindings.map(binding => { $: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding) let clone = Object.assign({}, binding)
@ -97,6 +99,17 @@
dispatch("change", value) dispatch("change", value)
} }
const onChangeSetting = (e, field) => {
let fields = {}
fields[field] = {
clearRelationships: e.detail,
}
dispatch("change", {
key: "meta",
fields,
})
}
// Ensure any nullish tableId values get set to empty string so // Ensure any nullish tableId values get set to empty string so
// that the select works // that the select works
$: if (value?.tableId == null) value = { tableId: "" } $: if (value?.tableId == null) value = { tableId: "" }
@ -124,21 +137,33 @@
{onChange} {onChange}
/> />
{:else} {:else}
<svelte:component <div>
this={isTestModal ? ModalBindableInput : DrawerBindableInput} <svelte:component
placeholder={placeholders[schema.type]} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
panel={AutomationBindingPanel} placeholder={placeholders[schema.type]}
value={Array.isArray(value[field]) panel={AutomationBindingPanel}
? value[field].join(" ") value={Array.isArray(value[field])
: value[field]} ? value[field].join(" ")
on:change={e => onChange(e, field, schema.type)} : value[field]}
label={field} on:change={e => onChange(e, field, schema.type)}
type="string" label={field}
bindings={parsedBindings} type="string"
fillWidth={true} bindings={parsedBindings}
allowJS={true} fillWidth={true}
updateOnChange={false} allowJS={true}
/> updateOnChange={false}
/>
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e => onChangeSetting(e, field)}
/>
</div>
{/if}
</div>
{/if} {/if}
{/if} {/if}
{/if} {/if}
@ -155,4 +180,12 @@
.schema-fields :global(label) { .schema-fields :global(label) {
text-transform: capitalize; text-transform: capitalize;
} }
.checkbox-field {
padding-bottom: var(--spacing-s);
padding-left: 1px;
padding-top: var(--spacing-s);
}
.checkbox-field :global(label) {
text-transform: none;
}
</style> </style>

View File

@ -58,7 +58,7 @@
entries = entries.filter(f => f.name !== originalName) entries = entries.filter(f => f.name !== originalName)
} }
value = entries.reduce((newVals, current) => { value = entries.reduce((newVals, current) => {
newVals[current.name] = current.type newVals[current.name.trim()] = current.type
return newVals return newVals
}, {}) }, {})
dispatch("change", value) dispatch("change", value)

View File

@ -12,7 +12,7 @@
Modal, Modal,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/backend" import { tables, datasources } from "stores/backend"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -48,7 +48,22 @@
const { hide } = getContext(Context.Modal) const { hide } = getContext(Context.Modal)
let fieldDefinitions = cloneDeep(FIELDS) let fieldDefinitions = cloneDeep(FIELDS)
export let field = { export let field
let originalName
let linkEditDisabled
let primaryDisplay
let indexes = [...($tables.selected.indexes || [])]
let isCreating
let table = $tables.selected
let confirmDeleteDialog
let deletion
let savingColumn
let deleteColName
let jsonSchemaModal
let editableColumn = {
type: "string", type: "string",
constraints: fieldDefinitions.STRING.constraints, constraints: fieldDefinitions.STRING.constraints,
@ -56,48 +71,80 @@
fieldName: $tables.selected.name, fieldName: $tables.selected.name,
} }
let originalName = field.name $: if (primaryDisplay) {
const linkEditDisabled = originalName != null editableColumn.constraints.presence = { allowEmpty: false }
let primaryDisplay = }
$tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === field.name
let isCreating = originalName == null
let table = $tables.selected $: if (field && !savingColumn) {
let indexes = [...($tables.selected.indexes || [])] editableColumn = cloneDeep(field)
let confirmDeleteDialog originalName = editableColumn.name ? editableColumn.name + "" : null
let deletion linkEditDisabled = originalName != null
let deleteColName isCreating = originalName == null
let jsonSchemaModal primaryDisplay =
$tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name
}
$: checkConstraints(field) $: checkConstraints(editableColumn)
$: required = !!field?.constraints?.presence || primaryDisplay $: required = !!editableColumn?.constraints?.presence || primaryDisplay
$: uneditable = $: uneditable =
$tables.selected?._id === TableNames.USERS && $tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(field.name) UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
$: invalid = $: invalid =
!field.name || !editableColumn?.name ||
(field.type === LINK_TYPE && !field.tableId) || (editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0 Object.keys(errors).length !== 0
$: errors = checkErrors(field) $: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find( $: datasource = $datasources.list.find(
source => source._id === table?.sourceId source => source._id === table?.sourceId
) )
const getTableAutoColumnTypes = table => {
return Object.keys(table?.schema).reduce((acc, key) => {
let fieldSchema = table?.schema[key]
if (fieldSchema.autocolumn) {
acc.push(fieldSchema.subtype)
}
return acc
}, [])
}
let autoColumnInfo = getAutoColumnInformation()
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
if (!tableAutoColumnsTypes.includes(key)) {
acc[key] = autoColumnInfo[key]
}
return acc
}, {})
$: availableAutoColumnKeys = availableAutoColumns
? Object.keys(availableAutoColumns)
: []
$: autoColumnOptions = editableColumn.autocolumn
? autoColumnInfo
: availableAutoColumns
// used to select what different options can be displayed for column type // used to select what different options can be displayed for column type
$: canBeSearched = $: canBeSearched =
field.type !== LINK_TYPE && editableColumn?.type !== LINK_TYPE &&
field.type !== JSON_TYPE && editableColumn?.type !== JSON_TYPE &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY && editableColumn?.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY && editableColumn?.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY &&
field.type !== FORMULA_TYPE editableColumn?.type !== FORMULA_TYPE
$: canBeDisplay = $: canBeDisplay =
field.type !== LINK_TYPE && editableColumn?.type !== LINK_TYPE &&
field.type !== AUTO_TYPE && editableColumn?.type !== AUTO_TYPE &&
field.type !== JSON_TYPE editableColumn?.type !== JSON_TYPE &&
!editableColumn.autocolumn
$: canBeRequired = $: canBeRequired =
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE editableColumn?.type !== LINK_TYPE &&
$: relationshipOptions = getRelationshipOptions(field) !uneditable &&
editableColumn?.type !== AUTO_TYPE &&
!editableColumn.autocolumn
$: relationshipOptions = getRelationshipOptions(editableColumn)
$: external = table.type === "external" $: external = table.type === "external"
// in the case of internal tables the sourceId will just be undefined // in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter( $: tableOptions = $tables.list.filter(
@ -108,76 +155,90 @@
) )
$: typeEnabled = $: typeEnabled =
!originalName || !originalName ||
(originalName && SWITCHABLE_TYPES.indexOf(field.type) !== -1) (originalName &&
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
!editableColumn?.autocolumn)
async function saveColumn() { async function saveColumn() {
if (field.type === AUTO_TYPE) { savingColumn = true
field = buildAutoColumn($tables.selected.name, field.name, field.subtype) if (errors?.length) {
return
} }
if (field.type !== LINK_TYPE) {
delete field.fieldName let saveColumn = cloneDeep(editableColumn)
if (saveColumn.type === AUTO_TYPE) {
saveColumn = buildAutoColumn(
$tables.draft.name,
saveColumn.name,
saveColumn.subtype
)
}
if (saveColumn.type !== LINK_TYPE) {
delete saveColumn.fieldName
} }
try { try {
await tables.saveField({ await tables.saveField({
originalName, originalName,
field, field: saveColumn,
primaryDisplay, primaryDisplay,
indexes, indexes,
}) })
dispatch("updatecolumns") dispatch("updatecolumns")
} catch (err) { } catch (err) {
notifications.error("Error saving column") console.log(err)
notifications.error(`Error saving column: ${err.message}`)
} }
} }
function cancelEdit() { function cancelEdit() {
field.name = originalName editableColumn.name = originalName
} }
function deleteColumn() { function deleteColumn() {
try { try {
field.name = deleteColName editableColumn.name = deleteColName
if (field.name === $tables.selected.primaryDisplay) { if (editableColumn.name === $tables.selected.primaryDisplay) {
notifications.error("You cannot delete the display column") notifications.error("You cannot delete the display column")
} else { } else {
tables.deleteField(field) tables.deleteField(editableColumn)
notifications.success(`Column ${field.name} deleted.`) notifications.success(`Column ${editableColumn.name} deleted.`)
confirmDeleteDialog.hide() confirmDeleteDialog.hide()
hide() hide()
deletion = false deletion = false
dispatch("updatecolumns") dispatch("updatecolumns")
} }
} catch (error) { } catch (error) {
notifications.error("Error deleting column") notifications.error(`Error deleting column: ${error.message}`)
} }
} }
function handleTypeChange(event) { function handleTypeChange(event) {
// remove any extra fields that may not be related to this type // remove any extra fields that may not be related to this type
delete field.autocolumn delete editableColumn.autocolumn
delete field.subtype delete editableColumn.subtype
delete field.tableId delete editableColumn.tableId
delete field.relationshipType delete editableColumn.relationshipType
delete field.formulaType delete editableColumn.formulaType
// Add in defaults and initial definition // Add in defaults and initial definition
const definition = fieldDefinitions[event.detail?.toUpperCase()] const definition = fieldDefinitions[event.detail?.toUpperCase()]
if (definition?.constraints) { if (definition?.constraints) {
field.constraints = definition.constraints editableColumn.constraints = definition.constraints
} }
// Default relationships many to many // Default relationships many to many
if (field.type === LINK_TYPE) { if (editableColumn.type === LINK_TYPE) {
field.relationshipType = RelationshipTypes.MANY_TO_MANY editableColumn.relationshipType = RelationshipTypes.MANY_TO_MANY
} }
if (field.type === FORMULA_TYPE) { if (editableColumn.type === FORMULA_TYPE) {
field.formulaType = "dynamic" editableColumn.formulaType = "dynamic"
} }
} }
function onChangeRequired(e) { function onChangeRequired(e) {
const req = e.detail const req = e.detail
field.constraints.presence = req ? { allowEmpty: false } : false editableColumn.constraints.presence = req ? { allowEmpty: false } : false
required = req required = req
} }
@ -185,17 +246,17 @@
const isPrimary = e.detail const isPrimary = e.detail
// primary display is always required // primary display is always required
if (isPrimary) { if (isPrimary) {
field.constraints.presence = { allowEmpty: false } editableColumn.constraints.presence = { allowEmpty: false }
} }
} }
function onChangePrimaryIndex(e) { function onChangePrimaryIndex(e) {
indexes = e.detail ? [field.name] : [] indexes = e.detail ? [editableColumn.name] : []
} }
function onChangeSecondaryIndex(e) { function onChangeSecondaryIndex(e) {
if (e.detail) { if (e.detail) {
indexes[1] = field.name indexes[1] = editableColumn.name
} else { } else {
indexes = indexes.slice(0, 1) indexes = indexes.slice(0, 1)
} }
@ -246,11 +307,14 @@
} }
function getAllowedTypes() { function getAllowedTypes() {
if (originalName && ALLOWABLE_STRING_TYPES.indexOf(field.type) !== -1) { if (
originalName &&
ALLOWABLE_STRING_TYPES.indexOf(editableColumn.type) !== -1
) {
return ALLOWABLE_STRING_OPTIONS return ALLOWABLE_STRING_OPTIONS
} else if ( } else if (
originalName && originalName &&
ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1 ALLOWABLE_NUMBER_TYPES.indexOf(editableColumn.type) !== -1
) { ) {
return ALLOWABLE_NUMBER_OPTIONS return ALLOWABLE_NUMBER_OPTIONS
} else if (!external) { } else if (!external) {
@ -275,6 +339,9 @@
} }
function checkConstraints(fieldToCheck) { function checkConstraints(fieldToCheck) {
if (!fieldToCheck) {
return
}
// most types need this, just make sure its always present // most types need this, just make sure its always present
if (fieldToCheck && !fieldToCheck.constraints) { if (fieldToCheck && !fieldToCheck.constraints) {
fieldToCheck.constraints = {} fieldToCheck.constraints = {}
@ -296,10 +363,16 @@
} }
function checkErrors(fieldInfo) { function checkErrors(fieldInfo) {
if (!editableColumn) {
return {}
}
function inUse(tbl, column, ogName = null) { function inUse(tbl, column, ogName = null) {
return Object.keys(tbl?.schema || {}).some( const parsedColumn = column ? column.toLowerCase().trim() : column
key => key !== ogName && key === column
) return Object.keys(tbl?.schema || {}).some(key => {
let lowerKey = key.toLowerCase()
return lowerKey !== ogName?.toLowerCase() && lowerKey === parsedColumn
})
} }
const newError = {} const newError = {}
if (!external && fieldInfo.name?.startsWith("_")) { if (!external && fieldInfo.name?.startsWith("_")) {
@ -313,6 +386,11 @@
} else if (inUse($tables.selected, fieldInfo.name, originalName)) { } else if (inUse($tables.selected, fieldInfo.name, originalName)) {
newError.name = `Column name already in use.` newError.name = `Column name already in use.`
} }
if (fieldInfo.type == "auto" && !fieldInfo.subtype) {
newError.subtype = `Auto Column requires a type`
}
if (fieldInfo.fieldName && fieldInfo.tableId) { if (fieldInfo.fieldName && fieldInfo.tableId) {
const relatedTable = $tables.list.find( const relatedTable = $tables.list.find(
tbl => tbl._id === fieldInfo.tableId tbl => tbl._id === fieldInfo.tableId
@ -323,12 +401,6 @@
} }
return newError return newError
} }
onMount(() => {
if (primaryDisplay) {
field.constraints.presence = { allowEmpty: false }
}
})
</script> </script>
<ModalContent <ModalContent
@ -340,19 +412,26 @@
> >
<Input <Input
label="Name" label="Name"
bind:value={field.name} bind:value={editableColumn.name}
disabled={uneditable || (linkEditDisabled && field.type === LINK_TYPE)} disabled={uneditable ||
(linkEditDisabled && editableColumn.type === LINK_TYPE)}
error={errors?.name} error={errors?.name}
/> />
<Select <Select
disabled={!typeEnabled} disabled={!typeEnabled}
label="Type" label="Type"
bind:value={field.type} bind:value={editableColumn.type}
on:change={handleTypeChange} on:change={handleTypeChange}
options={getAllowedTypes()} options={getAllowedTypes()}
getOptionLabel={field => field.name} getOptionLabel={field => field.name}
getOptionValue={field => field.type} getOptionValue={field => field.type}
isOptionEnabled={option => {
if (option.type == AUTO_TYPE) {
return availableAutoColumnKeys?.length > 0
}
return true
}}
/> />
{#if canBeRequired || canBeDisplay} {#if canBeRequired || canBeDisplay}
@ -381,32 +460,32 @@
<div> <div>
<Label>Search Indexes</Label> <Label>Search Indexes</Label>
<Toggle <Toggle
value={indexes[0] === field.name} value={indexes[0] === editableColumn.name}
disabled={indexes[1] === field.name} disabled={indexes[1] === editableColumn.name}
on:change={onChangePrimaryIndex} on:change={onChangePrimaryIndex}
text="Primary" text="Primary"
/> />
<Toggle <Toggle
value={indexes[1] === field.name} value={indexes[1] === editableColumn.name}
disabled={!indexes[0] || indexes[0] === field.name} disabled={!indexes[0] || indexes[0] === editableColumn.name}
on:change={onChangeSecondaryIndex} on:change={onChangeSecondaryIndex}
text="Secondary" text="Secondary"
/> />
</div> </div>
{/if} {/if}
{#if field.type === "string"} {#if editableColumn.type === "string"}
<Input <Input
type="number" type="number"
label="Max Length" label="Max Length"
bind:value={field.constraints.length.maximum} bind:value={editableColumn.constraints.length.maximum}
/> />
{:else if field.type === "options"} {:else if editableColumn.type === "options"}
<ValuesList <ValuesList
label="Options (one per line)" label="Options (one per line)"
bind:values={field.constraints.inclusion} bind:values={editableColumn.constraints.inclusion}
/> />
{:else if field.type === "longform"} {:else if editableColumn.type === "longform"}
<div> <div>
<Label <Label
size="M" size="M"
@ -415,21 +494,24 @@
Formatting Formatting
</Label> </Label>
<Toggle <Toggle
bind:value={field.useRichText} bind:value={editableColumn.useRichText}
text="Enable rich text support (markdown)" text="Enable rich text support (markdown)"
/> />
</div> </div>
{:else if field.type === "array"} {:else if editableColumn.type === "array"}
<ValuesList <ValuesList
label="Options (one per line)" label="Options (one per line)"
bind:values={field.constraints.inclusion} bind:values={editableColumn.constraints.inclusion}
/> />
{:else if field.type === "datetime"} {:else if editableColumn.type === "datetime" && !editableColumn.autocolumn}
<DatePicker <DatePicker
label="Earliest" label="Earliest"
bind:value={field.constraints.datetime.earliest} bind:value={editableColumn.constraints.datetime.earliest}
/>
<DatePicker
label="Latest"
bind:value={editableColumn.constraints.datetime.latest}
/> />
<DatePicker label="Latest" bind:value={field.constraints.datetime.latest} />
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"} {#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
<div> <div>
<Label <Label
@ -439,25 +521,28 @@
> >
Time zones Time zones
</Label> </Label>
<Toggle bind:value={field.ignoreTimezones} text="Ignore time zones" /> <Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
/>
</div> </div>
{/if} {/if}
{:else if field.type === "number"} {:else if editableColumn.type === "number" && !editableColumn.autocolumn}
<Input <Input
type="number" type="number"
label="Min Value" label="Min Value"
bind:value={field.constraints.numericality.greaterThanOrEqualTo} bind:value={editableColumn.constraints.numericality.greaterThanOrEqualTo}
/> />
<Input <Input
type="number" type="number"
label="Max Value" label="Max Value"
bind:value={field.constraints.numericality.lessThanOrEqualTo} bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
/> />
{:else if field.type === "link"} {:else if editableColumn.type === "link"}
<Select <Select
label="Table" label="Table"
disabled={linkEditDisabled} disabled={linkEditDisabled}
bind:value={field.tableId} bind:value={editableColumn.tableId}
options={tableOptions} options={tableOptions}
getOptionLabel={table => table.name} getOptionLabel={table => table.name}
getOptionValue={table => table._id} getOptionValue={table => table._id}
@ -466,7 +551,7 @@
<RadioGroup <RadioGroup
disabled={linkEditDisabled} disabled={linkEditDisabled}
label="Define the relationship" label="Define the relationship"
bind:value={field.relationshipType} bind:value={editableColumn.relationshipType}
options={relationshipOptions} options={relationshipOptions}
getOptionLabel={option => option.name} getOptionLabel={option => option.name}
getOptionValue={option => option.value} getOptionValue={option => option.value}
@ -476,14 +561,14 @@
<Input <Input
disabled={linkEditDisabled} disabled={linkEditDisabled}
label={`Column name in other table`} label={`Column name in other table`}
bind:value={field.fieldName} bind:value={editableColumn.fieldName}
error={errors.relatedName} error={errors.relatedName}
/> />
{:else if field.type === FORMULA_TYPE} {:else if editableColumn.type === FORMULA_TYPE}
{#if !table.sql} {#if !table.sql}
<Select <Select
label="Formula type" label="Formula type"
bind:value={field.formulaType} bind:value={editableColumn.formulaType}
options={[ options={[
{ label: "Dynamic", value: "dynamic" }, { label: "Dynamic", value: "dynamic" },
{ label: "Static", value: "static" }, { label: "Static", value: "static" },
@ -497,25 +582,28 @@
<ModalBindableInput <ModalBindableInput
title="Formula" title="Formula"
label="Formula" label="Formula"
value={field.formula} value={editableColumn.formula}
on:change={e => (field.formula = e.detail)} on:change={e => (editableColumn.formula = e.detail)}
bindings={getBindings({ table })} bindings={getBindings({ table })}
allowJS allowJS
/> />
{:else if field.type === AUTO_TYPE} {:else if editableColumn.type === JSON_TYPE}
<Select
label="Auto column type"
value={field.subtype}
on:change={e => (field.subtype = e.detail)}
options={Object.entries(getAutoColumnInformation())}
getOptionLabel={option => option[1].name}
getOptionValue={option => option[0]}
/>
{:else if field.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor} <Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button >Open schema editor</Button
> >
{/if} {/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select
label="Auto column type"
value={editableColumn.subtype}
on:change={e => (editableColumn.subtype = e.detail)}
options={Object.entries(autoColumnOptions)}
getOptionLabel={option => option[1].name}
getOptionValue={option => option[0]}
disabled={!availableAutoColumnKeys?.length || editableColumn.autocolumn}
error={errors?.subtype}
/>
{/if}
<div slot="footer"> <div slot="footer">
{#if !uneditable && originalName != null} {#if !uneditable && originalName != null}
@ -525,11 +613,11 @@
</ModalContent> </ModalContent>
<Modal bind:this={jsonSchemaModal}> <Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal <JSONSchemaModal
schema={field.schema} schema={editableColumn.schema}
json={field.json} json={editableColumn.json}
on:save={({ detail }) => { on:save={({ detail }) => {
field.schema = detail.schema editableColumn.schema = detail.schema
field.json = detail.json editableColumn.json = detail.json
}} }}
/> />
</Modal> </Modal>

View File

@ -177,7 +177,7 @@
<EnvDropdown <EnvDropdown
showModal={() => showModal(configKey)} showModal={() => showModal(configKey)}
variables={$environment.variables} variables={$environment.variables}
type={schema[configKey].type} type={configKey === "port" ? "string" : schema[configKey].type}
on:change on:change
bind:value={config[configKey]} bind:value={config[configKey]}
error={$validation.errors[configKey]} error={$validation.errors[configKey]}

View File

@ -82,7 +82,7 @@
let displayString let displayString
if (throughTableName) { if (throughTableName) {
displayString = `${fromTableName} through ${throughTableName} ${toTableName}` displayString = `${fromTableName} ${toTableName}`
} else { } else {
displayString = `${fromTableName} → ${toTableName}` displayString = `${fromTableName} → ${toTableName}`
} }

View File

@ -10,17 +10,17 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { RelationshipErrorChecker } from "./relationshipErrors"
import { onMount } from "svelte"
export let save export let save
export let datasource export let datasource
export let plusTables = [] export let plusTables = []
export let fromRelationship = {} export let fromRelationship = {}
export let toRelationship = {} export let toRelationship = {}
export let selectedFromTable
export let close export let close
const colNotSet = "Please specify a column name"
const relationshipAlreadyExists =
"A relationship between these tables already exists."
const relationshipTypes = [ const relationshipTypes = [
{ {
label: "One to Many", label: "One to Many",
@ -42,63 +42,28 @@
) )
let tableOptions let tableOptions
let errorChecker = new RelationshipErrorChecker(
invalidThroughTable,
relationshipExists
)
let errors = {} let errors = {}
let hasClickedSave = !!fromRelationship.relationshipType let fromPrimary, fromForeign, fromColumn, toColumn
let fromPrimary,
fromForeign,
fromTable,
toTable,
throughTable,
fromColumn,
toColumn
let fromId, toId, throughId, throughToKey, throughFromKey let fromId, toId, throughId, throughToKey, throughFromKey
let isManyToMany, isManyToOne, relationshipType let isManyToMany, isManyToOne, relationshipType
let hasValidated = false
$: {
if (!fromPrimary) {
fromPrimary = fromRelationship.foreignKey
fromForeign = toRelationship.foreignKey
}
if (!fromColumn && !errors.fromColumn) {
fromColumn = toRelationship.name
}
if (!toColumn && !errors.toColumn) {
toColumn = fromRelationship.name
}
if (!fromId) {
fromId = toRelationship.tableId
}
if (!toId) {
toId = fromRelationship.tableId
}
if (!throughId) {
throughId = fromRelationship.through
throughFromKey = fromRelationship.throughFrom
throughToKey = fromRelationship.throughTo
}
if (!relationshipType) {
relationshipType = fromRelationship.relationshipType
}
}
$: tableOptions = plusTables.map(table => ({ $: tableOptions = plusTables.map(table => ({
label: table.name, label: table.name,
value: table._id, value: table._id,
})) }))
$: valid = getErrorCount(errors) === 0 || !hasClickedSave $: valid = getErrorCount(errors) === 0 && allRequiredAttributesSet()
$: isManyToMany = relationshipType === RelationshipTypes.MANY_TO_MANY $: isManyToMany = relationshipType === RelationshipTypes.MANY_TO_MANY
$: isManyToOne = relationshipType === RelationshipTypes.MANY_TO_ONE $: isManyToOne = relationshipType === RelationshipTypes.MANY_TO_ONE
$: fromTable = plusTables.find(table => table._id === fromId)
$: toTable = plusTables.find(table => table._id === toId)
$: throughTable = plusTables.find(table => table._id === throughId)
$: toRelationship.relationshipType = fromRelationship?.relationshipType $: toRelationship.relationshipType = fromRelationship?.relationshipType
const getErrorCount = errors => function getTable(id) {
Object.entries(errors) return plusTables.find(table => table._id === id)
.filter(entry => !!entry[1]) }
.map(entry => entry[0]).length
function invalidThroughTable() { function invalidThroughTable() {
// need to know the foreign key columns to check error // need to know the foreign key columns to check error
@ -116,93 +81,103 @@
} }
return false return false
} }
function relationshipExists() {
function validate() {
const isMany = relationshipType === RelationshipTypes.MANY_TO_MANY
const tableNotSet = "Please specify a table"
const foreignKeyNotSet = "Please pick a foreign key"
const errObj = {}
if (!relationshipType) {
errObj.relationshipType = "Please specify a relationship type"
}
if (!fromTable) {
errObj.fromTable = tableNotSet
}
if (!toTable) {
errObj.toTable = tableNotSet
}
if (isMany && !throughTable) {
errObj.throughTable = tableNotSet
}
if (isMany && !throughFromKey) {
errObj.throughFromKey = foreignKeyNotSet
}
if (isMany && !throughToKey) {
errObj.throughToKey = foreignKeyNotSet
}
if (invalidThroughTable()) {
errObj.throughTable =
"Ensure non-key columns are nullable or auto-generated"
}
if (!isMany && !fromForeign) {
errObj.fromForeign = foreignKeyNotSet
}
if (!fromColumn) {
errObj.fromColumn = colNotSet
}
if (!toColumn) {
errObj.toColumn = colNotSet
}
if (!isMany && !fromPrimary) {
errObj.fromPrimary = "Please pick the primary key"
}
if (isMany && relationshipExists()) {
errObj.fromTable = relationshipAlreadyExists
errObj.toTable = relationshipAlreadyExists
}
// currently don't support relationships back onto the table itself, needs to relate out
const tableError = "From/to/through tables must be different"
if (fromTable && (fromTable === toTable || fromTable === throughTable)) {
errObj.fromTable = tableError
}
if (toTable && (toTable === fromTable || toTable === throughTable)) {
errObj.toTable = tableError
}
if ( if (
throughTable && originalFromTable &&
(throughTable === fromTable || throughTable === toTable) originalToTable &&
originalFromTable === getTable(fromId) &&
originalToTable === getTable(toId)
) { ) {
errObj.throughTable = tableError
}
const colError = "Column name cannot be an existing column"
if (isColumnNameBeingUsed(toTable, fromColumn, originalFromColumnName)) {
errObj.fromColumn = colError
}
if (isColumnNameBeingUsed(fromTable, toColumn, originalToColumnName)) {
errObj.toColumn = colError
}
let fromType, toType
if (fromPrimary && fromForeign) {
fromType = fromTable?.schema[fromPrimary]?.type
toType = toTable?.schema[fromForeign]?.type
}
if (fromType && toType && fromType !== toType) {
errObj.fromForeign =
"Column type of the foreign key must match the primary key"
}
errors = errObj
return getErrorCount(errors) === 0
}
function isColumnNameBeingUsed(table, columnName, originalName) {
if (!table || !columnName || columnName === originalName) {
return false return false
} }
const keys = Object.keys(table.schema).map(key => key.toLowerCase()) let fromThroughLinks = Object.values(
return keys.indexOf(columnName.toLowerCase()) !== -1 datasource.entities[getTable(fromId).name].schema
).filter(value => value.through)
let toThroughLinks = Object.values(
datasource.entities[getTable(toId).name].schema
).filter(value => value.through)
const matchAgainstUserInput = (fromTableId, toTableId) =>
(fromTableId === fromId && toTableId === toId) ||
(fromTableId === toId && toTableId === fromId)
return !!fromThroughLinks.find(from =>
toThroughLinks.find(
to =>
from.through === to.through &&
matchAgainstUserInput(from.tableId, to.tableId)
)
)
}
function getErrorCount(errors) {
return Object.entries(errors).filter(entry => !!entry[1]).length
}
function allRequiredAttributesSet() {
const base = getTable(fromId) && getTable(toId) && fromColumn && toColumn
if (relationshipType === RelationshipTypes.MANY_TO_ONE) {
return base && fromPrimary && fromForeign
} else {
return base && getTable(throughId) && throughFromKey && throughToKey
}
}
function validate() {
if (!allRequiredAttributesSet() && !hasValidated) {
return
}
hasValidated = true
errorChecker.setType(relationshipType)
const fromTable = getTable(fromId),
toTable = getTable(toId),
throughTable = getTable(throughId)
errors = {
relationshipType: errorChecker.relationshipTypeSet(relationshipType),
fromTable:
errorChecker.tableSet(fromTable) ||
errorChecker.doesRelationshipExists() ||
errorChecker.differentTables(fromId, toId, throughId),
toTable:
errorChecker.tableSet(toTable) ||
errorChecker.doesRelationshipExists() ||
errorChecker.differentTables(toId, fromId, throughId),
throughTable:
errorChecker.throughTableSet(throughTable) ||
errorChecker.throughIsNullable() ||
errorChecker.differentTables(throughId, fromId, toId),
throughFromKey:
errorChecker.manyForeignKeySet(throughFromKey) ||
errorChecker.manyTypeMismatch(
fromTable,
throughTable,
fromTable.primary[0],
throughFromKey
),
throughToKey:
errorChecker.manyForeignKeySet(throughToKey) ||
errorChecker.manyTypeMismatch(
toTable,
throughTable,
toTable.primary[0],
throughToKey
),
fromForeign:
errorChecker.foreignKeySet(fromForeign) ||
errorChecker.typeMismatch(fromTable, toTable, fromPrimary, fromForeign),
fromPrimary: errorChecker.primaryKeySet(fromPrimary),
fromColumn: errorChecker.columnBeingUsed(
toTable,
fromColumn,
originalFromColumnName
),
toColumn: errorChecker.columnBeingUsed(
fromTable,
toColumn,
originalToColumnName
),
}
return getErrorCount(errors) === 0
} }
function buildRelationships() { function buildRelationships() {
@ -243,13 +218,13 @@
if (manyToMany) { if (manyToMany) {
relateFrom = { relateFrom = {
...relateFrom, ...relateFrom,
through: throughTable._id, through: getTable(throughId)._id,
fieldName: toTable.primary[0], fieldName: getTable(toId).primary[0],
} }
relateTo = { relateTo = {
...relateTo, ...relateTo,
through: throughTable._id, through: getTable(throughId)._id,
fieldName: fromTable.primary[0], fieldName: getTable(fromId).primary[0],
throughFrom: relateFrom.throughTo, throughFrom: relateFrom.throughTo,
throughTo: relateFrom.throughFrom, throughTo: relateFrom.throughFrom,
} }
@ -277,35 +252,6 @@
toRelationship = relateTo toRelationship = relateTo
} }
function relationshipExists() {
if (
originalFromTable &&
originalToTable &&
originalFromTable === fromTable &&
originalToTable === toTable
) {
return false
}
let fromThroughLinks = Object.values(
datasource.entities[fromTable.name].schema
).filter(value => value.through)
let toThroughLinks = Object.values(
datasource.entities[toTable.name].schema
).filter(value => value.through)
const matchAgainstUserInput = (fromTableId, toTableId) =>
(fromTableId === fromId && toTableId === toId) ||
(fromTableId === toId && toTableId === fromId)
return !!fromThroughLinks.find(from =>
toThroughLinks.find(
to =>
from.through === to.through &&
matchAgainstUserInput(from.tableId, to.tableId)
)
)
}
function removeExistingRelationship() { function removeExistingRelationship() {
if (originalFromTable && originalFromColumnName) { if (originalFromTable && originalFromColumnName) {
delete datasource.entities[originalFromTable.name].schema[ delete datasource.entities[originalFromTable.name].schema[
@ -320,7 +266,6 @@
} }
async function saveRelationship() { async function saveRelationship() {
hasClickedSave = true
if (!validate()) { if (!validate()) {
return false return false
} }
@ -328,10 +273,10 @@
removeExistingRelationship() removeExistingRelationship()
// source of relationship // source of relationship
datasource.entities[fromTable.name].schema[fromRelationship.name] = datasource.entities[getTable(fromId).name].schema[fromRelationship.name] =
fromRelationship fromRelationship
// save other side of relationship in the other schema // save other side of relationship in the other schema
datasource.entities[toTable.name].schema[toRelationship.name] = datasource.entities[getTable(toId).name].schema[toRelationship.name] =
toRelationship toRelationship
await save() await save()
@ -342,6 +287,36 @@
await tables.fetch() await tables.fetch()
close() close()
} }
function changed(fn) {
if (typeof fn === "function") {
fn()
}
validate()
}
onMount(() => {
if (fromRelationship) {
fromPrimary = fromRelationship.foreignKey
toId = fromRelationship.tableId
throughId = fromRelationship.through
throughFromKey = fromRelationship.throughFrom
throughToKey = fromRelationship.throughTo
toColumn = fromRelationship.name
}
if (toRelationship) {
fromForeign = toRelationship.foreignKey
fromId = toRelationship.tableId
fromColumn = toRelationship.name
}
relationshipType =
fromRelationship.relationshipType || RelationshipTypes.MANY_TO_ONE
if (selectedFromTable) {
fromId = selectedFromTable._id
fromColumn = selectedFromTable.name
fromPrimary = selectedFromTable?.primary[0] || null
}
})
</script> </script>
<ModalContent <ModalContent
@ -355,34 +330,35 @@
options={relationshipTypes} options={relationshipTypes}
bind:value={relationshipType} bind:value={relationshipType}
bind:error={errors.relationshipType} bind:error={errors.relationshipType}
on:change={() => (errors.relationshipType = null)} on:change={() =>
changed(() => {
hasValidated = false
})}
/> />
<div class="headings"> <div class="headings">
<Detail>Tables</Detail> <Detail>Tables</Detail>
</div> </div>
<Select {#if !selectedFromTable}
label="Select from table"
options={tableOptions}
bind:value={fromId}
bind:error={errors.fromTable}
on:change={e => {
fromColumn = tableOptions.find(opt => opt.value === e.detail)?.label || ""
if (errors.fromTable === relationshipAlreadyExists) {
errors.toColumn = null
}
errors.fromTable = null
errors.fromColumn = null
errors.toTable = null
errors.throughTable = null
}}
/>
{#if isManyToOne && fromTable}
<Select <Select
label={`Primary Key (${fromTable.name})`} label="Select from table"
options={Object.keys(fromTable.schema)} options={tableOptions}
bind:value={fromId}
bind:error={errors.fromTable}
on:change={e =>
changed(() => {
const table = plusTables.find(tbl => tbl._id === e.detail)
fromColumn = table?.name || ""
fromPrimary = table?.primary?.[0]
})}
/>
{/if}
{#if isManyToOne && fromId}
<Select
label={`Primary Key (${getTable(fromId).name})`}
options={Object.keys(getTable(fromId).schema)}
bind:value={fromPrimary} bind:value={fromPrimary}
bind:error={errors.fromPrimary} bind:error={errors.fromPrimary}
on:change={() => (errors.fromPrimary = null)} on:change={changed}
/> />
{/if} {/if}
<Select <Select
@ -390,16 +366,12 @@
options={tableOptions} options={tableOptions}
bind:value={toId} bind:value={toId}
bind:error={errors.toTable} bind:error={errors.toTable}
on:change={e => { on:change={e =>
toColumn = tableOptions.find(opt => opt.value === e.detail)?.label || "" changed(() => {
if (errors.toTable === relationshipAlreadyExists) { const table = plusTables.find(tbl => tbl._id === e.detail)
errors.fromColumn = null toColumn = table.name || ""
} fromForeign = null
errors.toTable = null })}
errors.toColumn = null
errors.fromTable = null
errors.throughTable = null
}}
/> />
{#if isManyToMany} {#if isManyToMany}
<Select <Select
@ -407,45 +379,45 @@
options={tableOptions} options={tableOptions}
bind:value={throughId} bind:value={throughId}
bind:error={errors.throughTable} bind:error={errors.throughTable}
on:change={() => { on:change={() =>
errors.fromTable = null changed(() => {
errors.toTable = null throughToKey = null
errors.throughTable = null throughFromKey = null
}} })}
/> />
{#if fromTable && toTable && throughTable} {#if fromId && toId && throughId}
<Select <Select
label={`Foreign Key (${fromTable?.name})`} label={`Foreign Key (${getTable(fromId)?.name})`}
options={Object.keys(throughTable?.schema)} options={Object.keys(getTable(throughId)?.schema)}
bind:value={throughToKey} bind:value={throughToKey}
bind:error={errors.throughToKey} bind:error={errors.throughToKey}
on:change={e => { on:change={e =>
if (throughFromKey === e.detail) { changed(() => {
throughFromKey = null if (throughFromKey === e.detail) {
} throughFromKey = null
errors.throughToKey = null }
}} })}
/> />
<Select <Select
label={`Foreign Key (${toTable?.name})`} label={`Foreign Key (${getTable(toId)?.name})`}
options={Object.keys(throughTable?.schema)} options={Object.keys(getTable(throughId)?.schema)}
bind:value={throughFromKey} bind:value={throughFromKey}
bind:error={errors.throughFromKey} bind:error={errors.throughFromKey}
on:change={e => { on:change={e =>
if (throughToKey === e.detail) { changed(() => {
throughToKey = null if (throughToKey === e.detail) {
} throughToKey = null
errors.throughFromKey = null }
}} })}
/> />
{/if} {/if}
{:else if isManyToOne && toTable} {:else if isManyToOne && toId}
<Select <Select
label={`Foreign Key (${toTable?.name})`} label={`Foreign Key (${getTable(toId)?.name})`}
options={Object.keys(toTable?.schema)} options={Object.keys(getTable(toId)?.schema)}
bind:value={fromForeign} bind:value={fromForeign}
bind:error={errors.fromForeign} bind:error={errors.fromForeign}
on:change={() => (errors.fromForeign = null)} on:change={changed}
/> />
{/if} {/if}
<div class="headings"> <div class="headings">
@ -459,15 +431,13 @@
label="From table column" label="From table column"
bind:value={fromColumn} bind:value={fromColumn}
bind:error={errors.fromColumn} bind:error={errors.fromColumn}
on:change={e => { on:change={changed}
errors.fromColumn = e.detail?.length > 0 ? null : colNotSet
}}
/> />
<Input <Input
label="To table column" label="To table column"
bind:value={toColumn} bind:value={toColumn}
bind:error={errors.toColumn} bind:error={errors.toColumn}
on:change={e => (errors.toColumn = e.detail?.length > 0 ? null : colNotSet)} on:change={changed}
/> />
<div slot="footer"> <div slot="footer">
{#if originalFromColumnName != null} {#if originalFromColumnName != null}

View File

@ -0,0 +1,103 @@
import { RelationshipTypes } from "constants/backend"
const typeMismatch = "Column type of the foreign key must match the primary key"
const columnBeingUsed = "Column name cannot be an existing column"
const mustBeDifferentTables = "From/to/through tables must be different"
const primaryKeyNotSet = "Please pick the primary key"
const throughNotNullable =
"Ensure non-key columns are nullable or auto-generated"
const noRelationshipType = "Please specify a relationship type"
const tableNotSet = "Please specify a table"
const foreignKeyNotSet = "Please pick a foreign key"
const relationshipAlreadyExists =
"A relationship between these tables already exists"
function isColumnNameBeingUsed(table, columnName, originalName) {
if (!table || !columnName || columnName === originalName) {
return false
}
const keys = Object.keys(table.schema).map(key => key.toLowerCase())
return keys.indexOf(columnName.toLowerCase()) !== -1
}
function typeMismatchCheck(fromTable, toTable, primary, foreign) {
let fromType, toType
if (primary && foreign) {
fromType = fromTable?.schema[primary]?.type
toType = toTable?.schema[foreign]?.type
}
return fromType && toType && fromType !== toType ? typeMismatch : null
}
export class RelationshipErrorChecker {
constructor(invalidThroughTableFn, relationshipExistsFn) {
this.invalidThroughTable = invalidThroughTableFn
this.relationshipExists = relationshipExistsFn
}
setType(type) {
this.type = type
}
isMany() {
return this.type === RelationshipTypes.MANY_TO_MANY
}
relationshipTypeSet(type) {
return !type ? noRelationshipType : null
}
tableSet(table) {
return !table ? tableNotSet : null
}
throughTableSet(table) {
return this.isMany() && !table ? tableNotSet : null
}
manyForeignKeySet(key) {
return this.isMany() && !key ? foreignKeyNotSet : null
}
foreignKeySet(key) {
return !this.isMany() && !key ? foreignKeyNotSet : null
}
primaryKeySet(key) {
return !this.isMany() && !key ? primaryKeyNotSet : null
}
throughIsNullable() {
return this.invalidThroughTable() ? throughNotNullable : null
}
doesRelationshipExists() {
return this.isMany() && this.relationshipExists()
? relationshipAlreadyExists
: null
}
differentTables(table1, table2, table3) {
// currently don't support relationships back onto the table itself, needs to relate out
const error = table1 && (table1 === table2 || (table3 && table1 === table3))
return error ? mustBeDifferentTables : null
}
columnBeingUsed(table, column, ogName) {
return isColumnNameBeingUsed(table, column, ogName) ? columnBeingUsed : null
}
typeMismatch(fromTable, toTable, primary, foreign) {
if (this.isMany()) {
return null
}
return typeMismatchCheck(fromTable, toTable, primary, foreign)
}
manyTypeMismatch(table, throughTable, primary, foreign) {
if (!this.isMany()) {
return null
}
return typeMismatchCheck(table, throughTable, primary, foreign)
}
}

View File

@ -22,6 +22,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
let valid = true
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue $: tempValue = readableValue
@ -76,12 +77,15 @@
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={handleClose}>Save</Button> <Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
Save
</Button>
<svelte:component <svelte:component
this={panel} this={panel}
slot="body" slot="body"
value={readableValue} value={readableValue}
close={handleClose} close={handleClose}
bind:valid
on:change={event => (tempValue = event.detail)} on:change={event => (tempValue = event.detail)}
{bindings} {bindings}
{allowJS} {allowJS}

View File

@ -106,4 +106,8 @@
border: var(--border-light); border: var(--border-light);
border-radius: 4px; border-radius: 4px;
} }
.control :global(.spectrum-Textfield-input) {
padding-right: 40px;
}
</style> </style>

View File

@ -56,7 +56,7 @@ const componentMap = {
"field/link": FormFieldSelect, "field/link": FormFieldSelect,
"field/array": FormFieldSelect, "field/array": FormFieldSelect,
"field/json": FormFieldSelect, "field/json": FormFieldSelect,
"field/barcode/qr": FormFieldSelect, "field/barcodeqr": FormFieldSelect,
// Some validation types are the same as others, so not all types are // Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation // explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor, "validation/string": ValidationEditor,

View File

@ -11,7 +11,10 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { getAvailableActions } from "./index" import { getAvailableActions } from "./index"
import { generate } from "shortid" import { generate } from "shortid"
import { getEventContextBindings } from "builderStore/dataBinding" import {
getEventContextBindings,
makeStateBinding,
} from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
const flipDurationMs = 150 const flipDurationMs = 150
@ -52,7 +55,7 @@
actions, actions,
selectedAction?.id selectedAction?.id
) )
$: allBindings = eventContexBindings.concat(bindings) $: allBindings = getAllBindings(bindings, eventContexBindings, actions)
$: { $: {
// Ensure each action has a unique ID // Ensure each action has a unique ID
if (actions) { if (actions) {
@ -74,8 +77,18 @@
} }
const deleteAction = index => { const deleteAction = index => {
// Check if we're deleting the selected action
const selectedIndex = actions.indexOf(selectedAction)
const isSelected = index === selectedIndex
// Delete the action
actions.splice(index, 1) actions.splice(index, 1)
actions = actions actions = actions
// Select a new action if we deleted the selected one
if (isSelected) {
selectedAction = actions?.length ? actions[0] : null
}
} }
const toggleActionList = () => { const toggleActionList = () => {
@ -111,6 +124,37 @@
function handleDndFinalize(e) { function handleDndFinalize(e) {
actions = e.detail.items actions = e.detail.items
} }
const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings)
if (!actions) {
return []
}
// Ensure bindings are generated for all "update state" action keys
actions
.filter(action => {
// Find all "Update State" actions which set values
return (
action[EVENT_TYPE_KEY] === "Update State" &&
action.parameters?.type === "set" &&
action.parameters.key
)
})
.forEach(action => {
// Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding
})
if (!hasKey) {
allBindings.push(stateBinding)
}
})
return allBindings
}
</script> </script>
<DrawerContent> <DrawerContent>
@ -186,7 +230,7 @@
<div class="selected-action-container"> <div class="selected-action-container">
<svelte:component <svelte:component
this={selectedActionComponent} this={selectedActionComponent}
parameters={selectedAction.parameters} bind:parameters={selectedAction.parameters}
bindings={allBindings} bindings={allBindings}
{nested} {nested}
/> />

View File

@ -36,7 +36,13 @@
$: selectedSchema = selectedAutomation?.schema $: selectedSchema = selectedAutomation?.schema
const onFieldsChanged = e => { const onFieldsChanged = e => {
parameters.fields = e.detail parameters.fields = Object.entries(e.detail || {}).reduce(
(acc, [key, value]) => {
acc[key.trim()] = value
return acc
},
{}
)
} }
const setNew = () => { const setNew = () => {

View File

@ -25,7 +25,7 @@
const getOptions = (schema, type) => { const getOptions = (schema, type) => {
let entries = Object.entries(schema ?? {}) let entries = Object.entries(schema ?? {})
let types = [] let types = []
if (type === "field/options" || type === "field/barcode/qr") { if (type === "field/options") {
// allow options to be used on both options and string fields // allow options to be used on both options and string fields
types = [type, "field/string"] types = [type, "field/string"]
} else { } else {
@ -35,6 +35,7 @@
types = types.map(type => type.slice(type.indexOf("/") + 1)) types = types.map(type => type.slice(type.indexOf("/") + 1))
entries = entries.filter(entry => types.includes(entry[1].type)) entries = entries.filter(entry => types.includes(entry[1].type))
return entries.map(entry => entry[0]) return entries.map(entry => entry[0])
} }
</script> </script>

View File

@ -108,50 +108,52 @@
} }
</script> </script>
{#key tourStepKey} {#if tourKey}
<Popover {#key tourStepKey}
align={tourStep?.align} <Popover
bind:this={popover} align={tourStep?.align}
anchor={popoverAnchor} bind:this={popover}
maxWidth={300} anchor={popoverAnchor}
dismissible={false} maxWidth={300}
offset={15} dismissible={false}
> offset={15}
<div class="tour-content"> >
<Layout noPadding gap="M"> <div class="tour-content">
<div class="tour-header"> <Layout noPadding gap="M">
<Heading size="XS">{tourStep?.title || "-"}</Heading> <div class="tour-header">
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div> <Heading size="XS">{tourStep?.title || "-"}</Heading>
</div> <div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
<Body size="S">
<span class="tour-body">
{#if tourStep.layout}
<svelte:component this={tourStep.layout} />
{:else}
{tourStep?.body || ""}
{/if}
</span>
</Body>
<div class="tour-footer">
<div class="tour-navigation">
{#if tourStepIdx > 0}
<Button
secondary
on:click={previousStep}
disabled={tourStepIdx == 0}
>
<div>Back</div>
</Button>
{/if}
<Button cta on:click={nextStep}>
<div>{lastStep ? "Finish" : "Next"}</div>
</Button>
</div> </div>
</div> <Body size="S">
</Layout> <span class="tour-body">
</div> {#if tourStep.layout}
</Popover> <svelte:component this={tourStep.layout} />
{/key} {:else}
{tourStep?.body || ""}
{/if}
</span>
</Body>
<div class="tour-footer">
<div class="tour-navigation">
{#if tourStepIdx > 0}
<Button
secondary
on:click={previousStep}
disabled={tourStepIdx == 0}
>
<div>Back</div>
</Button>
{/if}
<Button cta on:click={nextStep}>
<div>{lastStep ? "Finish" : "Next"}</div>
</Button>
</div>
</div>
</Layout>
</div>
</Popover>
{/key}
{/if}
<style> <style>
.tour-content { .tour-content {

View File

@ -1,5 +1,5 @@
<div> <div>
In this section you can mange the data for your app: In this section you can manage the data for your app:
<ul class="feature-list"> <ul class="feature-list">
<li>Connect data sources</li> <li>Connect data sources</li>
<li>Edit data</li> <li>Edit data</li>

View File

@ -138,7 +138,6 @@
} }
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
// apps.load()
} catch (error) { } catch (error) {
creating = false creating = false
console.error(error) console.error(error)

View File

@ -4,6 +4,7 @@ import { get } from "svelte/store"
export const TENANT_FEATURE_FLAGS = { export const TENANT_FEATURE_FLAGS = {
LICENSING: "LICENSING", LICENSING: "LICENSING",
USER_GROUPS: "USER_GROUPS", USER_GROUPS: "USER_GROUPS",
ONBOARDING_TOUR: "ONBOARDING_TOUR",
} }
export const isEnabled = featureFlag => { export const isEnabled = featureFlag => {

View File

@ -2,6 +2,7 @@
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { roles, flags } from "stores/backend" import { roles, flags } from "stores/backend"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
ActionMenu, ActionMenu,
MenuItem, MenuItem,
@ -68,7 +69,10 @@
} }
const initTour = async () => { const initTour = async () => {
if (!$auth.user?.onboardedAt) { if (
!$auth.user?.onboardedAt &&
isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)
) {
// Determine the correct step // Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path)) const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING] const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]

View File

@ -1,9 +1,45 @@
<script> <script>
import TableDataTable from "components/backend/DataTable/DataTable.svelte" import TableDataTable from "components/backend/DataTable/DataTable.svelte"
import { tables, database } from "stores/backend" import { tables, database } from "stores/backend"
import { Banner } from "@budibase/bbui"
const verifyAutocolumns = table => {
// Check for duplicates
return Object.values(table?.schema || {}).reduce((acc, fieldSchema) => {
if (!fieldSchema.autocolumn || !fieldSchema.subtype) {
return acc
}
let fieldKey = fieldSchema.tableId
? `${fieldSchema.tableId}-${fieldSchema.subtype}`
: fieldSchema.subtype
acc[fieldKey] = acc[fieldKey] || []
acc[fieldKey].push(fieldSchema)
return acc
}, {})
}
$: autoColumnStatus = verifyAutocolumns($tables?.selected)
$: duplicates = Object.values(autoColumnStatus).reduce((acc, status) => {
if (status.length > 1) {
acc = [...acc, ...status]
}
return acc
}, [])
$: invalidColumnText = duplicates.map(entry => {
return `${entry.name} (${entry.subtype})`
})
</script> </script>
{#if $database?._id && $tables?.selected} {#if $database?._id && $tables?.selected?.name}
{#if duplicates?.length}
<div class="alert-wrap">
<Banner type="warning" showCloseButton={false}>
{`Schema Invalid - There are duplicate auto column types defined in this schema.
Please delete the duplicate entries where appropriate: -
${invalidColumnText.join(", ")}`}
</Banner>
</div>
{/if}
<TableDataTable /> <TableDataTable />
{:else} {:else}
<i>Create your first table to start building</i> <i>Create your first table to start building</i>
@ -15,4 +51,11 @@
color: var(--grey-5); color: var(--grey-5);
margin-top: 2px; margin-top: 2px;
} }
.alert-wrap {
display: flex;
width: 100%;
}
.alert-wrap :global(> *) {
flex: 1;
}
</style> </style>

View File

@ -30,7 +30,7 @@
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id} {#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft> <Panel {title} icon={componentDefinition?.icon} borderLeft>
{#if componentDefinition.info} {#if componentDefinition?.info}
<ComponentInfoSection {componentDefinition} /> <ComponentInfoSection {componentDefinition} />
{/if} {/if}
<ComponentSettingsSection <ComponentSettingsSection

View File

@ -52,7 +52,7 @@
<span class="back-chev" on:click={() => $goto("../")}> <span class="back-chev" on:click={() => $goto("../")}>
<Icon name="ChevronLeft" size="XL" /> <Icon name="ChevronLeft" size="XL" />
</span> </span>
Forgotten your password? Forgot your password?
</div> </div>
</Heading> </Heading>
</span> </span>
@ -83,7 +83,12 @@
</FancyForm> </FancyForm>
</Layout> </Layout>
<div> <div>
<Button disabled={!email || error || submitted} cta on:click={forgot}> <Button
size="L"
disabled={!email || error || submitted}
cta
on:click={forgot}
>
Reset password Reset password
</Button> </Button>
</div> </div>
@ -92,7 +97,7 @@
<style> <style>
img { img {
width: 48px; width: 46px;
} }
.back-chev { .back-chev {
display: inline-block; display: inline-block;
@ -102,5 +107,6 @@
.heading-content { .heading-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-m);
} }
</style> </style>

View File

@ -66,7 +66,7 @@
<svelte:window on:keydown={handleKeydown} /> <svelte:window on:keydown={handleKeydown} />
<TestimonialPage> <TestimonialPage>
<Layout gap="S" noPadding> <Layout gap="L" noPadding>
<Layout justifyItems="center" noPadding> <Layout justifyItems="center" noPadding>
{#if loaded} {#if loaded}
<img alt="logo" src={$organisation.logoUrl || Logo} /> <img alt="logo" src={$organisation.logoUrl || Logo} />
@ -124,14 +124,19 @@
</FancyForm> </FancyForm>
</Layout> </Layout>
<Layout gap="XS" noPadding justifyItems="center"> <Layout gap="XS" noPadding justifyItems="center">
<Button cta disabled={Object.keys(errors).length > 0} on:click={login}> <Button
size="L"
cta
disabled={Object.keys(errors).length > 0}
on:click={login}
>
Log in to {company} Log in to {company}
</Button> </Button>
</Layout> </Layout>
<Layout gap="XS" noPadding justifyItems="center"> <Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions"> <div class="user-actions">
<ActionButton quiet on:click={() => $goto("./forgot")}> <ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
Forgot password Forgot password?
</ActionButton> </ActionButton>
</div> </div>
</Layout> </Layout>

View File

@ -68,7 +68,7 @@
</script> </script>
<TestimonialPage> <TestimonialPage>
<Layout gap="S" noPadding> <Layout gap="M" noPadding>
<img alt="logo" src={$organisation.logoUrl || Logo} /> <img alt="logo" src={$organisation.logoUrl || Logo} />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Join {company}</Heading> <Heading size="M">Join {company}</Heading>
@ -175,6 +175,7 @@
</Layout> </Layout>
<div> <div>
<Button <Button
size="L"
disabled={Object.keys(errors).length > 0 || onboarding} disabled={Object.keys(errors).length > 0 || onboarding}
cta cta
on:click={acceptInvite} on:click={acceptInvite}

View File

@ -14,7 +14,7 @@
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: fullScreen = !$apps?.length $: fullscreen = !$apps.length
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -37,7 +37,8 @@
$redirect("../") $redirect("../")
} else { } else {
try { try {
await organisation.init() // We need to load apps to know if we need to show onboarding fullscreen
await Promise.all([apps.load(), organisation.init()])
} catch (error) { } catch (error) {
notifications.error("Error getting org config") notifications.error("Error getting org config")
} }
@ -47,37 +48,39 @@
}) })
</script> </script>
{#if fullScreen} {#if $auth.user && loaded}
<slot /> {#if fullscreen}
{:else if $auth.user && loaded} <slot />
<HelpMenu /> {:else}
<div class="container"> <HelpMenu />
<div class="nav"> <div class="container">
<div class="branding"> <div class="nav">
<Logo /> <div class="branding">
<Logo />
</div>
<div class="desktop">
<Tabs selected={activeTab}>
{#each $menu as { title, href }}
<Tab {title} on:click={() => $goto(href)} />
{/each}
</Tabs>
</div>
<div class="mobile">
<Icon hoverable name="ShowMenu" on:click={showMobileMenu} />
</div>
<div class="desktop">
<UpgradeButton />
</div>
<div class="dropdown">
<UserDropdown />
</div>
</div> </div>
<div class="desktop"> <div class="main">
<Tabs selected={activeTab}> <slot />
{#each $menu as { title, href }}
<Tab {title} on:click={() => $goto(href)} />
{/each}
</Tabs>
</div>
<div class="mobile">
<Icon hoverable name="ShowMenu" on:click={showMobileMenu} />
</div>
<div class="desktop">
<UpgradeButton />
</div>
<div class="dropdown">
<UserDropdown />
</div> </div>
<MobileMenu visible={mobileMenuVisible} on:close={hideMobileMenu} />
</div> </div>
<div class="main"> {/if}
<slot />
</div>
<MobileMenu visible={mobileMenuVisible} on:close={hideMobileMenu} />
</div>
{/if} {/if}
<style> <style>

View File

@ -10,13 +10,11 @@
onMount(async () => { onMount(async () => {
try { try {
// Always load latest // Always load latest
await apps.load() await Promise.all([
await licensing.init() licensing.init(),
await templates.load() templates.load(),
groups.actions.init(),
if ($licensing.groupsEnabled) { ])
await groups.actions.init()
}
if ($templates?.length === 0) { if ($templates?.length === 0) {
notifications.error("There was a problem loading quick start templates") notifications.error("There was a problem loading quick start templates")

View File

@ -5,6 +5,8 @@
export let name = "" export let name = ""
export let url = "" export let url = ""
export let onNext = () => {} export let onNext = () => {}
const nameRegex = /^[a-zA-Z0-9\s]*$/
let nameError = null let nameError = null
let urlError = null let urlError = null
@ -14,6 +16,9 @@
if (name.length < 1) { if (name.length < 1) {
return "Name must be provided" return "Name must be provided"
} }
if (!nameRegex.test(name)) {
return "No special characters are allowed"
}
} }
const validateUrl = url => { const validateUrl = url => {

View File

@ -17,8 +17,8 @@
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen" import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
let name = "" let name = "My first app"
let url = "" let url = "my-first-app"
let stage = "name" let stage = "name"
let appId = null let appId = null
@ -57,7 +57,7 @@
defaultScreenTemplate.routing.roldId = Roles.BASIC defaultScreenTemplate.routing.roldId = Roles.BASIC
await store.actions.screens.save(defaultScreenTemplate) await store.actions.screens.save(defaultScreenTemplate)
return createdApp.instance._id appId = createdApp.instance._id
} }
const getIntegrations = async () => { const getIntegrations = async () => {
@ -79,14 +79,14 @@
} }
} }
const goToApp = appId => { const goToApp = () => {
$goto(`/builder/app/${appId}`) $goto(`/builder/app/${appId}`)
notifications.success(`App created successfully`) notifications.success(`App created successfully`)
} }
const handleCreateApp = async ({ datasourceConfig, useSampleData }) => { const handleCreateApp = async ({ datasourceConfig, useSampleData }) => {
try { try {
appId = await createApp(useSampleData) await createApp(useSampleData)
if (datasourceConfig) { if (datasourceConfig) {
await saveDatasource({ await saveDatasource({
@ -99,7 +99,7 @@
}) })
} }
goToApp(appId) goToApp()
} catch (e) { } catch (e) {
console.log(e) console.log(e)
notifications.error("There was a problem creating your app") notifications.error("There was a problem creating your app")
@ -111,7 +111,7 @@
<CreateTableModal <CreateTableModal
name="Your Data" name="Your Data"
beforeSave={createApp} beforeSave={createApp}
afterSave={() => goToApp(appId)} afterSave={goToApp}
/> />
</Modal> </Modal>
@ -142,7 +142,7 @@
<div class="dataButtonIcon"> <div class="dataButtonIcon">
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" /> <FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
</div> </div>
Upload file Upload data (CSV or JSON)
</div> </div>
</FancyButton> </FancyButton>
</div> </div>

View File

@ -100,8 +100,9 @@
const deleteApp = async () => { const deleteApp = async () => {
try { try {
await API.deleteApp(app?.devId) await API.deleteApp(app?.devId)
apps.load()
notifications.success("App deleted successfully") notifications.success("App deleted successfully")
$goto("../") $goto("../../")
} catch (err) { } catch (err) {
notifications.error("Error deleting app") notifications.error("Error deleting app")
} }

View File

@ -1,17 +1,12 @@
<script> <script>
import { apps, groups, licensing } from "stores/portal" import { groups } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
let loaded = !!$apps?.length let loaded = false
onMount(async () => { onMount(async () => {
if (!loaded) { await groups.actions.init()
await apps.load() loaded = true
if ($licensing.groupsEnabled) {
await groups.actions.init()
}
loaded = true
}
}) })
</script> </script>

View File

@ -146,7 +146,7 @@
onMount(async () => { onMount(async () => {
try { try {
await Promise.all([groups.actions.init(), apps.load(), roles.fetch()]) await Promise.all([groups.actions.init(), roles.fetch()])
loaded = true loaded = true
} catch (error) { } catch (error) {
notifications.error("Error fetching user group data") notifications.error("Error fetching user group data")

View File

@ -80,9 +80,7 @@
try { try {
// always load latest // always load latest
await licensing.init() await licensing.init()
if ($licensing.groupsEnabled) { await groups.actions.init()
await groups.actions.init()
}
} catch (error) { } catch (error) {
notifications.error("Error getting user groups") notifications.error("Error getting user groups")
} }

View File

@ -215,12 +215,7 @@
onMount(async () => { onMount(async () => {
try { try {
await Promise.all([ await Promise.all([fetchUser(), groups.actions.init(), roles.fetch()])
fetchUser(),
groups.actions.init(),
apps.load(),
roles.fetch(),
])
loaded = true loaded = true
} catch (error) { } catch (error) {
notifications.error("Error getting user groups") notifications.error("Error getting user groups")

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {
@ -26,9 +26,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "2.2.12-alpha.59", "@budibase/backend-core": "2.2.27-alpha.0",
"@budibase/string-templates": "2.2.12-alpha.59", "@budibase/string-templates": "2.2.27-alpha.0",
"@budibase/types": "2.2.12-alpha.59", "@budibase/types": "2.2.27-alpha.0",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",

View File

@ -3241,7 +3241,7 @@
}, },
"settings": [ "settings": [
{ {
"type": "field/barcode/qr", "type": "field/barcodeqr",
"label": "Field", "label": "Field",
"key": "field", "key": "field",
"required": true "required": true

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.2.12-alpha.59", "@budibase/bbui": "2.2.27-alpha.0",
"@budibase/frontend-core": "2.2.12-alpha.59", "@budibase/frontend-core": "2.2.27-alpha.0",
"@budibase/string-templates": "2.2.12-alpha.59", "@budibase/string-templates": "2.2.27-alpha.0",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View File

@ -171,6 +171,15 @@
$: pad = pad || (interactive && hasChildren && inDndPath) $: pad = pad || (interactive && hasChildren && inDndPath)
$: $dndIsDragging, (pad = false) $: $dndIsDragging, (pad = false)
// Determine whether we should render a skeleton loader for this component
$: showSkeleton =
$loading &&
definition.name !== "Screenslot" &&
children.length === 0 &&
!instance._blockElementHasChildren &&
!definition.block &&
definition.skeleton !== false
// Update component context // Update component context
$: store.set({ $: store.set({
id, id,
@ -473,14 +482,6 @@
componentStore.actions.unregisterInstance(id) componentStore.actions.unregisterInstance(id)
} }
}) })
$: showSkeleton =
$loading &&
definition.name !== "Screenslot" &&
children.length === 0 &&
!instance._blockElementHasChildren &&
!definition.block &&
definition.skeleton !== false
</script> </script>
{#if showSkeleton} {#if showSkeleton}

View File

@ -11,20 +11,23 @@
export let limit export let limit
export let paginate export let paginate
const loading = writable(false)
const { styleable, Provider, ActionTypes, API } = getContext("sdk") const { styleable, Provider, ActionTypes, API } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
// Update loading state
const parentLoading = getContext("loading")
const loading = writable(true)
setContext("loading", loading)
// We need to manage our lucene query manually as we want to allow components // We need to manage our lucene query manually as we want to allow components
// to extend it // to extend it
let queryExtensions = {} let queryExtensions = {}
$: defaultQuery = LuceneUtils.buildLuceneQuery(filter) $: defaultQuery = LuceneUtils.buildLuceneQuery(filter)
$: query = extendQuery(defaultQuery, queryExtensions) $: query = extendQuery(defaultQuery, queryExtensions)
// Keep our data fetch instance up to date // Fetch data and refresh when needed
$: fetch = createFetch(dataSource) $: fetch = createFetch(dataSource, $parentLoading)
$: fetch.update({ $: updateFetch({
query, query,
sortColumn, sortColumn,
sortOrder, sortOrder,
@ -32,6 +35,9 @@
paginate, paginate,
}) })
// Keep loading context updated
$: loading.set($parentLoading || !$fetch.loaded)
// Build our action context // Build our action context
$: actions = [ $: actions = [
{ {
@ -80,14 +86,21 @@
sortColumn: $fetch.sortColumn, sortColumn: $fetch.sortColumn,
sortOrder: $fetch.sortOrder, sortOrder: $fetch.sortOrder,
}, },
limit: limit, limit,
} }
const parentLoading = getContext("loading") const createFetch = (datasource, parentLoading) => {
setContext("loading", loading) // Return a dummy fetch if parent is still loading. We do this so that we
$: loading.set($parentLoading || !$fetch.loaded) // can still properly subscribe to a valid fetch object and check all
// properties, but we want to avoid fetching the real data until all parents
// have finished loading.
// This logic is only needed due to skeleton loaders, as previously we
// simply blocked component rendering until data was ready.
if (parentLoading) {
return fetchData({ API })
}
const createFetch = datasource => { // Otherwise return the real thing
return fetchData({ return fetchData({
API, API,
datasource, datasource,
@ -101,6 +114,14 @@
}) })
} }
const updateFetch = opts => {
// Only update fetch if parents have stopped loading. Otherwise we will
// trigger a fetch of the real data before parents are ready.
if (!$parentLoading) {
fetch.update(opts)
}
}
const addQueryExtension = (key, extension) => { const addQueryExtension = (key, extension) => {
if (!key || !extension) { if (!key || !extension) {
return return

View File

@ -1,7 +1,8 @@
<script> <script>
import { getContext } from "svelte" import { getContext, setContext } from "svelte"
import InnerForm from "./InnerForm.svelte" import InnerForm from "./InnerForm.svelte"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { writable } from "svelte/store"
export let dataSource export let dataSource
export let theme export let theme
@ -20,6 +21,12 @@
const context = getContext("context") const context = getContext("context")
const { API, fetchDatasourceSchema } = getContext("sdk") const { API, fetchDatasourceSchema } = getContext("sdk")
// Forms also use loading context as they require loading a schema
const parentLoading = getContext("loading")
const loading = writable(true)
setContext("loading", loading)
let loaded = false
let schema let schema
let table let table
@ -29,6 +36,7 @@
$: resetKey = Helpers.hashString( $: resetKey = Helpers.hashString(
schemaKey + JSON.stringify(initialValues) + disabled schemaKey + JSON.stringify(initialValues) + disabled
) )
$: loading.set($parentLoading || !loaded)
// Returns the closes data context which isn't a built in context // Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, context) => { const getInitialValues = (type, dataSource, context) => {
@ -60,6 +68,9 @@
} }
const res = await fetchDatasourceSchema(dataSource) const res = await fetchDatasourceSchema(dataSource)
schema = res || {} schema = res || {}
if (!loaded) {
loaded = true
}
} }
// Generates a predictable string that uniquely identifies a schema. We can't // Generates a predictable string that uniquely identifies a schema. We can't

View File

@ -128,21 +128,15 @@
return fields.find(field => get(field).name === name) return fields.find(field => get(field).name === name)
} }
const getDefault = (defaultValue, schema, type) => { // Sanitises a value by ensuring it doesn't contain any invalid data
// Remove any values not present in the field schema const sanitiseValue = (value, schema, type) => {
// Convert any values supplied to string // Check arrays - remove any values not present in the field schema and
if (Array.isArray(defaultValue) && type == "array" && schema) { // convert any values supplied to strings
return defaultValue.reduce((acc, entry) => { if (Array.isArray(value) && type === "array" && schema) {
let processedOption = String(entry) const options = schema?.constraints.inclusion || []
let schemaOptions = schema.constraints.inclusion return value.map(opt => String(opt)).filter(opt => options.includes(opt))
if (schemaOptions.indexOf(processedOption) > -1) {
acc.push(processedOption)
}
return acc
}, [])
} else {
return defaultValue
} }
return value
} }
const formApi = { const formApi = {
@ -160,7 +154,6 @@
// Create validation function based on field schema // Create validation function based on field schema
const schemaConstraints = schema?.[field]?.constraints const schemaConstraints = schema?.[field]?.constraints
const validator = disableValidation const validator = disableValidation
? null ? null
: createValidatorFromConstraints( : createValidatorFromConstraints(
@ -170,10 +163,11 @@
table table
) )
const parsedDefault = getDefault(defaultValue, schema?.[field], type) // Sanitise the default value to ensure it doesn't contain invalid data
defaultValue = sanitiseValue(defaultValue, schema?.[field], type)
// If we've already registered this field then keep some existing state // If we've already registered this field then keep some existing state
let initialValue = Helpers.deepGet(initialValues, field) ?? parsedDefault let initialValue = Helpers.deepGet(initialValues, field) ?? defaultValue
let initialError = null let initialError = null
let fieldId = `id-${Helpers.uuid()}` let fieldId = `id-${Helpers.uuid()}`
const existingField = getField(field) const existingField = getField(field)
@ -183,7 +177,9 @@
// Determine the initial value for this field, reusing the current // Determine the initial value for this field, reusing the current
// value if one exists // value if one exists
initialValue = fieldState.value ?? initialValue if (fieldState.value != null && fieldState.value !== "") {
initialValue = fieldState.value
}
// If this field has already been registered and we previously had an // If this field has already been registered and we previously had an
// error set, then re-run the validator to see if we can unset it // error set, then re-run the validator to see if we can unset it
@ -206,11 +202,11 @@
error: initialError, error: initialError,
disabled: disabled:
disabled || fieldDisabled || (isAutoColumn && !editAutoColumns), disabled || fieldDisabled || (isAutoColumn && !editAutoColumns),
defaultValue: parsedDefault, defaultValue,
validator, validator,
lastUpdate: Date.now(), lastUpdate: Date.now(),
}, },
fieldApi: makeFieldApi(field, parsedDefault), fieldApi: makeFieldApi(field),
fieldSchema: schema?.[field] ?? {}, fieldSchema: schema?.[field] ?? {},
}) })
@ -225,18 +221,9 @@
return fieldInfo return fieldInfo
}, },
validate: () => { validate: () => {
let valid = true return fields
let validationFields = fields .filter(field => get(field).step === get(currentStep))
.every(field => get(field).fieldApi.validate())
validationFields = fields.filter(f => get(f).step === get(currentStep))
// Validate fields and check if any are invalid
validationFields.forEach(field => {
if (!get(field).fieldApi.validate()) {
valid = false
}
})
return valid
}, },
reset: () => { reset: () => {
// Reset the form by resetting each individual field // Reset the form by resetting each individual field

View File

@ -79,6 +79,7 @@
getOptionLabel={flatOptions ? x => x : x => x.label} getOptionLabel={flatOptions ? x => x : x => x.label}
getOptionTitle={flatOptions ? x => x : x => x.label} getOptionTitle={flatOptions ? x => x : x => x.label}
getOptionValue={flatOptions ? x => x : x => x.value} getOptionValue={flatOptions ? x => x : x => x.value}
{sort}
/> />
{/if} {/if}
{/if} {/if}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -1,12 +1,12 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"description": "Budibase frontend core libraries used in builder and client", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"dependencies": { "dependencies": {
"@budibase/bbui": "2.2.12-alpha.59", "@budibase/bbui": "2.2.27-alpha.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"svelte": "^3.46.2" "svelte": "^3.46.2"
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/sdk", "name": "@budibase/sdk",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"description": "Budibase Public API SDK", "description": "Budibase Public API SDK",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",

View File

@ -12,7 +12,7 @@ ENV COUCH_DB_URL=https://couchdb.budi.live:5984
ENV BUDIBASE_ENVIRONMENT=PRODUCTION ENV BUDIBASE_ENVIRONMENT=PRODUCTION
ENV SERVICE=app-service ENV SERVICE=app-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
# copy files and install dependencies # copy files and install dependencies

View File

@ -1,9 +1,9 @@
import { Config } from "jest" import { Config } from "@jest/types"
import * as fs from "fs" import * as fs from "fs"
const preset = require("ts-jest/jest-preset") const preset = require("ts-jest/jest-preset")
const testContainersSettings = { const baseConfig: Config.InitialProjectOptions = {
...preset, ...preset,
preset: "@trendyol/jest-testcontainers", preset: "@trendyol/jest-testcontainers",
setupFiles: ["./src/tests/jestEnv.ts"], setupFiles: ["./src/tests/jestEnv.ts"],
@ -15,30 +15,30 @@ const testContainersSettings = {
if (!process.env.CI) { if (!process.env.CI) {
// use sources when not in CI // use sources when not in CI
testContainersSettings.moduleNameMapper = { baseConfig.moduleNameMapper = {
"@budibase/backend-core/(.*)": "<rootDir>/../backend-core/$1", "@budibase/backend-core/(.*)": "<rootDir>/../backend-core/$1",
"@budibase/backend-core": "<rootDir>/../backend-core/src", "@budibase/backend-core": "<rootDir>/../backend-core/src",
"@budibase/types": "<rootDir>/../types/src", "@budibase/types": "<rootDir>/../types/src",
} }
// add pro sources if they exist // add pro sources if they exist
if (fs.existsSync("../../../budibase-pro")) { if (fs.existsSync("../../../budibase-pro")) {
testContainersSettings.moduleNameMapper["@budibase/pro"] = baseConfig.moduleNameMapper["@budibase/pro"] =
"<rootDir>/../../../budibase-pro/packages/pro/src" "<rootDir>/../../../budibase-pro/packages/pro/src"
} }
} else { } else {
console.log("Running tests with compiled dependency sources") console.log("Running tests with compiled dependency sources")
} }
const config: Config = { const config: Config.InitialOptions = {
projects: [ projects: [
{ {
...testContainersSettings, ...baseConfig,
displayName: "sequential test", displayName: "sequential test",
testMatch: ["<rootDir>/**/*.seq.spec.[jt]s"], testMatch: ["<rootDir>/**/*.seq.spec.[jt]s"],
runner: "jest-serial-runner", runner: "jest-serial-runner",
}, },
{ {
...testContainersSettings, ...baseConfig,
testMatch: ["<rootDir>/**/!(*.seq).spec.[jt]s"], testMatch: ["<rootDir>/**/!(*.seq).spec.[jt]s"],
}, },
], ],

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "2.2.12-alpha.59", "version": "2.2.27-alpha.0",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -43,11 +43,11 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "10.0.3", "@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "2.2.12-alpha.59", "@budibase/backend-core": "2.2.27-alpha.0",
"@budibase/client": "2.2.12-alpha.59", "@budibase/client": "2.2.27-alpha.0",
"@budibase/pro": "2.2.12-alpha.58", "@budibase/pro": "2.2.27-alpha.0",
"@budibase/string-templates": "2.2.12-alpha.59", "@budibase/string-templates": "2.2.27-alpha.0",
"@budibase/types": "2.2.12-alpha.59", "@budibase/types": "2.2.27-alpha.0",
"@bull-board/api": "3.7.0", "@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4", "@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",

View File

@ -36,7 +36,7 @@ async function init() {
COUCH_DB_PASSWORD: "budibase", COUCH_DB_PASSWORD: "budibase",
COUCH_DB_USER: "budibase", COUCH_DB_USER: "budibase",
SELF_HOSTED: 1, SELF_HOSTED: 1,
DISABLE_ACCOUNT_PORTAL: "", DISABLE_ACCOUNT_PORTAL: 1,
MULTI_TENANCY: "", MULTI_TENANCY: "",
DISABLE_THREADING: 1, DISABLE_THREADING: 1,
SERVICE: "app-service", SERVICE: "app-service",
@ -44,7 +44,7 @@ async function init() {
BB_ADMIN_USER_EMAIL: "", BB_ADMIN_USER_EMAIL: "",
BB_ADMIN_USER_PASSWORD: "", BB_ADMIN_USER_PASSWORD: "",
PLUGINS_DIR: "", PLUGINS_DIR: "",
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS", TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
} }
let envFile = "" let envFile = ""
Object.keys(envFileJson).forEach(key => { Object.keys(envFileJson).forEach(key => {

View File

@ -41,7 +41,7 @@ const datasets = {
describe("Rest Importer", () => { describe("Rest Importer", () => {
const config = new TestConfig(false) const config = new TestConfig(false)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })

View File

@ -315,7 +315,13 @@ export async function checkForViewUpdates(
// Update view if required // Update view if required
if (needsUpdated) { if (needsUpdated) {
const newViewTemplate = viewTemplate(view.meta) const groupByField: any = Object.values(table.schema).find(
(field: any) => field.name == view.groupBy
)
const newViewTemplate = viewTemplate(
view.meta,
groupByField?.type === FieldTypes.ARRAY
)
await saveView(null, view.name, newViewTemplate) await saveView(null, view.name, newViewTemplate)
if (!newViewTemplate.meta.schema) { if (!newViewTemplate.meta.schema) {
newViewTemplate.meta.schema = table.schema newViewTemplate.meta.schema = table.schema

View File

@ -6,8 +6,9 @@ import { fetchView } from "../row"
import { context, events } from "@budibase/backend-core" import { context, events } from "@budibase/backend-core"
import { DocumentType } from "../../../db/utils" import { DocumentType } from "../../../db/utils"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { FieldTypes } from "../../../constants"
import { import {
BBContext, Ctx,
Row, Row,
Table, Table,
TableExportFormat, TableExportFormat,
@ -18,14 +19,22 @@ import { cleanExportRows } from "../row/utils"
const { cloneDeep, isEqual } = require("lodash") const { cloneDeep, isEqual } = require("lodash")
export async function fetch(ctx: BBContext) { export async function fetch(ctx: Ctx) {
ctx.body = await getViews() ctx.body = await getViews()
} }
export async function save(ctx: BBContext) { export async function save(ctx: Ctx) {
const db = context.getAppDB() const db = context.getAppDB()
const { originalName, ...viewToSave } = ctx.request.body const { originalName, ...viewToSave } = ctx.request.body
const view = viewTemplate(viewToSave)
const existingTable = await db.get(ctx.request.body.tableId)
const table = cloneDeep(existingTable)
const groupByField: any = Object.values(table.schema).find(
(field: any) => field.name == viewToSave.groupBy
)
const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY)
const viewName = viewToSave.name const viewName = viewToSave.name
if (!viewName) { if (!viewName) {
@ -35,8 +44,6 @@ export async function save(ctx: BBContext) {
await saveView(originalName, viewName, view) await saveView(originalName, viewName, view)
// add views to table document // add views to table document
const existingTable = await db.get(ctx.request.body.tableId)
const table = cloneDeep(existingTable)
if (!table.views) table.views = {} if (!table.views) table.views = {}
if (!view.meta.schema) { if (!view.meta.schema) {
view.meta.schema = table.schema view.meta.schema = table.schema
@ -111,7 +118,7 @@ async function handleViewEvents(existingView: View, newView: View) {
await filterEvents(existingView, newView) await filterEvents(existingView, newView)
} }
export async function destroy(ctx: BBContext) { export async function destroy(ctx: Ctx) {
const db = context.getAppDB() const db = context.getAppDB()
const viewName = decodeURIComponent(ctx.params.viewName) const viewName = decodeURIComponent(ctx.params.viewName)
const view = await deleteView(viewName) const view = await deleteView(viewName)
@ -123,7 +130,7 @@ export async function destroy(ctx: BBContext) {
ctx.body = view ctx.body = view
} }
export async function exportView(ctx: BBContext) { export async function exportView(ctx: Ctx) {
const viewName = decodeURIComponent(ctx.query.view as string) const viewName = decodeURIComponent(ctx.query.view as string)
const view = await getView(viewName) const view = await getView(viewName)

View File

@ -6,6 +6,7 @@ type ViewTemplateOpts = {
groupBy: string groupBy: string
filters: ViewFilter[] filters: ViewFilter[]
calculation: string calculation: string
groupByMulti: boolean
} }
const TOKEN_MAP: Record<string, string> = { const TOKEN_MAP: Record<string, string> = {
@ -41,6 +42,12 @@ const GROUP_PROPERTY: Record<string, { type: string }> = {
}, },
} }
const GROUP_PROPERTY_MULTI: Record<string, { type: string }> = {
group: {
type: "array",
},
}
const FIELD_PROPERTY: Record<string, { type: string }> = { const FIELD_PROPERTY: Record<string, { type: string }> = {
field: { field: {
type: "string", type: "string",
@ -136,13 +143,10 @@ function parseEmitExpression(field: string, groupBy: string) {
* filters: Array of filter objects containing predicates that are parsed into a JS expression * filters: Array of filter objects containing predicates that are parsed into a JS expression
* calculation: an optional calculation to be performed over the view data. * calculation: an optional calculation to be performed over the view data.
*/ */
export default function ({ export default function (
field, { field, tableId, groupBy, filters = [], calculation }: ViewTemplateOpts,
tableId, groupByMulti?: boolean
groupBy, ) {
filters = [],
calculation,
}: ViewTemplateOpts) {
// first filter can't have a conjunction // first filter can't have a conjunction
if (filters && filters.length > 0 && filters[0].conjunction) { if (filters && filters.length > 0 && filters[0].conjunction) {
delete filters[0].conjunction delete filters[0].conjunction
@ -151,9 +155,11 @@ export default function ({
let schema = null, let schema = null,
statFilter = null statFilter = null
let groupBySchema = groupByMulti ? GROUP_PROPERTY_MULTI : GROUP_PROPERTY
if (calculation) { if (calculation) {
schema = { schema = {
...(groupBy ? GROUP_PROPERTY : FIELD_PROPERTY), ...(groupBy ? groupBySchema : FIELD_PROPERTY),
...SCHEMA_MAP[calculation], ...SCHEMA_MAP[calculation],
} }
if ( if (

View File

@ -10,8 +10,11 @@ describe("/static", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
app = await config.init() app = await config.init()
})
beforeEach(()=>{
jest.clearAllMocks() jest.clearAllMocks()
}) })

View File

@ -7,7 +7,7 @@ describe("/api/keys", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })

View File

@ -14,18 +14,22 @@ jest.mock("../../../utilities/redis", () => ({
import { clearAllApps, checkBuilderEndpoint } from "./utilities/TestFunctions" import { clearAllApps, checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils" import { AppStatus } from "../../../db/utils"
import { events } from "@budibase/backend-core" import { events, utils } from "@budibase/backend-core"
import env from "../../../environment" import env from "../../../environment"
jest.setTimeout(15000)
describe("/applications", () => { describe("/applications", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await clearAllApps()
await config.init() await config.init()
})
beforeEach(async () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
@ -33,7 +37,7 @@ describe("/applications", () => {
it("creates empty app", async () => { it("creates empty app", async () => {
const res = await request const res = await request
.post("/api/applications") .post("/api/applications")
.field("name", "My App") .field("name", utils.newid())
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
@ -44,7 +48,7 @@ describe("/applications", () => {
it("creates app from template", async () => { it("creates app from template", async () => {
const res = await request const res = await request
.post("/api/applications") .post("/api/applications")
.field("name", "My App") .field("name", utils.newid())
.field("useTemplate", "true") .field("useTemplate", "true")
.field("templateKey", "test") .field("templateKey", "test")
.field("templateString", "{}") // override the file download .field("templateString", "{}") // override the file download
@ -59,7 +63,7 @@ describe("/applications", () => {
it("creates app from file", async () => { it("creates app from file", async () => {
const res = await request const res = await request
.post("/api/applications") .post("/api/applications")
.field("name", "My App") .field("name", utils.newid())
.field("useTemplate", "true") .field("useTemplate", "true")
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.attach("templateFile", "src/api/routes/tests/data/export.txt") .attach("templateFile", "src/api/routes/tests/data/export.txt")
@ -106,6 +110,11 @@ describe("/applications", () => {
}) })
describe("fetch", () => { describe("fetch", () => {
beforeEach(async () => {
// Clean all apps but the onde from config
await clearAllApps(config.getTenantId(), [config.getAppId()!])
})
it("lists all applications", async () => { it("lists all applications", async () => {
await config.createApp("app1") await config.createApp("app1")
await config.createApp("app2") await config.createApp("app2")
@ -266,6 +275,11 @@ describe("/applications", () => {
}) })
describe("unpublish", () => { describe("unpublish", () => {
beforeEach(async () => {
// We want to republish as the unpublish will delete the prod app
await config.publish()
})
it("should unpublish app with dev app ID", async () => { it("should unpublish app with dev app ID", async () => {
const appId = config.getAppId() const appId = config.getAppId()
await request await request

View File

@ -7,7 +7,7 @@ describe("/authenticate", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })

View File

@ -10,12 +10,16 @@ const MAX_RETRIES = 4
const { TRIGGER_DEFINITIONS, ACTION_DEFINITIONS } = require("../../../automations") const { TRIGGER_DEFINITIONS, ACTION_DEFINITIONS } = require("../../../automations")
const { events } = require("@budibase/backend-core") const { events } = require("@budibase/backend-core")
jest.setTimeout(30000)
describe("/automations", () => { describe("/automations", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(setup.afterAll)
// For some reason this cannot be a beforeAll or the test "tests the automation successfully" fail
beforeEach(async () => { beforeEach(async () => {
await config.init() await config.init()
}) })
@ -305,7 +309,7 @@ describe("/automations", () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
expect(res.body[0]).toEqual(expect.objectContaining(autoConfig)) expect(res.body[0]).toEqual(expect.objectContaining(autoConfig))
}) })
it("should apply authorization to endpoint", async () => { it("should apply authorization to endpoint", async () => {

View File

@ -7,7 +7,7 @@ describe("/component", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })

View File

@ -19,11 +19,13 @@ describe("/datasources", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { async function setupTest() {
await config.init() await config.init()
datasource = await config.createDatasource() datasource = await config.createDatasource()
jest.clearAllMocks() jest.clearAllMocks()
}) }
beforeAll(setupTest)
describe("create", () => { describe("create", () => {
it("should create a new datasource", async () => { it("should create a new datasource", async () => {
@ -102,6 +104,8 @@ describe("/datasources", () => {
}) })
describe("fetch", () => { describe("fetch", () => {
beforeAll(setupTest)
it("returns all the datasources from the server", async () => { it("returns all the datasources from the server", async () => {
const res = await request const res = await request
.get(`/api/datasources`) .get(`/api/datasources`)
@ -170,6 +174,8 @@ describe("/datasources", () => {
}) })
describe("destroy", () => { describe("destroy", () => {
beforeAll(setupTest)
it("deletes queries for the datasource after deletion and returns a success message", async () => { it("deletes queries for the datasource after deletion and returns a success message", async () => {
await config.createQuery() await config.createQuery()

View File

@ -8,7 +8,7 @@ describe("/dev", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
jest.clearAllMocks() jest.clearAllMocks()
}) })

View File

@ -7,7 +7,7 @@ describe("/integrations", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })

View File

@ -10,7 +10,7 @@ describe("/layouts", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
layout = await config.createLayout() layout = await config.createLayout()
jest.clearAllMocks() jest.clearAllMocks()

View File

@ -9,7 +9,7 @@ describe("/metadata", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
automation = await config.createAutomation() automation = await config.createAutomation()
}) })

View File

@ -7,7 +7,7 @@ describe("run misc tests", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })

View File

@ -15,8 +15,11 @@ describe("/permission", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
})
beforeEach(async () => {
table = await config.createTable() table = await config.createTable()
row = await config.createRow() row = await config.createRow()
perms = await config.addPermission(STD_ROLE_ID, table._id) perms = await config.addPermission(STD_ROLE_ID, table._id)

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