1
0
Fork 0
mirror of synced 2024-08-15 10:01:34 +12:00

Merge branch 'master' into fix/security-deps

This commit is contained in:
Martin McKeaveney 2023-08-25 18:35:27 +01:00 committed by GitHub
commit 26aa47078b
229 changed files with 4342 additions and 2640 deletions

View file

@ -18,6 +18,8 @@ env:
BRANCH: ${{ github.event.pull_request.head.ref }} BRANCH: ${{ github.event.pull_request.head.ref }}
BASE_BRANCH: ${{ github.event.pull_request.base.ref}} BASE_BRANCH: ${{ github.event.pull_request.base.ref}}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
NX_BASE_BRANCH: origin/${{ github.base_ref }}
USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' && github.base_ref != 'master'}}
jobs: jobs:
lint: lint:
@ -25,20 +27,20 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn lint - run: yarn lint
build: build:
@ -46,45 +48,66 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
# Run build all the projects # Run build all the projects
- run: yarn build - name: Build
run: |
yarn build
# Check the types of the projects built via esbuild # Check the types of the projects built via esbuild
- run: yarn check:types - name: Check types
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn check:types --since=${{ env.NX_BASE_BRANCH }}
else
yarn check:types
fi
test-libraries: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro - name: Test
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
fi
- uses: codecov/codecov-action@v3 - uses: codecov/codecov-action@v3
with: with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
@ -96,21 +119,31 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn test --scope=@budibase/worker --scope=@budibase/server - name: Test worker and server
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/worker --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/worker --scope=@budibase/server
fi
- uses: codecov/codecov-action@v3 - uses: codecov/codecov-action@v3
with: with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
@ -119,42 +152,50 @@ jobs:
test-pro: test-pro:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn test --scope=@budibase/pro - name: Test
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/pro
fi
integration-test: integration-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn build --projects=@budibase/server,@budibase/worker,@budibase/client - name: Build packages
run: yarn build --scope @budibase/server --scope @budibase/worker --scope @budibase/client --scope @budibase/backend-core
- name: Run tests - name: Run tests
run: | run: |
cd qa-core cd qa-core
@ -166,13 +207,12 @@ jobs:
check-pro-submodule: check-pro-submodule:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: true submodules: true
fetch-depth: 0
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Check pro commit - name: Check pro commit

View file

@ -0,0 +1,29 @@
name: check_unreleased_changes
on:
pull_request:
branches:
- master
jobs:
check_unreleased:
runs-on: ubuntu-latest
steps:
- name: Check for unreleased changes
env:
REPO: "Budibase/budibase"
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \
"https://api.github.com/repos/$REPO/releases/latest" | \
jq -r .published_at)
COMMIT_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \
"https://api.github.com/repos/$REPO/commits/master" | \
jq -r .commit.committer.date)
RELEASE_SECONDS=$(date --date="$RELEASE_TIMESTAMP" "+%s")
COMMIT_SECONDS=$(date --date="$COMMIT_TIMESTAMP" "+%s")
if (( COMMIT_SECONDS > RELEASE_SECONDS )); then
echo "There are unreleased changes. Please release these changes before merging."
exit 1
fi
echo "No unreleased changes detected."

View file

@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 18.x
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions

2
.nvmrc
View file

@ -1 +1 @@
v14.20.1 v18.17.0

View file

@ -1,3 +1,3 @@
nodejs 14.21.3 nodejs 18.17.0
python 3.10.0 python 3.10.0
yarn 1.22.19 yarn 1.22.19

71
.vscode/launch.json vendored
View file

@ -1,42 +1,31 @@
{ {
// Use IntelliSense to learn about possible attributes. // Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes. // Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Budibase Server", "name": "Budibase Server",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"runtimeArgs": [ "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
"--nolazy", "args": ["${workspaceFolder}/packages/server/src/index.ts"],
"-r", "cwd": "${workspaceFolder}/packages/server"
"ts-node/register/transpile-only" },
], {
"args": [ "name": "Budibase Worker",
"${workspaceFolder}/packages/server/src/index.ts" "type": "node",
], "request": "launch",
"cwd": "${workspaceFolder}/packages/server" "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
}, "args": ["${workspaceFolder}/packages/worker/src/index.ts"],
{ "cwd": "${workspaceFolder}/packages/worker"
"name": "Budibase Worker", }
"type": "node", ],
"request": "launch", "compounds": [
"runtimeArgs": [ {
"--nolazy", "name": "Start Budibase",
"-r", "configurations": ["Budibase Server", "Budibase Worker"]
"ts-node/register/transpile-only" }
], ]
"args": [ }
"${workspaceFolder}/packages/worker/src/index.ts"
],
"cwd": "${workspaceFolder}/packages/worker"
},
],
"compounds": [
{
"name": "Start Budibase",
"configurations": ["Budibase Server", "Budibase Worker"]
}
]
}

View file

@ -90,7 +90,7 @@ Component libraries are collections of components as well as the definition of t
#### 1. Prerequisites #### 1. Prerequisites
- NodeJS version `14.x.x` - NodeJS version `18.x.x`
- Python version `3.x` - Python version `3.x`
### Using asdf (recommended) ### Using asdf (recommended)

View file

@ -1,47 +0,0 @@
version: "3"
# optional ports are specified throughout for more advanced use cases.
services:
minio-service:
restart: on-failure
# Last version that supports the "fs" backend
image: minio/minio:RELEASE.2022-10-24T18-35-07Z
ports:
- "9000"
- "9001"
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
couchdb-service:
# platform: linux/amd64
restart: on-failure
image: budibase/couchdb
environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER}
ports:
- "5984"
- "4369"
- "9100"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5984/_up"]
interval: 30s
timeout: 20s
retries: 3
redis-service:
restart: on-failure
image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]

View file

@ -1,7 +1,7 @@
FROM node:14-slim as build FROM node:18-slim as build
# install node-gyp dependencies # install node-gyp dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python3
# add pin script # add pin script
WORKDIR / WORKDIR /

View file

@ -1,9 +1,16 @@
module.exports = () => { module.exports = () => {
return { return {
dockerCompose: { couchdb: {
composeFilePath: "../../hosting", image: "budibase/couchdb",
composeFile: "docker-compose.test.yaml", ports: [5984],
startupTimeout: 10000, env: {
}, COUCHDB_PASSWORD: "budibase",
COUCHDB_USER: "budibase",
},
wait: {
type: "ports",
timeout: 20000,
}
}
} }
} }

View file

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

View file

@ -34,7 +34,7 @@
"preinstall": "node scripts/syncProPackage.js", "preinstall": "node scripts/syncProPackage.js",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build", "build": "lerna run build --stream",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types", "check:types": "lerna run check:types",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap", "backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
@ -109,7 +109,7 @@
"@budibase/types": "0.0.0" "@budibase/types": "0.0.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0 <15.0.0" "node": ">=18.0.0 <19.0.0"
}, },
"dependencies": {} "dependencies": {}
} }

View file

@ -4,6 +4,8 @@ import * as context from "../context"
import * as platform from "../platform" import * as platform from "../platform"
import env from "../environment" import env from "../environment"
import * as accounts from "../accounts" import * as accounts from "../accounts"
import { UserDB } from "../users"
import { sdk } from "@budibase/shared-core"
const EXPIRY_SECONDS = 3600 const EXPIRY_SECONDS = 3600
@ -60,6 +62,18 @@ export async function getUser(
// make sure the tenant ID is always correct/set // make sure the tenant ID is always correct/set
user.tenantId = tenantId user.tenantId = tenantId
} }
// if has groups, could have builder permissions granted by a group
if (user.userGroups && !sdk.users.isGlobalBuilder(user)) {
await context.doInTenant(tenantId, async () => {
const appIds = await UserDB.getGroupBuilderAppIds(user)
if (appIds.length) {
const existing = user.builder?.apps || []
user.builder = {
apps: [...new Set(existing.concat(appIds))],
}
}
})
}
return user return user
} }

View file

