1
0
Fork 0
mirror of synced 2024-09-03 03:01:14 +12:00

Merge remote-tracking branch 'origin/master' into feature/screen-deselect

This commit is contained in:
Dean 2024-04-02 09:14:22 +01:00
commit ff5c7ceda8
371 changed files with 4445 additions and 5436 deletions

View file

@ -12,4 +12,5 @@ packages/sdk/sdk
packages/account-portal/packages/server/build packages/account-portal/packages/server/build
packages/account-portal/packages/ui/.routify packages/account-portal/packages/ui/.routify
packages/account-portal/packages/ui/build packages/account-portal/packages/ui/build
**/*.ivm.bundle.js **/*.ivm.bundle.js
packages/server/build/oldClientVersions/**/**

View file

@ -34,18 +34,43 @@
}, },
{ {
"files": ["**/*.ts"], "files": ["**/*.ts"],
"excludedFiles": ["qa-core/**"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended"], "extends": ["eslint:recommended"],
"globals": {
"NodeJS": true
},
"rules": { "rules": {
"no-unused-vars": "off", "no-unused-vars": "off",
"no-inner-declarations": "off", "@typescript-eslint/no-unused-vars": "error",
"no-case-declarations": "off", "local-rules/no-budibase-imports": "error"
"no-useless-escape": "off", }
"no-undef": "off", },
"no-prototype-builtins": "off", {
"local-rules/no-budibase-imports": "error", "files": ["**/*.spec.ts"],
"excludedFiles": ["qa-core/**"],
"parser": "@typescript-eslint/parser",
"plugins": ["jest", "@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:jest/recommended"],
"env": {
"jest/globals": true
},
"globals": {
"NodeJS": true
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"local-rules/no-test-com": "error", "local-rules/no-test-com": "error",
"local-rules/email-domain-example-com": "error" "local-rules/email-domain-example-com": "error",
"no-console": "warn",
// We have a lot of tests that don't have assertions, they use our test
// API client that does the assertions for them
"jest/expect-expect": "off",
// We do this in some tests where the behaviour of internal tables
// differs to external, but the API is broadly the same
"jest/no-conditional-expect": "off"
} }
}, },
{ {

View file

@ -66,7 +66,8 @@ jobs:
# Run build all the projects # Run build all the projects
- name: Build - name: Build
run: | run: |
yarn build yarn build:oss
yarn build:account-portal
# Check the types of the projects built via esbuild # Check the types of the projects built via esbuild
- name: Check types - name: Check types
run: | run: |
@ -138,6 +139,8 @@ jobs:
test-server: test-server:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
DEBUG: testcontainers,testcontainers:exec,testcontainers:build,testcontainers:pull
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -151,7 +154,19 @@ jobs:
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
- name: Pull testcontainers images
run: |
docker pull mcr.microsoft.com/mssql/server:2022-latest
docker pull mysql:8.3
docker pull postgres:16.1-bullseye
docker pull mongo:7.0-jammy
docker pull mariadb:lts
docker pull testcontainers/ryuk:0.5.1
docker pull budibase/couchdb
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Test server - name: Test server
run: | run: |
if ${{ env.USE_NX_AFFECTED }}; then if ${{ env.USE_NX_AFFECTED }}; then
@ -217,27 +232,34 @@ jobs:
echo "pro_commit=$pro_commit" echo "pro_commit=$pro_commit"
echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT" echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT"
echo "base_commit=$base_commit" echo "base_commit=$base_commit"
echo "base_commit=$base_commit" >> "$GITHUB_OUTPUT"
base_commit_excluding_merges=$(git log --no-merges -n 1 --format=format:%H $base_commit)
echo "base_commit_excluding_merges=$base_commit_excluding_merges"
echo "base_commit_excluding_merges=$base_commit_excluding_merges" >> "$GITHUB_OUTPUT"
else else
echo "Nothing to do - branch to branch merge." echo "Nothing to do - branch to branch merge."
fi fi
- name: Check submodule merged to base branch - name: Check submodule merged and latest on base branch
if: ${{ steps.get_pro_commits.outputs.base_commit != '' }} if: ${{ steps.get_pro_commits.outputs.base_commit_excluding_merges != '' }}
uses: actions/github-script@v7 run: |
with: cd packages/pro
github-token: ${{ secrets.GITHUB_TOKEN }} base_commit_excluding_merges='${{ steps.get_pro_commits.outputs.base_commit_excluding_merges }}'
script: | pro_commit='${{ steps.get_pro_commits.outputs.pro_commit }}'
const submoduleCommit = '${{ steps.get_pro_commits.outputs.pro_commit }}';
const baseCommit = '${{ steps.get_pro_commits.outputs.base_commit }}';
if (submoduleCommit !== baseCommit) { any_commit=$(git log --no-merges $base_commit_excluding_merges...$pro_commit)
console.error('Submodule commit does not match the latest commit on the "${{ steps.get_pro_commits.outputs.target_branch }}" branch.');
console.error('Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/master/docs/getting_started.md') if [ -n "$any_commit" ]; then
process.exit(1); echo $any_commit
} else {
console.log('All good, the submodule had been merged and setup correctly!') echo "An error occurred: <error_message>"
} echo 'Submodule commit does not match the latest commit on the "${{ steps.get_pro_commits.outputs.target_branch }}" branch.'
echo 'Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/master/docs/getting_started.md'
exit 1
else
echo 'All good, the submodule had been merged and setup correctly!'
fi
check-accountportal-submodule: check-accountportal-submodule:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -250,7 +272,15 @@ jobs:
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Check account portal commit - uses: dorny/paths-filter@v3
id: changes
with:
filters: |
src:
- packages/account-portal/**
- if: steps.changes.outputs.src == 'true'
name: Check account portal commit
id: get_accountportal_commits id: get_accountportal_commits
run: | run: |
cd packages/account-portal cd packages/account-portal

4
.gitignore vendored
View file

@ -5,6 +5,9 @@ packages/server/runtime_apps/
bb-airgapped.tar.gz bb-airgapped.tar.gz
*.iml *.iml
packages/server/build/oldClientVersions/**/*
packages/builder/src/components/deploy/clientVersions.json
# Logs # Logs
logs logs
*.log *.log
@ -107,3 +110,4 @@ budibase-component
budibase-datasource budibase-datasource
*.iml *.iml
.nx

8
.vscode/launch.json vendored
View file

@ -1,4 +1,3 @@
{ {
// 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.
@ -20,6 +19,13 @@
"runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"], "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
"args": ["${workspaceFolder}/packages/worker/src/index.ts"], "args": ["${workspaceFolder}/packages/worker/src/index.ts"],
"cwd": "${workspaceFolder}/packages/worker" "cwd": "${workspaceFolder}/packages/worker"
},
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:10000",
"webRoot": "${workspaceFolder}"
} }
], ],
"compounds": [ "compounds": [

View file

@ -140,7 +140,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| ingress.className | string | `""` | What ingress class to use. | | ingress.className | string | `""` | What ingress class to use. |
| ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. | | ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. |
| ingress.hosts | list | `[]` | Standard hosts block for the Ingress resource. Defaults to pointing to the Budibase proxy. | | ingress.hosts | list | `[]` | Standard hosts block for the Ingress resource. Defaults to pointing to the Budibase proxy. |
| nameOverride | string | `""` | Override the name of the deploymen. Defaults to {{ .Chart.Name }}. | | nameOverride | string | `""` | Override the name of the deployment. Defaults to {{ .Chart.Name }}. |
| service.port | int | `10000` | Port to expose on the service. | | service.port | int | `10000` | Port to expose on the service. |
| service.type | string | `"ClusterIP"` | Service type for the service that points to the main Budibase proxy pod. | | service.type | string | `"ClusterIP"` | Service type for the service that points to the main Budibase proxy pod. |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account | | serviceAccount.annotations | object | `{}` | Annotations to add to the service account |

View file

@ -1,6 +1,6 @@
# -- Passed to all pods created by this chart. Should not ordinarily need to be changed. # -- Passed to all pods created by this chart. Should not ordinarily need to be changed.
imagePullSecrets: [] imagePullSecrets: []
# -- Override the name of the deploymen. Defaults to {{ .Chart.Name }}. # -- Override the name of the deployment. Defaults to {{ .Chart.Name }}.
nameOverride: "" nameOverride: ""
serviceAccount: serviceAccount:

View file

@ -25,11 +25,9 @@ module.exports = {
docs: { docs: {
description: description:
"disallow the use of 'test.com' in strings and replace it with 'example.com'", "disallow the use of 'test.com' in strings and replace it with 'example.com'",
category: "Possible Errors",
recommended: false,
}, },
schema: [], // no options schema: [],
fixable: "code", // Indicates that this rule supports automatic fixing fixable: "code",
}, },
create: function (context) { create: function (context) {
return { return {
@ -58,8 +56,6 @@ module.exports = {
docs: { docs: {
description: description:
"enforce using the example.com domain for generator.email calls", "enforce using the example.com domain for generator.email calls",
category: "Possible Errors",
recommended: false,
}, },
fixable: "code", fixable: "code",
schema: [], schema: [],

25
globalSetup.ts Normal file
View file

@ -0,0 +1,25 @@
import { GenericContainer, Wait } from "testcontainers"
export default async function setup() {
await new GenericContainer("budibase/couchdb")
.withExposedPorts(5984)
.withEnvironment({
COUCHDB_PASSWORD: "budibase",
COUCHDB_USER: "budibase",
})
.withCopyContentToContainer([
{
content: `
[log]
level = warn
`,
target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
},
])
.withWaitStrategy(
Wait.forSuccessfulCommand(
"curl http://budibase:budibase@localhost:5984/_up"
).withStartupTimeout(20000)
)
.start()
}

View file

@ -1,16 +0,0 @@
module.exports = () => {
return {
couchdb: {
image: "budibase/couchdb",
ports: [5984],
env: {
COUCHDB_PASSWORD: "budibase",
COUCHDB_USER: "budibase",
},
wait: {
type: "ports",
timeout: 20000,
}
}
}
}

View file

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

View file

@ -12,6 +12,7 @@
"esbuild-node-externals": "^1.8.0", "esbuild-node-externals": "^1.8.0",
"eslint": "^8.52.0", "eslint": "^8.52.0",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-local-rules": "^2.0.0", "eslint-plugin-local-rules": "^2.0.0",
"eslint-plugin-svelte": "^2.34.0", "eslint-plugin-svelte": "^2.34.0",
"husky": "^8.0.3", "husky": "^8.0.3",
@ -25,12 +26,16 @@
"svelte": "^4.2.10", "svelte": "^4.2.10",
"svelte-eslint-parser": "^0.33.1", "svelte-eslint-parser": "^0.33.1",
"typescript": "5.2.2", "typescript": "5.2.2",
"typescript-eslint": "^7.3.1",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js", "preinstall": "node scripts/syncProPackage.js",
"get-past-client-version": "node scripts/getPastClientVersion.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",
"build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream", "build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
"build:oss": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui",
"build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal --scope @budibase/account-portal-server --scope @budibase/account-portal-ui",
"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",
"build:sdk": "lerna run --stream build:sdk", "build:sdk": "lerna run --stream build:sdk",

@ -1 +1 @@
Subproject commit 23a1219732bd778654c0bcc4f49910c511e2d51f Subproject commit 63ce32bca871f0a752323f5f7ebb5ec16bbbacc3

View file

@ -1,8 +0,0 @@
const { join } = require("path")
require("dotenv").config({
path: join(__dirname, "..", "..", "hosting", ".env"),
})
const jestTestcontainersConfigGenerator = require("../../jestTestcontainersConfigGenerator")
module.exports = jestTestcontainersConfigGenerator()

View file

@ -1,8 +1,8 @@
import { Config } from "@jest/types" import { Config } from "@jest/types"
const baseConfig: Config.InitialProjectOptions = { const baseConfig: Config.InitialProjectOptions = {
preset: "@trendyol/jest-testcontainers",
setupFiles: ["./tests/jestEnv.ts"], setupFiles: ["./tests/jestEnv.ts"],
globalSetup: "./../../globalSetup.ts",
setupFilesAfterEnv: ["./tests/jestSetup.ts"], setupFilesAfterEnv: ["./tests/jestSetup.ts"],
transform: { transform: {
"^.+\\.ts?$": "@swc/jest", "^.+\\.ts?$": "@swc/jest",

View file

@ -60,7 +60,6 @@
"@shopify/jest-koa-mocks": "5.1.1", "@shopify/jest-koa-mocks": "5.1.1",
"@swc/core": "1.3.71", "@swc/core": "1.3.71",
"@swc/jest": "0.2.27", "@swc/jest": "0.2.27",
"@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/cookies": "0.7.8", "@types/cookies": "0.7.8",
"@types/jest": "29.5.5", "@types/jest": "29.5.5",

View file

@ -4,10 +4,10 @@ set -e
if [[ -n $CI ]] if [[ -n $CI ]]
then then
# --runInBand performs better in ci where resources are limited # --runInBand performs better in ci where resources are limited
echo "jest --coverage --runInBand --forceExit" echo "jest --coverage --runInBand --forceExit $@"
jest --coverage --runInBand --forceExit jest --coverage --runInBand --forceExit $@
else else
# --maxWorkers performs better in development # --maxWorkers performs better in development
echo "jest --coverage --detectOpenHandles" echo "jest --coverage --forceExit --detectOpenHandles $@"
jest --coverage --detectOpenHandles jest --coverage --forceExit --detectOpenHandles $@
fi fi

View file

@ -133,7 +133,7 @@ export async function refreshOAuthToken(
configId?: string configId?: string
): Promise<RefreshResponse> { ): Promise<RefreshResponse> {
switch (providerType) { switch (providerType) {
case SSOProviderType.OIDC: case SSOProviderType.OIDC: {
if (!configId) { if (!configId) {
return { err: { data: "OIDC config id not provided" } } return { err: { data: "OIDC config id not provided" } }
} }
@ -142,12 +142,14 @@ export async function refreshOAuthToken(
return { err: { data: "OIDC configuration not found" } } return { err: { data: "OIDC configuration not found" } }
} }
return refreshOIDCAccessToken(oidcConfig, refreshToken) return refreshOIDCAccessToken(oidcConfig, refreshToken)
case SSOProviderType.GOOGLE: }
case SSOProviderType.GOOGLE: {
let googleConfig = await configs.getGoogleConfig() let googleConfig = await configs.getGoogleConfig()
if (!googleConfig) { if (!googleConfig) {
return { err: { data: "Google configuration not found" } } return { err: { data: "Google configuration not found" } }
} }
return refreshGoogleAccessToken(googleConfig, refreshToken) return refreshGoogleAccessToken(googleConfig, refreshToken)
}
} }
} }

View file

@ -8,7 +8,7 @@ describe("platformLogout", () => {
await testEnv.withTenant(async () => { await testEnv.withTenant(async () => {
const ctx = structures.koa.newContext() const ctx = structures.koa.newContext()
await auth.platformLogout({ ctx, userId: "test" }) await auth.platformLogout({ ctx, userId: "test" })
expect(events.auth.logout).toBeCalledTimes(1) expect(events.auth.logout).toHaveBeenCalledTimes(1)
}) })
}) })
}) })

View file

@ -129,7 +129,7 @@ export default class BaseCache {
} }
} }
async bustCache(key: string, opts = { client: null }) { async bustCache(key: string) {
const client = await this.getClient() const client = await this.getClient()
try { try {
await client.delete(generateTenantKey(key)) await client.delete(generateTenantKey(key))

View file

@ -1,6 +1,6 @@
import { AnyDocument, Database } from "@budibase/types" import { AnyDocument, Database } from "@budibase/types"
import { JobQueue, createQueue } from "../queue" import { JobQueue, Queue, createQueue } from "../queue"
import * as dbUtils from "../db" import * as dbUtils from "../db"
interface ProcessDocMessage { interface ProcessDocMessage {
@ -12,18 +12,26 @@ interface ProcessDocMessage {
const PERSIST_MAX_ATTEMPTS = 100 const PERSIST_MAX_ATTEMPTS = 100
let processor: DocWritethroughProcessor | undefined let processor: DocWritethroughProcessor | undefined
export const docWritethroughProcessorQueue = createQueue<ProcessDocMessage>( export class DocWritethroughProcessor {
JobQueue.DOC_WRITETHROUGH_QUEUE, private static _queue: Queue
{
jobOptions: { public static get queue() {
attempts: PERSIST_MAX_ATTEMPTS, if (!DocWritethroughProcessor._queue) {
}, DocWritethroughProcessor._queue = createQueue<ProcessDocMessage>(
} JobQueue.DOC_WRITETHROUGH_QUEUE,
) {
jobOptions: {
attempts: PERSIST_MAX_ATTEMPTS,
},
}
)
}
return DocWritethroughProcessor._queue
}
class DocWritethroughProcessor {
init() { init() {
docWritethroughProcessorQueue.process(async message => { DocWritethroughProcessor.queue.process(async message => {
try { try {
await this.persistToDb(message.data) await this.persistToDb(message.data)
} catch (err: any) { } catch (err: any) {
@ -76,7 +84,7 @@ export class DocWritethrough {
} }
async patch(data: Record<string, any>) { async patch(data: Record<string, any>) {
await docWritethroughProcessorQueue.add({ await DocWritethroughProcessor.queue.add({
dbName: this.db.name, dbName: this.db.name,
docId: this.docId, docId: this.docId,
data, data,

View file

@ -1,5 +1,5 @@
import * as utils from "../utils" import * as utils from "../utils"
import { Duration, DurationType } from "../utils" import { Duration } from "../utils"
import env from "../environment" import env from "../environment"
import { getTenantId } from "../context" import { getTenantId } from "../context"
import * as redis from "../redis/init" import * as redis from "../redis/init"

View file

@ -6,7 +6,7 @@ import { getDB } from "../../db"
import { import {
DocWritethrough, DocWritethrough,
docWritethroughProcessorQueue, DocWritethroughProcessor,
init, init,
} from "../docWritethrough" } from "../docWritethrough"
@ -15,7 +15,7 @@ import InMemoryQueue from "../../queue/inMemoryQueue"
const initialTime = Date.now() const initialTime = Date.now()
async function waitForQueueCompletion() { async function waitForQueueCompletion() {
const queue: InMemoryQueue = docWritethroughProcessorQueue as never const queue: InMemoryQueue = DocWritethroughProcessor.queue as never
await queue.waitForCompletion() await queue.waitForCompletion()
} }
@ -235,11 +235,11 @@ describe("docWritethrough", () => {
return acc return acc
}, {}) }, {})
} }
const queueMessageSpy = jest.spyOn(docWritethroughProcessorQueue, "add") const queueMessageSpy = jest.spyOn(DocWritethroughProcessor.queue, "add")
await config.doInTenant(async () => { await config.doInTenant(async () => {
let patches = await parallelPatch(5) let patches = await parallelPatch(5)
expect(queueMessageSpy).toBeCalledTimes(5) expect(queueMessageSpy).toHaveBeenCalledTimes(5)
await waitForQueueCompletion() await waitForQueueCompletion()
expect(await db.get(documentId)).toEqual( expect(await db.get(documentId)).toEqual(
@ -247,7 +247,7 @@ describe("docWritethrough", () => {
) )
patches = { ...patches, ...(await parallelPatch(40)) } patches = { ...patches, ...(await parallelPatch(40)) }
expect(queueMessageSpy).toBeCalledTimes(45) expect(queueMessageSpy).toHaveBeenCalledTimes(45)
await waitForQueueCompletion() await waitForQueueCompletion()
expect(await db.get(documentId)).toEqual( expect(await db.get(documentId)).toEqual(
@ -255,7 +255,7 @@ describe("docWritethrough", () => {
) )
patches = { ...patches, ...(await parallelPatch(10)) } patches = { ...patches, ...(await parallelPatch(10)) }
expect(queueMessageSpy).toBeCalledTimes(55) expect(queueMessageSpy).toHaveBeenCalledTimes(55)
await waitForQueueCompletion() await waitForQueueCompletion()
expect(await db.get(documentId)).toEqual( expect(await db.get(documentId)).toEqual(
@ -265,6 +265,7 @@ describe("docWritethrough", () => {
}) })
// This is not yet supported // This is not yet supported
// eslint-disable-next-line jest/no-disabled-tests
it.skip("patches will execute in order", async () => { it.skip("patches will execute in order", async () => {
let incrementalValue = 0 let incrementalValue = 0
const keyToOverride = generator.word() const keyToOverride = generator.word()

View file

@ -55,8 +55,8 @@ describe("user cache", () => {
})), })),
}) })
expect(UserDB.bulkGet).toBeCalledTimes(1) expect(UserDB.bulkGet).toHaveBeenCalledTimes(1)
expect(UserDB.bulkGet).toBeCalledWith(userIdsToRequest) expect(UserDB.bulkGet).toHaveBeenCalledWith(userIdsToRequest)
}) })
it("on a second all, all of them are retrieved from cache", async () => { it("on a second all, all of them are retrieved from cache", async () => {
@ -82,7 +82,7 @@ describe("user cache", () => {
), ),
}) })
expect(UserDB.bulkGet).toBeCalledTimes(1) expect(UserDB.bulkGet).toHaveBeenCalledTimes(1)
}) })
it("when some users are cached, only the missing ones are retrieved from db", async () => { it("when some users are cached, only the missing ones are retrieved from db", async () => {
@ -110,8 +110,8 @@ describe("user cache", () => {
), ),
}) })
expect(UserDB.bulkGet).toBeCalledTimes(1) expect(UserDB.bulkGet).toHaveBeenCalledTimes(1)
expect(UserDB.bulkGet).toBeCalledWith([ expect(UserDB.bulkGet).toHaveBeenCalledWith([
userIdsToRequest[1], userIdsToRequest[1],
userIdsToRequest[2], userIdsToRequest[2],
userIdsToRequest[4], userIdsToRequest[4],

View file

@ -8,7 +8,7 @@ const DEFAULT_WRITE_RATE_MS = 10000
let CACHE: BaseCache | null = null let CACHE: BaseCache | null = null
interface CacheItem<T extends Document> { interface CacheItem<T extends Document> {
doc: any doc: T
lastWrite: number lastWrite: number
} }

View file

@ -246,7 +246,7 @@ describe("context", () => {
context.doInAppMigrationContext(db.generateAppID(), async () => { context.doInAppMigrationContext(db.generateAppID(), async () => {
await otherContextCall() await otherContextCall()
}) })
).rejects.toThrowError( ).rejects.toThrow(
"The context cannot be changed, a migration is currently running" "The context cannot be changed, a migration is currently running"
) )
} }

View file

@ -27,7 +27,7 @@ class Replication {
return resolve(info) return resolve(info)
}) })
.on("error", function (err) { .on("error", function (err) {
throw new Error(`Replication Error: ${err}`) throw err
}) })
}) })
} }

View file

@ -10,10 +10,6 @@ interface SearchResponse<T> {
totalRows: number totalRows: number
} }
interface PaginatedSearchResponse<T> extends SearchResponse<T> {
hasNextPage: boolean
}
export type SearchParams<T> = { export type SearchParams<T> = {
tableId?: string tableId?: string
sort?: string sort?: string
@ -247,7 +243,7 @@ export class QueryBuilder<T> {
} }
// Escape characters // Escape characters
if (!this.#noEscaping && escape && originalType === "string") { if (!this.#noEscaping && escape && originalType === "string") {
value = `${value}`.replace(/[ \/#+\-&|!(){}\]^"~*?:\\]/g, "\\$&") value = `${value}`.replace(/[ /#+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
} }
// Wrap in quotes // Wrap in quotes

View file

@ -34,12 +34,12 @@ export async function createUserIndex() {
} }
let idxKey = prev != null ? `${prev}.${key}` : key let idxKey = prev != null ? `${prev}.${key}` : key
if (typeof input[key] === "string") { if (typeof input[key] === "string") {
// @ts-expect-error index is available in a CouchDB map function
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
// @ts-ignore
index(idxKey, input[key].toLowerCase(), { facet: true }) index(idxKey, input[key].toLowerCase(), { facet: true })
} else if (typeof input[key] !== "object") { } else if (typeof input[key] !== "object") {
// @ts-expect-error index is available in a CouchDB map function
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
// @ts-ignore
index(idxKey, input[key], { facet: true }) index(idxKey, input[key], { facet: true })
} else { } else {
idx(input[key], idxKey) idx(input[key], idxKey)

View file

@ -17,13 +17,8 @@ export function init(processors: ProcessorMap) {
// if not processing in this instance, kick it off // if not processing in this instance, kick it off
if (!processingPromise) { if (!processingPromise) {
processingPromise = asyncEventQueue.process(async job => { processingPromise = asyncEventQueue.process(async job => {
const { event, identity, properties, timestamp } = job.data const { event, identity, properties } = job.data
await documentProcessor.processEvent( await documentProcessor.processEvent(event, identity, properties)
event,
identity,
properties,
timestamp
)
}) })
} }
} }

View file

@ -1,4 +1,4 @@
import { Event } from "@budibase/types" import { Event, Identity } from "@budibase/types"
import { processors } from "./processors" import { processors } from "./processors"
import identification from "./identification" import identification from "./identification"
import * as backfill from "./backfill" import * as backfill from "./backfill"
@ -7,12 +7,19 @@ import { publishAsyncEvent } from "./asyncEvents"
export const publishEvent = async ( export const publishEvent = async (
event: Event, event: Event,
properties: any, properties: any,
timestamp?: string | number timestamp?: string | number,
identityOverride?: Identity
) => { ) => {
// in future this should use async events via a distributed queue. // in future this should use async events via a distributed queue.
const identity = await identification.getCurrentIdentity() const identity =
identityOverride || (await identification.getCurrentIdentity())
// Backfilling is get from the user cache, but when we override the identity cache is not available. Overrides are
// normally performed in automatic actions or operations in async flows (BPM) where the user session is not available.
const backfilling = identityOverride
? false
: await backfill.isBackfillingEvent(event)
const backfilling = await backfill.isBackfillingEvent(event)
// no backfill - send the event and exit // no backfill - send the event and exit
if (!backfilling) { if (!backfilling) {
// send off async events if required // send off async events if required

View file

@ -1,7 +1,6 @@
import { import {
Event, Event,
Identity, Identity,
Group,
IdentityType, IdentityType,
AuditLogQueueEvent, AuditLogQueueEvent,
AuditLogFn, AuditLogFn,
@ -79,11 +78,11 @@ export default class AuditLogsProcessor implements EventProcessor {
} }
} }
async identify(identity: Identity, timestamp?: string | number) { async identify() {
// no-op // no-op
} }
async identifyGroup(group: Group, timestamp?: string | number) { async identifyGroup() {
// no-op // no-op
} }

View file

@ -8,8 +8,7 @@ export default class LoggingProcessor implements EventProcessor {
async processEvent( async processEvent(
event: Event, event: Event,
identity: Identity, identity: Identity,
properties: any, properties: any
timestamp?: string
): Promise<void> { ): Promise<void> {
if (skipLogging) { if (skipLogging) {
return return
@ -17,14 +16,14 @@ export default class LoggingProcessor implements EventProcessor {
console.log(`[audit] [identityType=${identity.type}] ${event}`, properties) console.log(`[audit] [identityType=${identity.type}] ${event}`, properties)
} }
async identify(identity: Identity, timestamp?: string | number) { async identify(identity: Identity) {
if (skipLogging) { if (skipLogging) {
return return
} }
console.log(`[audit] identified`, identity) console.log(`[audit] identified`, identity)
} }
async identifyGroup(group: Group, timestamp?: string | number) { async identifyGroup(group: Group) {
if (skipLogging) { if (skipLogging) {
return return
} }

View file

@ -14,12 +14,7 @@ export default class DocumentUpdateProcessor implements EventProcessor {
this.processors = processors this.processors = processors
} }
async processEvent( async processEvent(event: Event, identity: Identity, properties: any) {
event: Event,
identity: Identity,
properties: any,
timestamp?: string | number
) {
const tenantId = identity.realTenantId const tenantId = identity.realTenantId
const docId = getDocumentId(event, properties) const docId = getDocumentId(event, properties)
if (!tenantId || !docId) { if (!tenantId || !docId) {

View file

@ -5,13 +5,19 @@ import {
AccountCreatedEvent, AccountCreatedEvent,
AccountDeletedEvent, AccountDeletedEvent,
AccountVerifiedEvent, AccountVerifiedEvent,
Identity,
} from "@budibase/types" } from "@budibase/types"
async function created(account: Account) { async function created(account: Account, identityOverride?: Identity) {
const properties: AccountCreatedEvent = { const properties: AccountCreatedEvent = {
tenantId: account.tenantId, tenantId: account.tenantId,
} }
await publishEvent(Event.ACCOUNT_CREATED, properties) await publishEvent(
Event.ACCOUNT_CREATED,
properties,
undefined,
identityOverride
)
} }
async function deleted(account: Account) { async function deleted(account: Account) {

View file

@ -10,6 +10,18 @@ import { formats } from "dd-trace/ext"
import { localFileDestination } from "../system" import { localFileDestination } from "../system"
function isPlainObject(obj: any) {
return typeof obj === "object" && obj !== null && !(obj instanceof Error)
}
function isError(obj: any) {
return obj instanceof Error
}
function isMessage(obj: any) {
return typeof obj === "string"
}
// LOGGER // LOGGER
let pinoInstance: pino.Logger | undefined let pinoInstance: pino.Logger | undefined
@ -71,23 +83,11 @@ if (!env.DISABLE_PINO_LOGGER) {
err?: Error err?: Error
} }
function isPlainObject(obj: any) {
return typeof obj === "object" && obj !== null && !(obj instanceof Error)
}
function isError(obj: any) {
return obj instanceof Error
}
function isMessage(obj: any) {
return typeof obj === "string"
}
/** /**
* Backwards compatibility between console logging statements * Backwards compatibility between console logging statements
* and pino logging requirements. * and pino logging requirements.
*/ */
function getLogParams(args: any[]): [MergingObject, string] { const getLogParams = (args: any[]): [MergingObject, string] => {
let error = undefined let error = undefined
let objects: any[] = [] let objects: any[] = []
let message = "" let message = ""

View file

@ -11,7 +11,6 @@ export const buildMatcherRegex = (
return patterns.map(pattern => { return patterns.map(pattern => {
let route = pattern.route let route = pattern.route
const method = pattern.method const method = pattern.method
const strict = pattern.strict ? pattern.strict : false
// if there is a param in the route // if there is a param in the route
// use a wildcard pattern // use a wildcard pattern
@ -24,24 +23,17 @@ export const buildMatcherRegex = (
} }
} }
return { regex: new RegExp(route), method, strict, route } return { regex: new RegExp(route), method, route }
}) })
} }
export const matches = (ctx: BBContext, options: RegexMatcher[]) => { export const matches = (ctx: BBContext, options: RegexMatcher[]) => {
return options.find(({ regex, method, strict, route }) => { return options.find(({ regex, method }) => {
let urlMatch const urlMatch = regex.test(ctx.request.url)
if (strict) {
urlMatch = ctx.request.url === route
} else {
urlMatch = regex.test(ctx.request.url)
}
const methodMatch = const methodMatch =
method === "ALL" method === "ALL"
? true ? true
: ctx.request.method.toLowerCase() === method.toLowerCase() : ctx.request.method.toLowerCase() === method.toLowerCase()
return urlMatch && methodMatch return urlMatch && methodMatch
}) })
} }

View file

@ -3,7 +3,7 @@ import { Cookie } from "../../../constants"
import * as configs from "../../../configs" import * as configs from "../../../configs"
import * as cache from "../../../cache" import * as cache from "../../../cache"
import * as utils from "../../../utils" import * as utils from "../../../utils"
import { UserCtx, SSOProfile, DatasourceAuthCookie } from "@budibase/types" import { UserCtx, SSOProfile } from "@budibase/types"
import { ssoSaveUserNoOp } from "../sso/sso" import { ssoSaveUserNoOp } from "../sso/sso"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy

View file

@ -5,7 +5,6 @@ import * as context from "../../../context"
import fetch from "node-fetch" import fetch from "node-fetch"
import { import {
SaveSSOUserFunction, SaveSSOUserFunction,
SaveUserOpts,
SSOAuthDetails, SSOAuthDetails,
SSOUser, SSOUser,
User, User,
@ -14,10 +13,8 @@ import {
// no-op function for user save // no-op function for user save
// - this allows datasource auth and access token refresh to work correctly // - this allows datasource auth and access token refresh to work correctly
// - prefer no-op over an optional argument to ensure function is provided to login flows // - prefer no-op over an optional argument to ensure function is provided to login flows
export const ssoSaveUserNoOp: SaveSSOUserFunction = ( export const ssoSaveUserNoOp: SaveSSOUserFunction = (user: SSOUser) =>
user: SSOUser, Promise.resolve(user)
opts: SaveUserOpts
) => Promise.resolve(user)
/** /**
* Common authentication logic for third parties. e.g. OAuth, OIDC. * Common authentication logic for third parties. e.g. OAuth, OIDC.

View file

@ -114,11 +114,11 @@ describe("sso", () => {
// tenant id added // tenant id added
ssoUser.tenantId = context.getTenantId() ssoUser.tenantId = context.getTenantId()
expect(mockSaveUser).toBeCalledWith(ssoUser, { expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, {
hashPassword: false, hashPassword: false,
requirePassword: false, requirePassword: false,
}) })
expect(mockDone).toBeCalledWith(null, ssoUser) expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
}) })
}) })
}) })
@ -159,11 +159,11 @@ describe("sso", () => {
// existing id preserved // existing id preserved
ssoUser._id = existingUser._id ssoUser._id = existingUser._id
expect(mockSaveUser).toBeCalledWith(ssoUser, { expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, {
hashPassword: false, hashPassword: false,
requirePassword: false, requirePassword: false,
}) })
expect(mockDone).toBeCalledWith(null, ssoUser) expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
}) })
}) })
@ -187,11 +187,11 @@ describe("sso", () => {
// existing id preserved // existing id preserved
ssoUser._id = existingUser._id ssoUser._id = existingUser._id
expect(mockSaveUser).toBeCalledWith(ssoUser, { expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, {
hashPassword: false, hashPassword: false,
requirePassword: false, requirePassword: false,
}) })
expect(mockDone).toBeCalledWith(null, ssoUser) expect(mockDone).toHaveBeenCalledWith(null, ssoUser)
}) })
}) })
}) })

View file

@ -24,13 +24,13 @@ function buildUserCtx(user: ContextUser) {
} }
function passed(throwFn: jest.Func, nextFn: jest.Func) { function passed(throwFn: jest.Func, nextFn: jest.Func) {
expect(throwFn).not.toBeCalled() expect(throwFn).not.toHaveBeenCalled()
expect(nextFn).toBeCalled() expect(nextFn).toHaveBeenCalled()
} }
function threw(throwFn: jest.Func) { function threw(throwFn: jest.Func) {
// cant check next, the throw function doesn't actually throw - so it still continues // cant check next, the throw function doesn't actually throw - so it still continues
expect(throwFn).toBeCalled() expect(throwFn).toHaveBeenCalled()
} }
describe("adminOnly middleware", () => { describe("adminOnly middleware", () => {

View file

@ -34,23 +34,6 @@ describe("matchers", () => {
expect(!!matchers.matches(ctx, built)).toBe(true) expect(!!matchers.matches(ctx, built)).toBe(true)
}) })
it("doesn't wildcard path with strict", () => {
const pattern = [
{
route: "/api/tests",
method: "POST",
strict: true,
},
]
const ctx = structures.koa.newContext()
ctx.request.url = "/api/tests/id/something/else"
ctx.request.method = "POST"
const built = matchers.buildMatcherRegex(pattern)
expect(!!matchers.matches(ctx, built)).toBe(false)
})
it("matches with param", () => { it("matches with param", () => {
const pattern = [ const pattern = [
{ {
@ -67,23 +50,6 @@ describe("matchers", () => {
expect(!!matchers.matches(ctx, built)).toBe(true) expect(!!matchers.matches(ctx, built)).toBe(true)
}) })
// TODO: Support the below behaviour
// Strict does not work when a param is present
// it("matches with param with strict", () => {
// const pattern = [{
// route: "/api/tests/:testId",
// method: "GET",
// strict: true
// }]
// const ctx = structures.koa.newContext()
// ctx.request.url = "/api/tests/id"
// ctx.request.method = "GET"
//
// const built = matchers.buildMatcherRegex(pattern)
//
// expect(!!matchers.matches(ctx, built)).toBe(true)
// })
it("doesn't match by path", () => { it("doesn't match by path", () => {
const pattern = [ const pattern = [
{ {

View file

@ -45,10 +45,6 @@ export const runMigration = async (
options: MigrationOptions = {} options: MigrationOptions = {}
) => { ) => {
const migrationType = migration.type const migrationType = migration.type
let tenantId: string | undefined
if (migrationType !== MigrationType.INSTALLATION) {
tenantId = context.getTenantId()
}
const migrationName = migration.name const migrationName = migration.name
const silent = migration.silent const silent = migration.silent

View file

@ -126,7 +126,7 @@ describe("app", () => {
it("gets url with embedded minio", async () => { it("gets url with embedded minio", async () => {
testEnv.withMinio() testEnv.withMinio()
await testEnv.withTenant(tenantId => { await testEnv.withTenant(() => {
const url = getAppFileUrl() const url = getAppFileUrl()
expect(url).toBe( expect(url).toBe(
"/files/signed/prod-budi-app-assets/app_123/attachments/image.jpeg" "/files/signed/prod-budi-app-assets/app_123/attachments/image.jpeg"
@ -136,7 +136,7 @@ describe("app", () => {
it("gets url with custom S3", async () => { it("gets url with custom S3", async () => {
testEnv.withS3() testEnv.withS3()
await testEnv.withTenant(tenantId => { await testEnv.withTenant(() => {
const url = getAppFileUrl() const url = getAppFileUrl()
expect(url).toBe( expect(url).toBe(
"http://s3.example.com/prod-budi-app-assets/app_123/attachments/image.jpeg" "http://s3.example.com/prod-budi-app-assets/app_123/attachments/image.jpeg"
@ -146,7 +146,7 @@ describe("app", () => {
it("gets url with cloudfront + s3", async () => { it("gets url with cloudfront + s3", async () => {
testEnv.withCloudfront() testEnv.withCloudfront()
await testEnv.withTenant(tenantId => { await testEnv.withTenant(() => {
const url = getAppFileUrl() const url = getAppFileUrl()
// omit rest of signed params // omit rest of signed params
expect( expect(

View file

@ -3,7 +3,7 @@ import { DBTestConfiguration } from "../../../tests/extra"
import * as tenants from "../tenants" import * as tenants from "../tenants"
describe("tenants", () => { describe("tenants", () => {
const config = new DBTestConfiguration() new DBTestConfiguration()
describe("addTenant", () => { describe("addTenant", () => {
it("concurrently adds multiple tenants safely", async () => { it("concurrently adds multiple tenants safely", async () => {

View file

@ -20,7 +20,7 @@ export async function lookupTenantId(userId: string) {
return user.tenantId return user.tenantId
} }
async function getUserDoc(emailOrId: string): Promise<PlatformUser> { export async function getUserDoc(emailOrId: string): Promise<PlatformUser> {
const db = getPlatformDB() const db = getPlatformDB()
return db.get(emailOrId) return db.get(emailOrId)
} }
@ -79,6 +79,17 @@ async function addUserDoc(emailOrId: string, newDocFn: () => PlatformUser) {
} }
} }
export async function addSsoUser(
ssoId: string,
email: string,
userId: string,
tenantId: string
) {
return addUserDoc(ssoId, () =>
newUserSsoIdDoc(ssoId, email, userId, tenantId)
)
}
export async function addUser( export async function addUser(
tenantId: string, tenantId: string,
userId: string, userId: string,
@ -91,9 +102,7 @@ export async function addUser(
] ]
if (ssoId) { if (ssoId) {
promises.push( promises.push(addSsoUser(ssoId, email, userId, tenantId))
addUserDoc(ssoId, () => newUserSsoIdDoc(ssoId, email, userId, tenantId))
)
} }
await Promise.all(promises) await Promise.all(promises)

View file

@ -39,7 +39,7 @@ class InMemoryQueue implements Partial<Queue> {
_opts?: QueueOptions _opts?: QueueOptions
_messages: JobMessage[] _messages: JobMessage[]
_queuedJobIds: Set<string> _queuedJobIds: Set<string>
_emitter: EventEmitter _emitter: NodeJS.EventEmitter
_runCount: number _runCount: number
_addCount: number _addCount: number
@ -166,7 +166,7 @@ class InMemoryQueue implements Partial<Queue> {
return [] return []
} }
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async removeJobs(pattern: string) { async removeJobs(pattern: string) {
// no-op // no-op
} }

View file

@ -132,7 +132,7 @@ function logging(queue: Queue, jobQueue: JobQueue) {
// A Job is waiting to be processed as soon as a worker is idling. // A Job is waiting to be processed as soon as a worker is idling.
console.info(...getLogParams(eventType, BullEvent.WAITING, { jobId })) console.info(...getLogParams(eventType, BullEvent.WAITING, { jobId }))
}) })
.on(BullEvent.ACTIVE, async (job: Job, jobPromise: any) => { .on(BullEvent.ACTIVE, async (job: Job) => {
// A job has started. You can use `jobPromise.cancel()`` to abort it. // A job has started. You can use `jobPromise.cancel()`` to abort it.
await doInJobContext(job, () => { await doInJobContext(job, () => {
console.info(...getLogParams(eventType, BullEvent.ACTIVE, { job })) console.info(...getLogParams(eventType, BullEvent.ACTIVE, { job }))

View file

@ -40,6 +40,7 @@ export async function shutdown() {
if (inviteClient) await inviteClient.finish() if (inviteClient) await inviteClient.finish()
if (passwordResetClient) await passwordResetClient.finish() if (passwordResetClient) await passwordResetClient.finish()
if (socketClient) await socketClient.finish() if (socketClient) await socketClient.finish()
if (docWritethroughClient) await docWritethroughClient.finish()
} }
process.on("exit", async () => { process.on("exit", async () => {

View file

@ -120,7 +120,7 @@ describe("redis", () => {
await redis.bulkStore(data, ttl) await redis.bulkStore(data, ttl)
for (const [key, value] of Object.entries(data)) { for (const key of Object.keys(data)) {
expect(await redis.get(key)).toBe(null) expect(await redis.get(key)).toBe(null)
} }
@ -147,17 +147,6 @@ describe("redis", () => {
expect(results).toEqual([1, 2, 3, 4, 5]) expect(results).toEqual([1, 2, 3, 4, 5])
}) })
it("can increment on a new key", async () => {
const key1 = structures.uuid()
const key2 = structures.uuid()
const result1 = await redis.increment(key1)
expect(result1).toBe(1)
const result2 = await redis.increment(key2)
expect(result2).toBe(1)
})
it("can increment multiple times in parallel", async () => { it("can increment multiple times in parallel", async () => {
const key = structures.uuid() const key = structures.uuid()
const results = await Promise.all( const results = await Promise.all(
@ -184,7 +173,7 @@ describe("redis", () => {
const key = structures.uuid() const key = structures.uuid()
await redis.store(key, value) await redis.store(key, value)
await expect(redis.increment(key)).rejects.toThrowError( await expect(redis.increment(key)).rejects.toThrow(
"ERR value is not an integer or out of range" "ERR value is not an integer or out of range"
) )
}) })

View file

@ -96,8 +96,8 @@ describe("redlockImpl", () => {
task: mockTask, task: mockTask,
executionTimeMs: lockTtl * 2, executionTimeMs: lockTtl * 2,
}) })
).rejects.toThrowError( ).rejects.toThrow(
`Unable to fully release the lock on resource \"lock:${config.tenantId}_persist_writethrough\".` `Unable to fully release the lock on resource "lock:${config.tenantId}_persist_writethrough".`
) )
} }
) )

View file

@ -158,8 +158,8 @@ describe("getTenantIDFromCtx", () => {
], ],
} }
expect(getTenantIDFromCtx(ctx, mockOpts)).toBeUndefined() expect(getTenantIDFromCtx(ctx, mockOpts)).toBeUndefined()
expect(ctx.throw).toBeCalledTimes(1) expect(ctx.throw).toHaveBeenCalledTimes(1)
expect(ctx.throw).toBeCalledWith(403, "Tenant id not set") expect(ctx.throw).toHaveBeenCalledWith(403, "Tenant id not set")
}) })
it("returns undefined if allowNoTenant is true", () => { it("returns undefined if allowNoTenant is true", () => {

View file

@ -500,13 +500,13 @@ export class UserDB {
static async createAdminUser( static async createAdminUser(
email: string, email: string,
password: string,
tenantId: string, tenantId: string,
password?: string,
opts?: CreateAdminUserOpts opts?: CreateAdminUserOpts
) { ) {
const user: User = { const user: User = {
email: email, email: email,
password: password, password,
createdAt: Date.now(), createdAt: Date.now(),
roles: {}, roles: {},
builder: { builder: {

View file

@ -45,7 +45,7 @@ describe("Users", () => {
...{ _id: groupId, roles: { app1: "ADMIN" } }, ...{ _id: groupId, roles: { app1: "ADMIN" } },
} }
const users: User[] = [] const users: User[] = []
for (const _ of Array.from({ length: usersInGroup })) { for (let i = 0; i < usersInGroup; i++) {
const userId = `us_${generator.guid()}` const userId = `us_${generator.guid()}`
const user: User = structures.users.user({ const user: User = structures.users.user({
_id: userId, _id: userId,

View file

@ -3,7 +3,7 @@ import { generator } from "./generator"
export function userGroup(): UserGroup { export function userGroup(): UserGroup {
return { return {
name: generator.word(), name: generator.guid(),
icon: generator.word(), icon: generator.word(),
color: generator.word(), color: generator.word(),
} }

View file

@ -1,80 +1,58 @@
import { DatabaseImpl } from "../../../src/db"
import { execSync } from "child_process" import { execSync } from "child_process"
let dockerPsResult: string | undefined interface ContainerInfo {
Command: string
function formatDockerPsResult(serverName: string, port: number) { CreatedAt: string
const lines = dockerPsResult?.split("\n") ID: string
let first = true Image: string
if (!lines) { Labels: string
return null LocalVolumes: string
} Mounts: string
for (let line of lines) { Names: string
if (first) { Networks: string
first = false Ports: string
continue RunningFor: string
} Size: string
let toLookFor = serverName.split("-service")[0] State: string
if (!line.includes(toLookFor)) { Status: string
continue
}
const regex = new RegExp(`0.0.0.0:([0-9]*)->${port}`, "g")
const found = line.match(regex)
if (found) {
return found[0].split(":")[1].split("->")[0]
}
}
return null
} }
function getTestContainerSettings( function getTestcontainers(): ContainerInfo[] {
serverName: string, return execSync("docker ps --format json")
key: string .toString()
): string | null { .split("\n")
const entry = Object.entries(global).find( .filter(x => x.length > 0)
([k]) => .map(x => JSON.parse(x) as ContainerInfo)
k.includes(`${serverName.toUpperCase()}`) && .filter(x => x.Labels.includes("org.testcontainers=true"))
k.includes(`${key.toUpperCase()}`)
)
if (!entry) {
return null
}
return entry[1]
} }
function getContainerInfo(containerName: string, port: number) { function getContainerByImage(image: string) {
let assignedPort = getTestContainerSettings( return getTestcontainers().find(x => x.Image.startsWith(image))
containerName.toUpperCase(),
`PORT_${port}`
)
if (!dockerPsResult) {
try {
const outputBuffer = execSync("docker ps")
dockerPsResult = outputBuffer.toString("utf8")
} catch (err) {
//no-op
}
}
const possiblePort = formatDockerPsResult(containerName, port)
if (possiblePort) {
assignedPort = possiblePort
}
const host = getTestContainerSettings(containerName.toUpperCase(), "IP")
return {
port: assignedPort,
host,
url: host && assignedPort && `http://${host}:${assignedPort}`,
}
} }
function getCouchConfig() { function getExposedPort(container: ContainerInfo, port: number) {
return getContainerInfo("couchdb", 5984) const match = container.Ports.match(new RegExp(`0.0.0.0:(\\d+)->${port}/tcp`))
if (!match) {
return undefined
}
return parseInt(match[1])
} }
export function setupEnv(...envs: any[]) { export function setupEnv(...envs: any[]) {
const couch = getCouchConfig() const couch = getContainerByImage("budibase/couchdb")
if (!couch) {
throw new Error("CouchDB container not found")
}
const couchPort = getExposedPort(couch, 5984)
if (!couchPort) {
throw new Error("CouchDB port not found")
}
const configs = [ const configs = [
{ key: "COUCH_DB_PORT", value: couch.port }, { key: "COUCH_DB_PORT", value: `${couchPort}` },
{ key: "COUCH_DB_URL", value: couch.url }, { key: "COUCH_DB_URL", value: `http://localhost:${couchPort}` },
] ]
for (const config of configs.filter(x => !!x.value)) { for (const config of configs.filter(x => !!x.value)) {
@ -82,4 +60,7 @@ export function setupEnv(...envs: any[]) {
env._set(config.key, config.value) env._set(config.key, config.value)
} }
} }
// @ts-expect-error
DatabaseImpl.nano = undefined
} }

View file

@ -4,3 +4,7 @@ process.env.NODE_ENV = "jest"
process.env.MOCK_REDIS = "1" process.env.MOCK_REDIS = "1"
process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error" process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error"
process.env.REDIS_PASSWORD = "budibase" process.env.REDIS_PASSWORD = "budibase"
process.env.COUCH_DB_PASSWORD = "budibase"
process.env.COUCH_DB_USER = "budibase"
process.env.API_ENCRYPTION_KEY = "testsecret"
process.env.JWT_SECRET = "testsecret"

View file

@ -12,6 +12,13 @@ export default {
format: "esm", format: "esm",
file: "dist/bbui.es.js", file: "dist/bbui.es.js",
}, },
onwarn(warning, warn) {
// suppress eval warnings
if (warning.code === "EVAL") {
return
}
warn(warning)
},
plugins: [ plugins: [
resolve(), resolve(),
commonjs(), commonjs(),

View file

@ -39,19 +39,23 @@ const handleClick = event => {
return return
} }
if (handler.allowedType && event.type !== handler.allowedType) {
return
}
handler.callback?.(event) handler.callback?.(event)
}) })
} }
document.documentElement.addEventListener("click", handleClick, true) document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("contextmenu", handleClick, true) document.documentElement.addEventListener("mousedown", handleClick, true)
/** /**
* Adds or updates a click handler * Adds or updates a click handler
*/ */
const updateHandler = (id, element, anchor, callback) => { const updateHandler = (id, element, anchor, callback, allowedType) => {
let existingHandler = clickHandlers.find(x => x.id === id) let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) { if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback }) clickHandlers.push({ id, element, anchor, callback, allowedType })
} else { } else {
existingHandler.callback = callback existingHandler.callback = callback
} }
@ -75,9 +79,11 @@ const removeHandler = id => {
export default (element, opts) => { export default (element, opts) => {
const id = Math.random() const id = Math.random()
const update = newOpts => { const update = newOpts => {
const callback = newOpts?.callback || newOpts const callback =
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
const anchor = newOpts?.anchor || element const anchor = newOpts?.anchor || element
updateHandler(id, element, anchor, callback) const allowedType = newOpts?.allowedType || "click"
updateHandler(id, element, anchor, callback, allowedType)
} }
update(opts) update(opts)
return { return {

View file

@ -42,7 +42,6 @@
.main { .main {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
overflow-x: hidden;
} }
.padding .main { .padding .main {
padding: var(--spacing-xl); padding: var(--spacing-xl);

View file

@ -12,6 +12,7 @@
export let schema export let schema
export let value export let value
export let customRenderers = [] export let customRenderers = []
export let snippets
let renderer let renderer
const typeMap = { const typeMap = {
@ -44,7 +45,7 @@
if (!template) { if (!template) {
return value return value
} }
return processStringSync(template, { value }) return processStringSync(template, { value, snippets })
} }
</script> </script>

View file

@ -42,6 +42,7 @@
export let customPlaceholder = false export let customPlaceholder = false
export let showHeaderBorder = true export let showHeaderBorder = true
export let placeholderText = "No rows found" export let placeholderText = "No rows found"
export let snippets = []
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -425,6 +426,7 @@
<CellRenderer <CellRenderer
{customRenderers} {customRenderers}
{row} {row}
{snippets}
schema={schema[field]} schema={schema[field]}
value={deepGet(row, field)} value={deepGet(row, field)}
on:clickrelationship on:clickrelationship

View file

@ -1,15 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}

View file

@ -1,8 +0,0 @@
{
"javascript.format.enable": false,
"svelte.plugin.svelte.format.enable": false,
"html.format.enable": false,
"json.format.enable": false,
"editor.trimAutoWhitespace": false,
"sass.format.deleteWhitespace": false
}

View file

@ -191,8 +191,10 @@
// don't make field IDs for auto types // don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) { if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase() return type.toUpperCase()
} else { } else if (type === FieldType.BB_REFERENCE) {
return `${type}${subtype || ""}`.toUpperCase() return `${type}${subtype || ""}`.toUpperCase()
} else {
return type.toUpperCase()
} }
} }
@ -703,24 +705,6 @@
thin thin
text="Allow multiple users" text="Allow multiple users"
/> />
{:else if editableColumn.type === FieldType.ATTACHMENT}
<Toggle
value={editableColumn.constraints?.length?.maximum !== 1}
on:change={e => {
if (!e.detail) {
editableColumn.constraints ??= { length: {} }
editableColumn.constraints.length ??= {}
editableColumn.constraints.length.maximum = 1
editableColumn.constraints.length.message =
"cannot contain multiple files"
} else {
delete editableColumn.constraints?.length?.maximum
delete editableColumn.constraints?.length?.message
}
}}
thin
text="Allow multiple"
/>
{/if} {/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn} {#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select <Select

View file

@ -28,7 +28,6 @@
let deleteTableName let deleteTableName
$: externalTable = table?.sourceType === DB_TYPE_EXTERNAL $: externalTable = table?.sourceType === DB_TYPE_EXTERNAL
$: allowDeletion = !externalTable || table?.created
function showDeleteModal() { function showDeleteModal() {
templateScreens = $screenStore.screens.filter( templateScreens = $screenStore.screens.filter(
@ -56,7 +55,7 @@
$goto(`./datasource/${table.datasourceId}`) $goto(`./datasource/${table.datasourceId}`)
} }
} catch (error) { } catch (error) {
notifications.error("Error deleting table") notifications.error(`Error deleting table - ${error.message}`)
} }
} }
@ -86,17 +85,15 @@
} }
</script> </script>
{#if allowDeletion} <ActionMenu>
<ActionMenu> <div slot="control" class="icon">
<div slot="control" class="icon"> <Icon s hoverable name="MoreSmallList" />
<Icon s hoverable name="MoreSmallList" /> </div>
</div> {#if !externalTable}
{#if !externalTable} <MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem> {/if}
{/if} <MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem>
<MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem> </ActionMenu>
</ActionMenu>
{/if}
<Modal bind:this={editorModal} on:show={initForm}> <Modal bind:this={editorModal} on:show={initForm}>
<ModalContent <ModalContent

View file

@ -313,7 +313,7 @@ export const bindingsToCompletions = (bindings, mode) => {
...bindingByCategory[catKey].reduce((acc, binding) => { ...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({ acc.push({
label: binding.display?.name || "NO NAME", label: binding.display?.name || binding.readableBinding || "NO NAME",
info: completion => { info: completion => {
return buildBindingInfoNode(completion, binding) return buildBindingInfoNode(completion, binding)
}, },

View file

@ -371,6 +371,7 @@
<style> <style>
.binding-panel { .binding-panel {
height: 100%; height: 100%;
overflow: hidden;
} }
.binding-panel, .binding-panel,
.tabs { .tabs {

View file

@ -8,6 +8,7 @@
export let allowJS = false export let allowJS = false
export let allowHelpers = true export let allowHelpers = true
export let autofocusEditor = false export let autofocusEditor = false
export let context = null
$: enrichedBindings = enrichBindings(bindings) $: enrichedBindings = enrichBindings(bindings)
@ -27,7 +28,7 @@
<BindingPanel <BindingPanel
bindings={enrichedBindings} bindings={enrichedBindings}
context={$previewStore.selectedComponentContext} context={{ ...$previewStore.selectedComponentContext, ...context }}
snippets={$snippets} snippets={$snippets}
{value} {value}
{allowJS} {allowJS}

View file

@ -32,10 +32,14 @@
import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import PosthogClient from "../../analytics/PosthogClient"
export let application export let application
export let loaded export let loaded
const posthog = new PosthogClient(process.env.POSTHOG_TOKEN)
let unpublishModal let unpublishModal
let updateAppModal let updateAppModal
let revertModal let revertModal
@ -44,6 +48,8 @@
let appActionPopoverOpen = false let appActionPopoverOpen = false
let appActionPopoverAnchor let appActionPopoverAnchor
let publishing = false let publishing = false
let showNpsSurvey = false
let lastOpened
$: filteredApps = $appsStore.apps.filter(app => app.devId === application) $: filteredApps = $appsStore.apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
@ -57,7 +63,7 @@
$appStore.version && $appStore.version &&
$appStore.upgradableVersion !== $appStore.version $appStore.upgradableVersion !== $appStore.version
$: canPublish = !publishing && loaded && $sortedScreens.length > 0 $: canPublish = !publishing && loaded && $sortedScreens.length > 0
$: lastDeployed = getLastDeployedString($deploymentStore) $: lastDeployed = getLastDeployedString($deploymentStore, lastOpened)
const initialiseApp = async () => { const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($appStore.devId) const applicationPkg = await API.fetchAppPackage($appStore.devId)
@ -97,6 +103,7 @@
type: "success", type: "success",
icon: "GlobeCheck", icon: "GlobeCheck",
}) })
showNpsSurvey = true
await completePublish() await completePublish()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -147,6 +154,10 @@
notifications.error("Error refreshing app") notifications.error("Error refreshing app")
} }
} }
onMount(() => {
posthog.init()
})
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
@ -201,6 +212,7 @@
class="app-action-button publish app-action-popover" class="app-action-button publish app-action-popover"
on:click={() => { on:click={() => {
if (!appActionPopoverOpen) { if (!appActionPopoverOpen) {
lastOpened = new Date()
appActionPopover.show() appActionPopover.show()
} else { } else {
appActionPopover.hide() appActionPopover.hide()
@ -343,6 +355,10 @@
<RevertModal bind:this={revertModal} /> <RevertModal bind:this={revertModal} />
<VersionModal hideIcon bind:this={versionModal} /> <VersionModal hideIcon bind:this={versionModal} />
{#if showNpsSurvey}
<div class="nps-survey" />
{/if}
<style> <style>
.app-action-popover-content { .app-action-popover-content {
padding: var(--spacing-xl); padding: var(--spacing-xl);

View file

@ -0,0 +1,33 @@
<script>
import { API } from "api"
import clientVersions from "./clientVersions.json"
import { appStore } from "stores/builder"
import { Select } from "@budibase/bbui"
export let revertableVersion
$: appId = $appStore.appId
const handleChange = e => {
const value = e.detail
if (value == null) return
API.setRevertableVersion(appId, value)
}
</script>
<div class="select">
<Select
autoWidth
value={revertableVersion}
options={clientVersions}
on:change={handleChange}
footer={"Older versions of the Budibase client can be acquired using `yarn get-past-client-version x.x.x`. This toggle is only available in dev mode."}
/>
</div>
<style>
.select {
width: 120px;
display: inline-block;
}
</style>

View file

@ -1,4 +1,5 @@
<script> <script>
import { admin } from "stores/portal"
import { import {
Modal, Modal,
notifications, notifications,
@ -9,6 +10,7 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { appStore, initialise } from "stores/builder" import { appStore, initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import RevertModalVersionSelect from "./RevertModalVersionSelect.svelte"
export function show() { export function show() {
updateModal.show() updateModal.show()
@ -28,7 +30,9 @@
$appStore.upgradableVersion && $appStore.upgradableVersion &&
$appStore.version && $appStore.version &&
$appStore.upgradableVersion !== $appStore.version $appStore.upgradableVersion !== $appStore.version
$: revertAvailable = $appStore.revertableVersion != null $: revertAvailable =
$appStore.revertableVersion != null ||
($admin.isDev && $appStore.version === "0.0.0")
const refreshAppPackage = async () => { const refreshAppPackage = async () => {
try { try {
@ -62,7 +66,9 @@
// Don't wait for the async refresh, since this causes modal flashing // Don't wait for the async refresh, since this causes modal flashing
refreshAppPackage() refreshAppPackage()
notifications.success( notifications.success(
`App reverted successfully to version ${$appStore.revertableVersion}` $appStore.revertableVersion
? `App reverted successfully to version ${$appStore.revertableVersion}`
: "App reverted successfully"
) )
} catch (err) { } catch (err) {
notifications.error(`Error reverting app: ${err}`) notifications.error(`Error reverting app: ${err}`)
@ -103,7 +109,13 @@
{#if revertAvailable} {#if revertAvailable}
<Body size="S"> <Body size="S">
You can revert this app to version You can revert this app to version
<b>{$appStore.revertableVersion}</b> {#if $admin.isDev}
<RevertModalVersionSelect
revertableVersion={$appStore.revertableVersion}
/>
{:else}
<b>{$appStore.revertableVersion}</b>
{/if}
if you're experiencing issues with the current version. if you're experiencing issues with the current version.
</Body> </Body>
{/if} {/if}

View file

@ -0,0 +1 @@
[]

View file

@ -7,10 +7,13 @@
Layout, Layout,
Label, Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import { themeStore } from "stores/builder" import { themeStore, previewStore } from "stores/builder"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let column export let column
$: columnValue =
$previewStore.selectedComponentContext?.eventContext?.row?.[column.name]
</script> </script>
<DrawerContent> <DrawerContent>
@ -41,6 +44,9 @@
icon: "TableColumnMerge", icon: "TableColumnMerge",
}, },
]} ]}
context={{
value: columnValue,
}}
/> />
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Label>Background color</Label> <Label>Background color</Label>

View file

@ -279,3 +279,11 @@ export const buildContextTreeLookupMap = rootComponent => {
}) })
return map return map
} }
// Get a flat list of ids for all descendants of a component
export const getChildIdsForComponent = component => {
return [
component._id,
...(component?._children ?? []).map(getChildIdsForComponent).flat(1),
]
}

View file

@ -129,10 +129,7 @@
filteredUsers = $usersFetch.rows filteredUsers = $usersFetch.rows
.filter(user => user.email !== $auth.user.email) .filter(user => user.email !== $auth.user.email)
.map(user => { .map(user => {
const isAdminOrGlobalBuilder = sdk.users.isAdminOrGlobalBuilder( const isAdminOrGlobalBuilder = sdk.users.isAdminOrGlobalBuilder(user)
user,
prodAppId
)
const isAppBuilder = user.builder?.apps?.includes(prodAppId) const isAppBuilder = user.builder?.apps?.includes(prodAppId)
let role let role
if (isAdminOrGlobalBuilder) { if (isAdminOrGlobalBuilder) {

View file

@ -24,6 +24,13 @@
navigationStore, navigationStore,
} from "stores/builder" } from "stores/builder"
import { DefaultAppTheme } from "constants" import { DefaultAppTheme } from "constants"
import BarButtonList from "/src/components/design/settings/controls/BarButtonList.svelte"
$: alignmentOptions = [
{ value: "Left", barIcon: "TextAlignLeft" },
{ value: "Center", barIcon: "TextAlignCenter" },
{ value: "Right", barIcon: "TextAlignRight" },
]
$: screenRouteOptions = $screenStore.screens $: screenRouteOptions = $screenStore.screens
.map(screen => screen.routing?.route) .map(screen => screen.routing?.route)
@ -46,6 +53,10 @@
notifications.error("Error updating navigation settings") notifications.error("Error updating navigation settings")
} }
} }
const updateTextAlign = textAlignValue => {
navigationStore.syncAppNavigation({ textAlign: textAlignValue })
}
</script> </script>
<Panel <Panel
@ -133,6 +144,15 @@
on:change={e => update("title", e.detail)} on:change={e => update("title", e.detail)}
updateOnChange={false} updateOnChange={false}
/> />
<div class="label">
<Label size="M">Text align</Label>
</div>
<BarButtonList
options={alignmentOptions}
value={$navigationStore.textAlign}
onChange={updateTextAlign}
/>
{/if} {/if}
<div class="label"> <div class="label">
<Label>Background</Label> <Label>Background</Label>

View file

@ -3,8 +3,6 @@
"name": "Blocks", "name": "Blocks",
"icon": "Article", "icon": "Article",
"children": [ "children": [
"gridblock",
"tableblock",
"cardsblock", "cardsblock",
"repeaterblock", "repeaterblock",
"formblock", "formblock",
@ -16,7 +14,7 @@
{ {
"name": "Layout", "name": "Layout",
"icon": "ClassicGridView", "icon": "ClassicGridView",
"children": ["container", "section", "grid", "sidepanel"] "children": ["container", "section", "sidepanel"]
}, },
{ {
"name": "Data", "name": "Data",
@ -24,7 +22,7 @@
"children": [ "children": [
"dataprovider", "dataprovider",
"repeater", "repeater",
"table", "gridblock",
"spreadsheet", "spreadsheet",
"dynamicfilter", "dynamicfilter",
"daterangepicker" "daterangepicker"

View file

@ -10,20 +10,15 @@
navigationStore, navigationStore,
selectedScreen, selectedScreen,
hoverStore, hoverStore,
componentTreeNodesStore,
snippets, snippets,
} from "stores/builder" } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import { Layout, Heading, Body, Icon, notifications } from "@budibase/bbui"
ProgressCircle,
Layout,
Heading,
Body,
Icon,
notifications,
} from "@budibase/bbui"
import ErrorSVG from "@budibase/frontend-core/assets/error.svg?raw" import ErrorSVG from "@budibase/frontend-core/assets/error.svg?raw"
import { findComponent, findComponentPath } from "helpers/components" import { findComponent, findComponentPath } from "helpers/components"
import { isActive, goto } from "@roxi/routify" import { isActive, goto } from "@roxi/routify"
import { ClientAppSkeleton } from "@budibase/frontend-core"
let iframe let iframe
let layout let layout
@ -132,6 +127,7 @@
error = event.error || "An unknown error occurred" error = event.error || "An unknown error occurred"
} else if (type === "select-component" && "id" in data) { } else if (type === "select-component" && "id" in data) {
componentStore.select(data.id) componentStore.select(data.id)
componentTreeNodesStore.makeNodeVisible(data.id)
} else if (type === "hover-component") { } else if (type === "hover-component") {
hoverStore.hover(data.id, false) hoverStore.hover(data.id, false)
} else if (type === "update-prop") { } else if (type === "update-prop") {
@ -252,8 +248,16 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="component-container"> <div class="component-container">
{#if loading} {#if loading}
<div class="center"> <div
<ProgressCircle /> class={`loading ${$themeStore.baseTheme} ${$themeStore.theme}`}
class:tablet={$previewStore.previewDevice === "tablet"}
class:mobile={$previewStore.previewDevice === "mobile"}
>
<ClientAppSkeleton
sideNav={$navigationStore?.navigation === "Left"}
hideFooter
hideDevTools
/>
</div> </div>
{:else if error} {:else if error}
<div class="center error"> <div class="center error">
@ -270,8 +274,6 @@
bind:this={iframe} bind:this={iframe}
src="/app/preview" src="/app/preview"
class:hidden={loading || error} class:hidden={loading || error}
class:tablet={$previewStore.previewDevice === "tablet"}
class:mobile={$previewStore.previewDevice === "mobile"}
/> />
<div <div
class="add-component" class="add-component"
@ -291,6 +293,25 @@
/> />
<style> <style>
.loading {
position: absolute;
container-type: inline-size;
width: 100%;
height: 100%;
border: 2px solid transparent;
box-sizing: border-box;
}
.loading.tablet {
width: calc(1024px + 6px);
max-height: calc(768px + 6px);
}
.loading.mobile {
width: calc(390px + 6px);
max-height: calc(844px + 6px);
}
.component-container { .component-container {
grid-row-start: middle; grid-row-start: middle;
grid-column-start: middle; grid-column-start: middle;

View file

@ -4,12 +4,12 @@
selectedScreen, selectedScreen,
componentStore, componentStore,
selectedComponent, selectedComponent,
componentTreeNodesStore,
} from "stores/builder" } from "stores/builder"
import { findComponent } from "helpers/components" import { findComponent, getChildIdsForComponent } from "helpers/components"
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 componentTreeNodesStore from "stores/portal/componentTreeNodesStore"
let confirmDeleteDialog let confirmDeleteDialog
let confirmEjectDialog let confirmEjectDialog
@ -63,38 +63,25 @@
componentStore.selectNext() componentStore.selectNext()
}, },
["ArrowRight"]: component => { ["ArrowRight"]: component => {
componentTreeNodesStore.expandNode(component._id) componentTreeNodesStore.expandNodes([component._id])
}, },
["ArrowLeft"]: component => { ["ArrowLeft"]: component => {
componentTreeNodesStore.collapseNode(component._id) // Select the collapsing root component to ensure the currently selected component is not
// hidden in a collapsed node
componentStore.select(component._id)
componentTreeNodesStore.collapseNodes([component._id])
}, },
["Ctrl+ArrowRight"]: component => { ["Ctrl+ArrowRight"]: component => {
componentTreeNodesStore.expandNode(component._id) const childIds = getChildIdsForComponent(component)
componentTreeNodesStore.expandNodes(childIds)
const expandChildren = component => {
const children = component._children ?? []
children.forEach(child => {
componentTreeNodesStore.expandNode(child._id)
expandChildren(child)
})
}
expandChildren(component)
}, },
["Ctrl+ArrowLeft"]: component => { ["Ctrl+ArrowLeft"]: component => {
componentTreeNodesStore.collapseNode(component._id) // Select the collapsing root component to ensure the currently selected component is not
// hidden in a collapsed node
componentStore.select(component._id)
const collapseChildren = component => { const childIds = getChildIdsForComponent(component)
const children = component._children ?? [] componentTreeNodesStore.collapseNodes(childIds)
children.forEach(child => {
componentTreeNodesStore.collapseNode(child._id)
collapseChildren(child)
})
}
collapseChildren(component)
}, },
["Escape"]: () => { ["Escape"]: () => {
if ($isActive(`./:componentId/new`)) { if ($isActive(`./:componentId/new`)) {

View file

@ -7,8 +7,8 @@
componentStore, componentStore,
userSelectedResourceMap, userSelectedResourceMap,
selectedComponent, selectedComponent,
selectedComponentPath,
hoverStore, hoverStore,
componentTreeNodesStore,
} from "stores/builder" } from "stores/builder"
import { import {
findComponentPath, findComponentPath,
@ -17,7 +17,6 @@
} from "helpers/components" } from "helpers/components"
import { get } from "svelte/store" import { get } from "svelte/store"
import { dndStore } from "./dndStore" import { dndStore } from "./dndStore"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"
export let components = [] export let components = []
export let level = 0 export let level = 0
@ -64,14 +63,11 @@
} }
} }
const isOpen = (component, selectedComponentPath, openNodes) => { const isOpen = component => {
if (!component?._children?.length) { if (!component?._children?.length) {
return false return false
} }
if (selectedComponentPath.slice(0, -1).includes(component._id)) { return componentTreeNodesStore.isNodeExpanded(component._id)
return true
}
return openNodes[`nodeOpen-${component._id}`]
} }
const isChildOfSelectedComponent = component => { const isChildOfSelectedComponent = component => {
@ -83,6 +79,11 @@
return findComponentPath($selectedComponent, component._id)?.length > 0 return findComponentPath($selectedComponent, component._id)?.length > 0
} }
const handleIconClick = componentId => {
componentStore.select(componentId)
componentTreeNodesStore.toggleNode(componentId)
}
const hover = hoverStore.hover const hover = hoverStore.hover
</script> </script>
@ -90,7 +91,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<ul> <ul>
{#each filteredComponents || [] as component, index (component._id)} {#each filteredComponents || [] as component, index (component._id)}
{@const opened = isOpen(component, $selectedComponentPath, openNodes)} {@const opened = isOpen(component, openNodes)}
<li <li
on:click|stopPropagation={() => { on:click|stopPropagation={() => {
componentStore.select(component._id) componentStore.select(component._id)
@ -104,7 +105,7 @@
on:dragend={dndStore.actions.reset} on:dragend={dndStore.actions.reset}
on:dragstart={() => dndStore.actions.dragstart(component)} on:dragstart={() => dndStore.actions.dragstart(component)}
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => componentTreeNodesStore.toggleNode(component._id)} on:iconClick={() => handleIconClick(component._id)}
on:drop={onDrop} on:drop={onDrop}
hovering={$hoverStore.componentId === component._id} hovering={$hoverStore.componentId === component._id}
on:mouseenter={() => hover(component._id)} on:mouseenter={() => hover(component._id)}

View file

@ -19,7 +19,8 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import formScreen from "templates/formScreen" import formScreen from "templates/formScreen"
import rowListScreen from "templates/rowListScreen" import gridListScreen from "templates/gridListScreen"
import gridDetailsScreen from "templates/gridDetailsScreen"
let mode let mode
let pendingScreen let pendingScreen
@ -127,7 +128,7 @@
screenAccessRole = Roles.BASIC screenAccessRole = Roles.BASIC
formType = null formType = null
if (mode === "table" || mode === "grid" || mode === "form") { if (mode === "grid" || mode === "gridDetails" || mode === "form") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
let templates = getTemplates($tables.list) let templates = getTemplates($tables.list)
@ -153,7 +154,10 @@
// Handler for Datasource Screen Creation // Handler for Datasource Screen Creation
const completeDatasourceScreenCreation = async () => { const completeDatasourceScreenCreation = async () => {
templates = rowListScreen(selectedDatasources, mode) templates =
mode === "grid"
? gridListScreen(selectedDatasources)
: gridDetailsScreen(selectedDatasources)
const screens = templates.map(template => { const screens = templates.map(template => {
let screenTemplate = template.create() let screenTemplate = template.create()

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -2,8 +2,8 @@
import { Body } from "@budibase/bbui" import { Body } from "@budibase/bbui"
import CreationPage from "components/common/CreationPage.svelte" import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./images/blank.png" import blankImage from "./images/blank.png"
import tableImage from "./images/table.png" import tableInline from "./images/tableInline.png"
import gridImage from "./images/grid.png" import tableDetails from "./images/tableDetails.png"
import formImage from "./images/form.png" import formImage from "./images/form.png"
import CreateScreenModal from "./CreateScreenModal.svelte" import CreateScreenModal from "./CreateScreenModal.svelte"
import { screenStore } from "stores/builder" import { screenStore } from "stores/builder"
@ -38,23 +38,23 @@
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("table")}> <div class="card" on:click={() => createScreenModal.show("grid")}>
<div class="image"> <div class="image">
<img alt="" src={tableImage} /> <img alt="" src={tableInline} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Table</Body> <Body size="S">Table with inline editing</Body>
<Body size="XS">View, edit and delete rows on a table</Body> <Body size="XS">View, edit and delete rows inline</Body>
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("grid")}> <div class="card" on:click={() => createScreenModal.show("gridDetails")}>
<div class="image"> <div class="image">
<img alt="" src={gridImage} /> <img alt="" src={tableDetails} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Grid</Body> <Body size="S">Table with details panel</Body>
<Body size="XS">View and manipulate rows on a grid</Body> <Body size="XS">Manage your row details in a side panel</Body>
</div> </div>
</div> </div>
@ -113,6 +113,11 @@
width: 100%; width: 100%;
} }
.card .image {
min-height: 130px;
min-width: 235px;
}
.text { .text {
border: 1px solid var(--grey-4); border: 1px solid var(--grey-4);
border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px;

View file

@ -1,6 +1,12 @@
<script> <script>
import { onMount, onDestroy } from "svelte"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { auth, sideBarCollapsed, enrichedApps } from "stores/portal" import {
licensing,
auth,
sideBarCollapsed,
enrichedApps,
} from "stores/portal"
import AppRowContext from "components/start/AppRowContext.svelte" import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte" import FavouriteAppButton from "../FavouriteAppButton.svelte"
import { import {
@ -14,12 +20,17 @@
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import ErrorSVG from "./ErrorSVG.svelte" import ErrorSVG from "./ErrorSVG.svelte"
import { getBaseTheme, ClientAppSkeleton } from "@budibase/frontend-core"
$: app = $enrichedApps.find(app => app.appId === $params.appId) $: app = $enrichedApps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app) $: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
let loading = true
const getIframeURL = app => { const getIframeURL = app => {
loading = true
if (app.status === "published") { if (app.status === "published") {
return `/app${app.url}` return `/app${app.url}`
} }
@ -37,6 +48,20 @@
} }
$: fetchScreens(app?.devId) $: fetchScreens(app?.devId)
const receiveMessage = async message => {
if (message.data.type === "docLoaded") {
loading = false
}
}
onMount(() => {
window.addEventListener("message", receiveMessage)
})
onDestroy(() => {
window.removeEventListener("message", receiveMessage)
})
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
@ -108,7 +133,26 @@
</Body> </Body>
</div> </div>
{:else} {:else}
<iframe src={iframeUrl} title={app.name} /> <div
class:hide={!loading || !app?.features?.skeletonLoader}
class="loading"
>
<div
class={`loadingThemeWrapper ${getBaseTheme(app.theme)} ${app.theme}`}
>
<ClientAppSkeleton
noAnimation
hideDevTools={app?.status === "published"}
sideNav={app?.navigation.navigation === "Left"}
hideFooter={$licensing.brandingEnabled}
/>
</div>
</div>
<iframe
class:hide={loading && app?.features?.skeletonLoader}
src={iframeUrl}
title={app.name}
/>
{/if} {/if}
</div> </div>
@ -139,6 +183,23 @@
flex: 0 0 50px; flex: 0 0 50px;
} }
.loading {
height: 100%;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: var(--spacing-s);
overflow: hidden;
}
.loadingThemeWrapper {
height: 100%;
container-type: inline-size;
}
.hide {
visibility: hidden;
height: 0;
border: none;
}
iframe { iframe {
flex: 1 1 auto; flex: 1 1 auto;
border-radius: var(--spacing-s); border-radius: var(--spacing-s);

View file

@ -0,0 +1,67 @@
import { get } from "svelte/store"
import { createSessionStorageStore } from "@budibase/frontend-core"
import { selectedScreen as selectedScreenStore } from "./screens"
import { findComponentPath } from "helpers/components"
const baseStore = createSessionStorageStore("openNodes", {})
const toggleNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = !openNodes[`nodeOpen-${componentId}`]
return openNodes
})
}
const expandNodes = componentIds => {
baseStore.update(openNodes => {
const newNodes = Object.fromEntries(
componentIds.map(id => [`nodeOpen-${id}`, true])
)
return { ...openNodes, ...newNodes }
})
}
const collapseNodes = componentIds => {
baseStore.update(openNodes => {
const newNodes = Object.fromEntries(
componentIds.map(id => [`nodeOpen-${id}`, false])
)
return { ...openNodes, ...newNodes }
})
}
// Will ensure all parents of a node are expanded so that it is visible in the tree
const makeNodeVisible = componentId => {
const selectedScreen = get(selectedScreenStore)
const path = findComponentPath(selectedScreen.props, componentId)
const componentIds = path.map(component => component._id)
baseStore.update(openNodes => {
const newNodes = Object.fromEntries(
componentIds.map(id => [`nodeOpen-${id}`, true])
)
return { ...openNodes, ...newNodes }
})
}
const isNodeExpanded = componentId => {
const openNodes = get(baseStore)
return !!openNodes[`nodeOpen-${componentId}`]
}
const store = {
subscribe: baseStore.subscribe,
toggleNode,
expandNodes,
makeNodeVisible,
collapseNodes,
isNodeExpanded,
}
export default store

View file

@ -19,6 +19,7 @@ import {
appStore, appStore,
previewStore, previewStore,
tables, tables,
componentTreeNodesStore,
} from "stores/builder/index" } from "stores/builder/index"
import { buildFormSchema, getSchemaForDatasource } from "dataBinding" import { buildFormSchema, getSchemaForDatasource } from "dataBinding"
import { import {
@ -29,7 +30,6 @@ import {
} from "constants/backend" } from "constants/backend"
import BudiStore from "../BudiStore" import BudiStore from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"
export const INITIAL_COMPONENTS_STATE = { export const INITIAL_COMPONENTS_STATE = {
components: {}, components: {},
@ -279,12 +279,10 @@ export class ComponentStore extends BudiStore {
else { else {
if (setting.type === "dataProvider") { if (setting.type === "dataProvider") {
// Validate data provider exists, or else clear it // Validate data provider exists, or else clear it
const treeId = parent?._id || component._id const providers = findAllMatchingComponents(
const path = findComponentPath(screen?.props, treeId) screen?.props,
const providers = path.filter(component => x => x._component === "@budibase/standard-components/dataprovider"
component._component?.endsWith("/dataprovider")
) )
// Validate non-empty values
const valid = providers?.some(dp => value.includes?.(dp._id)) const valid = providers?.some(dp => value.includes?.(dp._id))
if (!valid) { if (!valid) {
if (providers.length) { if (providers.length) {
@ -653,8 +651,11 @@ export class ComponentStore extends BudiStore {
this.update(state => { this.update(state => {
state.selectedScreenId = targetScreenId state.selectedScreenId = targetScreenId
state.selectedComponentId = newComponentId state.selectedComponentId = newComponentId
return state return state
}) })
componentTreeNodesStore.makeNodeVisible(newComponentId)
} }
getPrevious() { getPrevious() {
@ -663,7 +664,6 @@ export class ComponentStore extends BudiStore {
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)
const componentTreeNodes = get(componentTreeNodesStore)
// Check for screen and navigation component edge cases // Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen` const screenComponentId = `${screen._id}-screen`
@ -680,16 +680,16 @@ export class ComponentStore extends BudiStore {
// 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) {
// If sibling before us accepts children, select a descendant // If sibling before us accepts children, and is not collapsed, select a descendant
const previousSibling = parent._children[index - 1] const previousSibling = parent._children[index - 1]
if ( if (
previousSibling._children?.length && previousSibling._children?.length &&
componentTreeNodes[`nodeOpen-${previousSibling._id}`] componentTreeNodesStore.isNodeExpanded(previousSibling._id)
) { ) {
let target = previousSibling let target = previousSibling
while ( while (
target._children?.length && target._children?.length &&
componentTreeNodes[`nodeOpen-${target._id}`] componentTreeNodesStore.isNodeExpanded(target._id)
) { ) {
target = target._children[target._children.length - 1] target = target._children[target._children.length - 1]
} }
@ -711,7 +711,6 @@ export class ComponentStore extends BudiStore {
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)
const componentTreeNodes = get(componentTreeNodesStore)
// Check for screen and navigation component edge cases // Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen` const screenComponentId = `${screen._id}-screen`
@ -720,11 +719,11 @@ export class ComponentStore extends BudiStore {
return navComponentId return navComponentId
} }
// If we have children, select first child // If we have children, select first child, and the node is not collapsed
if ( if (
component._children?.length && component._children?.length &&
(state.selectedComponentId === navComponentId || (state.selectedComponentId === navComponentId ||
componentTreeNodes[`nodeOpen-${component._id}`]) componentTreeNodesStore.isNodeExpanded(component._id))
) { ) {
return component._children[0]._id return component._children[0]._id
} else if (!parent) { } else if (!parent) {
@ -803,7 +802,10 @@ export class ComponentStore extends BudiStore {
// sibling // sibling
const previousSibling = parent._children[index - 1] const previousSibling = parent._children[index - 1]
const definition = this.getDefinition(previousSibling._component) const definition = this.getDefinition(previousSibling._component)
if (definition.hasChildren) { if (
definition.hasChildren &&
componentTreeNodesStore.isNodeExpanded(previousSibling._id)
) {
previousSibling._children.push(originalComponent) previousSibling._children.push(originalComponent)
} }
@ -852,10 +854,13 @@ export class ComponentStore extends BudiStore {
// Move below the next sibling if we are not the last sibling // Move below the next sibling if we are not the last sibling
if (index < parent._children.length) { if (index < parent._children.length) {
// If the next sibling has children, become the first child // If the next sibling has children, and is not collapsed, become the first child
const nextSibling = parent._children[index] const nextSibling = parent._children[index]
const definition = this.getDefinition(nextSibling._component) const definition = this.getDefinition(nextSibling._component)
if (definition.hasChildren) { if (
definition.hasChildren &&
componentTreeNodesStore.isNodeExpanded(nextSibling._id)
) {
nextSibling._children.splice(0, 0, originalComponent) nextSibling._children.splice(0, 0, originalComponent)
} }
@ -1151,13 +1156,3 @@ export const selectedComponent = derived(
return clone return clone
} }
) )
export const selectedComponentPath = derived(
[componentStore, selectedScreen],
([$store, $selectedScreen]) => {
return findComponentPath(
$selectedScreen?.props,
$store.selectedComponentId
).map(component => component._id)
}
)

View file

@ -7,12 +7,25 @@ export const INITIAL_HOVER_STATE = {
} }
export class HoverStore extends BudiStore { export class HoverStore extends BudiStore {
hoverTimeout
constructor() { constructor() {
super({ ...INITIAL_HOVER_STATE }) super({ ...INITIAL_HOVER_STATE })
this.hover = this.hover.bind(this) this.hover = this.hover.bind(this)
} }
hover(componentId, notifyClient = true) { hover(componentId, notifyClient = true) {
clearTimeout(this.hoverTimeout)
if (componentId) {
this.processHover(componentId, notifyClient)
} else {
this.hoverTimeout = setTimeout(() => {
this.processHover(componentId, notifyClient)
}, 10)
}
}
processHover(componentId, notifyClient) {
if (componentId === get(this.store).componentId) { if (componentId === get(this.store).componentId) {
return return
} }

View file

@ -1,10 +1,6 @@
import { layoutStore } from "./layouts.js" import { layoutStore } from "./layouts.js"
import { appStore } from "./app.js" import { appStore } from "./app.js"
import { import { componentStore, selectedComponent } from "./components"
componentStore,
selectedComponent,
selectedComponentPath,
} from "./components"
import { navigationStore } from "./navigation.js" import { navigationStore } from "./navigation.js"
import { themeStore } from "./theme.js" import { themeStore } from "./theme.js"
import { screenStore, selectedScreen, sortedScreens } from "./screens.js" import { screenStore, selectedScreen, sortedScreens } from "./screens.js"
@ -31,8 +27,10 @@ import { integrations } from "./integrations"
import { sortedIntegrations } from "./sortedIntegrations" import { sortedIntegrations } from "./sortedIntegrations"
import { queries } from "./queries" import { queries } from "./queries"
import { flags } from "./flags" import { flags } from "./flags"
import componentTreeNodesStore from "./componentTreeNodes"
export { export {
componentTreeNodesStore,
layoutStore, layoutStore,
appStore, appStore,
componentStore, componentStore,
@ -51,7 +49,6 @@ export {
isOnlyUser, isOnlyUser,
deploymentStore, deploymentStore,
selectedComponent, selectedComponent,
selectedComponentPath,
tables, tables,
views, views,
viewsV2, viewsV2,

View file

@ -11,6 +11,7 @@ export const INITIAL_NAVIGATION_STATE = {
hideLogo: null, hideLogo: null,
logoUrl: null, logoUrl: null,
hideTitle: null, hideTitle: null,
textAlign: "Left",
navBackground: null, navBackground: null,
navWidth: null, navWidth: null,
navTextColor: null, navTextColor: null,

View file

@ -1,5 +1,6 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { getBaseTheme } from "@budibase/frontend-core"
const INITIAL_THEMES_STATE = { const INITIAL_THEMES_STATE = {
theme: "", theme: "",
@ -12,11 +13,15 @@ export const themes = () => {
}) })
const syncAppTheme = app => { const syncAppTheme = app => {
store.update(state => ({ store.update(state => {
...state, const theme = app.theme || "spectrum--light"
theme: app.theme || "spectrum--light", return {
customTheme: app.customTheme, ...state,
})) theme,
baseTheme: getBaseTheme(theme),
customTheme: app.customTheme,
}
})
} }
const save = async (theme, appId) => { const save = async (theme, appId) => {

View file

@ -1,36 +0,0 @@
import { createSessionStorageStore } from "@budibase/frontend-core"
const baseStore = createSessionStorageStore("openNodes", {})
const toggleNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = !openNodes[`nodeOpen-${componentId}`]
return openNodes
})
}
const expandNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = true
return openNodes
})
}
const collapseNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = false
return openNodes
})
}
const store = {
subscribe: baseStore.subscribe,
toggleNode,
expandNode,
collapseNode,
}
export default store

View file

@ -0,0 +1,158 @@
import sanitizeUrl from "helpers/sanitizeUrl"
import { Screen } from "./Screen"
import { Component } from "./Component"
import { generate } from "shortid"
import { makePropSafe as safe } from "@budibase/string-templates"
import { Utils } from "@budibase/frontend-core"
export default function (datasources) {
if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return {
name: `${datasource.label} - List with panel`,
create: () => createScreen(datasource),
id: GRID_DETAILS_TEMPLATE,
resourceId: datasource.resourceId,
}
})
}
export const GRID_DETAILS_TEMPLATE = "GRID_DETAILS_TEMPLATE"
export const gridDetailsUrl = datasource => sanitizeUrl(`/${datasource.label}`)
const createScreen = datasource => {
/*
Create Row
*/
const createRowSidePanel = new Component(
"@budibase/standard-components/sidepanel"
).instanceName("New row side panel")
const buttonGroup = new Component("@budibase/standard-components/buttongroup")
const createButton = new Component("@budibase/standard-components/button")
createButton.customProps({
onClick: [
{
id: 0,
"##eventHandlerType": "Open Side Panel",
parameters: {
id: createRowSidePanel._json._id,
},
},
],
text: "Create row",
type: "cta",
})
buttonGroup.instanceName(`${datasource.label} - Create`).customProps({
hAlign: "right",
buttons: [createButton.json()],
})
const gridHeader = new Component("@budibase/standard-components/container")
.instanceName("Heading container")
.customProps({
direction: "row",
hAlign: "stretch",
})
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: datasource?.label,
})
gridHeader.addChild(heading)
gridHeader.addChild(buttonGroup)
const createFormBlock = new Component(
"@budibase/standard-components/formblock"
)
createFormBlock.instanceName("Create row form block").customProps({
dataSource: datasource,
labelPosition: "left",
buttonPosition: "top",
actionType: "Create",
title: "Create row",
buttons: Utils.buildFormBlockButtonConfig({
_id: createFormBlock._json._id,
showDeleteButton: false,
showSaveButton: true,
saveButtonLabel: "Save",
actionType: "Create",
dataSource: datasource,
}),
})
createRowSidePanel.addChild(createFormBlock)
/*
Edit Row
*/
const stateKey = `ID_${generate()}`
const detailsSidePanel = new Component(
"@budibase/standard-components/sidepanel"
).instanceName("Edit row side panel")
const editFormBlock = new Component("@budibase/standard-components/formblock")
editFormBlock.instanceName("Edit row form block").customProps({
dataSource: datasource,
labelPosition: "left",
buttonPosition: "top",
actionType: "Update",
title: "Edit",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
buttons: Utils.buildFormBlockButtonConfig({
_id: editFormBlock._json._id,
showDeleteButton: true,
showSaveButton: true,
saveButtonLabel: "Save",
deleteButtonLabel: "Delete",
actionType: "Update",
dataSource: datasource,
}),
})
detailsSidePanel.addChild(editFormBlock)
const gridBlock = new Component("@budibase/standard-components/gridblock")
gridBlock
.customProps({
table: datasource,
allowAddRows: false,
allowEditRows: false,
allowDeleteRows: false,
onRowClick: [
{
id: 0,
"##eventHandlerType": "Update State",
parameters: {
key: stateKey,
type: "set",
persist: false,
value: `{{ ${safe("eventContext")}.${safe("row")}._id }}`,
},
},
{
id: 1,
"##eventHandlerType": "Open Side Panel",
parameters: {
id: detailsSidePanel._json._id,
},
},
],
})
.instanceName(`${datasource.label} - Table`)
return new Screen()
.route(gridDetailsUrl(datasource))
.instanceName(`${datasource.label} - List and details`)
.addChild(gridHeader)
.addChild(gridBlock)
.addChild(createRowSidePanel)
.addChild(detailsSidePanel)
.json()
}

View file

@ -0,0 +1,41 @@
import sanitizeUrl from "helpers/sanitizeUrl"
import { Screen } from "./Screen"
import { Component } from "./Component"
export default function (datasources) {
if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return {
name: `${datasource.label} - List`,
create: () => createScreen(datasource),
id: GRID_LIST_TEMPLATE,
resourceId: datasource.resourceId,
}
})
}
export const GRID_LIST_TEMPLATE = "GRID_LIST_TEMPLATE"
export const gridListUrl = datasource => sanitizeUrl(`/${datasource.label}`)
const createScreen = datasource => {
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: datasource?.label,
})
const gridBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${datasource.label} - Table`)
.customProps({
table: datasource,
})
return new Screen()
.route(gridListUrl(datasource))
.instanceName(`${datasource.label} - List`)
.addChild(heading)
.addChild(gridBlock)
.json()
}

View file

@ -1,9 +1,11 @@
import rowListScreen from "./rowListScreen" import gridListScreen from "./gridListScreen"
import gridDetailsScreen from "./gridDetailsScreen"
import createFromScratchScreen from "./createFromScratchScreen" import createFromScratchScreen from "./createFromScratchScreen"
import formScreen from "./formScreen" import formScreen from "./formScreen"
const allTemplates = datasources => [ const allTemplates = datasources => [
...rowListScreen(datasources), ...gridListScreen(datasources),
...gridDetailsScreen(datasources),
...formScreen(datasources), ...formScreen(datasources),
] ]

View file

@ -1,63 +0,0 @@
import sanitizeUrl from "helpers/sanitizeUrl"
import { Screen } from "./Screen"
import { Component } from "./Component"
export default function (datasources, mode = "table") {
if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return {
name: `${datasource.label} - List`,
create: () => createScreen(datasource, mode),
id: ROW_LIST_TEMPLATE,
resourceId: datasource.resourceId,
}
})
}
export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE"
export const rowListUrl = datasource => sanitizeUrl(`/${datasource.label}`)
const generateTableBlock = datasource => {
const tableBlock = new Component("@budibase/standard-components/tableblock")
tableBlock
.customProps({
title: datasource.label,
dataSource: datasource,
sortOrder: "Ascending",
size: "spectrum--medium",
paginate: true,
rowCount: 8,
clickBehaviour: "details",
showTitleButton: true,
titleButtonText: "Create row",
titleButtonClickBehaviour: "new",
sidePanelSaveLabel: "Save",
sidePanelDeleteLabel: "Delete",
})
.instanceName(`${datasource.label} - Table block`)
return tableBlock
}
const generateGridBlock = datasource => {
const gridBlock = new Component("@budibase/standard-components/gridblock")
gridBlock
.customProps({
table: datasource,
})
.instanceName(`${datasource.label} - Grid block`)
return gridBlock
}
const createScreen = (datasource, mode) => {
return new Screen()
.route(rowListUrl(datasource))
.instanceName(`${datasource.label} - List`)
.addChild(
mode === "table"
? generateTableBlock(datasource)
: generateGridBlock(datasource)
)
.json()
}

View file

@ -4,6 +4,16 @@
"composite": true, "composite": true,
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"baseUrl": "." "baseUrl": ".",
"paths": {
"assets/*": ["./assets/*"],
"@budibase/*": [
"../*/src/index.ts",
"../*/src/index.js",
"../*",
"../../node_modules/@budibase/*"
],
"*": ["./src/*"]
}
} }
} }

View file

@ -11,6 +11,7 @@
"types": ["node", "jest"], "types": ["node", "jest"],
"outDir": "dist", "outDir": "dist",
"skipLibCheck": true, "skipLibCheck": true,
"baseUrl": ".",
"paths": { "paths": {
"@budibase/types": ["../types/src"], "@budibase/types": ["../types/src"],
"@budibase/backend-core": ["../backend-core/src"], "@budibase/backend-core": ["../backend-core/src"],

View file

@ -10,7 +10,8 @@
"rowSelection": true, "rowSelection": true,
"continueIfAction": true, "continueIfAction": true,
"showNotificationAction": true, "showNotificationAction": true,
"sidePanel": true "sidePanel": true,
"skeletonLoader": true
}, },
"layout": { "layout": {
"name": "Layout", "name": "Layout",
@ -4673,6 +4674,7 @@
} }
}, },
"table": { "table": {
"deprecated": true,
"name": "Table", "name": "Table",
"icon": "Table", "icon": "Table",
"illegalChildren": ["section"], "illegalChildren": ["section"],
@ -5418,6 +5420,7 @@
] ]
}, },
"tableblock": { "tableblock": {
"deprecated": true,
"block": true, "block": true,
"name": "Table Block", "name": "Table Block",
"icon": "Table", "icon": "Table",
@ -6595,7 +6598,7 @@
] ]
}, },
"gridblock": { "gridblock": {
"name": "Grid Block", "name": "Table",
"icon": "Table", "icon": "Table",
"styles": ["size"], "styles": ["size"],
"size": { "size": {

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