@ -1,7 +1,6 @@
import fetch from "node-fetch" import fetch from "node-fetch"
import { getCouchInfo } from "./couch" import { getCouchInfo } from "./couch"
import { SearchFilters, Row } from "@budibase/types" import { SearchFilters, Row, EmptyFilterOption } from "@budibase/types"
import { createUserIndex } from "./searchIndexes/searchIndexes"
const QUERY_START_REGEX = /\d[0-9]*:/g const QUERY_START_REGEX = /\d[0-9]*:/g
@ -65,6 +64,7 @@ export class QueryBuilder<T> {
this.#index = index this.#index = index
this.#query = { this.#query = {
allOr: false, allOr: false,
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
string: {}, string: {},
fuzzy: {}, fuzzy: {},
range: {}, range: {},
@ -218,6 +218,10 @@ export class QueryBuilder<T> {
this.#query.allOr = true this.#query.allOr = true
} }
setOnEmptyFilter(value: EmptyFilterOption) {
this.#query.onEmptyFilter = value
}
handleSpaces(input: string) { handleSpaces(input: string) {
if (this.#noEscaping) { if (this.#noEscaping) {
return input return input
@ -289,8 +293,9 @@ export class QueryBuilder<T> {
const builder = this const builder = this
let allOr = this.#query && this.#query.allOr let allOr = this.#query && this.#query.allOr
let query = allOr ? "" : "*:*" let query = allOr ? "" : "*:*"
let allFiltersEmpty = true
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true } const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
let tableId let tableId: string = ""
if (this.#query.equal!.tableId) { if (this.#query.equal!.tableId) {
tableId = this.#query.equal!.tableId tableId = this.#query.equal!.tableId
delete this.#query.equal!.tableId delete this.#query.equal!.tableId
@ -305,7 +310,7 @@ export class QueryBuilder<T> {
} }
const contains = (key: string, value: any, mode = "AND") => { const contains = (key: string, value: any, mode = "AND") => {
if (Array.isArray(value) && value.length === 0) { if (!value || (Array.isArray(value) && value.length === 0)) {
return null return null
} }
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -384,6 +389,12 @@ export class QueryBuilder<T> {
built += ` ${mode} ` built += ` ${mode} `
} }
built += expression built += expression
if (
(typeof value !== "string" && value != null) ||
(typeof value === "string" && value !== tableId && value !== "")
) {
allFiltersEmpty = false
}
} }
if (opts?.returnBuilt) { if (opts?.returnBuilt) {
return built return built
@ -463,6 +474,13 @@ export class QueryBuilder<T> {
allOr = false allOr = false
build({ tableId }, equal) build({ tableId }, equal)
} }
if (allFiltersEmpty) {
if (this.#query.onEmptyFilter === EmptyFilterOption.RETURN_NONE) {
return ""
} else if (this.#query?.allOr) {
return query.replace("()", "(*:*)")
}
}
return query return query
} }

View file

@ -1,6 +1,6 @@
import { newid } from "../../docIds/newid" import { newid } from "../../docIds/newid"
import { getDB } from "../db" import { getDB } from "../db"
import { Database } from "@budibase/types" import { Database, EmptyFilterOption } from "@budibase/types"
import { QueryBuilder, paginatedSearch, fullSearch } from "../lucene" import { QueryBuilder, paginatedSearch, fullSearch } from "../lucene"
const INDEX_NAME = "main" const INDEX_NAME = "main"
@ -156,6 +156,76 @@ describe("lucene", () => {
expect(resp.rows.length).toBe(2) expect(resp.rows.length).toBe(2)
}) })
describe("empty filters behaviour", () => {
it("should return all rows by default", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addEqual("property", "")
builder.addEqual("number", null)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(3)
})
it("should return all rows when onEmptyFilter is ALL", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setOnEmptyFilter(EmptyFilterOption.RETURN_ALL)
builder.setAllOr()
builder.addEqual("property", "")
builder.addEqual("number", null)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(3)
})
it("should return no rows when onEmptyFilter is NONE", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setOnEmptyFilter(EmptyFilterOption.RETURN_NONE)
builder.addEqual("property", "")
builder.addEqual("number", null)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(0)
})
it("should return all matching rows when onEmptyFilter is NONE, but a filter value is provided", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setOnEmptyFilter(EmptyFilterOption.RETURN_NONE)
builder.addEqual("property", "")
builder.addEqual("number", 1)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
})
describe("skip", () => { describe("skip", () => {
const skipDbName = `db-${newid()}` const skipDbName = `db-${newid()}`
let docs: { let docs: {

View file

@ -1,5 +1,6 @@
import env from "../environment" import env from "../environment"
import * as context from "../context" import * as context from "../context"
export * from "./installation"
/** /**
* Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant. * Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant.

View file

@ -0,0 +1,17 @@
export function processFeatureEnvVar<T>(
fullList: string[],
featureList?: string
) {
let list
if (!featureList) {
list = fullList
} else {
list = featureList.split(",")
}
for (let feature of list) {
if (!fullList.includes(feature)) {
throw new Error(`Feature: ${feature} is not an allowed option`)
}
}
return list as unknown as T[]
}

View file

@ -6,7 +6,8 @@ export * as roles from "./security/roles"
export * as permissions from "./security/permissions" export * as permissions from "./security/permissions"
export * as accounts from "./accounts" export * as accounts from "./accounts"
export * as installation from "./installation" export * as installation from "./installation"
export * as featureFlags from "./featureFlags" export * as featureFlags from "./features"
export * as features from "./features/installation"
export * as sessions from "./security/sessions" export * as sessions from "./security/sessions"
export * as platform from "./platform" export * as platform from "./platform"
export * as auth from "./auth" export * as auth from "./auth"

View file

@ -5,11 +5,12 @@ import env from "../environment"
export default async (ctx: UserCtx, next: any) => { export default async (ctx: UserCtx, next: any) => {
const appId = getAppId() const appId = getAppId()
const builderFn = env.isWorker() const builderFn =
? hasBuilderPermissions env.isWorker() || !appId
: env.isApps() ? hasBuilderPermissions
? isBuilder : env.isApps()
: undefined ? isBuilder
: undefined
if (!builderFn) { if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.") throw new Error("Service name unknown - middleware inactive.")
} }

View file

@ -5,11 +5,12 @@ import env from "../environment"
export default async (ctx: UserCtx, next: any) => { export default async (ctx: UserCtx, next: any) => {
const appId = getAppId() const appId = getAppId()
const builderFn = env.isWorker() const builderFn =
? hasBuilderPermissions env.isWorker() || !appId
: env.isApps() ? hasBuilderPermissions
? isBuilder : env.isApps()
: undefined ? isBuilder
: undefined
if (!builderFn) { if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.") throw new Error("Service name unknown - middleware inactive.")
} }

View file

@ -78,7 +78,6 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.READ),
new Permission(PermissionType.TABLE, PermissionLevel.READ), new Permission(PermissionType.TABLE, PermissionLevel.READ),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
], ],
}, },
WRITE: { WRITE: {
@ -87,7 +86,6 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.WRITE), new Permission(PermissionType.QUERY, PermissionLevel.WRITE),
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
], ],
}, },
@ -98,7 +96,6 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.USER, PermissionLevel.READ), new Permission(PermissionType.USER, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
], ],
}, },
@ -109,7 +106,6 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.ADMIN), new Permission(PermissionType.TABLE, PermissionLevel.ADMIN),
new Permission(PermissionType.USER, PermissionLevel.ADMIN), new Permission(PermissionType.USER, PermissionLevel.ADMIN),
new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN),
new Permission(PermissionType.VIEW, PermissionLevel.ADMIN),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
], ],

View file

@ -1,30 +1,32 @@
import env from "../environment" import env from "../environment"
import * as eventHelpers from "./events" import * as eventHelpers from "./events"
import * as accounts from "../accounts" import * as accounts from "../accounts"
import * as accountSdk from "../accounts"
import * as cache from "../cache" import * as cache from "../cache"
import { getIdentity, getTenantId, getGlobalDB } from "../context" import { getGlobalDB, getIdentity, getTenantId } from "../context"
import * as dbUtils from "../db" import * as dbUtils from "../db"
import { EmailUnavailableError, HTTPError } from "../errors" import { EmailUnavailableError, HTTPError } from "../errors"
import * as platform from "../platform" import * as platform from "../platform"
import * as sessions from "../security/sessions" import * as sessions from "../security/sessions"
import * as usersCore from "./users" import * as usersCore from "./users"
import { import {
Account,
AllDocsResponse, AllDocsResponse,
BulkUserCreated, BulkUserCreated,
BulkUserDeleted, BulkUserDeleted,
isSSOAccount,
isSSOUser,
RowResponse, RowResponse,
SaveUserOpts, SaveUserOpts,
User, User,
Account,
isSSOUser,
isSSOAccount,
UserStatus, UserStatus,
UserGroup,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import * as accountSdk from "../accounts"
import { import {
validateUniqueUser,
getAccountHolderFromUserIds, getAccountHolderFromUserIds,
isAdmin, isAdmin,
validateUniqueUser,
} from "./utils" } from "./utils"
import { searchExistingEmails } from "./lookup" import { searchExistingEmails } from "./lookup"
import { hash } from "../utils" import { hash } from "../utils"
@ -32,8 +34,14 @@ import { hash } from "../utils"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any> type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any> type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean> type FeatureFn = () => Promise<Boolean>
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
type GroupBuildersFn = (user: User) => Promise<string[]>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn } type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn } type GroupFns = {
addUsers: GroupUpdateFn
getBulk: GroupGetFn
getGroupBuilderAppIds: GroupBuildersFn
}
type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn } type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn }
const bulkDeleteProcessing = async (dbUser: User) => { const bulkDeleteProcessing = async (dbUser: User) => {
@ -179,6 +187,14 @@ export class UserDB {
return user return user
} }
static async bulkGet(userIds: string[]) {
return await usersCore.bulkGetGlobalUsersById(userIds)
}
static async bulkUpdate(users: User[]) {
return await usersCore.bulkUpdateGlobalUsers(users)
}
static async save(user: User, opts: SaveUserOpts = {}): Promise<User> { static async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// default booleans to true // default booleans to true
if (opts.hashPassword == null) { if (opts.hashPassword == null) {
@ -457,4 +473,12 @@ export class UserDB {
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" }) await sessions.invalidateSessions(userId, { reason: "deletion" })
} }
static async getGroups(groupIds: string[]) {
return await this.groups.getBulk(groupIds)
}
static async getGroupBuilderAppIds(user: User) {
return await this.groups.getGroupBuilderAppIds(user)
}
} }

View file

@ -86,6 +86,10 @@ export const useAuditLogs = () => {
return useFeature(Feature.AUDIT_LOGS) return useFeature(Feature.AUDIT_LOGS)
} }
export const usePublicApiUserRoles = () => {
return useFeature(Feature.USER_ROLE_PUBLIC_API)
}
export const useScimIntegration = () => { export const useScimIntegration = () => {
return useFeature(Feature.SCIM) return useFeature(Feature.SCIM)
} }
@ -98,6 +102,10 @@ export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS) return useFeature(Feature.APP_BUILDERS)
} }
export const useViewPermissions = () => {
return useFeature(Feature.VIEW_PERMISSIONS)
}
// QUOTAS // QUOTAS
export const setAutomationLogsQuota = (value: number) => { export const setAutomationLogsQuota = (value: number) => {

View file

@ -32,8 +32,8 @@ function getTestContainerSettings(
): string | null { ): string | null {
const entry = Object.entries(global).find( const entry = Object.entries(global).find(
([k]) => ([k]) =>
k.includes(`_${serverName.toUpperCase()}`) && k.includes(`${serverName.toUpperCase()}`) &&
k.includes(`_${key.toUpperCase()}__`) k.includes(`${key.toUpperCase()}`)
) )
if (!entry) { if (!entry) {
return null return null
@ -67,27 +67,14 @@ function getContainerInfo(containerName: string, port: number) {
} }
function getCouchConfig() { function getCouchConfig() {
return getContainerInfo("couchdb-service", 5984) return getContainerInfo("couchdb", 5984)
}
function getMinioConfig() {
return getContainerInfo("minio-service", 9000)
}
function getRedisConfig() {
return getContainerInfo("redis-service", 6379)
} }
export function setupEnv(...envs: any[]) { export function setupEnv(...envs: any[]) {
const couch = getCouchConfig(), const couch = getCouchConfig()
minio = getCouchConfig(),
redis = getRedisConfig()
const configs = [ const configs = [
{ key: "COUCH_DB_PORT", value: couch.port }, { key: "COUCH_DB_PORT", value: couch.port },
{ key: "COUCH_DB_URL", value: couch.url }, { key: "COUCH_DB_URL", value: couch.url },
{ key: "MINIO_PORT", value: minio.port },
{ key: "MINIO_URL", value: minio.url },
{ key: "REDIS_URL", value: redis.url },
] ]
for (const config of configs.filter(x => !!x.value)) { for (const config of configs.filter(x => !!x.value)) {

View file

@ -32,11 +32,10 @@ export default function positionDropdown(element, opts) {
left: null, left: null,
top: null, top: null,
} }
// Determine vertical styles // Determine vertical styles
if (align === "right-outside") { if (align === "right-outside") {
styles.top = anchorBounds.top styles.top = anchorBounds.top
} else if (window.innerHeight - anchorBounds.bottom < 100) { } else if (window.innerHeight - anchorBounds.bottom < (maxHeight || 100)) {
styles.top = anchorBounds.top - elementBounds.height - offset styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240 styles.maxHeight = maxHeight || 240
} else { } else {

View file

@ -1,8 +1,8 @@
<script> <script>
import Popover from "../Popover/Popover.svelte"
import Layout from "../Layout/Layout.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Input from "../Form/Input.svelte" import Input from "../Form/Input.svelte"
import { capitalise } from "../helpers" import { capitalise } from "../helpers"
@ -10,9 +10,11 @@
export let value export let value
export let size = "M" export let size = "M"
export let spectrumTheme export let spectrumTheme
export let alignRight = false export let offset
export let align
let open = false let dropdown
let preview
$: customValue = getCustomValue(value) $: customValue = getCustomValue(value)
$: checkColor = getCheckColor(value) $: checkColor = getCheckColor(value)
@ -82,7 +84,7 @@
const onChange = value => { const onChange = value => {
dispatch("change", value) dispatch("change", value)
open = false dropdown.hide()
} }
const getCustomValue = value => { const getCustomValue = value => {
@ -119,30 +121,25 @@
return "var(--spectrum-global-color-static-gray-900)" return "var(--spectrum-global-color-static-gray-900)"
} }
const handleOutsideClick = event => {
if (open) {
event.stopPropagation()
open = false
}
}
</script> </script>
<div class="container"> <div
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}> bind:this={preview}
<div class="preview size--{size || 'M'}"
class="fill {spectrumTheme || ''}" on:click={() => {
style={value ? `background: ${value};` : ""} dropdown.toggle()
class:placeholder={!value} }}
/> >
</div> <div
{#if open} class="fill {spectrumTheme || ''}"
<div style={value ? `background: ${value};` : ""}
use:clickOutside={handleOutsideClick} class:placeholder={!value}
transition:fly|local={{ y: -20, duration: 200 }} />
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" </div>
class:spectrum-Popover--align-right={alignRight}
> <Popover bind:this={dropdown} anchor={preview} maxHeight={320} {offset} {align}>
<Layout paddingX="XL" paddingY="L">
<div class="container">
{#each categories as category} {#each categories as category}
<div class="category"> <div class="category">
<div class="heading">{category.label}</div> <div class="heading">{category.label}</div>
@ -187,8 +184,8 @@
</div> </div>
</div> </div>
</div> </div>
{/if} </Layout>
</div> </Popover>
<style> <style>
.container { .container {
@ -248,20 +245,6 @@
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
.spectrum-Popover {
width: 210px;
z-index: 999;
top: 100%;
padding: var(--spacing-l) var(--spacing-xl);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.spectrum-Popover--align-right {
right: 0;
}
.colors { .colors {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
@ -297,7 +280,11 @@
.category--custom .heading { .category--custom .heading {
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.spectrum-wrapper { .spectrum-wrapper {
background-color: transparent; background-color: transparent;
} }

View file

@ -35,6 +35,14 @@
open = false open = false
} }
export const toggle = () => {
if (!open) {
show()
} else {
hide()
}
}
const handleOutsideClick = e => { const handleOutsideClick = e => {
if (open) { if (open) {
// Stop propagation if the source is the anchor // Stop propagation if the source is the anchor

View file

@ -4,7 +4,7 @@ import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users" import { getUserStore } from "./store/users"
import { getDeploymentStore } from "./store/deployments" import { getDeploymentStore } from "./store/deployments"
import { derived } from "svelte/store" import { derived, writable } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history" import { createHistoryStore } from "builderStore/store/history"
@ -61,6 +61,12 @@ export const selectedLayout = derived(store, $store => {
export const selectedComponent = derived( export const selectedComponent = derived(
[store, selectedScreen], [store, selectedScreen],
([$store, $selectedScreen]) => { ([$store, $selectedScreen]) => {
if (
$selectedScreen &&
$store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`)
) {
return $selectedScreen?.props
}
if (!$selectedScreen || !$store.selectedComponentId) { if (!$selectedScreen || !$store.selectedComponentId) {
return null return null
} }
@ -141,3 +147,5 @@ export const userSelectedResourceMap = derived(userStore, $userStore => {
export const isOnlyUser = derived(userStore, $userStore => { export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2 return $userStore.length < 2
}) })
export const screensHeight = writable("210px")

View file

@ -225,7 +225,6 @@ export const getFrontendStore = () => {
// Select new screen // Select new screen
store.update(state => { store.update(state => {
state.selectedScreenId = screen._id state.selectedScreenId = screen._id
state.selectedComponentId = screen.props?._id
return state return state
}) })
}, },
@ -769,9 +768,13 @@ export const getFrontendStore = () => {
else { else {
await store.actions.screens.patch(screen => { await store.actions.screens.patch(screen => {
// Find the selected component // Find the selected component
let selectedComponentId = state.selectedComponentId
if (selectedComponentId.startsWith(`${screen._id}-`)) {
selectedComponentId = screen?.props._id
}
const currentComponent = findComponent( const currentComponent = findComponent(
screen.props, screen.props,
state.selectedComponentId selectedComponentId
) )
if (!currentComponent) { if (!currentComponent) {
return false return false
@ -994,12 +997,20 @@ export const getFrontendStore = () => {
const componentId = state.selectedComponentId const componentId = state.selectedComponentId
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
// Check we aren't right at the top of the tree
const index = parent?._children.findIndex(x => x._id === componentId) const index = parent?._children.findIndex(x => x._id === componentId)
if (!parent || componentId === screen.props._id) {
// Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen`
const navComponentId = `${screen._id}-navigation`
if (componentId === screenComponentId) {
return null return null
} }
if (componentId === navComponentId) {
return screenComponentId
}
if (parent._id === screen.props._id && index === 0) {
return navComponentId
}
// If we have siblings above us, choose the sibling or a descendant // If we have siblings above us, choose the sibling or a descendant
if (index > 0) { if (index > 0) {
@ -1021,12 +1032,20 @@ export const getFrontendStore = () => {
return parent._id return parent._id
}, },
getNext: () => { getNext: () => {
const state = get(store)
const component = get(selectedComponent) const component = get(selectedComponent)
const componentId = component?._id const componentId = component?._id
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(x => x._id === componentId) const index = parent?._children.findIndex(x => x._id === componentId)
// Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen`
const navComponentId = `${screen._id}-navigation`
if (state.selectedComponentId === screenComponentId) {
return navComponentId
}
// If we have children, select first child // If we have children, select first child
if (component._children?.length) { if (component._children?.length) {
return component._children[0]._id return component._children[0]._id

View file

@ -6,13 +6,15 @@
Select, Select,
Toggle, Toggle,
RadioGroup, RadioGroup,
Icon,
DatePicker, DatePicker,
Modal, Modal,
notifications, notifications,
OptionSelectDnD, OptionSelectDnD,
Layout, Layout,
AbsTooltip,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext, onMount } 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"
@ -47,6 +49,7 @@
export let field export let field
let mounted = false
let fieldDefinitions = cloneDeep(FIELDS) let fieldDefinitions = cloneDeep(FIELDS)
let originalName let originalName
let linkEditDisabled let linkEditDisabled
@ -413,16 +416,22 @@
} }
return newError return newError
} }
onMount(() => {
mounted = true
})
</script> </script>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<Input {#if mounted}
bind:value={editableColumn.name} <Input
disabled={uneditable || autofocus
(linkEditDisabled && editableColumn.type === LINK_TYPE)} bind:value={editableColumn.name}
error={errors?.name} disabled={uneditable ||
/> (linkEditDisabled && editableColumn.type === LINK_TYPE)}
error={errors?.name}
/>
{/if}
<Select <Select
disabled={!typeEnabled} disabled={!typeEnabled}
bind:value={editableColumn.type} bind:value={editableColumn.type}
@ -452,12 +461,17 @@
/> />
{:else if editableColumn.type === "longform"} {:else if editableColumn.type === "longform"}
<div> <div>
<Label <div class="tooltip-alignment">
size="M" <Label size="M">Formatting</Label>
tooltip="Rich text includes support for images, links, tables, lists and more" <AbsTooltip
> position="top"
Formatting type="info"
</Label> text={"Rich text includes support for images, link"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle <Toggle
bind:value={editableColumn.useRichText} bind:value={editableColumn.useRichText}
text="Enable rich text support (markdown)" text="Enable rich text support (markdown)"
@ -488,13 +502,18 @@
</div> </div>
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"} {#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
<div> <div>
<Label <div>
tooltip={isCreating <Label>Time zones</Label>
? null <AbsTooltip
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"} position="top"
> type="info"
Time zones text={isCreating
</Label> ? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle <Toggle
bind:value={editableColumn.ignoreTimezones} bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones" text="Ignore time zones"
@ -671,6 +690,12 @@
align-items: center; align-items: center;
} }
.tooltip-alignment {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.label-length { .label-length {
flex-basis: 40%; flex-basis: 40%;
} }

View file

@ -121,7 +121,9 @@
type: "Screen", type: "Screen",
name: screen.routing.route, name: screen.routing.route,
icon: "WebPage", icon: "WebPage",
action: () => $goto(`./design/${screen._id}/components`), action: () => {
$goto(`./design/${screen._id}/${screen._id}-screen`)
},
})), })),
...($automationStore?.automations?.map(automation => ({ ...($automationStore?.automations?.map(automation => ({
type: "Automation", type: "Automation",

View file

@ -21,6 +21,7 @@
export let id export let id
export let showTooltip = false export let showTooltip = false
export let selectedBy = null export let selectedBy = null
export let compact = false
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -80,8 +81,9 @@
{#if withArrow} {#if withArrow}
<div <div
class:opened class:opened
class:relative={indentLevel === 0} class:relative={indentLevel === 0 && !compact}
class:absolute={indentLevel > 0} class:absolute={indentLevel > 0 && !compact}
class:compact
class="icon arrow" class="icon arrow"
on:click={onIconClick} on:click={onIconClick}
> >
@ -194,10 +196,21 @@
padding: 8px; padding: 8px;
margin-left: -8px; margin-left: -8px;
} }
.compact {
position: absolute;
left: 6px;
padding: 8px;
margin-left: -8px;
}
.icon.arrow :global(svg) { .icon.arrow :global(svg) {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
.icon.arrow.compact :global(svg) {
width: 9px;
height: 9px;
}
.icon.arrow.relative { .icon.arrow.relative {
position: relative; position: relative;
margin: 0 -6px 0 -4px; margin: 0 -6px 0 -4px;

View file

@ -1,5 +1,5 @@
<script> <script>
import { Icon, Heading } from "@budibase/bbui" import { Icon, Body } from "@budibase/bbui"
export let title export let title
export let icon export let icon
@ -25,7 +25,7 @@
<Icon name={icon} /> <Icon name={icon} />
{/if} {/if}
<div class="title"> <div class="title">
<Heading size="XXS">{title || ""}</Heading> <Body size="S">{title}</Body>
</div> </div>
{#if showAddButton} {#if showAddButton}
<div class="add-button" on:click={onClickAddButton}> <div class="add-button" on:click={onClickAddButton}>
@ -78,15 +78,14 @@
align-items: center; align-items: center;
padding: 0 var(--spacing-l); padding: 0 var(--spacing-l);
border-bottom: var(--border-light); border-bottom: var(--border-light);
gap: var(--spacing-l); gap: var(--spacing-m);
} }
.title { .title {
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
} }
.title :global(h1) { .title :global(p) {
overflow: hidden; overflow: hidden;
font-weight: 600;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }

View file

@ -1,10 +1,12 @@
<script> <script>
import { Select, Label, Stepper } from "@budibase/bbui" import { Select, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding" import { getActionProviderComponents } from "builderStore/dataBinding"
import { onMount } from "svelte" import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = []
$: actionProviders = getActionProviderComponents( $: actionProviders = getActionProviderComponents(
$currentAsset, $currentAsset,
@ -51,7 +53,11 @@
<Select bind:value={parameters.type} options={typeOptions} /> <Select bind:value={parameters.type} options={typeOptions} />
{#if parameters.type === "specific"} {#if parameters.type === "specific"}
<Label small>Number</Label> <Label small>Number</Label>
<Stepper bind:value={parameters.number} /> <DrawerBindableInput
{bindings}
value={parameters.number}
on:change={e => (parameters.number = e.detail)}
/>
{/if} {/if}
</div> </div>

View file

@ -42,7 +42,6 @@
<ColorPicker <ColorPicker
value={column.background} value={column.background}
on:change={e => (column.background = e.detail)} on:change={e => (column.background = e.detail)}
alignRight
spectrumTheme={$store.theme} spectrumTheme={$store.theme}
/> />
</Layout> </Layout>
@ -51,7 +50,6 @@
<ColorPicker <ColorPicker
value={column.color} value={column.color}
on:change={e => (column.color = e.detail)} on:change={e => (column.color = e.detail)}
alignRight
spectrumTheme={$store.theme} spectrumTheme={$store.theme}
/> />
</Layout> </Layout>

View file

@ -17,7 +17,7 @@
import { generate } from "shortid" import { generate } from "shortid"
import { LuceneUtils, Constants } from "@budibase/frontend-core" import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { getFields } from "helpers/searchFields" import { getFields } from "helpers/searchFields"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, onMount } from "svelte"
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
@ -35,22 +35,28 @@
{ value: "and", label: "Match all filters" }, { value: "and", label: "Match all filters" },
{ value: "or", label: "Match any filter" }, { value: "or", label: "Match any filter" },
] ]
const onEmptyOptions = [
{ value: "all", label: "Return all table rows" },
{ value: "none", label: "Return no rows" },
]
let rawFilters let rawFilters
let matchAny = false let matchAny = false
let onEmptyFilter = "all"
$: parseFilters(filters) $: parseFilters(filters)
$: dispatch("change", enrichFilters(rawFilters, matchAny)) $: dispatch("change", enrichFilters(rawFilters, matchAny, onEmptyFilter))
$: enrichedSchemaFields = getFields(schemaFields || [], { allowLinks: true }) $: enrichedSchemaFields = getFields(schemaFields || [], { allowLinks: true })
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || [] $: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"] $: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
// Remove field key prefixes and determine whether to use the "match all" // Remove field key prefixes and determine which behaviours to use
// or "match any" behaviour
const parseFilters = filters => { const parseFilters = filters => {
matchAny = filters?.find(filter => filter.operator === "allOr") != null matchAny = filters?.find(filter => filter.operator === "allOr") != null
onEmptyFilter =
filters?.find(filter => filter.onEmptyFilter)?.onEmptyFilter ?? "all"
rawFilters = (filters || []) rawFilters = (filters || [])
.filter(filter => filter.operator !== "allOr") .filter(filter => filter.operator !== "allOr" && !filter.onEmptyFilter)
.map(filter => { .map(filter => {
const { field } = filter const { field } = filter
let newFilter = { ...filter } let newFilter = { ...filter }
@ -64,9 +70,18 @@
}) })
} }
onMount(() => {
parseFilters(filters)
rawFilters.forEach(filter => {
filter.type =
schemaFields.find(field => field.name === filter.field)?.type ||
filter.type
})
})
// Add field key prefixes and a special metadata filter object to indicate // Add field key prefixes and a special metadata filter object to indicate
// whether to use the "match all" or "match any" behaviour // how to handle filter behaviour
const enrichFilters = (rawFilters, matchAny) => { const enrichFilters = (rawFilters, matchAny, onEmptyFilter) => {
let count = 1 let count = 1
return rawFilters return rawFilters
.filter(filter => filter.field) .filter(filter => filter.field)
@ -75,6 +90,7 @@
field: `${count++}:${filter.field}`, field: `${count++}:${filter.field}`,
})) }))
.concat(matchAny ? [{ operator: "allOr" }] : []) .concat(matchAny ? [{ operator: "allOr" }] : [])
.concat([{ onEmptyFilter }])
} }
const addFilter = () => { const addFilter = () => {
@ -186,6 +202,17 @@
on:change={e => (matchAny = e.detail === "or")} on:change={e => (matchAny = e.detail === "or")}
placeholder={null} placeholder={null}
/> />
{#if datasource?.type === "table"}
<Select
label="When filter empty"
value={onEmptyFilter}
options={onEmptyOptions}
getOptionLabel={opt => opt.label}
getOptionValue={opt => opt.value}
on:change={e => (onEmptyFilter = e.detail)}
placeholder={null}
/>
{/if}
</div> </div>
<div> <div>
<div class="filter-label"> <div class="filter-label">

View file

@ -56,6 +56,11 @@ export const syncURLToState = options => {
// Navigate to a certain URL // Navigate to a certain URL
const gotoUrl = (url, params) => { const gotoUrl = (url, params) => {
// Clean URL
if (url?.endsWith("/index")) {
url = url.replace("/index", "")
}
// Allow custom URL handling
if (beforeNavigate) { if (beforeNavigate) {
const res = beforeNavigate(url, params) const res = beforeNavigate(url, params)
if (res?.url) { if (res?.url) {

View file

@ -1,6 +1,6 @@
<script> <script>
import { Button, Drawer } from "@budibase/bbui" import { Button, Drawer } from "@budibase/bbui"
import NavigationLinksDrawer from "./NavigationLinksDrawer.svelte" import NavigationLinksDrawer from "./LinksDrawer.svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { store } from "builderStore" import { store } from "builderStore"
@ -20,12 +20,8 @@
} }
</script> </script>
<Button cta on:click={openDrawer}>Configure links</Button> <Button cta on:click={openDrawer}>Configure Links</Button>
<Drawer <Drawer bind:this={drawer} title={"Navigation Links"}>
bind:this={drawer}
title={"Navigation Links"}
width="calc(100% - 334px)"
>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Configure the links in your navigation bar. Configure the links in your navigation bar.
</svelte:fragment> </svelte:fragment>

View file

@ -0,0 +1,225 @@
<script>
import LinksEditor from "./LinksEditor.svelte"
import { get } from "svelte/store"
import Panel from "components/design/Panel.svelte"
import {
Detail,
Toggle,
Body,
Icon,
ColorPicker,
Input,
Label,
ActionGroup,
ActionButton,
Checkbox,
notifications,
Select,
} from "@budibase/bbui"
import { selectedScreen, store } from "builderStore"
import { DefaultAppTheme } from "constants"
const updateShowNavigation = async e => {
await store.actions.screens.updateSetting(
get(selectedScreen),
"showNavigation",
e.detail
)
}
const update = async (key, value) => {
try {
let navigation = $store.navigation
navigation[key] = value
await store.actions.navigation.save(navigation)
} catch (error) {
notifications.error("Error updating navigation settings")
}
}
</script>
<Panel
title="Navigation"
icon={$selectedScreen.showNavigation ? "Visibility" : "VisibilityOff"}
borderLeft
wide
>
<div class="generalSection">
<div class="subheading">
<Detail>General</Detail>
</div>
<div class="toggle">
<Toggle
on:change={updateShowNavigation}
value={$selectedScreen.showNavigation}
/>
<Body size="S">Show nav on this screen</Body>
</div>
</div>
{#if $selectedScreen.showNavigation}
<div class="divider" />
<div class="customizeSection">
<div class="subheading">
<Detail>Customize</Detail>
</div>
<div class="info">
<Icon name="InfoOutline" size="S" />
<Body size="S">These settings apply to all screens</Body>
</div>
<div class="configureLinks">
<LinksEditor />
</div>
<div class="controls">
<div class="label">
<Label size="M">Position</Label>
</div>
<ActionGroup quiet>
<ActionButton
selected={$store.navigation.navigation === "Top"}
quiet={$store.navigation.navigation !== "Top"}
icon="PaddingTop"
on:click={() => update("navigation", "Top")}
/>
<ActionButton
selected={$store.navigation.navigation === "Left"}
quiet={$store.navigation.navigation !== "Left"}
icon="PaddingLeft"
on:click={() => update("navigation", "Left")}
/>
</ActionGroup>
{#if $store.navigation.navigation === "Top"}
<div class="label">
<Label size="M">Sticky header</Label>
</div>
<Checkbox
value={$store.navigation.sticky}
on:change={e => update("sticky", e.detail)}
/>
<div class="label">
<Label size="M">Width</Label>
</div>
<Select
options={["Max", "Large", "Medium", "Small"]}
plaveholder={null}
value={$store.navigation.navWidth}
on:change={e => update("navWidth", e.detail)}
/>
{/if}
<div class="label">
<Label size="M">Show logo</Label>
</div>
<Checkbox
value={!$store.navigation.hideLogo}
on:change={e => update("hideLogo", !e.detail)}
/>
{#if !$store.navigation.hideLogo}
<div class="label">
<Label size="M">Logo URL</Label>
</div>
<Input
value={$store.navigation.logoUrl}
on:change={e => update("logoUrl", e.detail)}
updateOnChange={false}
/>
{/if}
<div class="label">
<Label size="M">Show title</Label>
</div>
<Checkbox
value={!$store.navigation.hideTitle}
on:change={e => update("hideTitle", !e.detail)}
/>
{#if !$store.navigation.hideTitle}
<div class="label">
<Label size="M">Title</Label>
</div>
<Input
value={$store.navigation.title}
on:change={e => update("title", e.detail)}
updateOnChange={false}
/>
{/if}
<div class="label">
<Label>Background</Label>
</div>
<ColorPicker
spectrumTheme={$store.theme}
value={$store.navigation.navBackground ||
DefaultAppTheme.navBackground}
on:change={e => update("navBackground", e.detail)}
/>
<div class="label">
<Label>Text</Label>
</div>
<ColorPicker
spectrumTheme={$store.theme}
value={$store.navigation.navTextColor || DefaultAppTheme.navTextColor}
on:change={e => update("navTextColor", e.detail)}
/>
</div>
</div>
{/if}
</Panel>
<style>
.generalSection {
padding: 13px 13px 25px;
}
.customizeSection {
padding: 13px 13px 25px;
}
.subheading {
margin-bottom: 10px;
}
.subheading :global(p) {
color: var(--grey-6);
}
.toggle {
display: flex;
align-items: center;
}
.divider {
border-top: 1px solid var(--grey-3);
}
.controls {
position: relative;
display: grid;
grid-template-columns: 90px 1fr;
align-items: start;
transition: background 130ms ease-out, border-color 130ms ease-out;
border-left: 4px solid transparent;
margin: 0 calc(-1 * var(--spacing-xl));
padding: 0 var(--spacing-xl) 0 calc(var(--spacing-xl) - 4px);
gap: 12px;
}
.label {
margin-top: 16px;
transform: translateY(-50%);
}
.info {
background-color: var(--background-alt);
padding: 12px;
display: flex;
border-radius: 4px;
gap: 4px;
margin-bottom: 16px;
}
.info :global(svg) {
margin-right: 5px;
color: var(--spectrum-global-color-gray-600);
}
.configureLinks :global(button) {
margin-bottom: 20px;
width: 100%;
}
</style>

View file

@ -1,12 +1,8 @@
<script> <script>
import Panel from "components/design/Panel.svelte"
import { get } from "svelte/store" import { get } from "svelte/store"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { import {
Input, Input,
Layout,
Button,
Toggle,
Checkbox, Checkbox,
Banner, Banner,
Select, Select,
@ -16,7 +12,6 @@
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte" import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
import { selectedScreen, store } from "builderStore" import { selectedScreen, store } from "builderStore"
import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl" import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl"
import { goto } from "@roxi/routify"
import ButtonActionEditor from "components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte" import ButtonActionEditor from "components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte"
import { getBindableProperties } from "builderStore/dataBinding" import { getBindableProperties } from "builderStore/dataBinding"
@ -119,15 +114,6 @@
label: "On screen load", label: "On screen load",
control: ButtonActionEditor, control: ButtonActionEditor,
}, },
{
key: "showNavigation",
label: "Navigation",
control: Toggle,
props: {
text: "Show nav",
disabled: !!$selectedScreen.layoutId,
},
},
{ {
key: "width", key: "width",
label: "Width", label: "Width",
@ -145,36 +131,24 @@
} }
</script> </script>
<Panel {#if $selectedScreen.layoutId}
title={$selectedScreen.routing.route} <Banner
icon={$selectedScreen.routing.route === "/" ? "Home" : "WebPage"} type="warning"
borderLeft extraButtonText="Detach custom layout"
wide extraButtonAction={removeCustomLayout}
> showCloseButton={false}
<Layout gap="S" paddingX="L" paddingY="XL"> >
{#if $selectedScreen.layoutId} This screen uses a custom layout, which is deprecated
<Banner </Banner>
type="warning" {/if}
extraButtonText="Detach custom layout" {#each screenSettings as setting (setting.key)}
extraButtonAction={removeCustomLayout} <PropertyControl
showCloseButton={false} control={setting.control}
> label={setting.label}
This screen uses a custom layout, which is deprecated key={setting.key}
</Banner> value={Helpers.deepGet($selectedScreen, setting.key)}
{/if} onChange={val => setScreenSetting(setting, val)}
{#each screenSettings as setting (setting.key)} props={{ ...setting.props, error: errors[setting.key] }}
<PropertyControl {bindings}
control={setting.control} />
label={setting.label} {/each}
key={setting.key}
value={Helpers.deepGet($selectedScreen, setting.key)}
onChange={val => setScreenSetting(setting, val)}
props={{ ...setting.props, error: errors[setting.key] }}
{bindings}
/>
{/each}
<Button secondary on:click={() => $goto("../components")}>
View components
</Button>
</Layout>
</Panel>

View file

@ -0,0 +1,78 @@
<script>
import {
Layout,
Label,
ColorPicker,
notifications,
Icon,
Body,
} from "@budibase/bbui"
import { store } from "builderStore"
import { get } from "svelte/store"
import { DefaultAppTheme } from "constants"
import AppThemeSelect from "./AppThemeSelect.svelte"
import ButtonRoundnessSelect from "./ButtonRoundnessSelect.svelte"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
$: customTheme = $store.customTheme || {}
const update = async (property, value) => {
try {
store.actions.customTheme.save({
...get(store).customTheme,
[property]: value,
})
} catch (error) {
notifications.error("Error updating custom theme")
}
}
</script>
<div class="info">
<Icon name="InfoOutline" size="S" />
<Body size="S">These settings apply to all screens</Body>
</div>
<Layout noPadding gap="S">
<Layout noPadding gap="XS">
<AppThemeSelect />
</Layout>
<Layout noPadding gap="XS">
<Label>Button roundness</Label>
<ButtonRoundnessSelect
{customTheme}
on:change={e => update("buttonBorderRadius", e.detail)}
/>
</Layout>
<PropertyControl
label="Accent color"
control={ColorPicker}
value={customTheme.primaryColor || DefaultAppTheme.primaryColor}
onChange={val => update("primaryColor", val)}
props={{
spectrumTheme: $store.theme,
}}
/>
<PropertyControl
label="Hover"
control={ColorPicker}
value={customTheme.primaryColorHover || DefaultAppTheme.primaryColorHover}
onChange={val => update("primaryColorHover", val)}
props={{
spectrumTheme: $store.theme,
}}
/>
</Layout>
<style>
.info {
background-color: var(--background-alt);
padding: 12px;
display: flex;
border-radius: 4px;
gap: 4px;
}
.info :global(svg) {
margin-right: 5px;
color: var(--spectrum-global-color-gray-600);
}
</style>

View file

@ -0,0 +1,51 @@
<script>
import GeneralPanel from "./GeneralPanel.svelte"
import ThemePanel from "./ThemePanel.svelte"
import { selectedScreen } from "builderStore"
import Panel from "components/design/Panel.svelte"
import { capitalise } from "helpers"
import { ActionButton, Layout } from "@budibase/bbui"
let activeTab = "general"
const tabs = ["general", "theme"]
</script>
<Panel
title={$selectedScreen.routing.route}
icon={$selectedScreen.routing.route === "/" ? "Home" : "WebPage"}
borderLeft
wide
>
<div slot="panel-header-content">
<div class="settings-tabs">
{#each tabs as tab}
<ActionButton
size="M"
quiet
selected={activeTab === tab}
on:click={() => {
activeTab = tab
}}
>
{capitalise(tab)}
</ActionButton>
{/each}
</div>
</div>
<Layout gap="S" paddingX="L" paddingY="XL">
{#if activeTab === "theme"}
<ThemePanel />
{:else}
<GeneralPanel />
{/if}
</Layout>
</Panel>
<style>
.settings-tabs {
display: flex;
gap: var(--spacing-s);
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
}
</style>

View file

@ -0,0 +1,52 @@
<script>
import { syncURLToState } from "helpers/urlStateSync"
import { store, selectedScreen } from "builderStore"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
import { findComponent } from "builderStore/componentUtils"
import ComponentSettingsPanel from "./_components/Component/ComponentSettingsPanel.svelte"
import NavigationPanel from "./_components/Navigation/index.svelte"
import ScreenSettingsPanel from "./_components/Screen/index.svelte"
$: componentId = $store.selectedComponentId
$: store.actions.websocket.selectResource(componentId)
$: params = routify.params
$: routeComponentId = $params.componentId
// Hide new component panel whenever component ID changes
const closeNewComponentPanel = url => {
if (url?.endsWith("/new")) {
url = url.replace("/new", "")
}
return { url }
}
const validate = id => {
if (id === `${$store.selectedScreenId}-screen`) return true
if (id === `${$store.selectedScreenId}-navigation`) return true
return !!findComponent($selectedScreen.props, id)
}
// Keep URL and state in sync for selected component ID
const stopSyncing = syncURLToState({
urlParam: "componentId",
stateKey: "selectedComponentId",
validate,
fallbackUrl: "../",
store,
routify,
beforeNavigate: closeNewComponentPanel,
})
onDestroy(stopSyncing)
</script>
{#if routeComponentId === `${$store.selectedScreenId}-screen`}
<ScreenSettingsPanel />
{:else if routeComponentId === `${$store.selectedScreenId}-navigation`}
<NavigationPanel />
{:else}
<ComponentSettingsPanel />
{/if}
<slot />

View file

@ -0,0 +1 @@
<!-- Required to make Routify happy -->

View file

@ -31,6 +31,10 @@
$: orderMap = createComponentOrderMap(componentList) $: orderMap = createComponentOrderMap(componentList)
const getAllowedComponents = (allComponents, screen, component) => { const getAllowedComponents = (allComponents, screen, component) => {
// Default to using the root screen container if no component specified
if (!component) {
component = screen.props
}
const path = findComponentPath(screen?.props, component?._id) const path = findComponentPath(screen?.props, component?._id)
if (!path?.length) { if (!path?.length) {
return [] return []

View file

@ -1,32 +1,16 @@
<script> <script>
import DevicePreviewSelect from "./DevicePreviewSelect.svelte" import DevicePreviewSelect from "./DevicePreviewSelect.svelte"
import AppPreview from "./AppPreview.svelte" import AppPreview from "./AppPreview.svelte"
import { store, sortedScreens, screenHistoryStore } from "builderStore" import { store, screenHistoryStore } from "builderStore"
import { Select } from "@budibase/bbui"
import { RoleUtils } from "@budibase/frontend-core"
import UndoRedoControl from "components/common/UndoRedoControl.svelte" import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { isActive } from "@roxi/routify"
</script> </script>
<div class="app-panel"> <div class="app-panel">
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<Select <UndoRedoControl store={screenHistoryStore} />
placeholder={null}
options={$sortedScreens}
getOptionLabel={x => x.routing.route}
getOptionValue={x => x._id}
getOptionColour={x => RoleUtils.getRoleColour(x.routing.roleId)}
value={$store.selectedScreenId}
on:change={e => store.actions.screens.select(e.detail)}
quiet
autoWidth
/>
</div> </div>
<div class="header-right"> <div class="header-right">
{#if $isActive("./screens") || $isActive("./components")}
<UndoRedoControl store={screenHistoryStore} />
{/if}
{#if $store.clientFeatures.devicePreview} {#if $store.clientFeatures.devicePreview}
<DevicePreviewSelect /> <DevicePreviewSelect />
{/if} {/if}
@ -47,37 +31,24 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
gap: var(--spacing-m); padding: 9px var(--spacing-m);
padding: var(--spacing-l) var(--spacing-xl);
} }
.header { .header {
display: flex; display: flex;
flex-direction: row; margin-bottom: 9px;
justify-content: space-between; }
align-items: flex-start;
gap: var(--spacing-l); .header-left :global(div) {
margin: 0 2px; border-right: none;
z-index: 1;
} }
.header-left,
.header-right { .header-right {
margin-left: auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.header-left {
flex: 1 1 auto;
width: 0;
}
.header-left :global(> *) {
max-width: 100%;
}
.header-left :global(.spectrum-Picker) {
font-weight: 600;
color: var(--spectrum-global-color-gray-900);
}
.content { .content {
flex: 1 1 auto; flex: 1 1 auto;
} }

View file

@ -1,13 +1,7 @@
<script> <script>
import { get } from "svelte/store" import { get } from "svelte/store"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { import { store, selectedScreen, currentAsset } from "builderStore"
store,
selectedComponent,
selectedScreen,
selectedLayout,
currentAsset,
} from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
ProgressCircle, ProgressCircle,
@ -20,12 +14,10 @@
import ErrorSVG from "@budibase/frontend-core/assets/error.svg?raw" import ErrorSVG from "@budibase/frontend-core/assets/error.svg?raw"
import { findComponent, findComponentPath } from "builderStore/componentUtils" import { findComponent, findComponentPath } from "builderStore/componentUtils"
import { isActive, goto } from "@roxi/routify" import { isActive, goto } from "@roxi/routify"
import { Screen } from "builderStore/store/screenTemplates/utils/Screen"
let iframe let iframe
let layout let layout
let screen let screen
let selectedComponentId
let confirmDeleteDialog let confirmDeleteDialog
let idToDelete let idToDelete
let loading = true let loading = true
@ -39,36 +31,11 @@
BUDIBASE: "type", BUDIBASE: "type",
} }
const placeholderScreen = new Screen()
.name("Screen Placeholder")
.route("/")
.component("@budibase/standard-components/screenslot")
.instanceName("Content Placeholder")
.normalStyle({ flex: "1 1 auto" })
.json()
// Extract data to pass to the iframe // Extract data to pass to the iframe
$: { $: screen = $selectedScreen
// If viewing legacy layouts, always show the custom layout
if ($isActive("./layouts")) {
screen = placeholderScreen
layout = $selectedLayout
} else {
screen = $selectedScreen
layout = $store.layouts.find(layout => layout._id === screen?.layoutId)
}
}
// Determine selected component ID // Determine selected component ID
$: { $: selectedComponentId = $store.selectedComponentId
if ($isActive("./components")) {
selectedComponentId = $store.selectedComponentId
} else if ($isActive("./navigation")) {
selectedComponentId = "navigation"
} else {
selectedComponentId = null
}
}
$: previewData = { $: previewData = {
appId: $store.appId, appId: $store.appId,
@ -98,9 +65,7 @@
$: refreshContent(json) $: refreshContent(json)
// Determine if the add component menu is active // Determine if the add component menu is active
$: isAddingComponent = $isActive( $: isAddingComponent = $isActive(`./${selectedComponentId}/new`)
`./components/${$selectedComponent?._id}/new`
)
// Register handler to send custom to the preview // Register handler to send custom to the preview
$: sendPreviewEvent = (name, payload) => { $: sendPreviewEvent = (name, payload) => {
@ -152,9 +117,6 @@
error = event.error || "An unknown error occurred" error = event.error || "An unknown error occurred"
} else if (type === "select-component" && data.id) { } else if (type === "select-component" && data.id) {
$store.selectedComponentId = data.id $store.selectedComponentId = data.id
if (!$isActive("./components")) {
$goto("./components")
}
} else if (type === "update-prop") { } else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value) await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "update-styles") { } else if (type === "update-styles") {
@ -194,10 +156,6 @@
store.actions.components.copy(source, true, false) store.actions.components.copy(source, true, false)
await store.actions.components.paste(destination, data.mode) await store.actions.components.paste(destination, data.mode)
} }
} else if (type === "click-nav") {
if (!$isActive("./navigation")) {
$goto("./navigation")
}
} else if (type === "request-add-component") { } else if (type === "request-add-component") {
toggleAddComponent() toggleAddComponent()
} else if (type === "highlight-setting") { } else if (type === "highlight-setting") {
@ -247,11 +205,10 @@
} }
const toggleAddComponent = () => { const toggleAddComponent = () => {
if (isAddingComponent) { if ($isActive(`./:componentId/new`)) {
$goto(`../${$selectedScreen._id}/components/${$selectedComponent?._id}`) $goto(`./:componentId`)
} else { } else {
const id = $selectedComponent?._id || $selectedScreen?.props?._id $goto(`./:componentId/new`)
$goto(`../${$selectedScreen._id}/components/${id}/new`)
} }
} }

View file

@ -5,7 +5,6 @@
import { goto, isActive } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { isBuilderInputFocused } from "helpers"
let confirmDeleteDialog let confirmDeleteDialog
let confirmEjectDialog let confirmEjectDialog
@ -37,7 +36,7 @@
confirmEjectDialog.show() confirmEjectDialog.show()
}, },
["Ctrl+Enter"]: () => { ["Ctrl+Enter"]: () => {
$goto("./new") $goto(`./:componentId/new`)
}, },
["Delete"]: component => { ["Delete"]: component => {
// Don't show confirmation for the screen itself // Don't show confirmation for the screen itself
@ -54,8 +53,8 @@
store.actions.components.selectNext() store.actions.components.selectNext()
}, },
["Escape"]: () => { ["Escape"]: () => {
if ($isActive("./new")) { if ($isActive(`./:componentId/new`)) {
$goto("./") $goto(`./${$store.selectedComponentId}`)
} }
}, },
} }
@ -85,10 +84,13 @@
const handler = keyHandlers[key] const handler = keyHandlers[key]
if (!handler) { if (!handler) {
return false return false
} else if (event) { }
if (event && key !== "Escape") {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
return await handler(component) return await handler(component)
} catch (error) { } catch (error) {
notifications.error(error || "Error handling key press") notifications.error(error || "Error handling key press")
@ -101,7 +103,13 @@
return return
} }
// Ignore events when typing // Ignore events when typing
if (isBuilderInputFocused(e)) { const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor =
document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
return return
} }
// Key events are always for the selected component // Key events are always for the selected component

View file

@ -9,14 +9,14 @@
if (!bounds) { if (!bounds) {
return return
} }
const sidebarWidth = 259 const sidebarWidth = 310
const navItemHeight = 32 const navItemHeight = 32
const { scrollLeft, scrollTop, offsetHeight } = scrollRef const { scrollLeft, scrollTop, offsetHeight } = scrollRef
let scrollBounds = scrollRef.getBoundingClientRect() let scrollBounds = scrollRef.getBoundingClientRect()
let newOffsets = {} let newOffsets = {}
// Calculate left offset // Calculate left offset
const offsetX = bounds.left + bounds.width + scrollLeft - 36 const offsetX = bounds.left + bounds.width + scrollLeft + 16
if (offsetX > sidebarWidth) { if (offsetX > sidebarWidth) {
newOffsets.left = offsetX - sidebarWidth newOffsets.left = offsetX - sidebarWidth
} else { } else {
@ -64,6 +64,7 @@
</script> </script>
<div <div
on:scroll
bind:this={scrollRef} bind:this={scrollRef}
on:drop={onDrop} on:drop={onDrop}
ondragover="return false" ondragover="return false"
@ -74,7 +75,6 @@
<style> <style>
div { div {
padding: var(--spacing-xl) 0;
flex: 1 1 auto; flex: 1 1 auto;
overflow: auto; overflow: auto;
height: 0; height: 0;

View file

@ -107,6 +107,7 @@
id={`component-${component._id}`} id={`component-${component._id}`}
> >
<NavItem <NavItem
compact
scrollable scrollable
draggable draggable
on:dragend={dndStore.actions.reset} on:dragend={dndStore.actions.reset}
@ -117,7 +118,7 @@
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
withArrow={componentHasChildren(component)} withArrow={componentHasChildren(component)}
indentLevel={level + 1} indentLevel={level}
selected={$store.selectedComponentId === component._id} selected={$store.selectedComponentId === component._id}
{opened} {opened}
highlighted={isChildOfSelectedComponent(component)} highlighted={isChildOfSelectedComponent(component)}

View file

@ -0,0 +1,163 @@
<script>
import { notifications, Icon, Body } from "@budibase/bbui"
import { isActive, goto } from "@roxi/routify"
import { store, selectedScreen, userSelectedResourceMap } from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import ComponentTree from "./ComponentTree.svelte"
import { dndStore } from "./dndStore.js"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { DropPosition } from "./dndStore"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
let scrolling = false
const toNewComponentRoute = () => {
if ($isActive(`./:componentId/new`)) {
$goto(`./:componentId`)
} else {
$goto(`./:componentId/new`)
}
}
const onDrop = async () => {
try {
await dndStore.actions.drop()
} catch (error) {
console.error(error)
notifications.error("Error saving component")
}
}
const handleScroll = e => {
scrolling = e.target.scrollTop !== 0
}
</script>
<div class="components">
<div class="header" class:scrolling>
<Body size="S">Components</Body>
<div on:click={toNewComponentRoute} class="addButton">
<Icon name="Add" />
</div>
</div>
<div class="list-panel">
<ComponentScrollWrapper on:scroll={handleScroll}>
<ul>
<li>
<NavItem
text="Screen"
indentLevel={0}
selected={$store.selectedComponentId ===
`${$store.selectedScreenId}-screen`}
opened
scrollable
icon="WebPage"
on:drop={onDrop}
on:click={() => {
$store.selectedComponentId = `${$store.selectedScreenId}-screen`
}}
id={`component-screen`}
selectedBy={$userSelectedResourceMap[
`${$store.selectedScreenId}-screen`
]}
>
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem>
<NavItem
text="Navigation"
indentLevel={0}
selected={$store.selectedComponentId ===
`${$store.selectedScreenId}-navigation`}
opened
scrollable
icon={$selectedScreen.showNavigation
? "Visibility"
: "VisibilityOff"}
on:drop={onDrop}
on:click={() => {
$store.selectedComponentId = `${$store.selectedScreenId}-navigation`
}}
id={`component-nav`}
selectedBy={$userSelectedResourceMap[
`${$store.selectedScreenId}-navigation`
]}
/>
<ComponentTree
level={0}
components={$selectedScreen?.props._children}
/>
<!-- Show drop indicators for the target and the parent -->
{#if $dndStore.dragging && $dndStore.valid}
<DNDPositionIndicator
component={$dndStore.target}
position={$dndStore.dropPosition}
/>
{#if $dndStore.dropPosition !== DropPosition.INSIDE}
<DNDPositionIndicator
component={$dndStore.targetParent}
position={DropPosition.INSIDE}
/>
{/if}
{/if}
</li>
</ul>
</ComponentScrollWrapper>
</div>
<ComponentKeyHandler />
</div>
<style>
.components {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
}
.header {
height: 50px;
box-sizing: border-box;
padding: var(--spacing-l);
display: flex;
align-items: center;
border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out;
}
.header.scrolling {
border-bottom: var(--border-light);
}
.components :global(.nav-item) {
padding-right: 8px !important;
}
.addButton {
margin-left: auto;
color: var(--grey-7);
cursor: pointer;
}
.addButton:hover {
color: var(--ink);
}
.list-panel {
display: flex;
flex-direction: column;
flex: 1;
}
ul {
list-style: none;
padding-left: 0;
margin: 0;
position: relative;
}
ul,
li {
min-width: max-content;
}
</style>

View file

@ -0,0 +1,21 @@
<script>
import ScreenList from "./ScreenList/index.svelte"
import ComponentList from "./ComponentList/index.svelte"
</script>
<div class="panel">
<ScreenList />
<ComponentList />
</div>
<style>
.panel {
width: 310px;
height: 100%;
border-right: var(--border-light);
display: flex;
flex-direction: column;
background: var(--background);
position: relative;
}
</style>

View file

@ -56,7 +56,7 @@
const deleteScreen = async () => { const deleteScreen = async () => {
try { try {
await store.actions.screens.delete(screen) await store.actions.screens.delete(screen)
notifications.success("Deleted screen successfully.") notifications.success("Deleted screen successfully")
} catch (err) { } catch (err) {
notifications.error("Error deleting screen") notifications.error("Error deleting screen")
} }

View file

@ -26,7 +26,7 @@
<StatusLight square {color} /> <StatusLight square {color} />
{#if showTooltip} {#if showTooltip}
<div class="tooltip"> <div class="tooltip">
<Tooltip textWrapping text={tooltip} direction="left" /> <Tooltip textWrapping text={tooltip} direction="right" />
</div> </div>
{/if} {/if}
</div> </div>
@ -38,13 +38,11 @@
.tooltip { .tooltip {
z-index: 1; z-index: 1;
position: absolute; position: absolute;
top: 50%; bottom: -5px;
left: calc(50% - 8px); left: 13px;
transform: translateX(-100%) translateY(-50%);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
width: 200px;
pointer-events: none; pointer-events: none;
} }
.tooltip :global(.spectrum-Tooltip) { .tooltip :global(.spectrum-Tooltip) {

View file

@ -0,0 +1,304 @@
<script>
import { Icon, Layout, Body } from "@budibase/bbui"
import {
store,
sortedScreens,
userSelectedResourceMap,
screensHeight,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import RoleIndicator from "./RoleIndicator.svelte"
import DropdownMenu from "./DropdownMenu.svelte"
import { onMount, tick } from "svelte"
import { goto } from "@roxi/routify"
let search = false
let resizing = false
let searchValue = ""
let searchInput
let container
let screensContainer
let scrolling = false
let previousHeight = null
let dragOffset
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const openSearch = async () => {
search = true
await tick()
searchInput.focus()
screensContainer.scroll({ top: 0, behavior: "smooth" })
previousHeight = $screensHeight
$screensHeight = "calc(100% + 1px)"
}
const closeSearch = async () => {
if (previousHeight) {
// Restore previous height and wait for animation
$screensHeight = previousHeight
previousHeight = null
await sleep(300)
}
search = false
searchValue = ""
}
const getFilteredScreens = (screens, search) => {
return screens.filter(screen => {
return !search || screen.routing.route.includes(search)
})
}
const handleAddButton = () => {
if (search) {
closeSearch()
} else {
$goto("../new")
}
}
const onKeyDown = e => {
if (e.key === "Escape") {
closeSearch()
}
}
const handleScroll = e => {
scrolling = e.target.scrollTop !== 0
}
const startResizing = e => {
// Reset the height store to match the true height
$screensHeight = `${container.getBoundingClientRect().height}px`
// Store an offset to easily compute new height when moving the mouse
dragOffset = parseInt($screensHeight) - e.clientY
// Add event listeners
resizing = true
document.addEventListener("mousemove", resize)
document.addEventListener("mouseup", stopResizing)
}
const resize = e => {
// Prevent negative heights as this screws with layout
const newHeight = Math.max(0, e.clientY + dragOffset)
if (newHeight == null || isNaN(newHeight)) {
return
}
$screensHeight = `${newHeight}px`
}
const stopResizing = () => {
resizing = false
document.removeEventListener("mousemove", resize)
}
onMount(() => {
// Ensure we aren't stuck at 100% height from leaving while searching
if ($screensHeight == null || isNaN(parseInt($screensHeight))) {
$screensHeight = "210px"
}
})
</script>
<svelte:window on:keydown={onKeyDown} />
<div
class="screens"
class:search
class:resizing
style={`height:${$screensHeight};`}
bind:this={container}
>
<div class="header" class:scrolling>
<input
readonly={!search}
bind:value={searchValue}
bind:this={searchInput}
class="input"
placeholder="Search for screens"
/>
<div class="title" class:hide={search}>
<Body size="S">Screens</Body>
</div>
<div on:click={openSearch} class="searchButton" class:hide={search}>
<Icon size="S" name="Search" />
</div>
<div
on:click={handleAddButton}
class="addButton"
class:closeButton={search}
>
<Icon name="Add" />
</div>
</div>
<div on:scroll={handleScroll} bind:this={screensContainer} class="content">
{#if filteredScreens?.length}
{#each filteredScreens as screen (screen._id)}
<NavItem
icon={screen.routing.homeScreen ? "Home" : null}
indentLevel={0}
selected={$store.selectedScreenId === screen._id}
text={screen.routing.route}
on:click={() => store.actions.screens.select(screen._id)}
rightAlignIcon
showTooltip
selectedBy={$userSelectedResourceMap[screen._id]}
>
<DropdownMenu screenId={screen._id} />
<div slot="icon" class="icon">
<RoleIndicator roleId={screen.routing.roleId} />
</div>
</NavItem>
{/each}
{:else}
<Layout paddingY="none" paddingX="L">
<div class="no-results">
There aren't any screens matching that route
</div>
</Layout>
{/if}
</div>
<div
class="divider"
on:mousedown={startResizing}
on:dblclick={() => screensHeight.set("210px")}
/>
</div>
<style>
.screens {
display: flex;
flex-direction: column;
min-height: 147px;
max-height: calc(100% - 147px);
position: relative;
}
.screens.search {
transition: height 300ms ease-out;
max-height: none;
}
.screens.resizing {
user-select: none;
cursor: row-resize;
}
.header {
flex-shrink: 0;
position: relative;
height: 50px;
box-sizing: border-box;
padding: 0 var(--spacing-l);
display: flex;
align-items: center;
border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out;
}
.header.scrolling {
border-bottom: var(--border-light);
}
.input {
font-family: var(--font-sans);
position: absolute;
color: var(--ink);
background-color: transparent;
border: none;
font-size: var(--spectrum-alias-font-size-default);
width: 260px;
box-sizing: border-box;
display: none;
}
.input:focus {
outline: none;
}
.input::placeholder {
color: var(--spectrum-global-color-gray-600);
}
.screens.search input {
display: block;
}
.title {
display: flex;
align-items: center;
height: 100%;
box-sizing: border-box;
flex: 1;
opacity: 1;
z-index: 1;
}
.content {
overflow: auto;
flex-grow: 1;
}
.screens.resizing .content {
pointer-events: none;
}
.screens :global(.nav-item) {
padding-right: 8px !important;
}
.searchButton {
color: var(--grey-7);
cursor: pointer;
margin-right: 10px;
opacity: 1;
}
.searchButton:hover {
color: var(--ink);
}
.hide {
opacity: 0;
pointer-events: none;
}
.addButton {
color: var(--grey-7);
cursor: pointer;
transition: transform 300ms ease-out;
}
.addButton:hover {
color: var(--ink);
}
.closeButton {
transform: rotate(45deg);
}
.icon {
margin-left: 4px;
margin-right: 4px;
}
.no-results {
color: var(--spectrum-global-color-gray-600);
}
.divider {
position: absolute;
bottom: 0;
transform: translateY(50%);
height: 16px;
width: 100%;
}
.divider:after {
content: "";
position: absolute;
background: var(--spectrum-global-color-gray-200);
height: 2px;
width: 100%;
top: 50%;
transform: translateY(-50%);
}
.divider:hover {
cursor: row-resize;
}
</style>

View file

@ -1,5 +0,0 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("../screens")
</script>

View file

@ -1,14 +1,10 @@
<script> <script>
import { IconSideNav, IconSideNavItem } from "@budibase/bbui"
import * as routify from "@roxi/routify"
import AppPanel from "./_components/AppPanel.svelte" import AppPanel from "./_components/AppPanel.svelte"
import * as routify from "@roxi/routify"
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import { store, selectedScreen } from "builderStore" import { store, selectedScreen } from "builderStore"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
const { isActive, goto } = routify import LeftPanel from "./_components/LeftPanel.svelte"
$: screenId = $store.selectedScreenId
$: store.actions.websocket.selectResource(screenId)
// Keep URL and state in sync for selected screen ID // Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
@ -23,51 +19,15 @@
onDestroy(stopSyncing) onDestroy(stopSyncing)
</script> </script>
<div class="design"> {#if $selectedScreen}
<div class="icon-nav"> <div class="design">
<IconSideNav> <div class="content">
<IconSideNavItem <LeftPanel />
icon="WebPage"
tooltip="Screens"
active={$isActive("./screens")}
on:click={() => $goto("./screens")}
/>
<IconSideNavItem
icon="ViewList"
tooltip="Components"
active={$isActive("./components")}
on:click={() => $goto("./components")}
/>
<IconSideNavItem
icon="Brush"
tooltip="Theme"
active={$isActive("./theme")}
on:click={() => $goto("./theme")}
/>
<IconSideNavItem
icon="Link"
tooltip="Navigation"
active={$isActive("./navigation")}
on:click={() => $goto("./navigation")}
/>
{#if $store.layouts?.length}
<IconSideNavItem
icon="Experience"
tooltip="Layouts"
active={$isActive("./layouts")}
on:click={() => $goto("./layouts")}
/>
{/if}
</IconSideNav>
</div>
<div class="content">
{#if $selectedScreen}
<slot />
<AppPanel /> <AppPanel />
{/if} <slot />
</div>
</div> </div>
</div> {/if}
<style> <style>
.design { .design {
@ -78,10 +38,7 @@
align-items: stretch; align-items: stretch;
height: 0; height: 0;
} }
.icon-nav {
background: var(--background);
border-right: var(--border-light);
}
.content { .content {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -89,17 +46,4 @@
align-items: stretch; align-items: stretch;
flex: 1 1 auto; flex: 1 1 auto;
} }
/*
This is hacky, yes, but it's the only way to prevent routify from
remounting the iframe on route changes.
*/
.content :global(> *:last-child) {
order: 1;
}
.content :global(> *:first-child) {
order: 0;
}
.content :global(> *:nth-child(2)) {
order: 2;
}
</style> </style>

View file

@ -1,90 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import ComponentTree from "./ComponentTree.svelte"
import { dndStore } from "./dndStore.js"
import { goto } from "@roxi/routify"
import { store, selectedScreen, userSelectedResourceMap } from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { DropPosition } from "./dndStore"
import { notifications, Button } from "@budibase/bbui"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
const onDrop = async () => {
try {
await dndStore.actions.drop()
} catch (error) {
console.error(error)
notifications.error("Error saving component")
}
}
</script>
<Panel title="Components" showExpandIcon borderRight>
<div class="add-component">
<Button on:click={() => $goto("./new")} cta>Add component</Button>
</div>
<ComponentScrollWrapper>
<ul>
<li>
<NavItem
text="Screen"
indentLevel={0}
selected={$store.selectedComponentId === $selectedScreen?.props._id}
opened
scrollable
icon="WebPage"
on:drop={onDrop}
on:click={() => {
$store.selectedComponentId = $selectedScreen?.props._id
}}
id={`component-${$selectedScreen?.props._id}`}
selectedBy={$userSelectedResourceMap[$selectedScreen?.props._id]}
>
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem>
<ComponentTree
level={0}
components={$selectedScreen?.props._children}
/>
<!-- Show drop indicators for the target and the parent -->
{#if $dndStore.dragging && $dndStore.valid}
<DNDPositionIndicator
component={$dndStore.target}
position={$dndStore.dropPosition}
/>
{#if $dndStore.dropPosition !== DropPosition.INSIDE}
<DNDPositionIndicator
component={$dndStore.targetParent}
position={DropPosition.INSIDE}
/>
{/if}
{/if}
</li>
</ul>
</ComponentScrollWrapper>
</Panel>
<ComponentKeyHandler />
<style>
.add-component {
padding: var(--spacing-xl) var(--spacing-l);
padding-bottom: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
ul {
list-style: none;
padding-left: 0;
margin: 0;
position: relative;
}
ul,
li {
min-width: max-content;
}
</style>

View file

@ -1,41 +0,0 @@
<script>
import { syncURLToState } from "helpers/urlStateSync"
import { store, selectedScreen } from "builderStore"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
import { findComponent } from "builderStore/componentUtils"
import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte"
import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte"
$: componentId = $store.selectedComponentId
$: store.actions.websocket.selectResource(componentId)
const cleanUrl = url => {
// Strip trailing slashes
if (url?.endsWith("/index")) {
url = url.replace("/index", "")
}
// Hide new component panel whenever component ID changes
if (url?.endsWith("/new")) {
url = url.replace("/new", "")
}
return { url }
}
// Keep URL and state in sync for selected component ID
const stopSyncing = syncURLToState({
urlParam: "componentId",
stateKey: "selectedComponentId",
validate: id => !!findComponent($selectedScreen.props, id),
fallbackUrl: "../",
store,
routify,
beforeNavigate: cleanUrl,
})
onDestroy(stopSyncing)
</script>
<ComponentListPanel />
<ComponentSettingsPanel />
<slot />

View file

@ -1,4 +0,0 @@
<!--
Placeholder file so that routify works.
No unique content is needed in this index page.
-->

View file

@ -1,18 +0,0 @@
<script>
import { selectedScreen, selectedComponent } from "builderStore"
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
onMount(() => {
if ($selectedComponent) {
// Navigate to the selected component if one exists
$redirect(`./${$selectedComponent._id}`)
} else if ($selectedScreen) {
// Otherwise the screen slot if a screen exists
$redirect(`./${$selectedScreen.props._id}`)
} else {
// Otherwise go up so we can select a new valid screen
$redirect("../")
}
})
</script>

View file

@ -1,5 +1,6 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { store } from "builderStore"
$redirect("./screens") $redirect(`./${$store.selectedScreenId}-screen`)
</script> </script>

View file

@ -1,41 +0,0 @@
<script>
import { store } from "builderStore"
import { notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
export let layout
let confirmDeleteDialog
const deleteLayout = async () => {
try {
await store.actions.layouts.delete(layout)
notifications.success("Layout deleted successfully")
} catch (err) {
notifications.error(err?.message || "Error deleting layout")
}
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={"Are you sure you wish to delete this layout?"}
okText="Delete layout"
onOk={deleteLayout}
/>
<style>
.icon {
display: grid;
place-items: center;
}
</style>

View file

@ -1,29 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import NavItem from "components/common/NavItem.svelte"
import { store } from "builderStore"
import LayoutDropdownMenu from "./LayoutDropdownMenu.svelte"
</script>
<Panel title="Layouts" borderRight>
<div class="layouts">
{#each $store.layouts as layout (layout._id)}
<NavItem
icon="Experience"
indentLevel={0}
selected={$store.selectedLayoutId === layout._id}
text={layout.name}
on:click={() => store.actions.layouts.select(layout._id)}
>
<LayoutDropdownMenu {layout} />
</NavItem>
{/each}
</div>
</Panel>
<style>
.layouts {
margin-top: var(--spacing-xl);
overflow: hidden;
}
</style>

View file

@ -1,53 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import { store, selectedLayout } from "builderStore"
import { Layout, Body, Button, Banner, notifications } from "@budibase/bbui"
import { Component } from "builderStore/store/screenTemplates/utils/Component"
const copyLayout = () => {
// Build an outer container component to put layout contents inside
let container = new Component("@budibase/standard-components/container")
.instanceName($selectedLayout.name)
.customProps({
gap: "M",
direction: "column",
hAlign: "stretch",
vAlign: "top",
size: "shrink",
})
.json()
// Attach layout components
container._children = $selectedLayout.props._children
// Replace the screenslot component with a container. This is better than
// simply removing it as it still shows its position.
container = JSON.parse(
JSON.stringify(container).replace(
"@budibase/standard-components/screenslot",
"@budibase/standard-components/container"
)
)
// Copy new component structure
store.actions.components.copy(container)
notifications.success("Components copied successfully")
}
</script>
<Panel title={$selectedLayout?.name} icon="Experience" borderLeft wide>
<Layout paddingX="L" paddingY="XL" gap="S">
<Banner type="warning" showCloseButton={false}>
Custom layouts are being deprecated. They will be removed in a future
release.
</Banner>
<Body size="S">
You can save the content of this layout by pressing the button below.
</Body>
<Body size="S">
This will copy all components inside your layout, which you can then paste
into a screen.
</Body>
<Button cta on:click={copyLayout}>Copy components</Button>
</Layout>
</Panel>

View file

@ -1,20 +0,0 @@
<script>
import { syncURLToState } from "helpers/urlStateSync"
import { store } from "builderStore"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
// Keep URL and state in sync for selected component ID
const stopSyncing = syncURLToState({
urlParam: "layoutId",
stateKey: "selectedLayoutId",
validate: id => $store.layouts?.some(layout => layout._id === id),
fallbackUrl: "../",
store,
routify,
})
onDestroy(stopSyncing)
</script>
<slot />

View file

@ -1,7 +0,0 @@
<script>
import LayoutListPanel from "./_components/LayoutListPanel.svelte"
import LayoutSettingsPanel from "./_components/LayoutSettingsPanel.svelte"
</script>
<LayoutListPanel />
<LayoutSettingsPanel />

View file

@ -1,12 +0,0 @@
<script>
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
$: {
if (!$store.layouts?.length) {
$redirect("../")
}
}
</script>
<slot />

View file

@ -1,12 +0,0 @@
<script>
import { store } from "builderStore"
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
onMount(() => {
if ($store.layouts?.length) {
$redirect(`./${$store.layouts[0]._id}`)
}
// The redirection when no layouts exist is handled by the routify layout
})
</script>

View file

@ -1,33 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import { Body, Layout, Banner } from "@budibase/bbui"
import { selectedScreen, store } from "builderStore"
import { get } from "svelte/store"
const removeCustomLayout = async () => {
return store.actions.screens.removeCustomLayout(get(selectedScreen))
}
</script>
<Panel borderLeft title="Navigation" icon="InfoOutline" wide>
<Layout paddingX="L" paddingY="XL" gap="S">
{#if $selectedScreen.layoutId}
<Banner
type="warning"
extraButtonText="Detach custom layout"
extraButtonAction={removeCustomLayout}
showCloseButton={false}
>
You can't preview your navigation settings using this screen as it uses
a custom layout, which is deprecated
</Banner>
{/if}
<Body size="S">
Your navigation is configured for all the screens within your app.
</Body>
<Body size="S">
You can hide and show your navigation for each screen in the screen
settings.
</Body>
</Layout>
</Panel>

View file

@ -1,110 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import {
Layout,
Label,
ActionGroup,
ActionButton,
Checkbox,
Select,
ColorPicker,
Input,
notifications,
} from "@budibase/bbui"
import NavigationLinksEditor from "./NavigationLinksEditor.svelte"
import { store } from "builderStore"
import { DefaultAppTheme } from "constants"
const update = async (key, value) => {
try {
let navigation = $store.navigation
navigation[key] = value
await store.actions.navigation.save(navigation)
} catch (error) {
notifications.error("Error updating navigation settings")
}
}
</script>
<Panel title="Navigation" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<NavigationLinksEditor />
<Layout noPadding gap="XS">
<Label>Position</Label>
<ActionGroup quiet>
<ActionButton
selected={$store.navigation.navigation === "Top"}
quiet={$store.navigation.navigation !== "Top"}
icon="PaddingTop"
on:click={() => update("navigation", "Top")}
/>
<ActionButton
selected={$store.navigation.navigation === "Left"}
quiet={$store.navigation.navigation !== "Left"}
icon="PaddingLeft"
on:click={() => update("navigation", "Left")}
/>
</ActionGroup>
</Layout>
{#if $store.navigation.navigation === "Top"}
<Checkbox
text="Sticky header"
value={$store.navigation.sticky}
on:change={e => update("sticky", e.detail)}
/>
<Select
label="Width"
options={["Max", "Large", "Medium", "Small"]}
plaveholder={null}
value={$store.navigation.navWidth}
on:change={e => update("navWidth", e.detail)}
/>
{/if}
<Layout noPadding gap="XS">
<Checkbox
text="Logo"
value={!$store.navigation.hideLogo}
on:change={e => update("hideLogo", !e.detail)}
/>
{#if !$store.navigation.hideLogo}
<Input
value={$store.navigation.logoUrl}
on:change={e => update("logoUrl", e.detail)}
placeholder="Add logo URL"
updateOnChange={false}
/>
{/if}
</Layout>
<Layout noPadding gap="XS">
<Checkbox
text="Title"
value={!$store.navigation.hideTitle}
on:change={e => update("hideTitle", !e.detail)}
/>
{#if !$store.navigation.hideTitle}
<Input
value={$store.navigation.title}
on:change={e => update("title", e.detail)}
placeholder="Add title"
updateOnChange={false}
/>
{/if}
</Layout>
<Layout noPadding gap="XS">
<Label>Background color</Label>
<ColorPicker
spectrumTheme={$store.theme}
value={$store.navigation.navBackground || DefaultAppTheme.navBackground}
on:change={e => update("navBackground", e.detail)}
/>
</Layout>
<Layout noPadding gap="XS">
<Label>Text color</Label>
<ColorPicker
spectrumTheme={$store.theme}
value={$store.navigation.navTextColor || DefaultAppTheme.navTextColor}
on:change={e => update("navTextColor", e.detail)}
/>
</Layout>
</Layout>
</Panel>

View file

@ -1,7 +0,0 @@
<script>
import NavigationSettingsPanel from "./_components/NavigationSettingsPanel.svelte"
import NavigationInfoPanel from "./_components/NavigationInfoPanel.svelte"
</script>
<NavigationSettingsPanel />
<NavigationInfoPanel />

View file

@ -1,75 +0,0 @@
<script>
import { Search, Layout, Select, Body, Button } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte"
import { goto } from "@roxi/routify"
import { roles } from "stores/backend"
import { store, sortedScreens, userSelectedResourceMap } from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte"
import RoleIndicator from "./RoleIndicator.svelte"
import { RoleUtils } from "@budibase/frontend-core"
let searchString
let accessRole = "all"
$: filteredScreens = getFilteredScreens(
$sortedScreens,
searchString,
accessRole
)
const getFilteredScreens = (screens, search, role) => {
return screens.filter(screen => {
const searchMatch = !search || screen.routing.route.includes(search)
const roleMatch =
!role || role === "all" || screen.routing.roleId === role
return searchMatch && roleMatch
})
}
</script>
<Panel title="Screens" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Button on:click={() => $goto("../../new")} cta>Add screen</Button>
<Search
placeholder="Search"
value={searchString}
on:change={e => (searchString = e.detail)}
/>
<Select
bind:value={accessRole}
placeholder={null}
getOptionLabel={role => role.name}
getOptionValue={role => role._id}
getOptionColour={role => {
if (role?._id === "all") {
return null
}
return RoleUtils.getRoleColour(role._id)
}}
options={[{ name: "All screens", _id: "all" }, ...$roles]}
/>
</Layout>
{#each filteredScreens as screen (screen._id)}
<NavItem
icon={screen.routing.homeScreen ? "Home" : null}
indentLevel={0}
selected={$store.selectedScreenId === screen._id}
text={screen.routing.route}
on:click={() => store.actions.screens.select(screen._id)}
rightAlignIcon
showTooltip
selectedBy={$userSelectedResourceMap[screen._id]}
>
<ScreenDropdownMenu screenId={screen._id} />
<RoleIndicator slot="right" roleId={screen.routing.roleId} />
</NavItem>
{/each}
{#if !filteredScreens?.length}
<Layout paddingY="" paddingX="L">
<Body size="S">
There aren't any screens matching the current filters
</Body>
</Layout>
{/if}
</Panel>

View file

@ -1,12 +0,0 @@
<script>
import { selectedScreen } from "builderStore"
import ScreenListPanel from "./_components/ScreenListPanel.svelte"
import ScreenSettingsPanel from "./_components/ScreenSettingsPanel.svelte"
</script>
<ScreenListPanel />
{#if $selectedScreen}
{#key $selectedScreen._id}
<ScreenSettingsPanel />
{/key}
{/if}

View file

@ -1,12 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import { Body, Layout } from "@budibase/bbui"
</script>
<Panel borderLeft title="Theme" icon="InfoOutline" wide>
<Layout paddingX="L" paddingY="XL">
<Body size="S">
Your theme is set across all the screens within your app.
</Body>
</Layout>
</Panel>

View file

@ -1,55 +0,0 @@
<script>
import Panel from "components/design/Panel.svelte"
import { Layout, Label, ColorPicker, notifications } from "@budibase/bbui"
import { store } from "builderStore"
import { get } from "svelte/store"
import { DefaultAppTheme } from "constants"
import AppThemeSelect from "./AppThemeSelect.svelte"
import ButtonRoundnessSelect from "./ButtonRoundnessSelect.svelte"
$: customTheme = $store.customTheme || {}
const update = async (property, value) => {
try {
store.actions.customTheme.save({
...get(store).customTheme,
[property]: value,
})
} catch (error) {
notifications.error("Error updating custom theme")
}
}
</script>
<Panel title="Theme" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Layout noPadding gap="XS">
<Label>Theme</Label>
<AppThemeSelect />
</Layout>
<Layout noPadding gap="XS">
<Label>Button roundness</Label>
<ButtonRoundnessSelect
{customTheme}
on:change={e => update("buttonBorderRadius", e.detail)}
/>
</Layout>
<Layout noPadding gap="XS">
<Label>Accent color</Label>
<ColorPicker
spectrumTheme={$store.theme}
value={customTheme.primaryColor || DefaultAppTheme.primaryColor}
on:change={e => update("primaryColor", e.detail)}
/>
</Layout>
<Layout noPadding gap="XS">
<Label>Accent color (hover)</Label>
<ColorPicker
spectrumTheme={$store.theme}
value={customTheme.primaryColorHover ||
DefaultAppTheme.primaryColorHover}
on:change={e => update("primaryColorHover", e.detail)}
/>
</Layout>
</Layout>
</Panel>

View file

@ -1,7 +0,0 @@
<script>
import ThemeSettingsPanel from "./_components/ThemeSettingsPanel.svelte"
import ThemeInfoPanel from "./_components/ThemeInfoPanel.svelte"
</script>
<ThemeSettingsPanel />
<ThemeInfoPanel />

View file

@ -58,16 +58,17 @@
const response = await store.actions.screens.save(screen) const response = await store.actions.screens.save(screen)
screenId = response._id screenId = response._id
// Add link in layout for list screens // Add link in layout. We only ever actually create 1 screen now, even
if (screen.props._instanceName.endsWith("List")) { // for autoscreens, so it's always safe to do this.
await store.actions.links.save( await store.actions.links.save(
screen.routing.route, screen.routing.route,
capitalise(screen.routing.route.split("/")[1]) capitalise(screen.routing.route.split("/")[1])
) )
}
} }
// Go to new screen
$goto(`./${screenId}`) $goto(`./${screenId}`)
store.actions.screens.select(screenId)
} catch (error) { } catch (error) {
console.log(error) console.log(error)
notifications.error("Error creating screens") notifications.error("Error creating screens")

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