commit
5431747020
225 changed files with 5644 additions and 3550 deletions
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -6,6 +6,7 @@ labels: bug
|
|||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have searched budibase discussions and github issues to check if my issue already exists
|
||||
|
||||
|
|
53
.github/workflows/budibase_ci.yml
vendored
53
.github/workflows/budibase_ci.yml
vendored
|
@ -57,10 +57,9 @@ jobs:
|
|||
- run: yarn
|
||||
- run: yarn bootstrap
|
||||
- run: yarn test
|
||||
- uses: codecov/codecov-action@v1
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
|
||||
files: ./packages/server/coverage/clover.xml,./packages/worker/coverage/clover.xml,./packages/backend-core/coverage/clover.xml
|
||||
name: codecov-umbrella
|
||||
verbose: true
|
||||
|
||||
|
@ -78,28 +77,28 @@ jobs:
|
|||
- run: yarn bootstrap
|
||||
- run: yarn test:pro
|
||||
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
couchdb:
|
||||
image: ibmcom/couchdb3
|
||||
env:
|
||||
COUCHDB_PASSWORD: budibase
|
||||
COUCHDB_USER: budibase
|
||||
ports:
|
||||
- 4567:5984
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Install Pro
|
||||
run: yarn install:pro $BRANCH $BASE_BRANCH
|
||||
- run: yarn
|
||||
- run: yarn bootstrap
|
||||
- run: yarn build
|
||||
- run: |
|
||||
cd qa-core
|
||||
yarn
|
||||
yarn api:test:ci
|
||||
# integration-test:
|
||||
# runs-on: ubuntu-latest
|
||||
# services:
|
||||
# couchdb:
|
||||
# image: ibmcom/couchdb3
|
||||
# env:
|
||||
# COUCHDB_PASSWORD: budibase
|
||||
# COUCHDB_USER: budibase
|
||||
# ports:
|
||||
# - 4567:5984
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# - name: Use Node.js 14.x
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: 14.x
|
||||
# - name: Install Pro
|
||||
# run: yarn install:pro $BRANCH $BASE_BRANCH
|
||||
# - run: yarn
|
||||
# - run: yarn bootstrap
|
||||
# - run: yarn build
|
||||
# - run: |
|
||||
# cd qa-core
|
||||
# yarn
|
||||
# yarn api:test:ci
|
||||
|
|
4
.github/workflows/deploy-preprod.yml
vendored
4
.github/workflows/deploy-preprod.yml
vendored
|
@ -23,7 +23,6 @@ jobs:
|
|||
release_version=${{ github.event.inputs.version }}
|
||||
fi
|
||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v1
|
||||
with:
|
||||
|
@ -38,7 +37,6 @@ jobs:
|
|||
-o values.preprod.yaml \
|
||||
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
|
||||
wc -l values.preprod.yaml
|
||||
|
||||
- name: Deploy to Preprod Environment
|
||||
uses: budibase/helm@v1.8.0
|
||||
with:
|
||||
|
@ -65,4 +63,4 @@ jobs:
|
|||
with:
|
||||
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
|
||||
content: "Preprod Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Pre-prod."
|
||||
embed-title: ${{ env.RELEASE_VERSION }}
|
||||
embed-title: ${{ env.RELEASE_VERSION }}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.4.43",
|
||||
"version": "2.4.44-alpha.1",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/backend-core",
|
||||
"version": "2.4.43",
|
||||
"version": "2.4.44-alpha.1",
|
||||
"description": "Budibase backend core libraries used in server and worker",
|
||||
"main": "dist/src/index.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
|
@ -24,7 +24,7 @@
|
|||
"dependencies": {
|
||||
"@budibase/nano": "10.1.2",
|
||||
"@budibase/pouchdb-replication-stream": "1.2.10",
|
||||
"@budibase/types": "^2.4.43",
|
||||
"@budibase/types": "2.4.44-alpha.1",
|
||||
"@shopify/jest-koa-mocks": "5.0.1",
|
||||
"@techpass/passport-openidconnect": "0.3.2",
|
||||
"aws-cloudfront-sign": "2.2.0",
|
||||
|
|
|
@ -199,7 +199,6 @@ export async function platformLogout(opts: PlatformLogoutOpts) {
|
|||
} else {
|
||||
// clear cookies
|
||||
clearCookie(ctx, Cookie.Auth)
|
||||
clearCookie(ctx, Cookie.CurrentApp)
|
||||
}
|
||||
|
||||
const sessionIds = sessions.map(({ sessionId }) => sessionId)
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { structures, DBTestConfiguration } from "../../../tests"
|
||||
import {
|
||||
structures,
|
||||
DBTestConfiguration,
|
||||
expectFunctionWasCalledTimesWith,
|
||||
} from "../../../tests"
|
||||
import { Writethrough } from "../writethrough"
|
||||
import { getDB } from "../../db"
|
||||
import tk from "timekeeper"
|
||||
|
||||
const START_DATE = Date.now()
|
||||
tk.freeze(START_DATE)
|
||||
tk.freeze(Date.now())
|
||||
|
||||
const DELAY = 5000
|
||||
|
||||
|
@ -17,34 +20,99 @@ describe("writethrough", () => {
|
|||
const writethrough = new Writethrough(db, DELAY)
|
||||
const writethrough2 = new Writethrough(db2, DELAY)
|
||||
|
||||
const docId = structures.uuid()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("put", () => {
|
||||
let first: any
|
||||
let current: any
|
||||
|
||||
it("should be able to store, will go to DB", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const response = await writethrough.put({ _id: "test", value: 1 })
|
||||
const response = await writethrough.put({
|
||||
_id: docId,
|
||||
value: 1,
|
||||
})
|
||||
const output = await db.get(response.id)
|
||||
first = output
|
||||
current = output
|
||||
expect(output.value).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("second put shouldn't update DB", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const response = await writethrough.put({ ...first, value: 2 })
|
||||
const response = await writethrough.put({ ...current, value: 2 })
|
||||
const output = await db.get(response.id)
|
||||
expect(first._rev).toBe(output._rev)
|
||||
expect(current._rev).toBe(output._rev)
|
||||
expect(output.value).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("should put it again after delay period", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
tk.freeze(START_DATE + DELAY + 1)
|
||||
const response = await writethrough.put({ ...first, value: 3 })
|
||||
tk.freeze(Date.now() + DELAY + 1)
|
||||
const response = await writethrough.put({ ...current, value: 3 })
|
||||
const output = await db.get(response.id)
|
||||
expect(response.rev).not.toBe(first._rev)
|
||||
expect(response.rev).not.toBe(current._rev)
|
||||
expect(output.value).toBe(3)
|
||||
|
||||
current = output
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle parallel DB updates ignoring conflicts", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
tk.freeze(Date.now() + DELAY + 1)
|
||||
const responses = await Promise.all([
|
||||
writethrough.put({ ...current, value: 4 }),
|
||||
writethrough.put({ ...current, value: 4 }),
|
||||
writethrough.put({ ...current, value: 4 }),
|
||||
])
|
||||
|
||||
const newRev = responses.map(x => x.rev).find(x => x !== current._rev)
|
||||
expect(newRev).toBeDefined()
|
||||
expect(responses.map(x => x.rev)).toEqual(
|
||||
expect.arrayContaining([current._rev, current._rev, newRev])
|
||||
)
|
||||
expectFunctionWasCalledTimesWith(
|
||||
console.warn,
|
||||
2,
|
||||
"bb-warn: Ignoring redlock conflict in write-through cache"
|
||||
)
|
||||
|
||||
const output = await db.get(current._id)
|
||||
expect(output.value).toBe(4)
|
||||
expect(output._rev).toBe(newRev)
|
||||
|
||||
current = output
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle updates with documents falling behind", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
tk.freeze(Date.now() + DELAY + 1)
|
||||
|
||||
const id = structures.uuid()
|
||||
await writethrough.put({ _id: id, value: 1 })
|
||||
const doc = await writethrough.get(id)
|
||||
|
||||
// Updating document
|
||||
tk.freeze(Date.now() + DELAY + 1)
|
||||
await writethrough.put({ ...doc, value: 2 })
|
||||
|
||||
// Update with the old rev value
|
||||
tk.freeze(Date.now() + DELAY + 1)
|
||||
const res = await writethrough.put({
|
||||
...doc,
|
||||
value: 3,
|
||||
})
|
||||
expect(res.ok).toBe(true)
|
||||
|
||||
const output = await db.get(id)
|
||||
expect(output.value).toBe(3)
|
||||
expect(output._rev).toBe(res.rev)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -52,8 +120,8 @@ describe("writethrough", () => {
|
|||
describe("get", () => {
|
||||
it("should be able to retrieve", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const response = await writethrough.get("test")
|
||||
expect(response.value).toBe(3)
|
||||
const response = await writethrough.get(docId)
|
||||
expect(response.value).toBe(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
71
packages/backend-core/src/cache/writethrough.ts
vendored
71
packages/backend-core/src/cache/writethrough.ts
vendored
|
@ -1,7 +1,8 @@
|
|||
import BaseCache from "./base"
|
||||
import { getWritethroughClient } from "../redis/init"
|
||||
import { logWarn } from "../logging"
|
||||
import { Database } from "@budibase/types"
|
||||
import { Database, Document, LockName, LockType } from "@budibase/types"
|
||||
import * as locks from "../redis/redlockImpl"
|
||||
|
||||
const DEFAULT_WRITE_RATE_MS = 10000
|
||||
let CACHE: BaseCache | null = null
|
||||
|
@ -27,44 +28,62 @@ function makeCacheItem(doc: any, lastWrite: number | null = null): CacheItem {
|
|||
return { doc, lastWrite: lastWrite || Date.now() }
|
||||
}
|
||||
|
||||
export async function put(
|
||||
async function put(
|
||||
db: Database,
|
||||
doc: any,
|
||||
doc: Document,
|
||||
writeRateMs: number = DEFAULT_WRITE_RATE_MS
|
||||
) {
|
||||
const cache = await getCache()
|
||||
const key = doc._id
|
||||
let cacheItem: CacheItem | undefined = await cache.get(makeCacheKey(db, key))
|
||||
let cacheItem: CacheItem | undefined
|
||||
if (key) {
|
||||
cacheItem = await cache.get(makeCacheKey(db, key))
|
||||
}
|
||||
const updateDb = !cacheItem || cacheItem.lastWrite < Date.now() - writeRateMs
|
||||
let output = doc
|
||||
if (updateDb) {
|
||||
const writeDb = async (toWrite: any) => {
|
||||
// doc should contain the _id and _rev
|
||||
const response = await db.put(toWrite)
|
||||
output = {
|
||||
...doc,
|
||||
_id: response.id,
|
||||
_rev: response.rev,
|
||||
}
|
||||
}
|
||||
try {
|
||||
await writeDb(doc)
|
||||
} catch (err: any) {
|
||||
if (err.status !== 409) {
|
||||
throw err
|
||||
} else {
|
||||
// Swallow 409s but log them
|
||||
logWarn(`Ignoring conflict in write-through cache`)
|
||||
const lockResponse = await locks.doWithLock(
|
||||
{
|
||||
type: LockType.TRY_ONCE,
|
||||
name: LockName.PERSIST_WRITETHROUGH,
|
||||
resource: key,
|
||||
ttl: 1000,
|
||||
},
|
||||
async () => {
|
||||
const writeDb = async (toWrite: any) => {
|
||||
// doc should contain the _id and _rev
|
||||
const response = await db.put(toWrite, { force: true })
|
||||
output = {
|
||||
...doc,
|
||||
_id: response.id,
|
||||
_rev: response.rev,
|
||||
}
|
||||
}
|
||||
try {
|
||||
await writeDb(doc)
|
||||
} catch (err: any) {
|
||||
if (err.status !== 409) {
|
||||
throw err
|
||||
} else {
|
||||
// Swallow 409s but log them
|
||||
logWarn(`Ignoring conflict in write-through cache`)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!lockResponse.executed) {
|
||||
logWarn(`Ignoring redlock conflict in write-through cache`)
|
||||
}
|
||||
}
|
||||
// if we are updating the DB then need to set the lastWrite to now
|
||||
cacheItem = makeCacheItem(output, updateDb ? null : cacheItem?.lastWrite)
|
||||
await cache.store(makeCacheKey(db, key), cacheItem)
|
||||
if (output._id) {
|
||||
await cache.store(makeCacheKey(db, output._id), cacheItem)
|
||||
}
|
||||
return { ok: true, id: output._id, rev: output._rev }
|
||||
}
|
||||
|
||||
export async function get(db: Database, id: string): Promise<any> {
|
||||
async function get(db: Database, id: string): Promise<any> {
|
||||
const cache = await getCache()
|
||||
const cacheKey = makeCacheKey(db, id)
|
||||
let cacheItem: CacheItem = await cache.get(cacheKey)
|
||||
|
@ -76,11 +95,7 @@ export async function get(db: Database, id: string): Promise<any> {
|
|||
return cacheItem.doc
|
||||
}
|
||||
|
||||
export async function remove(
|
||||
db: Database,
|
||||
docOrId: any,
|
||||
rev?: any
|
||||
): Promise<void> {
|
||||
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
|
||||
const cache = await getCache()
|
||||
if (!docOrId) {
|
||||
throw new Error("No ID/Rev provided.")
|
||||
|
|
|
@ -32,8 +32,7 @@ export async function getConfig<T extends Config>(
|
|||
const db = context.getGlobalDB()
|
||||
try {
|
||||
// await to catch error
|
||||
const config = (await db.get(generateConfigID(type))) as T
|
||||
return config
|
||||
return (await db.get(generateConfigID(type))) as T
|
||||
} catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
return
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import { DBTestConfiguration, generator, testEnv } from "../../../tests"
|
||||
import {
|
||||
DBTestConfiguration,
|
||||
generator,
|
||||
testEnv,
|
||||
structures,
|
||||
} from "../../../tests"
|
||||
import { ConfigType } from "@budibase/types"
|
||||
import env from "../../environment"
|
||||
import * as configs from "../configs"
|
||||
|
@ -113,4 +118,71 @@ describe("configs", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGoogleDatasourceConfig", () => {
|
||||
function setEnvVars() {
|
||||
env.GOOGLE_CLIENT_SECRET = "test"
|
||||
env.GOOGLE_CLIENT_ID = "test"
|
||||
}
|
||||
|
||||
function unsetEnvVars() {
|
||||
env.GOOGLE_CLIENT_SECRET = undefined
|
||||
env.GOOGLE_CLIENT_ID = undefined
|
||||
}
|
||||
|
||||
describe("cloud", () => {
|
||||
beforeEach(() => {
|
||||
testEnv.cloudHosted()
|
||||
})
|
||||
|
||||
it("returns from env vars", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
setEnvVars()
|
||||
const config = await configs.getGoogleDatasourceConfig()
|
||||
unsetEnvVars()
|
||||
|
||||
expect(config).toEqual({
|
||||
activated: true,
|
||||
clientID: "test",
|
||||
clientSecret: "test",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("returns undefined when no env vars are configured", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const config = await configs.getGoogleDatasourceConfig()
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("self host", () => {
|
||||
beforeEach(() => {
|
||||
testEnv.selfHosted()
|
||||
})
|
||||
|
||||
it("returns from config", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const googleDoc = structures.sso.googleConfigDoc()
|
||||
await configs.save(googleDoc)
|
||||
const config = await configs.getGoogleDatasourceConfig()
|
||||
expect(config).toEqual(googleDoc.config)
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to env vars when config is disabled", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
setEnvVars()
|
||||
const config = await configs.getGoogleDatasourceConfig()
|
||||
unsetEnvVars()
|
||||
expect(config).toEqual({
|
||||
activated: true,
|
||||
clientID: "test",
|
||||
clientSecret: "test",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -4,7 +4,6 @@ export enum UserStatus {
|
|||
}
|
||||
|
||||
export enum Cookie {
|
||||
CurrentApp = "budibase:currentapp",
|
||||
Auth = "budibase:auth",
|
||||
Init = "budibase:init",
|
||||
ACCOUNT_RETURN_URL = "budibase:account:returnurl",
|
||||
|
|
|
@ -199,6 +199,10 @@ export class QueryBuilder<T> {
|
|||
return this
|
||||
}
|
||||
|
||||
setAllOr() {
|
||||
this.query.allOr = true
|
||||
}
|
||||
|
||||
handleSpaces(input: string) {
|
||||
if (this.noEscaping) {
|
||||
return input
|
||||
|
@ -236,6 +240,36 @@ export class QueryBuilder<T> {
|
|||
return value
|
||||
}
|
||||
|
||||
isMultiCondition() {
|
||||
let count = 0
|
||||
for (let filters of Object.values(this.query)) {
|
||||
// not contains is one massive filter in allOr mode
|
||||
if (typeof filters === "object") {
|
||||
count += Object.keys(filters).length
|
||||
}
|
||||
}
|
||||
return count > 1
|
||||
}
|
||||
|
||||
compressFilters(filters: Record<string, string[]>) {
|
||||
const compressed: typeof filters = {}
|
||||
for (let key of Object.keys(filters)) {
|
||||
const finalKey = removeKeyNumbering(key)
|
||||
if (compressed[finalKey]) {
|
||||
compressed[finalKey] = compressed[finalKey].concat(filters[key])
|
||||
} else {
|
||||
compressed[finalKey] = filters[key]
|
||||
}
|
||||
}
|
||||
// add prefixes back
|
||||
const final: typeof filters = {}
|
||||
let count = 1
|
||||
for (let [key, value] of Object.entries(compressed)) {
|
||||
final[`${count++}:${key}`] = value
|
||||
}
|
||||
return final
|
||||
}
|
||||
|
||||
buildSearchQuery() {
|
||||
const builder = this
|
||||
let allOr = this.query && this.query.allOr
|
||||
|
@ -272,9 +306,9 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
|
||||
const notContains = (key: string, value: any) => {
|
||||
// @ts-ignore
|
||||
const allPrefix = allOr === "" ? "*:* AND" : ""
|
||||
return allPrefix + "NOT " + contains(key, value)
|
||||
const allPrefix = allOr ? "*:* AND " : ""
|
||||
const mode = allOr ? "AND" : undefined
|
||||
return allPrefix + "NOT " + contains(key, value, mode)
|
||||
}
|
||||
|
||||
const containsAny = (key: string, value: any) => {
|
||||
|
@ -299,21 +333,32 @@ export class QueryBuilder<T> {
|
|||
return `${key}:(${orStatement})`
|
||||
}
|
||||
|
||||
function build(structure: any, queryFn: any) {
|
||||
function build(
|
||||
structure: any,
|
||||
queryFn: (key: string, value: any) => string | null,
|
||||
opts?: { returnBuilt?: boolean; mode?: string }
|
||||
) {
|
||||
let built = ""
|
||||
for (let [key, value] of Object.entries(structure)) {
|
||||
// check for new format - remove numbering if needed
|
||||
key = removeKeyNumbering(key)
|
||||
key = builder.preprocess(builder.handleSpaces(key), {
|
||||
escape: true,
|
||||
})
|
||||
const expression = queryFn(key, value)
|
||||
let expression = queryFn(key, value)
|
||||
if (expression == null) {
|
||||
continue
|
||||
}
|
||||
if (query.length > 0) {
|
||||
query += ` ${allOr ? "OR" : "AND"} `
|
||||
if (built.length > 0 || query.length > 0) {
|
||||
const mode = opts?.mode ? opts.mode : allOr ? "OR" : "AND"
|
||||
built += ` ${mode} `
|
||||
}
|
||||
query += expression
|
||||
built += expression
|
||||
}
|
||||
if (opts?.returnBuilt) {
|
||||
return built
|
||||
} else {
|
||||
query += built
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -384,14 +429,14 @@ export class QueryBuilder<T> {
|
|||
build(this.query.contains, contains)
|
||||
}
|
||||
if (this.query.notContains) {
|
||||
build(this.query.notContains, notContains)
|
||||
build(this.compressFilters(this.query.notContains), notContains)
|
||||
}
|
||||
if (this.query.containsAny) {
|
||||
build(this.query.containsAny, containsAny)
|
||||
}
|
||||
// make sure table ID is always added as an AND
|
||||
if (tableId) {
|
||||
query = `(${query})`
|
||||
query = this.isMultiCondition() ? `(${query})` : query
|
||||
allOr = false
|
||||
build({ tableId }, equal)
|
||||
}
|
||||
|
|
|
@ -6,9 +6,13 @@ import { QueryBuilder, paginatedSearch, fullSearch } from "../lucene"
|
|||
const INDEX_NAME = "main"
|
||||
|
||||
const index = `function(doc) {
|
||||
let props = ["property", "number"]
|
||||
let props = ["property", "number", "array"]
|
||||
for (let key of props) {
|
||||
if (doc[key]) {
|
||||
if (Array.isArray(doc[key])) {
|
||||
for (let val of doc[key]) {
|
||||
index(key, val)
|
||||
}
|
||||
} else if (doc[key]) {
|
||||
index(key, doc[key])
|
||||
}
|
||||
}
|
||||
|
@ -21,9 +25,14 @@ describe("lucene", () => {
|
|||
dbName = `db-${newid()}`
|
||||
// create the DB for testing
|
||||
db = getDB(dbName)
|
||||
await db.put({ _id: newid(), property: "word" })
|
||||
await db.put({ _id: newid(), property: "word2" })
|
||||
await db.put({ _id: newid(), property: "word3", number: 1 })
|
||||
await db.put({ _id: newid(), property: "word", array: ["1", "4"] })
|
||||
await db.put({ _id: newid(), property: "word2", array: ["3", "1"] })
|
||||
await db.put({
|
||||
_id: newid(),
|
||||
property: "word3",
|
||||
number: 1,
|
||||
array: ["1", "2"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to create a lucene index", async () => {
|
||||
|
@ -118,6 +127,15 @@ describe("lucene", () => {
|
|||
const resp = await builder.run()
|
||||
expect(resp.rows.length).toBe(2)
|
||||
})
|
||||
|
||||
it("should be able to perform an or not contains search", async () => {
|
||||
const builder = new QueryBuilder(dbName, INDEX_NAME)
|
||||
builder.addNotContains("array", ["1"])
|
||||
builder.addNotContains("array", ["2"])
|
||||
builder.setAllOr()
|
||||
const resp = await builder.run()
|
||||
expect(resp.rows.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("paginated search", () => {
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
export class BudibaseError extends Error {
|
||||
code: string
|
||||
type: string
|
||||
|
||||
constructor(message: string, code: string, type: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.type = type
|
||||
}
|
||||
}
|
|
@ -1,37 +1,99 @@
|
|||
import * as licensing from "./licensing"
|
||||
// BASE
|
||||
|
||||
// combine all error codes into single object
|
||||
export abstract class BudibaseError extends Error {
|
||||
code: string
|
||||
|
||||
export const codes = {
|
||||
...licensing.codes,
|
||||
constructor(message: string, code: ErrorCode) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
|
||||
protected getPublicError?(): any
|
||||
}
|
||||
|
||||
// combine all error types
|
||||
export const types = [licensing.type]
|
||||
// ERROR HANDLING
|
||||
|
||||
// combine all error contexts
|
||||
const context = {
|
||||
...licensing.context,
|
||||
export enum ErrorCode {
|
||||
USAGE_LIMIT_EXCEEDED = "usage_limit_exceeded",
|
||||
FEATURE_DISABLED = "feature_disabled",
|
||||
INVALID_API_KEY = "invalid_api_key",
|
||||
HTTP = "http",
|
||||
}
|
||||
|
||||
// derive a public error message using codes, types and any custom contexts
|
||||
/**
|
||||
* For the given error, build the public representation that is safe
|
||||
* to be exposed over an api.
|
||||
*/
|
||||
export const getPublicError = (err: any) => {
|
||||
let error
|
||||
if (err.code || err.type) {
|
||||
if (err.code) {
|
||||
// add generic error information
|
||||
error = {
|
||||
code: err.code,
|
||||
type: err.type,
|
||||
}
|
||||
|
||||
if (err.code && context[err.code]) {
|
||||
if (err.getPublicError) {
|
||||
error = {
|
||||
...error,
|
||||
// get any additional context from this error
|
||||
...context[err.code](err),
|
||||
...err.getPublicError(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
// HTTP
|
||||
|
||||
export class HTTPError extends BudibaseError {
|
||||
status: number
|
||||
|
||||
constructor(message: string, httpStatus: number, code = ErrorCode.HTTP) {
|
||||
super(message, code)
|
||||
this.status = httpStatus
|
||||
}
|
||||
}
|
||||
|
||||
// LICENSING
|
||||
|
||||
export class UsageLimitError extends HTTPError {
|
||||
limitName: string
|
||||
|
||||
constructor(message: string, limitName: string) {
|
||||
super(message, 400, ErrorCode.USAGE_LIMIT_EXCEEDED)
|
||||
this.limitName = limitName
|
||||
}
|
||||
|
||||
getPublicError() {
|
||||
return {
|
||||
limitName: this.limitName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FeatureDisabledError extends HTTPError {
|
||||
featureName: string
|
||||
|
||||
constructor(message: string, featureName: string) {
|
||||
super(message, 400, ErrorCode.FEATURE_DISABLED)
|
||||
this.featureName = featureName
|
||||
}
|
||||
|
||||
getPublicError() {
|
||||
return {
|
||||
featureName: this.featureName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH
|
||||
|
||||
export class InvalidAPIKeyError extends BudibaseError {
|
||||
constructor() {
|
||||
super(
|
||||
"Invalid API key - may need re-generated, or user doesn't exist",
|
||||
ErrorCode.INVALID_API_KEY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { BudibaseError } from "./base"
|
||||
|
||||
export class GenericError extends BudibaseError {
|
||||
constructor(message: string, code: string, type: string) {
|
||||
super(message, code, type ? type : "generic")
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { GenericError } from "./generic"
|
||||
|
||||
export class HTTPError extends GenericError {
|
||||
status: number
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
httpStatus: number,
|
||||
code = "http",
|
||||
type = "generic"
|
||||
) {
|
||||
super(message, code, type)
|
||||
this.status = httpStatus
|
||||
}
|
||||
}
|
|
@ -1,3 +1 @@
|
|||
export * from "./errors"
|
||||
export { UsageLimitError, FeatureDisabledError } from "./licensing"
|
||||
export { HTTPError } from "./http"
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
import { HTTPError } from "./http"
|
||||
|
||||
export const type = "license_error"
|
||||
|
||||
export const codes = {
|
||||
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
|
||||
FEATURE_DISABLED: "feature_disabled",
|
||||
}
|
||||
|
||||
export const context = {
|
||||
[codes.USAGE_LIMIT_EXCEEDED]: (err: any) => {
|
||||
return {
|
||||
limitName: err.limitName,
|
||||
}
|
||||
},
|
||||
[codes.FEATURE_DISABLED]: (err: any) => {
|
||||
return {
|
||||
featureName: err.featureName,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export class UsageLimitError extends HTTPError {
|
||||
limitName: string
|
||||
|
||||
constructor(message: string, limitName: string) {
|
||||
super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type)
|
||||
this.limitName = limitName
|
||||
}
|
||||
}
|
||||
|
||||
export class FeatureDisabledError extends HTTPError {
|
||||
featureName: string
|
||||
|
||||
constructor(message: string, featureName: string) {
|
||||
super(message, 400, codes.FEATURE_DISABLED, type)
|
||||
this.featureName = featureName
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ export * as redis from "./redis"
|
|||
export * as locks from "./redis/redlockImpl"
|
||||
export * as utils from "./utils"
|
||||
export * as errors from "./errors"
|
||||
export * as timers from "./timers"
|
||||
export { default as env } from "./environment"
|
||||
export * as blacklist from "./blacklist"
|
||||
export { SearchParams } from "./db"
|
||||
|
|
|
@ -14,6 +14,7 @@ import { decrypt } from "../security/encryption"
|
|||
import * as identity from "../context/identity"
|
||||
import env from "../environment"
|
||||
import { Ctx, EndpointMatcher } from "@budibase/types"
|
||||
import { InvalidAPIKeyError, ErrorCode } from "../errors"
|
||||
|
||||
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
|
||||
? parseInt(env.SESSION_UPDATE_PERIOD)
|
||||
|
@ -48,22 +49,27 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
|
|||
const decrypted = decrypt(apiKey)
|
||||
const tenantId = decrypted.split(SEPARATOR)[0]
|
||||
return doInTenant(tenantId, async () => {
|
||||
const db = getGlobalDB()
|
||||
// api key is encrypted in the database
|
||||
const userId = (await queryGlobalView(
|
||||
ViewName.BY_API_KEY,
|
||||
{
|
||||
key: apiKey,
|
||||
},
|
||||
db
|
||||
)) as string
|
||||
let userId
|
||||
try {
|
||||
const db = getGlobalDB()
|
||||
// api key is encrypted in the database
|
||||
userId = (await queryGlobalView(
|
||||
ViewName.BY_API_KEY,
|
||||
{
|
||||
key: apiKey,
|
||||
},
|
||||
db
|
||||
)) as string
|
||||
} catch (err) {
|
||||
userId = undefined
|
||||
}
|
||||
if (userId) {
|
||||
return {
|
||||
valid: true,
|
||||
user: await getUser(userId, tenantId, populateUser),
|
||||
}
|
||||
} else {
|
||||
throw "Invalid API key"
|
||||
throw new InvalidAPIKeyError()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -164,8 +170,10 @@ export default function (
|
|||
console.error(`Auth Error: ${err.message}`)
|
||||
console.error(err)
|
||||
// invalid token, clear the cookie
|
||||
if (err && err.name === "JsonWebTokenError") {
|
||||
if (err?.name === "JsonWebTokenError") {
|
||||
clearCookie(ctx, Cookie.Auth)
|
||||
} else if (err?.code === ErrorCode.INVALID_API_KEY) {
|
||||
ctx.throw(403, err.message)
|
||||
}
|
||||
// allow configuring for public access
|
||||
if ((opts && opts.publicAllowed) || publicEndpoint) {
|
||||
|
|
|
@ -78,17 +78,23 @@ export async function postAuth(
|
|||
),
|
||||
{ successRedirect: "/", failureRedirect: "/error" },
|
||||
async (err: any, tokens: string[]) => {
|
||||
const baseUrl = `/builder/app/${authStateCookie.appId}/data`
|
||||
// update the DB for the datasource with all the user info
|
||||
await doWithDB(authStateCookie.appId, async (db: Database) => {
|
||||
const datasource = await db.get(authStateCookie.datasourceId)
|
||||
let datasource
|
||||
try {
|
||||
datasource = await db.get(authStateCookie.datasourceId)
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
ctx.redirect(baseUrl)
|
||||
}
|
||||
}
|
||||
if (!datasource.config) {
|
||||
datasource.config = {}
|
||||
}
|
||||
datasource.config.auth = { type: "google", ...tokens }
|
||||
await db.put(datasource)
|
||||
ctx.redirect(
|
||||
`/builder/app/${authStateCookie.appId}/data/datasource/${authStateCookie.datasourceId}`
|
||||
)
|
||||
ctx.redirect(`${baseUrl}/datasource/${authStateCookie.datasourceId}`)
|
||||
})
|
||||
}
|
||||
)(ctx, next)
|
||||
|
|
|
@ -4,6 +4,7 @@ import { JobQueue } from "./constants"
|
|||
import InMemoryQueue from "./inMemoryQueue"
|
||||
import BullQueue from "bull"
|
||||
import { addListeners, StalledFn } from "./listeners"
|
||||
import * as timers from "../timers"
|
||||
|
||||
const CLEANUP_PERIOD_MS = 60 * 1000
|
||||
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
|
||||
|
@ -29,8 +30,8 @@ export function createQueue<T>(
|
|||
}
|
||||
addListeners(queue, jobQueue, opts?.removeStalledCb)
|
||||
QUEUES.push(queue)
|
||||
if (!cleanupInterval) {
|
||||
cleanupInterval = setInterval(cleanup, CLEANUP_PERIOD_MS)
|
||||
if (!cleanupInterval && !env.isTest()) {
|
||||
cleanupInterval = timers.set(cleanup, CLEANUP_PERIOD_MS)
|
||||
// fire off an initial cleanup
|
||||
cleanup().catch(err => {
|
||||
console.error(`Unable to cleanup automation queue initially - ${err}`)
|
||||
|
@ -41,7 +42,7 @@ export function createQueue<T>(
|
|||
|
||||
export async function shutdown() {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval)
|
||||
timers.clear(cleanupInterval)
|
||||
}
|
||||
if (QUEUES.length) {
|
||||
for (let queue of QUEUES) {
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
SEPARATOR,
|
||||
SelectableDatabase,
|
||||
} from "./utils"
|
||||
import * as timers from "../timers"
|
||||
|
||||
const RETRY_PERIOD_MS = 2000
|
||||
const STARTUP_TIMEOUT_MS = 5000
|
||||
|
@ -117,9 +118,9 @@ function waitForConnection(selectDb: number = DEFAULT_SELECT_DB) {
|
|||
return
|
||||
}
|
||||
// check if the connection is ready
|
||||
const interval = setInterval(() => {
|
||||
const interval = timers.set(() => {
|
||||
if (CONNECTED) {
|
||||
clearInterval(interval)
|
||||
timers.clear(interval)
|
||||
resolve("")
|
||||
}
|
||||
}, 500)
|
||||
|
|
|
@ -24,7 +24,7 @@ const getClient = async (type: LockType): Promise<Redlock> => {
|
|||
}
|
||||
}
|
||||
|
||||
export const OPTIONS = {
|
||||
const OPTIONS = {
|
||||
TRY_ONCE: {
|
||||
// immediately throws an error if the lock is already held
|
||||
retryCount: 0,
|
||||
|
@ -56,14 +56,29 @@ export const OPTIONS = {
|
|||
},
|
||||
}
|
||||
|
||||
export const newRedlock = async (opts: Options = {}) => {
|
||||
const newRedlock = async (opts: Options = {}) => {
|
||||
let options = { ...OPTIONS.DEFAULT, ...opts }
|
||||
const redisWrapper = await getLockClient()
|
||||
const client = redisWrapper.getClient()
|
||||
return new Redlock([client], options)
|
||||
}
|
||||
|
||||
export const doWithLock = async (opts: LockOptions, task: any) => {
|
||||
type SuccessfulRedlockExecution<T> = {
|
||||
executed: true
|
||||
result: T
|
||||
}
|
||||
type UnsuccessfulRedlockExecution = {
|
||||
executed: false
|
||||
}
|
||||
|
||||
type RedlockExecution<T> =
|
||||
| SuccessfulRedlockExecution<T>
|
||||
| UnsuccessfulRedlockExecution
|
||||
|
||||
export const doWithLock = async <T>(
|
||||
opts: LockOptions,
|
||||
task: () => Promise<T>
|
||||
): Promise<RedlockExecution<T>> => {
|
||||
const redlock = await getClient(opts.type)
|
||||
let lock
|
||||
try {
|
||||
|
@ -73,8 +88,8 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
|||
let name: string = `lock:${prefix}_${opts.name}`
|
||||
|
||||
// add additional unique name if required
|
||||
if (opts.nameSuffix) {
|
||||
name = name + `_${opts.nameSuffix}`
|
||||
if (opts.resource) {
|
||||
name = name + `_${opts.resource}`
|
||||
}
|
||||
|
||||
// create the lock
|
||||
|
@ -83,7 +98,7 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
|||
// perform locked task
|
||||
// need to await to ensure completion before unlocking
|
||||
const result = await task()
|
||||
return result
|
||||
return { executed: true, result }
|
||||
} catch (e: any) {
|
||||
console.warn("lock error")
|
||||
// lock limit exceeded
|
||||
|
@ -92,7 +107,7 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
|||
// don't throw for try-once locks, they will always error
|
||||
// due to retry count (0) exceeded
|
||||
console.warn(e)
|
||||
return
|
||||
return { executed: false }
|
||||
} else {
|
||||
console.error(e)
|
||||
throw e
|
||||
|
|
1
packages/backend-core/src/timers/index.ts
Normal file
1
packages/backend-core/src/timers/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./timers"
|
22
packages/backend-core/src/timers/timers.ts
Normal file
22
packages/backend-core/src/timers/timers.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
let intervals: NodeJS.Timeout[] = []
|
||||
|
||||
export function set(callback: () => any, period: number) {
|
||||
const interval = setInterval(callback, period)
|
||||
intervals.push(interval)
|
||||
return interval
|
||||
}
|
||||
|
||||
export function clear(interval: NodeJS.Timeout) {
|
||||
const idx = intervals.indexOf(interval)
|
||||
if (idx !== -1) {
|
||||
intervals.splice(idx, 1)
|
||||
}
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
for (let interval of intervals) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
intervals = []
|
||||
}
|
|
@ -5,6 +5,8 @@ import {
|
|||
generateAppUserID,
|
||||
queryGlobalView,
|
||||
UNICODE_MAX,
|
||||
DocumentType,
|
||||
SEPARATOR,
|
||||
directCouchFind,
|
||||
} from "./db"
|
||||
import { BulkDocsResponse, User } from "@budibase/types"
|
||||
|
@ -45,6 +47,16 @@ export const bulkGetGlobalUsersById = async (
|
|||
return users
|
||||
}
|
||||
|
||||
export const getAllUserIds = async () => {
|
||||
const db = getGlobalDB()
|
||||
const startKey = `${DocumentType.USER}${SEPARATOR}`
|
||||
const response = await db.allDocs({
|
||||
startkey: startKey,
|
||||
endkey: `${startKey}${UNICODE_MAX}`,
|
||||
})
|
||||
return response.rows.map(row => row.id)
|
||||
}
|
||||
|
||||
export const bulkUpdateGlobalUsers = async (users: User[]) => {
|
||||
const db = getGlobalDB()
|
||||
return (await db.bulkDocs(users)) as BulkDocsResponse
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import "./logging"
|
||||
import env from "../src/environment"
|
||||
import { cleanup } from "../src/timers"
|
||||
import { mocks, testContainerUtils } from "./utilities"
|
||||
|
||||
// must explicitly enable fetch mock
|
||||
|
@ -21,3 +22,7 @@ if (!process.env.CI) {
|
|||
}
|
||||
|
||||
testContainerUtils.setupEnv(env)
|
||||
|
||||
afterAll(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
|
|
@ -4,4 +4,6 @@ export { generator } from "./structures"
|
|||
export * as testEnv from "./testEnv"
|
||||
export * as testContainerUtils from "./testContainerUtils"
|
||||
|
||||
export * from "./jestUtils"
|
||||
|
||||
export { default as DBTestConfiguration } from "./DBTestConfiguration"
|
||||
|
|
9
packages/backend-core/tests/utilities/jestUtils.ts
Normal file
9
packages/backend-core/tests/utilities/jestUtils.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export function expectFunctionWasCalledTimesWith(
|
||||
jestFunction: any,
|
||||
times: number,
|
||||
argument: any
|
||||
) {
|
||||
expect(
|
||||
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
|
||||
).toBe(times)
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
import { structures } from ".."
|
||||
import { newid } from "../../../src/newid"
|
||||
|
||||
export function id() {
|
||||
return `db_${newid()}`
|
||||
}
|
||||
|
||||
export function rev() {
|
||||
return `${structures.generator.character({
|
||||
numeric: true,
|
||||
})}-${structures.uuid().replace(/-/, "")}`
|
||||
}
|
||||
|
|
|
@ -8,4 +8,5 @@ export * as plugins from "./plugins"
|
|||
export * as sso from "./sso"
|
||||
export * as tenant from "./tenants"
|
||||
export * as users from "./users"
|
||||
export * as userGroups from "./userGroups"
|
||||
export { generator } from "./generator"
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import {
|
||||
ConfigType,
|
||||
GoogleConfig,
|
||||
GoogleInnerConfig,
|
||||
JwtClaims,
|
||||
OAuth2,
|
||||
|
@ -10,10 +12,10 @@ import {
|
|||
User,
|
||||
} from "@budibase/types"
|
||||
import { generator } from "./generator"
|
||||
import { uuid, email } from "./common"
|
||||
import { email, uuid } from "./common"
|
||||
import * as shared from "./shared"
|
||||
import _ from "lodash"
|
||||
import { user } from "./shared"
|
||||
import _ from "lodash"
|
||||
|
||||
export function OAuth(): OAuth2 {
|
||||
return {
|
||||
|
@ -107,3 +109,11 @@ export function googleConfig(): GoogleInnerConfig {
|
|||
clientSecret: generator.string(),
|
||||
}
|
||||
}
|
||||
|
||||
export function googleConfigDoc(): GoogleConfig {
|
||||
return {
|
||||
_id: "config_google",
|
||||
type: ConfigType.GOOGLE,
|
||||
config: googleConfig(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { UserGroup } from "@budibase/types"
|
||||
import { generator } from "./generator"
|
||||
|
||||
export function userGroup(): UserGroup {
|
||||
return {
|
||||
name: generator.word(),
|
||||
icon: generator.word(),
|
||||
color: generator.word(),
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@budibase/bbui",
|
||||
"description": "A UI solution used in the different Budibase projects.",
|
||||
"version": "2.4.43",
|
||||
"version": "2.4.44-alpha.1",
|
||||
"license": "MPL-2.0",
|
||||
"svelte": "src/index.js",
|
||||
"module": "dist/bbui.es.js",
|
||||
|
@ -38,8 +38,8 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"@adobe/spectrum-css-workflow-icons": "1.2.1",
|
||||
"@budibase/shared-core": "^2.4.43",
|
||||
"@budibase/string-templates": "^2.4.43",
|
||||
"@budibase/shared-core": "2.4.44-alpha.1",
|
||||
"@budibase/string-templates": "2.4.44-alpha.1",
|
||||
"@spectrum-css/accordion": "3.0.24",
|
||||
"@spectrum-css/actionbutton": "1.0.1",
|
||||
"@spectrum-css/actiongroup": "1.0.1",
|
||||
|
|
|
@ -113,6 +113,9 @@
|
|||
.spectrum-ActionButton--quiet {
|
||||
padding: 0 8px;
|
||||
}
|
||||
.spectrum-ActionButton--quiet.is-selected {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
.is-selected:not(.emphasized) .spectrum-Icon {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ export default function positionDropdown(element, opts) {
|
|||
styles.top = anchorBounds.top
|
||||
} else if (window.innerHeight - anchorBounds.bottom < 100) {
|
||||
styles.top = anchorBounds.top - elementBounds.height - offset
|
||||
styles.maxHeight = 240
|
||||
} else {
|
||||
styles.top = anchorBounds.bottom + offset
|
||||
styles.maxHeight = window.innerHeight - anchorBounds.bottom - 20
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
export let title
|
||||
export let fillWidth
|
||||
export let left = "314px"
|
||||
export let width = "calc(100% - 576px)"
|
||||
export let width = "calc(100% - 626px)"
|
||||
|
||||
let visible = false
|
||||
|
||||
|
|
115
packages/bbui/src/Form/Core/File.svelte
Normal file
115
packages/bbui/src/Form/Core/File.svelte
Normal file
|
@ -0,0 +1,115 @@
|
|||
<script>
|
||||
import ActionButton from "../../ActionButton/ActionButton.svelte"
|
||||
import { uuid } from "../../helpers"
|
||||
import Icon from "../../Icon/Icon.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let title = "Upload file"
|
||||
export let disabled = false
|
||||
export let allowClear = null
|
||||
export let extensions = null
|
||||
export let handleFileTooLarge = null
|
||||
export let fileSizeLimit = BYTES_IN_MB * 20
|
||||
export let id = null
|
||||
export let previewUrl = null
|
||||
|
||||
const fieldId = id || uuid()
|
||||
const BYTES_IN_KB = 1000
|
||||
const BYTES_IN_MB = 1000000
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let fileInput
|
||||
|
||||
$: inputAccept = Array.isArray(extensions) ? extensions.join(",") : "*"
|
||||
|
||||
async function processFile(targetFile) {
|
||||
if (handleFileTooLarge && targetFile?.size >= fileSizeLimit) {
|
||||
handleFileTooLarge(targetFile)
|
||||
return
|
||||
}
|
||||
dispatch("change", targetFile)
|
||||
}
|
||||
|
||||
function handleFile(evt) {
|
||||
processFile(evt.target.files[0])
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
dispatch("change", null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
id={fieldId}
|
||||
{disabled}
|
||||
type="file"
|
||||
accept={inputAccept}
|
||||
bind:this={fileInput}
|
||||
on:change={handleFile}
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
{#if value}
|
||||
<div class="file-view">
|
||||
{#if previewUrl}
|
||||
<img class="preview" alt="" src={previewUrl} />
|
||||
{/if}
|
||||
<div class="filename">{value.name}</div>
|
||||
{#if value.size}
|
||||
<div class="filesize">
|
||||
{#if value.size <= BYTES_IN_MB}
|
||||
{`${value.size / BYTES_IN_KB} KB`}
|
||||
{:else}
|
||||
{`${value.size / BYTES_IN_MB} MB`}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !disabled || (allowClear === true && disabled)}
|
||||
<div class="delete-button" on:click={clearFile}>
|
||||
<Icon name="Close" size="XS" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<ActionButton {disabled} on:click={fileInput.click()}>{title}</ActionButton>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.file-view {
|
||||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
border: 1px solid var(--spectrum-alias-border-color);
|
||||
border-radius: var(--spectrum-global-dimension-size-50);
|
||||
padding: 0px var(--spectrum-alias-item-padding-m);
|
||||
}
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
.delete-button {
|
||||
transition: all 0.3s;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
}
|
||||
.delete-button:hover {
|
||||
cursor: pointer;
|
||||
color: var(--red);
|
||||
}
|
||||
.filesize {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filename {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.preview {
|
||||
height: 1.5em;
|
||||
}
|
||||
</style>
|
|
@ -13,3 +13,4 @@ export { default as CoreDropzone } from "./Dropzone.svelte"
|
|||
export { default as CoreStepper } from "./Stepper.svelte"
|
||||
export { default as CoreRichTextField } from "./RichTextField.svelte"
|
||||
export { default as CoreSlider } from "./Slider.svelte"
|
||||
export { default as CoreFile } from "./File.svelte"
|
||||
|
|
37
packages/bbui/src/Form/File.svelte
Normal file
37
packages/bbui/src/Form/File.svelte
Normal file
|
@ -0,0 +1,37 @@
|
|||
<script>
|
||||
import Field from "./Field.svelte"
|
||||
import { CoreFile } from "./Core"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let disabled = false
|
||||
export let allowClear = null
|
||||
export let handleFileTooLarge = () => {}
|
||||
export let previewUrl = null
|
||||
export let extensions = null
|
||||
export let error = null
|
||||
export let title = null
|
||||
export let value = null
|
||||
export let tooltip = null
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
value = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {error} {tooltip}>
|
||||
<CoreFile
|
||||
{error}
|
||||
{disabled}
|
||||
{allowClear}
|
||||
{title}
|
||||
{value}
|
||||
{previewUrl}
|
||||
{handleFileTooLarge}
|
||||
{extensions}
|
||||
on:change={onChange}
|
||||
/>
|
||||
</Field>
|
|
@ -1,5 +1,7 @@
|
|||
<script>
|
||||
import { Input, Icon, notifications } from "@budibase/bbui"
|
||||
import Input from "../Form/Input.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import { notifications } from "../Stores/notifications"
|
||||
|
||||
export let label = null
|
||||
export let value
|
|
@ -29,6 +29,14 @@
|
|||
visible = false
|
||||
}
|
||||
|
||||
export function toggle() {
|
||||
if (visible) {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
export function cancel() {
|
||||
if (!visible) {
|
||||
return
|
||||
|
@ -61,7 +69,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
setContext(Context.Modal, { show, hide, cancel })
|
||||
setContext(Context.Modal, { show, hide, toggle, cancel })
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKey)
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
<div class="skeleton">
|
||||
<div class="children">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skeleton {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
background-color: var(--spectrum-global-color-gray-200) !important;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: fadeIn 130ms ease 0s 1 normal forwards;
|
||||
}
|
||||
|
||||
.children {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0.15) 20%,
|
||||
rgba(255, 255, 255, 0.3) 60%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
content: "";
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -4,7 +4,6 @@ import "./bbui.css"
|
|||
import "@spectrum-css/icon/dist/index-vars.css"
|
||||
|
||||
// Components
|
||||
export { default as Skeleton } from "./Skeleton/Skeleton.svelte"
|
||||
export { default as Input } from "./Form/Input.svelte"
|
||||
export { default as Stepper } from "./Form/Stepper.svelte"
|
||||
export { default as TextArea } from "./Form/TextArea.svelte"
|
||||
|
@ -67,6 +66,7 @@ export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte"
|
|||
export { default as IconPicker } from "./IconPicker/IconPicker.svelte"
|
||||
export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte"
|
||||
export { default as Banner } from "./Banner/Banner.svelte"
|
||||
export { default as CopyInput } from "./Input/CopyInput.svelte"
|
||||
export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte"
|
||||
export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
|
||||
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
|
||||
|
@ -77,6 +77,7 @@ export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
|
|||
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
|
||||
export { default as Slider } from "./Form/Slider.svelte"
|
||||
export { default as Accordion } from "./Accordion/Accordion.svelte"
|
||||
export { default as File } from "./Form/File.svelte"
|
||||
|
||||
// Renderers
|
||||
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
<!doctype html>
|
||||
<html class="spectrum spectrum--medium spectrum--darkest" lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset='utf8'>
|
||||
<meta name='viewport' content='width=device-width'>
|
||||
<title>Budibase</title>
|
||||
<link rel='icon' href='/src/favicon.png'>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap"
|
||||
rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body id="app">
|
||||
<script type="module" src='/src/main.js'></script>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/builder",
|
||||
"version": "2.4.43",
|
||||
"version": "2.4.44-alpha.1",
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
@ -58,11 +58,11 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^2.4.43",
|
||||
"@budibase/client": "^2.4.43",
|
||||
"@budibase/frontend-core": "^2.4.43",
|
||||
"@budibase/shared-core": "^2.4.43",
|
||||
"@budibase/string-templates": "^2.4.43",
|
||||
"@budibase/bbui": "2.4.44-alpha.1",
|
||||
"@budibase/client": "2.4.44-alpha.1",
|
||||
"@budibase/frontend-core": "2.4.44-alpha.1",
|
||||
"@budibase/shared-core": "2.4.44-alpha.1",
|
||||
"@budibase/string-templates": "2.4.44-alpha.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
findComponent,
|
||||
getComponentSettings,
|
||||
makeComponentUnique,
|
||||
findComponentPath,
|
||||
} from "../componentUtils"
|
||||
import { Helpers } from "@budibase/bbui"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
|
@ -30,7 +31,12 @@ import {
|
|||
DB_TYPE_INTERNAL,
|
||||
DB_TYPE_EXTERNAL,
|
||||
} from "constants/backend"
|
||||
import { getSchemaForDatasource } from "builderStore/dataBinding"
|
||||
import {
|
||||
buildFormSchema,
|
||||
getSchemaForDatasource,
|
||||
} from "builderStore/dataBinding"
|
||||
import { makePropSafe as safe } from "@budibase/string-templates"
|
||||
import { getComponentFieldOptions } from "helpers/formFields"
|
||||
|
||||
const INITIAL_FRONTEND_STATE = {
|
||||
apps: [],
|
||||
|
@ -63,17 +69,19 @@ const INITIAL_FRONTEND_STATE = {
|
|||
customTheme: {},
|
||||
previewDevice: "desktop",
|
||||
highlightedSettingKey: null,
|
||||
builderSidePanel: false,
|
||||
|
||||
// URL params
|
||||
selectedScreenId: null,
|
||||
selectedComponentId: null,
|
||||
selectedLayoutId: null,
|
||||
|
||||
// onboarding
|
||||
// Client state
|
||||
selectedComponentInstance: null,
|
||||
|
||||
// Onboarding
|
||||
onboarding: false,
|
||||
tourNodes: null,
|
||||
|
||||
builderSidePanel: false,
|
||||
}
|
||||
|
||||
export const getFrontendStore = () => {
|
||||
|
@ -262,22 +270,27 @@ export const getFrontendStore = () => {
|
|||
}
|
||||
},
|
||||
save: async screen => {
|
||||
/*
|
||||
Temporarily disabled to accomodate migration issues.
|
||||
store.actions.screens.validate(screen)
|
||||
*/
|
||||
const state = get(store)
|
||||
// Validate screen structure
|
||||
// Temporarily disabled to accommodate migration issues
|
||||
// store.actions.screens.validate(screen)
|
||||
|
||||
// Check screen definition for any component settings which need updated
|
||||
store.actions.screens.enrichEmptySettings(screen)
|
||||
|
||||
// Save screen
|
||||
const creatingNewScreen = screen._id === undefined
|
||||
const savedScreen = await API.saveScreen(screen)
|
||||
const routesResponse = await API.fetchAppRoutes()
|
||||
let usedPlugins = state.usedPlugins
|
||||
|
||||
// If plugins changed we need to fetch the latest app metadata
|
||||
const state = get(store)
|
||||
let usedPlugins = state.usedPlugins
|
||||
if (savedScreen.pluginAdded) {
|
||||
const { application } = await API.fetchAppPackage(state.appId)
|
||||
usedPlugins = application.usedPlugins || []
|
||||
}
|
||||
|
||||
// Update state
|
||||
store.update(state => {
|
||||
// Update screen object
|
||||
const idx = state.screens.findIndex(x => x._id === savedScreen._id)
|
||||
|
@ -298,7 +311,6 @@ export const getFrontendStore = () => {
|
|||
|
||||
// Update used plugins
|
||||
state.usedPlugins = usedPlugins
|
||||
|
||||
return state
|
||||
})
|
||||
return savedScreen
|
||||
|
@ -406,6 +418,17 @@ export const getFrontendStore = () => {
|
|||
}
|
||||
await store.actions.screens.patch(patch, screen._id)
|
||||
},
|
||||
enrichEmptySettings: screen => {
|
||||
// Flatten the recursive component tree
|
||||
const components = findAllMatchingComponents(screen.props, x => x)
|
||||
|
||||
// Iterate over all components and run checks
|
||||
components.forEach(component => {
|
||||
store.actions.components.enrichEmptySettings(component, {
|
||||
screen,
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
setDevice: device => {
|
||||
|
@ -493,65 +516,155 @@ export const getFrontendStore = () => {
|
|||
}
|
||||
return get(store).components[componentName]
|
||||
},
|
||||
createInstance: (componentName, presetProps) => {
|
||||
getDefaultDatasource: () => {
|
||||
// Ignore users table
|
||||
const validTables = get(tables).list.filter(x => x._id !== "ta_users")
|
||||
|
||||
// Try to use their own internal table first
|
||||
let table = validTables.find(table => {
|
||||
return (
|
||||
table.sourceId !== BUDIBASE_INTERNAL_DB_ID &&
|
||||
table.type === DB_TYPE_INTERNAL
|
||||
)
|
||||
})
|
||||
if (table) {
|
||||
return table
|
||||
}
|
||||
|
||||
// Then try sample data
|
||||
table = validTables.find(table => {
|
||||
return (
|
||||
table.sourceId === BUDIBASE_INTERNAL_DB_ID &&
|
||||
table.type === DB_TYPE_INTERNAL
|
||||
)
|
||||
})
|
||||
if (table) {
|
||||
return table
|
||||
}
|
||||
|
||||
// Finally try an external table
|
||||
return validTables.find(table => table.type === DB_TYPE_EXTERNAL)
|
||||
},
|
||||
enrichEmptySettings: (component, opts) => {
|
||||
if (!component?._component) {
|
||||
return
|
||||
}
|
||||
const defaultDS = store.actions.components.getDefaultDatasource()
|
||||
const settings = getComponentSettings(component._component)
|
||||
const { parent, screen, useDefaultValues } = opts || {}
|
||||
const treeId = parent?._id || component._id
|
||||
if (!screen) {
|
||||
return
|
||||
}
|
||||
settings.forEach(setting => {
|
||||
const value = component[setting.key]
|
||||
|
||||
// Fill empty settings
|
||||
if (value == null || value === "") {
|
||||
if (setting.type === "multifield" && setting.selectAllFields) {
|
||||
// Select all schema fields where required
|
||||
component[setting.key] = Object.keys(defaultDS?.schema || {})
|
||||
} else if (
|
||||
(setting.type === "dataSource" || setting.type === "table") &&
|
||||
defaultDS
|
||||
) {
|
||||
// Select default datasource where required
|
||||
component[setting.key] = {
|
||||
label: defaultDS.name,
|
||||
tableId: defaultDS._id,
|
||||
type: "table",
|
||||
}
|
||||
} else if (setting.type === "dataProvider") {
|
||||
// Pick closest data provider where required
|
||||
const path = findComponentPath(screen.props, treeId)
|
||||
const providers = path.filter(component =>
|
||||
component._component?.endsWith("/dataprovider")
|
||||
)
|
||||
if (providers.length) {
|
||||
const id = providers[providers.length - 1]?._id
|
||||
component[setting.key] = `{{ literal ${safe(id)} }}`
|
||||
}
|
||||
} else if (setting.type.startsWith("field/")) {
|
||||
// Autofill form field names
|
||||
// Get all available field names in this form schema
|
||||
let fieldOptions = getComponentFieldOptions(
|
||||
screen.props,
|
||||
treeId,
|
||||
setting.type,
|
||||
false
|
||||
)
|
||||
|
||||
// Get all currently used fields
|
||||
const form = findClosestMatchingComponent(
|
||||
screen.props,
|
||||
treeId,
|
||||
x => x._component === "@budibase/standard-components/form"
|
||||
)
|
||||
const usedFields = Object.keys(buildFormSchema(form) || {})
|
||||
|
||||
// Filter out already used fields
|
||||
fieldOptions = fieldOptions.filter(x => !usedFields.includes(x))
|
||||
|
||||
// Set field name and also assume we have a label setting
|
||||
if (fieldOptions[0]) {
|
||||
component[setting.key] = fieldOptions[0]
|
||||
component.label = fieldOptions[0]
|
||||
}
|
||||
} else if (useDefaultValues && setting.defaultValue !== undefined) {
|
||||
// Use default value where required
|
||||
component[setting.key] = setting.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Validate non-empty settings
|
||||
else {
|
||||
if (setting.type === "dataProvider") {
|
||||
// Validate data provider exists, or else clear it
|
||||
const treeId = parent?._id || component._id
|
||||
const path = findComponentPath(screen?.props, treeId)
|
||||
const providers = path.filter(component =>
|
||||
component._component?.endsWith("/dataprovider")
|
||||
)
|
||||
// Validate non-empty values
|
||||
const valid = providers?.some(dp => value.includes?.(dp._id))
|
||||
if (!valid) {
|
||||
if (providers.length) {
|
||||
const id = providers[providers.length - 1]?._id
|
||||
component[setting.key] = `{{ literal ${safe(id)} }}`
|
||||
} else {
|
||||
delete component[setting.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
createInstance: (componentName, presetProps, parent) => {
|
||||
const definition = store.actions.components.getDefinition(componentName)
|
||||
if (!definition) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Flattened settings
|
||||
const settings = getComponentSettings(componentName)
|
||||
|
||||
let dataSourceField = settings.find(
|
||||
setting => setting.type == "dataSource" || setting.type == "table"
|
||||
)
|
||||
|
||||
let defaultDatasource
|
||||
if (dataSourceField) {
|
||||
const _tables = get(tables)
|
||||
const filteredTables = _tables.list.filter(
|
||||
table => table._id != "ta_users"
|
||||
)
|
||||
|
||||
const internalTable = filteredTables.find(
|
||||
table =>
|
||||
table.sourceId === BUDIBASE_INTERNAL_DB_ID &&
|
||||
table.type == DB_TYPE_INTERNAL
|
||||
)
|
||||
|
||||
const defaultSourceTable = filteredTables.find(
|
||||
table =>
|
||||
table.sourceId !== BUDIBASE_INTERNAL_DB_ID &&
|
||||
table.type == DB_TYPE_INTERNAL
|
||||
)
|
||||
|
||||
const defaultExternalTable = filteredTables.find(
|
||||
table => table.type == DB_TYPE_EXTERNAL
|
||||
)
|
||||
|
||||
defaultDatasource =
|
||||
defaultSourceTable || internalTable || defaultExternalTable
|
||||
// Generate basic component structure
|
||||
let instance = {
|
||||
_id: Helpers.uuid(),
|
||||
_component: definition.component,
|
||||
_styles: {
|
||||
normal: {},
|
||||
hover: {},
|
||||
active: {},
|
||||
},
|
||||
_instanceName: `New ${definition.friendlyName || definition.name}`,
|
||||
...presetProps,
|
||||
}
|
||||
|
||||
// Generate default props
|
||||
let props = { ...presetProps }
|
||||
settings.forEach(setting => {
|
||||
if (setting.type === "multifield" && setting.selectAllFields) {
|
||||
props[setting.key] = Object.keys(defaultDatasource.schema || {})
|
||||
} else if (setting.defaultValue !== undefined) {
|
||||
props[setting.key] = setting.defaultValue
|
||||
}
|
||||
// Enrich empty settings
|
||||
store.actions.components.enrichEmptySettings(instance, {
|
||||
parent,
|
||||
screen: get(selectedScreen),
|
||||
useDefaultValues: true,
|
||||
})
|
||||
|
||||
// Set a default datasource
|
||||
if (dataSourceField && defaultDatasource) {
|
||||
props[dataSourceField.key] = {
|
||||
label: defaultDatasource.name,
|
||||
tableId: defaultDatasource._id,
|
||||
type: "table",
|
||||
}
|
||||
}
|
||||
|
||||
// Add any extra properties the component needs
|
||||
let extras = {}
|
||||
if (definition.hasChildren) {
|
||||
|
@ -569,17 +682,8 @@ export const getFrontendStore = () => {
|
|||
extras.step = formSteps.length + 1
|
||||
extras._instanceName = `Step ${formSteps.length + 1}`
|
||||
}
|
||||
|
||||
return {
|
||||
_id: Helpers.uuid(),
|
||||
_component: definition.component,
|
||||
_styles: {
|
||||
normal: {},
|
||||
hover: {},
|
||||
active: {},
|
||||
},
|
||||
_instanceName: `New ${definition.friendlyName || definition.name}`,
|
||||
...cloneDeep(props),
|
||||
...cloneDeep(instance),
|
||||
...extras,
|
||||
}
|
||||
},
|
||||
|
@ -587,7 +691,8 @@ export const getFrontendStore = () => {
|
|||
const state = get(store)
|
||||
const componentInstance = store.actions.components.createInstance(
|
||||
componentName,
|
||||
presetProps
|
||||
presetProps,
|
||||
parent
|
||||
)
|
||||
if (!componentInstance) {
|
||||
return
|
||||
|
@ -1123,6 +1228,52 @@ export const getFrontendStore = () => {
|
|||
})
|
||||
}
|
||||
},
|
||||
addParent: async (componentId, parentType) => {
|
||||
if (!componentId || !parentType) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create new parent instance
|
||||
const newParentDefinition = store.actions.components.createInstance(
|
||||
parentType,
|
||||
null,
|
||||
parent
|
||||
)
|
||||
if (!newParentDefinition) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace component with a version wrapped in a new parent
|
||||
await store.actions.screens.patch(screen => {
|
||||
// Get this component definition and parent definition
|
||||
let definition = findComponent(screen.props, componentId)
|
||||
let oldParentDefinition = findComponentParent(
|
||||
screen.props,
|
||||
componentId
|
||||
)
|
||||
if (!definition || !oldParentDefinition) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Replace component with parent
|
||||
const index = oldParentDefinition._children.findIndex(
|
||||
component => component._id === componentId
|
||||
)
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
oldParentDefinition._children[index] = {
|
||||
...newParentDefinition,
|
||||
_children: [definition],
|
||||
}
|
||||
})
|
||||
|
||||
// Select the new parent
|
||||
store.update(state => {
|
||||
state.selectedComponentId = newParentDefinition._id
|
||||
return state
|
||||
})
|
||||
},
|
||||
},
|
||||
links: {
|
||||
save: async (url, title) => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import CopyInput from "components/common/inputs/CopyInput.svelte"
|
||||
import { CopyInput } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
|
||||
|
|
|
@ -136,6 +136,7 @@
|
|||
const onUpdateColumns = () => {
|
||||
selectedRows = []
|
||||
fetch.refresh()
|
||||
tables.fetchTable(id)
|
||||
}
|
||||
|
||||
// Fetch data whenever rows are modified. Unfortunately we have to lose
|
||||
|
|
|
@ -5,18 +5,28 @@
|
|||
|
||||
export let preAuthStep
|
||||
export let datasource
|
||||
export let disabled
|
||||
|
||||
$: tenantId = $auth.tenantId
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:disabled
|
||||
{disabled}
|
||||
on:click={async () => {
|
||||
let ds = datasource
|
||||
let appId = $store.appId
|
||||
if (!ds) {
|
||||
ds = await preAuthStep()
|
||||
const resp = await preAuthStep()
|
||||
if (resp.datasource && resp.appId) {
|
||||
ds = resp.datasource
|
||||
appId = resp.appId
|
||||
} else {
|
||||
ds = resp
|
||||
}
|
||||
}
|
||||
window.open(
|
||||
`/api/global/auth/${tenantId}/datasource/google?datasourceId=${ds._id}&appId=${$store.appId}`,
|
||||
`/api/global/auth/${tenantId}/datasource/google?datasourceId=${ds._id}&appId=${appId}`,
|
||||
"_blank"
|
||||
)
|
||||
}}
|
||||
|
@ -26,6 +36,10 @@
|
|||
</button>
|
||||
|
||||
<style>
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 195px;
|
||||
height: 40px;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
// kill the reference so the input isn't saved
|
||||
let datasource = cloneDeep(integration)
|
||||
$: isGoogleConfigured = !!$organisation.google
|
||||
$: isGoogleConfigured = !!$organisation.googleDatasourceConfigured
|
||||
|
||||
onMount(async () => {
|
||||
await organisation.init()
|
||||
|
|
|
@ -0,0 +1,333 @@
|
|||
<script>
|
||||
import {
|
||||
Context,
|
||||
Icon,
|
||||
Input,
|
||||
ModalContent,
|
||||
Detail,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { API } from "api"
|
||||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
store,
|
||||
sortedScreens,
|
||||
automationStore,
|
||||
themeStore,
|
||||
} from "builderStore"
|
||||
import { datasources, queries, tables, views } from "stores/backend"
|
||||
import { getContext } from "svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
const modalContext = getContext(Context.Modal)
|
||||
const commands = [
|
||||
{
|
||||
type: "Access",
|
||||
name: "Invite users and manage app access",
|
||||
description: "",
|
||||
icon: "User",
|
||||
action: () =>
|
||||
store.update(state => ({ ...state, builderSidePanel: true })),
|
||||
},
|
||||
{
|
||||
type: "Navigate",
|
||||
name: "Portal",
|
||||
description: "",
|
||||
icon: "Compass",
|
||||
action: () => $goto("../../portal"),
|
||||
},
|
||||
{
|
||||
type: "Navigate",
|
||||
name: "Data",
|
||||
description: "",
|
||||
icon: "Compass",
|
||||
action: () => $goto("./data"),
|
||||
},
|
||||
{
|
||||
type: "Navigate",
|
||||
name: "Design",
|
||||
description: "",
|
||||
icon: "Compass",
|
||||
action: () => $goto("./design"),
|
||||
},
|
||||
{
|
||||
type: "Navigate",
|
||||
name: "Automations",
|
||||
description: "",
|
||||
icon: "Compass",
|
||||
action: () => $goto("./automate"),
|
||||
},
|
||||
{
|
||||
type: "Publish",
|
||||
name: "App",
|
||||
description: "Deploy your application",
|
||||
icon: "Box",
|
||||
action: deployApp,
|
||||
},
|
||||
{
|
||||
type: "Preview",
|
||||
name: "App",
|
||||
description: "",
|
||||
icon: "Play",
|
||||
action: () => window.open(`/${$store.appId}`),
|
||||
},
|
||||
{
|
||||
type: "Preview",
|
||||
name: "Published App",
|
||||
icon: "Play",
|
||||
action: () => window.open(`/app${$store.url}`),
|
||||
},
|
||||
{
|
||||
type: "Support",
|
||||
name: "Raise Github Discussion",
|
||||
icon: "Help",
|
||||
action: () =>
|
||||
window.open(`https://github.com/Budibase/budibase/discussions/new`),
|
||||
},
|
||||
{
|
||||
type: "Support",
|
||||
name: "Raise A Bug",
|
||||
icon: "Bug",
|
||||
action: () =>
|
||||
window.open(
|
||||
`https://github.com/Budibase/budibase/issues/new?assignees=&labels=bug&template=bug_report.md&title=`
|
||||
),
|
||||
},
|
||||
...$datasources?.list.map(datasource => ({
|
||||
type: "Datasource",
|
||||
name: `${datasource.name}`,
|
||||
icon: "Data",
|
||||
action: () => $goto(`./data/datasource/${datasource._id}`),
|
||||
})),
|
||||
...$tables?.list.map(table => ({
|
||||
type: "Table",
|
||||
name: table.name,
|
||||
icon: "Table",
|
||||
action: () => $goto(`./data/table/${table._id}`),
|
||||
})),
|
||||
...$views?.list.map(view => ({
|
||||
type: "View",
|
||||
name: view.name,
|
||||
icon: "Remove",
|
||||
action: () => $goto(`./data/view/${view.name}`),
|
||||
})),
|
||||
...$queries?.list.map(query => ({
|
||||
type: "Query",
|
||||
name: query.name,
|
||||
icon: "SQLQuery",
|
||||
action: () => $goto(`./data/query/${query._id}`),
|
||||
})),
|
||||
...$sortedScreens.map(screen => ({
|
||||
type: "Screen",
|
||||
name: screen.routing.route,
|
||||
icon: "WebPage",
|
||||
action: () => $goto(`./design/${screen._id}/components`),
|
||||
})),
|
||||
...$automationStore?.automations.map(automation => ({
|
||||
type: "Automation",
|
||||
name: automation.name,
|
||||
icon: "ShareAndroid",
|
||||
action: () => $goto(`./automate/${automation._id}`),
|
||||
})),
|
||||
...Constants.Themes.map(theme => ({
|
||||
type: "Change Builder Theme",
|
||||
name: theme.name,
|
||||
icon: "ColorPalette",
|
||||
action: () =>
|
||||
themeStore.update(state => {
|
||||
state.theme = theme.class
|
||||
return state
|
||||
}),
|
||||
})),
|
||||
]
|
||||
|
||||
let search
|
||||
let selected = null
|
||||
|
||||
$: enrichedCommands = commands.map(cmd => ({
|
||||
...cmd,
|
||||
searchValue: `${cmd.type} ${cmd.name}`.toLowerCase(),
|
||||
}))
|
||||
$: results = filterResults(enrichedCommands, search)
|
||||
$: categories = groupResults(results)
|
||||
|
||||
const filterResults = (commands, search) => {
|
||||
if (!search) {
|
||||
selected = null
|
||||
return commands
|
||||
}
|
||||
selected = 0
|
||||
search = search.toLowerCase()
|
||||
return commands
|
||||
.filter(cmd => cmd.searchValue.includes(search))
|
||||
.map((cmd, idx) => ({
|
||||
...cmd,
|
||||
idx,
|
||||
}))
|
||||
}
|
||||
|
||||
const groupResults = results => {
|
||||
let categories = {}
|
||||
results?.forEach(result => {
|
||||
if (!categories[result.type]) {
|
||||
categories[result.type] = []
|
||||
}
|
||||
categories[result.type].push(result)
|
||||
})
|
||||
return Object.entries(categories)
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
if (selected === null) {
|
||||
selected = 0
|
||||
return
|
||||
}
|
||||
if (selected < results.length - 1) {
|
||||
selected += 1
|
||||
}
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
if (selected === null) {
|
||||
selected = results.length - 1
|
||||
return
|
||||
}
|
||||
if (selected > 0) {
|
||||
selected -= 1
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
if (selected == null) {
|
||||
return
|
||||
}
|
||||
runAction(results[selected])
|
||||
} else if (e.key === "Escape") {
|
||||
modalContext.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async function deployApp() {
|
||||
try {
|
||||
await API.deployAppChanges()
|
||||
notifications.success("Application published successfully")
|
||||
} catch (error) {
|
||||
notifications.error("Error publishing app")
|
||||
}
|
||||
}
|
||||
|
||||
const runAction = command => {
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
command.action()
|
||||
modalContext.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
<ModalContent
|
||||
size="L"
|
||||
showCancelButton={false}
|
||||
showConfirmButton={false}
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<Icon size="XL" name="Search" />
|
||||
<Input bind:value={search} quiet placeholder="Search for command" />
|
||||
</div>
|
||||
<div class="commands">
|
||||
{#each categories as [name, results], catIdx}
|
||||
<div class="category">
|
||||
<Detail>{name}</Detail>
|
||||
<div class="options">
|
||||
{#each results as command, cmdIdx}
|
||||
<div
|
||||
class="command"
|
||||
on:click={() => runAction(command)}
|
||||
class:selected={command.idx === selected}
|
||||
>
|
||||
<Icon size="M" name={command.icon} />
|
||||
<strong>{command.type}: </strong>
|
||||
<div class="name">
|
||||
{command.name}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
margin: -40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-l)
|
||||
var(--spacing-xl);
|
||||
border-bottom: var(--border-dark);
|
||||
gap: var(--spacing-m);
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
.title :global(.spectrum-Textfield-input) {
|
||||
border-bottom: none;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.commands {
|
||||
height: 378px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.category {
|
||||
padding: var(--spacing-m) var(--spacing-xl);
|
||||
border-bottom: var(--border-light);
|
||||
}
|
||||
.category:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
.category :global(.spectrum-Detail) {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
.options {
|
||||
padding-top: var(--spacing-m);
|
||||
margin: 0 calc(-1 * var(--spacing-xl));
|
||||
}
|
||||
|
||||
.command {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: var(--spacing-s) var(--spacing-xl);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: color 130ms ease-out, background-color 130ms ease-out;
|
||||
}
|
||||
.command:hover,
|
||||
.selected {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
background-color: var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.command strong {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
|
@ -5,12 +5,12 @@
|
|||
notifications,
|
||||
ModalContent,
|
||||
Layout,
|
||||
ProgressCircle,
|
||||
CopyInput,
|
||||
} from "@budibase/bbui"
|
||||
import { API } from "api"
|
||||
import analytics, { Events, EventSource } from "analytics"
|
||||
import { store } from "builderStore"
|
||||
import { ProgressCircle } from "@budibase/bbui"
|
||||
import CopyInput from "components/common/inputs/CopyInput.svelte"
|
||||
import TourWrap from "../portal/onboarding/TourWrap.svelte"
|
||||
import { TOUR_STEP_KEYS } from "../portal/onboarding/tours.js"
|
||||
|
||||
|
|
|
@ -24,7 +24,10 @@
|
|||
let updateModal
|
||||
|
||||
$: appId = $store.appId
|
||||
$: updateAvailable = clientPackage.version !== $store.version
|
||||
$: updateAvailable =
|
||||
clientPackage.version &&
|
||||
$store.version &&
|
||||
clientPackage.version !== $store.version
|
||||
$: revertAvailable = $store.revertableVersion != null
|
||||
|
||||
const refreshAppPackage = async () => {
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
export let title
|
||||
export let icon
|
||||
export let expandable = false
|
||||
export let showAddButton = false
|
||||
export let showBackButton = false
|
||||
export let showCloseButton = false
|
||||
|
@ -12,12 +11,13 @@
|
|||
export let onClickCloseButton
|
||||
export let borderLeft = false
|
||||
export let borderRight = false
|
||||
export let wide = false
|
||||
|
||||
let wide = false
|
||||
$: customHeaderContent = $$slots["panel-header-content"]
|
||||
</script>
|
||||
|
||||
<div class="panel" class:wide class:borderLeft class:borderRight>
|
||||
<div class="header">
|
||||
<div class="header" class:custom={customHeaderContent}>
|
||||
{#if showBackButton}
|
||||
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
|
||||
{/if}
|
||||
|
@ -27,13 +27,6 @@
|
|||
<div class="title">
|
||||
<Heading size="XXS">{title || ""}</Heading>
|
||||
</div>
|
||||
{#if expandable}
|
||||
<Icon
|
||||
name={wide ? "Minimize" : "Maximize"}
|
||||
hoverable
|
||||
on:click={() => (wide = !wide)}
|
||||
/>
|
||||
{/if}
|
||||
{#if showAddButton}
|
||||
<div class="add-button" on:click={onClickAddButton}>
|
||||
<Icon name="Add" />
|
||||
|
@ -43,6 +36,13 @@
|
|||
<Icon name="Close" hoverable on:click={onClickCloseButton} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if customHeaderContent}
|
||||
<span class="custom-content-wrap">
|
||||
<slot name="panel-header-content" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="body">
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -66,8 +66,8 @@
|
|||
border-right: var(--border-light);
|
||||
}
|
||||
.panel.wide {
|
||||
width: 420px;
|
||||
flex: 0 0 420px;
|
||||
width: 310px;
|
||||
flex: 0 0 310px;
|
||||
}
|
||||
.header {
|
||||
flex: 0 0 48px;
|
||||
|
@ -116,4 +116,10 @@
|
|||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.header.custom {
|
||||
border: none;
|
||||
}
|
||||
.custom-content-wrap {
|
||||
border-bottom: var(--border-light);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
: enrichedSchemaFields?.map(field => field.name)
|
||||
$: sanitisedValue = getValidColumns(value, options)
|
||||
$: updateBoundValue(sanitisedValue)
|
||||
$: enrichedSchemaFields = getFields(Object.values(schema) || [], {
|
||||
$: enrichedSchemaFields = getFields(Object.values(schema || {}), {
|
||||
allowLinks: true,
|
||||
})
|
||||
|
||||
|
|
|
@ -3,23 +3,13 @@
|
|||
import { makePropSafe } from "@budibase/string-templates"
|
||||
import { currentAsset, store } from "builderStore"
|
||||
import { findComponentPath } from "builderStore/componentUtils"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
|
||||
export let value
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
|
||||
|
||||
$: path = findComponentPath($currentAsset?.props, $store.selectedComponentId)
|
||||
$: providers = path.filter(c => c._component?.endsWith("/dataprovider"))
|
||||
|
||||
// Set initial value to closest data provider
|
||||
onMount(() => {
|
||||
const valid = value && providers.find(x => getValue(x) === value) != null
|
||||
if (!valid && providers.length) {
|
||||
dispatch("change", getValue(providers[providers.length - 1]))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Select
|
||||
|
|
|
@ -1,43 +1,17 @@
|
|||
<script>
|
||||
import { Combobox } from "@budibase/bbui"
|
||||
import {
|
||||
getDatasourceForProvider,
|
||||
getSchemaForDatasource,
|
||||
} from "builderStore/dataBinding"
|
||||
import { currentAsset } from "builderStore"
|
||||
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||
import { getComponentFieldOptions } from "helpers/formFields"
|
||||
|
||||
export let componentInstance
|
||||
export let value
|
||||
export let type
|
||||
|
||||
$: form = findClosestMatchingComponent(
|
||||
$: options = getComponentFieldOptions(
|
||||
$currentAsset?.props,
|
||||
componentInstance._id,
|
||||
component => component._component === "@budibase/standard-components/form"
|
||||
componentInstance?._id,
|
||||
type
|
||||
)
|
||||
$: datasource = getDatasourceForProvider($currentAsset, form)
|
||||
$: schema = getSchemaForDatasource($currentAsset, datasource, {
|
||||
formSchema: true,
|
||||
}).schema
|
||||
$: options = getOptions(schema, type)
|
||||
|
||||
const getOptions = (schema, type) => {
|
||||
let entries = Object.entries(schema ?? {})
|
||||
let types = []
|
||||
if (type === "field/options" || type === "field/longform") {
|
||||
// allow options and longform to be used on string fields as well
|
||||
types = [type, "field/string"]
|
||||
} else {
|
||||
types = [type]
|
||||
}
|
||||
|
||||
types = types.map(type => type.slice(type.indexOf("/") + 1))
|
||||
|
||||
entries = entries.filter(entry => types.includes(entry[1].type))
|
||||
|
||||
return entries.map(entry => entry[0])
|
||||
}
|
||||
</script>
|
||||
|
||||
<Combobox on:change {value} {options} />
|
||||
|
|
|
@ -75,11 +75,13 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<div class="property-control" class:highlighted={highlighted && nullishValue}>
|
||||
{#if type !== "boolean" && label && !labelHidden}
|
||||
<div class="label">
|
||||
<Label>{label}</Label>
|
||||
</div>
|
||||
<div
|
||||
class="property-control"
|
||||
class:wide={!label || labelHidden}
|
||||
class:highlighted={highlighted && nullishValue}
|
||||
>
|
||||
{#if label && !labelHidden}
|
||||
<Label size="M">{label}</Label>
|
||||
{/if}
|
||||
<div id={`${key}-prop-control`} class="control">
|
||||
<svelte:component
|
||||
|
@ -91,7 +93,6 @@
|
|||
onChange={handleChange}
|
||||
bindings={allBindings}
|
||||
name={key}
|
||||
text={label}
|
||||
{nested}
|
||||
{key}
|
||||
{type}
|
||||
|
@ -106,28 +107,34 @@
|
|||
<style>
|
||||
.property-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr;
|
||||
align-items: center;
|
||||
transition: background 130ms ease-out, border-color 130ms ease-out;
|
||||
border-left: 4px solid transparent;
|
||||
margin: -6px calc(-1 * var(--spacing-xl));
|
||||
padding: 6px var(--spacing-xl) 6px calc(var(--spacing-xl) - 4px);
|
||||
margin: 0 calc(-1 * var(--spacing-xl));
|
||||
padding: 0 var(--spacing-xl) 0 calc(var(--spacing-xl) - 4px);
|
||||
gap: 8px;
|
||||
}
|
||||
.property-control :global(.spectrum-FieldLabel) {
|
||||
white-space: normal;
|
||||
}
|
||||
.property-control.highlighted {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
border-color: var(--spectrum-global-color-blue-400);
|
||||
}
|
||||
.label {
|
||||
padding-bottom: var(--spectrum-global-dimension-size-65);
|
||||
border-color: var(--spectrum-global-color-static-red-600);
|
||||
}
|
||||
.control {
|
||||
position: relative;
|
||||
}
|
||||
.property-control.wide .control {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.text {
|
||||
margin-top: var(--spectrum-global-dimension-size-65);
|
||||
font-size: var(--spectrum-global-dimension-font-size-75);
|
||||
color: var(--grey-6);
|
||||
grid-column: 2 / 2;
|
||||
}
|
||||
.property-control.wide .text {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -15,20 +15,12 @@
|
|||
$: tourKey = $store.tourKey
|
||||
$: tourStepKey = $store.tourStepKey
|
||||
|
||||
const initTour = targetKey => {
|
||||
if (!targetKey) {
|
||||
const updateTourStep = (targetStepKey, tourKey) => {
|
||||
if (!tourKey) {
|
||||
return
|
||||
}
|
||||
tourSteps = [...TOURS[targetKey]]
|
||||
tourStepIdx = 0
|
||||
tourStep = { ...tourSteps[tourStepIdx] }
|
||||
}
|
||||
|
||||
$: initTour(tourKey)
|
||||
|
||||
const updateTourStep = targetStepKey => {
|
||||
if (!tourSteps?.length) {
|
||||
return
|
||||
tourSteps = [...TOURS[tourKey]]
|
||||
}
|
||||
tourStepIdx = getCurrentStepIdx(tourSteps, targetStepKey)
|
||||
lastStep = tourStepIdx + 1 == tourSteps.length
|
||||
|
@ -36,7 +28,7 @@
|
|||
tourStep.onLoad()
|
||||
}
|
||||
|
||||
$: updateTourStep(tourStepKey)
|
||||
$: updateTourStep(tourStepKey, tourKey)
|
||||
|
||||
const showPopover = (tourStep, tourNodes, popover) => {
|
||||
if (!tourStep) {
|
||||
|
|
|
@ -8,20 +8,28 @@
|
|||
|
||||
let currentTourStep
|
||||
let ready = false
|
||||
let registered = false
|
||||
let handler
|
||||
|
||||
const registerTourNode = (tourKey, stepKey) => {
|
||||
if (ready && !registered && tourKey) {
|
||||
currentTourStep = TOURS[tourKey].find(step => step.id === stepKey)
|
||||
if (!currentTourStep) {
|
||||
return
|
||||
}
|
||||
const elem = document.querySelector(currentTourStep.query)
|
||||
handler = tourHandler(elem, stepKey)
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
|
||||
$: tourKeyWatch = $store.tourKey
|
||||
$: registerTourNode(tourKeyWatch, tourStepKey, ready)
|
||||
|
||||
onMount(() => {
|
||||
if (!$store.tourKey) return
|
||||
|
||||
currentTourStep = TOURS[$store.tourKey].find(
|
||||
step => step.id === tourStepKey
|
||||
)
|
||||
if (!currentTourStep) return
|
||||
|
||||
const elem = document.querySelector(currentTourStep.query)
|
||||
handler = tourHandler(elem, tourStepKey)
|
||||
ready = true
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (handler) {
|
||||
handler.destroy()
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
<script>
|
||||
import { ModalContent } from "@budibase/bbui"
|
||||
import { Body, notifications } from "@budibase/bbui"
|
||||
import { ModalContent, Body, notifications, CopyInput } from "@budibase/bbui"
|
||||
import { auth } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import CopyInput from "components/common/inputs/CopyInput.svelte"
|
||||
|
||||
let apiKey = null
|
||||
|
||||
|
|
32
packages/builder/src/helpers/formFields.js
Normal file
32
packages/builder/src/helpers/formFields.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||
import {
|
||||
getDatasourceForProvider,
|
||||
getSchemaForDatasource,
|
||||
} from "builderStore/dataBinding"
|
||||
|
||||
export const getComponentFieldOptions = (asset, id, type, loose = true) => {
|
||||
const form = findClosestMatchingComponent(
|
||||
asset,
|
||||
id,
|
||||
component => component._component === "@budibase/standard-components/form"
|
||||
)
|
||||
const datasource = getDatasourceForProvider(asset, form)
|
||||
const schema = getSchemaForDatasource(asset, datasource, {
|
||||
formSchema: true,
|
||||
}).schema
|
||||
|
||||
// Get valid types for this field
|
||||
let types = [type]
|
||||
if (loose) {
|
||||
if (type === "field/options" || type === "field/longform") {
|
||||
// Allow options and longform to be used on string fields as well
|
||||
types = [type, "field/string"]
|
||||
}
|
||||
}
|
||||
types = types.map(type => type.slice(type.indexOf("/") + 1))
|
||||
|
||||
// Find fields of valid types
|
||||
return Object.entries(schema || {})
|
||||
.filter(entry => types.includes(entry[1].type))
|
||||
.map(entry => entry[0])
|
||||
}
|
32
packages/builder/src/pages/builder/Branding.svelte
Normal file
32
packages/builder/src/pages/builder/Branding.svelte
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script>
|
||||
import { organisation, auth } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
let loaded = false
|
||||
|
||||
$: platformTitleText = $organisation.platformTitle
|
||||
$: platformTitle =
|
||||
!$auth.user && platformTitleText ? platformTitleText : "Budibase"
|
||||
|
||||
$: faviconUrl = $organisation.faviconUrl || "https://i.imgur.com/Xhdt1YP.png"
|
||||
|
||||
onMount(async () => {
|
||||
await organisation.init()
|
||||
loaded = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--
|
||||
In order to update the org elements, an update will have to be made to clear them.
|
||||
-->
|
||||
|
||||
<svelte:head>
|
||||
<title>{platformTitle}</title>
|
||||
|
||||
{#if loaded && !$auth.user && faviconUrl}
|
||||
<link rel="icon" href={faviconUrl} />
|
||||
{:else}
|
||||
<!-- A default must be set or the browser defaults to favicon.ico behaviour -->
|
||||
<link rel="icon" href={"https://i.imgur.com/Xhdt1YP.png"} />
|
||||
{/if}
|
||||
</svelte:head>
|
|
@ -4,29 +4,33 @@
|
|||
import { onMount } from "svelte"
|
||||
import { CookieUtils, Constants } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import Branding from "./Branding.svelte"
|
||||
|
||||
let loaded = false
|
||||
|
||||
$: multiTenancyEnabled = $admin.multiTenancy
|
||||
$: hasAdminUser = $admin?.checklist?.adminUser?.checked
|
||||
$: baseUrl = $admin?.baseUrl
|
||||
$: tenantSet = $auth.tenantSet
|
||||
$: cloud = $admin.cloud
|
||||
$: cloud = $admin?.cloud
|
||||
$: user = $auth.user
|
||||
|
||||
$: useAccountPortal = cloud && !$admin.disableAccountPortal
|
||||
|
||||
const validateTenantId = async () => {
|
||||
const host = window.location.host
|
||||
if (host.includes("localhost:")) {
|
||||
if (host.includes("localhost:") || !baseUrl) {
|
||||
// ignore local dev
|
||||
return
|
||||
}
|
||||
|
||||
// e.g. ['tenant', 'budibase', 'app'] vs ['budibase', 'app']
|
||||
const mainHost = new URL(baseUrl).host
|
||||
let urlTenantId
|
||||
const hostParts = host.split(".")
|
||||
if (hostParts.length > 2) {
|
||||
urlTenantId = hostParts[0]
|
||||
// remove the main host part
|
||||
const hostParts = host.split(mainHost).filter(part => part !== "")
|
||||
// if there is a part left, it has to be the tenant ID subdomain
|
||||
if (hostParts.length === 1) {
|
||||
urlTenantId = hostParts[0].replace(/\./g, "")
|
||||
}
|
||||
|
||||
if (user && user.tenantId) {
|
||||
|
@ -40,13 +44,15 @@
|
|||
return
|
||||
}
|
||||
|
||||
if (user.tenantId !== urlTenantId) {
|
||||
if (urlTenantId && user.tenantId !== urlTenantId) {
|
||||
// user should not be here - play it safe and log them out
|
||||
try {
|
||||
await auth.logout()
|
||||
await auth.setOrganisation(null)
|
||||
} catch (error) {
|
||||
// Swallow error and do nothing
|
||||
console.error(
|
||||
`Tenant mis-match - "${urlTenantId}" and "${user.tenantId}" - logout`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -73,7 +79,7 @@
|
|||
}
|
||||
|
||||
// Validate tenant if in a multi-tenant env
|
||||
if (useAccountPortal && multiTenancyEnabled) {
|
||||
if (multiTenancyEnabled) {
|
||||
await validateTenantId()
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -146,6 +152,9 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<!--Portal branding overrides -->
|
||||
<Branding />
|
||||
|
||||
{#if loaded}
|
||||
<slot />
|
||||
{/if}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
clickOutside,
|
||||
notifications,
|
||||
ActionButton,
|
||||
CopyInput,
|
||||
} from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import { groups, licensing, apps, users } from "stores/portal"
|
||||
|
@ -17,7 +18,6 @@
|
|||
import RoleSelect from "components/common/RoleSelect.svelte"
|
||||
import { Constants, Utils } from "@budibase/frontend-core"
|
||||
import { emailValidator } from "helpers/validation"
|
||||
import CopyInput from "components/common/inputs/CopyInput.svelte"
|
||||
import { roles } from "stores/backend"
|
||||
|
||||
let query = null
|
||||
|
@ -346,8 +346,15 @@
|
|||
|
||||
onMount(() => {
|
||||
rendered = true
|
||||
searchFocus = true
|
||||
})
|
||||
|
||||
function handleKeyDown(evt) {
|
||||
if (evt.key === "Enter" && queryIsEmail && !inviting) {
|
||||
onInviteUser()
|
||||
}
|
||||
}
|
||||
|
||||
const userTitle = user => {
|
||||
if (user.admin?.global) {
|
||||
return "Admin"
|
||||
|
@ -370,6 +377,8 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
|
||||
<div
|
||||
id="builder-side-panel-container"
|
||||
class:open={$store.builderSidePanel}
|
||||
|
@ -403,6 +412,7 @@
|
|||
autocomplete="off"
|
||||
disabled={inviting}
|
||||
value={query}
|
||||
autofocus
|
||||
on:input={e => {
|
||||
query = e.target.value.trim()
|
||||
}}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
Tabs,
|
||||
Tab,
|
||||
Heading,
|
||||
Modal,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
|
||||
|
@ -18,6 +19,7 @@
|
|||
import { isActive, goto, layout, redirect } from "@roxi/routify"
|
||||
import { capitalise } from "helpers"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import CommandPalette from "components/commandPalette/CommandPalette.svelte"
|
||||
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
|
||||
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
|
||||
|
@ -25,12 +27,9 @@
|
|||
|
||||
export let application
|
||||
|
||||
// Get Package and set store
|
||||
let promise = getPackage()
|
||||
// let betaAccess = false
|
||||
|
||||
// Sync once when you load the app
|
||||
let hasSynced = false
|
||||
let commandPaletteModal
|
||||
|
||||
$: selected = capitalise(
|
||||
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
|
||||
|
@ -50,7 +49,6 @@
|
|||
$redirect("../../")
|
||||
}
|
||||
}
|
||||
|
||||
// Handles navigation between frontend, backend, automation.
|
||||
// This remembers your last place on each of the sections
|
||||
// e.g. if one of your screens is selected on front end, then
|
||||
|
@ -67,6 +65,14 @@
|
|||
})
|
||||
}
|
||||
|
||||
// Event handler for the command palette
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
commandPaletteModal.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const initTour = async () => {
|
||||
// Check if onboarding is enabled.
|
||||
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
|
||||
|
@ -120,89 +126,91 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
{#await promise}
|
||||
<!-- This should probably be some kind of loading state? -->
|
||||
<div class="loading" />
|
||||
{:then _}
|
||||
<TourPopover />
|
||||
<TourPopover />
|
||||
|
||||
{#if $store.builderSidePanel}
|
||||
<BuilderSidePanel />
|
||||
{/if}
|
||||
{#if $store.builderSidePanel}
|
||||
<BuilderSidePanel />
|
||||
{/if}
|
||||
|
||||
<div class="root">
|
||||
<div class="top-nav">
|
||||
<div class="topleftnav">
|
||||
<ActionMenu>
|
||||
<div slot="control">
|
||||
<Icon size="M" hoverable name="ShowMenu" />
|
||||
</div>
|
||||
<MenuItem on:click={() => $goto("../../portal/apps")}>
|
||||
Exit to portal
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() => $goto(`../../portal/overview/${application}`)}
|
||||
>
|
||||
Overview
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/access`)}
|
||||
>
|
||||
Access
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/automation-history`)}
|
||||
>
|
||||
Automation history
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/backups`)}
|
||||
>
|
||||
Backups
|
||||
</MenuItem>
|
||||
<div class="root">
|
||||
<div class="top-nav">
|
||||
<div class="topleftnav">
|
||||
<ActionMenu>
|
||||
<div slot="control">
|
||||
<Icon size="M" hoverable name="ShowMenu" />
|
||||
</div>
|
||||
<MenuItem on:click={() => $goto("../../portal/apps")}>
|
||||
Exit to portal
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() => $goto(`../../portal/overview/${application}`)}
|
||||
>
|
||||
Overview
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() => $goto(`../../portal/overview/${application}/access`)}
|
||||
>
|
||||
Access
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/automation-history`)}
|
||||
>
|
||||
Automation history
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() => $goto(`../../portal/overview/${application}/backups`)}
|
||||
>
|
||||
Backups
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/name-and-url`)}
|
||||
>
|
||||
Name and URL
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/version`)}
|
||||
>
|
||||
Version
|
||||
</MenuItem>
|
||||
</ActionMenu>
|
||||
<Heading size="XS">{$store.name || "App"}</Heading>
|
||||
</div>
|
||||
<div class="topcenternav">
|
||||
<Tabs {selected} size="M">
|
||||
{#each $layout.children as { path, title }}
|
||||
<TourWrap tourStepKey={`builder-${title}-section`}>
|
||||
<Tab
|
||||
quiet
|
||||
selected={$isActive(path)}
|
||||
on:click={topItemNavigate(path)}
|
||||
title={capitalise(title)}
|
||||
id={`builder-${title}-tab`}
|
||||
/>
|
||||
</TourWrap>
|
||||
{/each}
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="toprightnav">
|
||||
<AppActions {application} />
|
||||
</div>
|
||||
<MenuItem
|
||||
on:click={() =>
|
||||
$goto(`../../portal/overview/${application}/name-and-url`)}
|
||||
>
|
||||
Name and URL
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
on:click={() => $goto(`../../portal/overview/${application}/version`)}
|
||||
>
|
||||
Version
|
||||
</MenuItem>
|
||||
</ActionMenu>
|
||||
<Heading size="XS">{$store.name}</Heading>
|
||||
</div>
|
||||
<div class="topcenternav">
|
||||
<Tabs {selected} size="M">
|
||||
{#each $layout.children as { path, title }}
|
||||
<TourWrap tourStepKey={`builder-${title}-section`}>
|
||||
<Tab
|
||||
quiet
|
||||
selected={$isActive(path)}
|
||||
on:click={topItemNavigate(path)}
|
||||
title={capitalise(title)}
|
||||
id={`builder-${title}-tab`}
|
||||
/>
|
||||
</TourWrap>
|
||||
{/each}
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="toprightnav">
|
||||
<AppActions {application} />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
{:catch error}
|
||||
<p>Something went wrong: {error.message}</p>
|
||||
{/await}
|
||||
{#await promise}
|
||||
<!-- This should probably be some kind of loading state? -->
|
||||
<div class="loading" />
|
||||
{:then _}
|
||||
<slot />
|
||||
{:catch error}
|
||||
<p>Something went wrong: {error.message}</p>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
<Modal bind:this={commandPaletteModal}>
|
||||
<CommandPalette />
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
|
|
|
@ -34,8 +34,8 @@
|
|||
{#if duplicates?.length}
|
||||
<div class="alert-wrap">
|
||||
<Banner type="warning" showCloseButton={false}>
|
||||
{`Schema Invalid - There are duplicate auto column types defined in this schema.
|
||||
Please delete the duplicate entries where appropriate: -
|
||||
{`Schema Invalid - There are duplicate auto column types defined in this schema.
|
||||
Please delete the duplicate entries where appropriate: -
|
||||
${invalidColumnText.join(", ")}`}
|
||||
</Banner>
|
||||
</div>
|
||||
|
|
|
@ -220,6 +220,9 @@
|
|||
} else if (type === "drop-new-component") {
|
||||
const { component, parent, index } = data
|
||||
await store.actions.components.create(component, null, parent, index)
|
||||
} else if (type === "add-parent-component") {
|
||||
const { componentId, parentType } = data
|
||||
await store.actions.components.addParent(componentId, parentType)
|
||||
} else {
|
||||
console.warn(`Client sent unknown event type: ${type}`)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
getBindableProperties,
|
||||
getComponentBindableProperties,
|
||||
} from "builderStore/dataBinding"
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
$: componentInstance = $selectedComponent
|
||||
$: componentDefinition = store.actions.components.getDefinition(
|
||||
|
@ -25,32 +27,69 @@
|
|||
)
|
||||
$: isScreen = $selectedComponent?._id === $selectedScreen?.props._id
|
||||
$: title = isScreen ? "Screen" : $selectedComponent?._instanceName
|
||||
|
||||
let section = "settings"
|
||||
const tabs = ["settings", "styles", "conditions"]
|
||||
|
||||
$: id = $selectedComponent?._id
|
||||
$: id, (section = tabs[0])
|
||||
</script>
|
||||
|
||||
{#if $selectedComponent}
|
||||
{#key $selectedComponent._id}
|
||||
<Panel {title} icon={componentDefinition?.icon} borderLeft>
|
||||
{#if componentDefinition?.info}
|
||||
<ComponentInfoSection {componentDefinition} />
|
||||
<Panel {title} icon={componentDefinition?.icon} borderLeft wide>
|
||||
<span slot="panel-header-content">
|
||||
<div class="settings-tabs">
|
||||
{#each tabs as tab}
|
||||
<ActionButton
|
||||
size="M"
|
||||
quiet
|
||||
selected={section === tab}
|
||||
on:click={() => {
|
||||
section = tab
|
||||
}}
|
||||
>
|
||||
{capitalise(tab)}
|
||||
</ActionButton>
|
||||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
{#if section == "settings"}
|
||||
{#if componentDefinition?.info}
|
||||
<ComponentInfoSection {componentDefinition} />
|
||||
{/if}
|
||||
<ComponentSettingsSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{isScreen}
|
||||
/>
|
||||
{/if}
|
||||
{#if section == "styles"}
|
||||
<DesignSection {componentInstance} {componentDefinition} {bindings} />
|
||||
<CustomStylesSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
/>
|
||||
{/if}
|
||||
{#if section == "conditions"}
|
||||
<ConditionalUISection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
/>
|
||||
{/if}
|
||||
<ComponentSettingsSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{isScreen}
|
||||
/>
|
||||
<DesignSection {componentInstance} {componentDefinition} {bindings} />
|
||||
<CustomStylesSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
/>
|
||||
<ConditionalUISection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
/>
|
||||
</Panel>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
padding: 0 var(--spacing-l);
|
||||
padding-bottom: var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -117,50 +117,52 @@
|
|||
{#each sections as section, idx (section.name)}
|
||||
{#if section.visible}
|
||||
<DetailSummary name={section.name} collapsible={false}>
|
||||
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen}
|
||||
<PropertyControl
|
||||
control={Input}
|
||||
label="Name"
|
||||
key="_instanceName"
|
||||
value={componentInstance._instanceName}
|
||||
onChange={val => updateSetting({ key: "_instanceName" }, val)}
|
||||
/>
|
||||
{/if}
|
||||
{#each section.settings as setting (setting.key)}
|
||||
{#if setting.visible}
|
||||
<div class="settings">
|
||||
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen}
|
||||
<PropertyControl
|
||||
type={setting.type}
|
||||
control={getComponentForSetting(setting)}
|
||||
label={setting.label}
|
||||
labelHidden={setting.labelHidden}
|
||||
key={setting.key}
|
||||
value={componentInstance[setting.key]}
|
||||
defaultValue={setting.defaultValue}
|
||||
nested={setting.nested}
|
||||
onChange={val => updateSetting(setting, val)}
|
||||
highlighted={$store.highlightedSettingKey === setting.key}
|
||||
info={setting.info}
|
||||
props={{
|
||||
// Generic settings
|
||||
placeholder: setting.placeholder || null,
|
||||
|
||||
// Select settings
|
||||
options: setting.options || [],
|
||||
|
||||
// Number fields
|
||||
min: setting.min ?? null,
|
||||
max: setting.max ?? null,
|
||||
}}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
control={Input}
|
||||
label="Name"
|
||||
key="_instanceName"
|
||||
value={componentInstance._instanceName}
|
||||
onChange={val => updateSetting({ key: "_instanceName" }, val)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
|
||||
<ResetFieldsButton {componentInstance} />
|
||||
{/if}
|
||||
{#each section.settings as setting (setting.key)}
|
||||
{#if setting.visible}
|
||||
<PropertyControl
|
||||
type={setting.type}
|
||||
control={getComponentForSetting(setting)}
|
||||
label={setting.label}
|
||||
labelHidden={setting.labelHidden}
|
||||
key={setting.key}
|
||||
value={componentInstance[setting.key]}
|
||||
defaultValue={setting.defaultValue}
|
||||
nested={setting.nested}
|
||||
onChange={val => updateSetting(setting, val)}
|
||||
highlighted={$store.highlightedSettingKey === setting.key}
|
||||
info={setting.info}
|
||||
props={{
|
||||
// Generic settings
|
||||
placeholder: setting.placeholder || null,
|
||||
|
||||
// Select settings
|
||||
options: setting.options || [],
|
||||
|
||||
// Number fields
|
||||
min: setting.min ?? null,
|
||||
max: setting.max ?? null,
|
||||
}}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
|
||||
<ResetFieldsButton {componentInstance} />
|
||||
{/if}
|
||||
</div>
|
||||
</DetailSummary>
|
||||
{/if}
|
||||
{/each}
|
||||
|
@ -169,3 +171,13 @@
|
|||
<EjectBlockButton />
|
||||
</DetailSummary>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -27,7 +27,6 @@
|
|||
<StyleSection
|
||||
{style}
|
||||
name={style.label}
|
||||
columns={style.columns}
|
||||
properties={style.settings}
|
||||
{componentInstance}
|
||||
{bindings}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
import { store } from "builderStore"
|
||||
|
||||
export let name
|
||||
export let columns
|
||||
export let properties
|
||||
export let componentInstance
|
||||
export let bindings = []
|
||||
|
@ -34,27 +33,27 @@
|
|||
</script>
|
||||
|
||||
<DetailSummary collapsible={false} name={`${name}${changed ? " *" : ""}`}>
|
||||
<div class="group-content" style="grid-template-columns: {columns || '1fr'}">
|
||||
<div class="styles">
|
||||
{#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)}
|
||||
<div style="grid-column: {prop.column || 'auto'}">
|
||||
<PropertyControl
|
||||
label={`${prop.label}${hasPropChanged(style, prop) ? " *" : ""}`}
|
||||
control={prop.control}
|
||||
key={prop.key}
|
||||
value={style[prop.key]}
|
||||
onChange={val => updateStyle(prop.key, val)}
|
||||
props={getControlProps(prop)}
|
||||
{bindings}
|
||||
/>
|
||||
</div>
|
||||
<PropertyControl
|
||||
label={`${prop.label}${hasPropChanged(style, prop) ? " *" : ""}`}
|
||||
control={prop.control}
|
||||
key={prop.key}
|
||||
value={style[prop.key]}
|
||||
onChange={val => updateStyle(prop.key, val)}
|
||||
props={getControlProps(prop)}
|
||||
{bindings}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSummary>
|
||||
|
||||
<style>
|
||||
.group-content {
|
||||
display: grid;
|
||||
.styles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-l);
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,7 +3,6 @@ import ColorPicker from "components/design/settings/controls/ColorPicker.svelte"
|
|||
|
||||
export const margin = {
|
||||
label: "Margin",
|
||||
columns: "1fr 1fr",
|
||||
settings: [
|
||||
{
|
||||
label: "Top",
|
||||
|
@ -90,7 +89,6 @@ export const margin = {
|
|||
|
||||
export const padding = {
|
||||
label: "Padding",
|
||||
columns: "1fr 1fr",
|
||||
settings: [
|
||||
{
|
||||
label: "Top",
|
||||
|
@ -177,7 +175,6 @@ export const padding = {
|
|||
|
||||
export const size = {
|
||||
label: "Size",
|
||||
columns: "1fr 1fr",
|
||||
settings: [
|
||||
{
|
||||
label: "Width",
|
||||
|
@ -196,7 +193,6 @@ export const size = {
|
|||
|
||||
export const background = {
|
||||
label: "Background",
|
||||
columns: "auto 1fr",
|
||||
settings: [
|
||||
{
|
||||
label: "Color",
|
||||
|
@ -285,7 +281,6 @@ export const background = {
|
|||
|
||||
export const border = {
|
||||
label: "Border",
|
||||
columns: "1fr 1fr",
|
||||
settings: [
|
||||
{
|
||||
label: "Color",
|
||||
|
|
|
@ -1,22 +1,13 @@
|
|||
<script>
|
||||
import Panel from "components/design/Panel.svelte"
|
||||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
Layout,
|
||||
ActionGroup,
|
||||
ActionButton,
|
||||
Search,
|
||||
Icon,
|
||||
Body,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { Layout, Search, Icon, Body, notifications } from "@budibase/bbui"
|
||||
import structure from "./componentStructure.json"
|
||||
import { store, selectedComponent, selectedScreen } from "builderStore"
|
||||
import { onMount } from "svelte"
|
||||
import { fly } from "svelte/transition"
|
||||
import { findComponentPath } from "builderStore/componentUtils"
|
||||
|
||||
let section = "components"
|
||||
let searchString
|
||||
let searchRef
|
||||
let selectedIndex
|
||||
|
@ -37,7 +28,6 @@
|
|||
allowedComponents,
|
||||
searchString
|
||||
)
|
||||
$: blocks = enrichedStructure.find(x => x.name === "Blocks").children
|
||||
$: orderMap = createComponentOrderMap(componentList)
|
||||
|
||||
const getAllowedComponents = (allComponents, screen, component) => {
|
||||
|
@ -127,6 +117,11 @@
|
|||
}
|
||||
})
|
||||
|
||||
// Swap blocks and plugins
|
||||
let tmp = enrichedStructure[1]
|
||||
enrichedStructure[1] = enrichedStructure[0]
|
||||
enrichedStructure[0] = tmp
|
||||
|
||||
return enrichedStructure
|
||||
}
|
||||
|
||||
|
@ -137,11 +132,6 @@
|
|||
return []
|
||||
}
|
||||
|
||||
// Remove blocks if there is no search string
|
||||
if (!search) {
|
||||
structure = structure.filter(category => category.name !== "Blocks")
|
||||
}
|
||||
|
||||
// Return only items which match the search string
|
||||
let filteredStructure = []
|
||||
structure.forEach(category => {
|
||||
|
@ -182,12 +172,13 @@
|
|||
}
|
||||
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === "Tab") {
|
||||
if (e.key === "Tab" || e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
// Cycle selected components on tab press
|
||||
if (selectedIndex == null) {
|
||||
selectedIndex = 0
|
||||
} else {
|
||||
selectedIndex = (selectedIndex + 1) % componentList.length
|
||||
const direction = e.key === "ArrowUp" ? -1 : 1
|
||||
selectedIndex = (selectedIndex + direction) % componentList.length
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
@ -224,6 +215,7 @@
|
|||
showCloseButton
|
||||
onClickCloseButton={() => $goto("../")}
|
||||
borderLeft
|
||||
wide
|
||||
>
|
||||
<Layout paddingX="L" paddingY="XL" gap="S">
|
||||
<Search
|
||||
|
@ -232,64 +224,31 @@
|
|||
on:change={e => (searchString = e.detail)}
|
||||
bind:inputRef={searchRef}
|
||||
/>
|
||||
{#if !searchString}
|
||||
<ActionGroup compact justified>
|
||||
<ActionButton
|
||||
fullWidth
|
||||
selected={section === "components"}
|
||||
on:click={() => (section = "components")}>Components</ActionButton
|
||||
>
|
||||
<ActionButton
|
||||
fullWidth
|
||||
selected={section === "blocks"}
|
||||
on:click={() => (section = "blocks")}>Blocks</ActionButton
|
||||
>
|
||||
</ActionGroup>
|
||||
{/if}
|
||||
{#if searchString || section === "components"}
|
||||
{#if filteredStructure.length}
|
||||
{#each filteredStructure as category}
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="category-label">{category.name}</div>
|
||||
{#each category.children as component}
|
||||
<div
|
||||
draggable="true"
|
||||
on:dragstart={() => onDragStart(component.component)}
|
||||
on:dragend={onDragEnd}
|
||||
class="component"
|
||||
class:selected={selectedIndex ===
|
||||
orderMap[component.component]}
|
||||
on:click={() => addComponent(component.component)}
|
||||
on:mouseover={() => (selectedIndex = null)}
|
||||
on:focus
|
||||
>
|
||||
<Icon name={component.icon} />
|
||||
<Body size="XS">{component.name}</Body>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
{/each}
|
||||
{:else}
|
||||
<Body size="S">
|
||||
There aren't any components matching the current filter
|
||||
</Body>
|
||||
{/if}
|
||||
{#if filteredStructure.length}
|
||||
{#each filteredStructure as category}
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="category-label">{category.name}</div>
|
||||
{#each category.children as component}
|
||||
<div
|
||||
draggable="true"
|
||||
on:dragstart={() => onDragStart(component.component)}
|
||||
on:dragend={onDragEnd}
|
||||
class="component"
|
||||
class:selected={selectedIndex === orderMap[component.component]}
|
||||
on:click={() => addComponent(component.component)}
|
||||
on:mouseover={() => (selectedIndex = null)}
|
||||
on:focus
|
||||
>
|
||||
<Icon name={component.icon} />
|
||||
<Body size="XS">{component.name}</Body>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
{/each}
|
||||
{:else}
|
||||
<Body size="S">Blocks are collections of pre-built components</Body>
|
||||
<Layout noPadding gap="XS">
|
||||
{#each blocks as block}
|
||||
<div
|
||||
draggable="true"
|
||||
class="component"
|
||||
on:click={() => addComponent(block.component)}
|
||||
on:dragstart={() => onDragStart(block.component)}
|
||||
on:dragend={onDragEnd}
|
||||
>
|
||||
<Icon name={block.icon} />
|
||||
<Body size="XS">{block.name}</Body>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
<Body size="S">
|
||||
There aren't any components matching the current filter
|
||||
</Body>
|
||||
{/if}
|
||||
</Layout>
|
||||
</Panel>
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
async function login() {
|
||||
form.validate()
|
||||
if (Object.keys(errors).length > 0) {
|
||||
console.log("errors")
|
||||
console.log("errors", errors)
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
@ -64,99 +64,106 @@
|
|||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<TestimonialPage>
|
||||
<Layout gap="L" noPadding>
|
||||
<Layout justifyItems="center" noPadding>
|
||||
{#if loaded}
|
||||
<img alt="logo" src={$organisation.logoUrl || Logo} />
|
||||
{/if}
|
||||
<Heading size="M">Log in to Budibase</Heading>
|
||||
</Layout>
|
||||
<Layout gap="S" noPadding>
|
||||
{#if loaded && ($organisation.google || $organisation.oidc)}
|
||||
<FancyForm>
|
||||
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
|
||||
<GoogleButton />
|
||||
</FancyForm>
|
||||
{/if}
|
||||
{#if loaded}
|
||||
<TestimonialPage enabled={$organisation.testimonialsEnabled}>
|
||||
<Layout gap="L" noPadding>
|
||||
<Layout justifyItems="center" noPadding>
|
||||
{#if loaded}
|
||||
<img alt="logo" src={$organisation.logoUrl || Logo} />
|
||||
{/if}
|
||||
<Heading size="M">
|
||||
{$organisation.loginHeading || "Log in to Budibase"}
|
||||
</Heading>
|
||||
</Layout>
|
||||
<Layout gap="S" noPadding>
|
||||
{#if loaded && ($organisation.google || $organisation.oidc)}
|
||||
<FancyForm>
|
||||
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
|
||||
<GoogleButton />
|
||||
</FancyForm>
|
||||
{/if}
|
||||
{#if !$organisation.isSSOEnforced}
|
||||
<Divider />
|
||||
<FancyForm bind:this={form}>
|
||||
<FancyInput
|
||||
label="Your work email"
|
||||
value={formData.username}
|
||||
on:change={e => {
|
||||
formData = {
|
||||
...formData,
|
||||
username: e.detail,
|
||||
}
|
||||
}}
|
||||
validate={() => {
|
||||
let fieldError = {
|
||||
username: !formData.username
|
||||
? "Please enter a valid email"
|
||||
: undefined,
|
||||
}
|
||||
errors = handleError({ ...errors, ...fieldError })
|
||||
}}
|
||||
error={errors.username}
|
||||
/>
|
||||
<FancyInput
|
||||
label="Password"
|
||||
value={formData.password}
|
||||
type="password"
|
||||
on:change={e => {
|
||||
formData = {
|
||||
...formData,
|
||||
password: e.detail,
|
||||
}
|
||||
}}
|
||||
validate={() => {
|
||||
let fieldError = {
|
||||
password: !formData.password
|
||||
? "Please enter your password"
|
||||
: undefined,
|
||||
}
|
||||
errors = handleError({ ...errors, ...fieldError })
|
||||
}}
|
||||
error={errors.password}
|
||||
/>
|
||||
</FancyForm>
|
||||
{/if}
|
||||
</Layout>
|
||||
{#if !$organisation.isSSOEnforced}
|
||||
<Divider />
|
||||
<FancyForm bind:this={form}>
|
||||
<FancyInput
|
||||
label="Your work email"
|
||||
value={formData.username}
|
||||
on:change={e => {
|
||||
formData = {
|
||||
...formData,
|
||||
username: e.detail,
|
||||
}
|
||||
}}
|
||||
validate={() => {
|
||||
let fieldError = {
|
||||
username: !formData.username
|
||||
? "Please enter a valid email"
|
||||
: undefined,
|
||||
}
|
||||
errors = handleError({ ...errors, ...fieldError })
|
||||
}}
|
||||
error={errors.username}
|
||||
/>
|
||||
<FancyInput
|
||||
label="Password"
|
||||
value={formData.password}
|
||||
type="password"
|
||||
on:change={e => {
|
||||
formData = {
|
||||
...formData,
|
||||
password: e.detail,
|
||||
}
|
||||
}}
|
||||
validate={() => {
|
||||
let fieldError = {
|
||||
password: !formData.password
|
||||
? "Please enter your password"
|
||||
: undefined,
|
||||
}
|
||||
errors = handleError({ ...errors, ...fieldError })
|
||||
}}
|
||||
error={errors.password}
|
||||
/>
|
||||
</FancyForm>
|
||||
<Layout gap="XS" noPadding justifyItems="center">
|
||||
<Button
|
||||
size="L"
|
||||
cta
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
on:click={login}
|
||||
>
|
||||
{$organisation.loginButton || `Log in to ${company}`}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Layout gap="XS" noPadding justifyItems="center">
|
||||
<div class="user-actions">
|
||||
<ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
|
||||
Forgot password?
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if cloud}
|
||||
<Body size="xs" textAlign="center">
|
||||
By using Budibase Cloud
|
||||
<br />
|
||||
you are agreeing to our
|
||||
<Link
|
||||
href="https://budibase.com/eula"
|
||||
target="_blank"
|
||||
secondary={true}
|
||||
>
|
||||
License Agreement
|
||||
</Link>
|
||||
</Body>
|
||||
{/if}
|
||||
</Layout>
|
||||
{#if !$organisation.isSSOEnforced}
|
||||
<Layout gap="XS" noPadding justifyItems="center">
|
||||
<Button
|
||||
size="L"
|
||||
cta
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
on:click={login}
|
||||
>
|
||||
Log in to {company}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Layout gap="XS" noPadding justifyItems="center">
|
||||
<div class="user-actions">
|
||||
<ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
|
||||
Forgot password?
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if cloud}
|
||||
<Body size="xs" textAlign="center">
|
||||
By using Budibase Cloud
|
||||
<br />
|
||||
you are agreeing to our
|
||||
<Link href="https://budibase.com/eula" target="_blank" secondary={true}>
|
||||
License Agreement
|
||||
</Link>
|
||||
</Body>
|
||||
{/if}
|
||||
</Layout>
|
||||
</TestimonialPage>
|
||||
</TestimonialPage>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.user-actions {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script>
|
||||
import { Button } from "@budibase/bbui"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { auth, admin } from "stores/portal"
|
||||
import { auth, admin, licensing } from "stores/portal"
|
||||
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
|
||||
</script>
|
||||
|
||||
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING)}
|
||||
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
|
||||
{#if $admin.cloud && $auth?.user?.accountPortalAccess}
|
||||
<Button
|
||||
cta
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
<script>
|
||||
import { Button, FancyForm, FancyInput, FancyCheckbox } from "@budibase/bbui"
|
||||
import GoogleButton from "components/backend/DatasourceNavigator/_components/GoogleButton.svelte"
|
||||
import { capitalise } from "helpers/helpers"
|
||||
import PanelHeader from "./PanelHeader.svelte"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
export let title = ""
|
||||
export let onBack = null
|
||||
export let onNext = () => {}
|
||||
export let fields = {}
|
||||
export let type = ""
|
||||
|
||||
let errors = {}
|
||||
|
||||
|
@ -57,8 +60,9 @@
|
|||
}
|
||||
|
||||
$: isValid = getIsValid(fields, errors, values)
|
||||
$: isGoogle = helpers.isGoogleSheets(type)
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
const parsedValues = {}
|
||||
|
||||
Object.entries(values).forEach(([name, value]) => {
|
||||
|
@ -69,7 +73,10 @@
|
|||
}
|
||||
})
|
||||
|
||||
return onNext(parsedValues)
|
||||
if (isGoogle) {
|
||||
parsedValues.isGoogle = isGoogle
|
||||
}
|
||||
return await onNext(parsedValues)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -99,7 +106,11 @@
|
|||
{/each}
|
||||
</FancyForm>
|
||||
</div>
|
||||
<Button cta disabled={!isValid} on:click={handleNext}>Connect</Button>
|
||||
{#if isGoogle}
|
||||
<GoogleButton disabled={!isValid} preAuthStep={handleNext} />
|
||||
{:else}
|
||||
<Button cta disabled={!isValid} on:click={handleNext}>Connect</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -4,19 +4,20 @@
|
|||
import DataPanel from "./_components/DataPanel.svelte"
|
||||
import DatasourceConfigPanel from "./_components/DatasourceConfigPanel.svelte"
|
||||
import ExampleApp from "./_components/ExampleApp.svelte"
|
||||
import { FancyButton, notifications, Modal } from "@budibase/bbui"
|
||||
import { FancyButton, notifications, Modal, Body } from "@budibase/bbui"
|
||||
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
|
||||
import { SplitPage } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { saveDatasource } from "builderStore/datasource"
|
||||
import { integrations } from "stores/backend"
|
||||
import { auth, admin } from "stores/portal"
|
||||
import { auth, admin, organisation } from "stores/portal"
|
||||
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
|
||||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
|
||||
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
|
||||
import { Roles } from "constants/backend"
|
||||
import Spinner from "components/common/Spinner.svelte"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
let name = "My first app"
|
||||
let url = "my-first-app"
|
||||
|
@ -25,10 +26,11 @@
|
|||
|
||||
let plusIntegrations = {}
|
||||
let integrationsLoading = true
|
||||
$: getIntegrations()
|
||||
let creationLoading = false
|
||||
|
||||
let uploadModal
|
||||
let googleComplete = false
|
||||
|
||||
$: getIntegrations()
|
||||
|
||||
const createApp = async useSampleData => {
|
||||
creationLoading = true
|
||||
|
@ -62,6 +64,7 @@
|
|||
await store.actions.screens.save(defaultScreenTemplate)
|
||||
|
||||
appId = createdApp.instance._id
|
||||
return createdApp
|
||||
} catch (e) {
|
||||
creationLoading = false
|
||||
throw e
|
||||
|
@ -74,6 +77,13 @@
|
|||
const newPlusIntegrations = {}
|
||||
|
||||
Object.entries($integrations).forEach(([integrationType, schema]) => {
|
||||
// google sheets not available in self-host
|
||||
if (
|
||||
helpers.isGoogleSheets(integrationType) &&
|
||||
!$organisation.googleDatasourceConfigured
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (schema?.plus) {
|
||||
newPlusIntegrations[integrationType] = schema
|
||||
}
|
||||
|
@ -92,12 +102,17 @@
|
|||
notifications.success(`App created successfully`)
|
||||
}
|
||||
|
||||
const handleCreateApp = async ({ datasourceConfig, useSampleData }) => {
|
||||
const handleCreateApp = async ({
|
||||
datasourceConfig,
|
||||
useSampleData,
|
||||
isGoogle,
|
||||
}) => {
|
||||
try {
|
||||
await createApp(useSampleData)
|
||||
const app = await createApp(useSampleData)
|
||||
|
||||
let datasource
|
||||
if (datasourceConfig) {
|
||||
await saveDatasource({
|
||||
datasource = await saveDatasource({
|
||||
plus: true,
|
||||
auth: undefined,
|
||||
name: plusIntegrations[stage].friendlyName,
|
||||
|
@ -107,7 +122,14 @@
|
|||
})
|
||||
}
|
||||
|
||||
goToApp()
|
||||
store.set()
|
||||
|
||||
if (isGoogle) {
|
||||
googleComplete = true
|
||||
return { datasource, appId: app.appId }
|
||||
} else {
|
||||
goToApp()
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
creationLoading = false
|
||||
|
@ -127,8 +149,15 @@
|
|||
<SplitPage>
|
||||
{#if stage === "name"}
|
||||
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
|
||||
{:else if googleComplete}
|
||||
<div class="centered">
|
||||
<Body
|
||||
>Please login to your Google account in the new tab which as opened to
|
||||
continue.</Body
|
||||
>
|
||||
</div>
|
||||
{:else if integrationsLoading || creationLoading}
|
||||
<div class="spinner">
|
||||
<div class="centered">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if stage === "data"}
|
||||
|
@ -174,8 +203,13 @@
|
|||
<DatasourceConfigPanel
|
||||
title={plusIntegrations[stage].friendlyName}
|
||||
fields={plusIntegrations[stage].datasource}
|
||||
type={stage}
|
||||
onBack={() => (stage = "data")}
|
||||
onNext={data => handleCreateApp({ datasourceConfig: data })}
|
||||
onNext={data => {
|
||||
const isGoogle = data.isGoogle
|
||||
delete data.isGoogle
|
||||
return handleCreateApp({ datasourceConfig: data, isGoogle })
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<p>There was an problem. Please refresh the page and try again.</p>
|
||||
|
@ -186,7 +220,7 @@
|
|||
</SplitPage>
|
||||
|
||||
<style>
|
||||
.spinner {
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
|
|
@ -0,0 +1,446 @@
|
|||
<script>
|
||||
import {
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Divider,
|
||||
File,
|
||||
notifications,
|
||||
Tags,
|
||||
Tag,
|
||||
Button,
|
||||
Toggle,
|
||||
Input,
|
||||
Label,
|
||||
TextArea,
|
||||
} from "@budibase/bbui"
|
||||
import { auth, organisation, licensing, admin } from "stores/portal"
|
||||
import { API } from "api"
|
||||
import { onMount } from "svelte"
|
||||
import { goto } from "@roxi/routify"
|
||||
|
||||
const imageExtensions = [
|
||||
".png",
|
||||
".tiff",
|
||||
".gif",
|
||||
".raw",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".svg",
|
||||
".bmp",
|
||||
".jfif",
|
||||
]
|
||||
|
||||
const faviconExtensions = [".png", ".ico", ".gif"]
|
||||
|
||||
let mounted = false
|
||||
let saving = false
|
||||
|
||||
let logoFile = null
|
||||
let logoPreview = null
|
||||
let faviconFile = null
|
||||
let faviconPreview = null
|
||||
|
||||
let config = {}
|
||||
let updated = false
|
||||
|
||||
$: onConfigUpdate(config, mounted)
|
||||
$: init = Object.keys(config).length > 0
|
||||
|
||||
$: isCloud = $admin.cloud
|
||||
$: brandingEnabled = $licensing.brandingEnabled
|
||||
|
||||
const onConfigUpdate = () => {
|
||||
if (!mounted || updated || !init) {
|
||||
return
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
|
||||
$: logo = config.logoUrl
|
||||
? { url: config.logoUrl, type: "image", name: "Logo" }
|
||||
: null
|
||||
|
||||
$: favicon = config.faviconUrl
|
||||
? { url: config.faviconUrl, type: "image", name: "Favicon" }
|
||||
: null
|
||||
|
||||
const previewUrl = async localFile => {
|
||||
if (!localFile) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let reader = new FileReader()
|
||||
try {
|
||||
reader.onload = e => {
|
||||
resolve({
|
||||
result: e.target.result,
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(localFile)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$: previewUrl(logoFile).then(response => {
|
||||
if (response) {
|
||||
logoPreview = response.result
|
||||
}
|
||||
})
|
||||
|
||||
$: previewUrl(faviconFile).then(response => {
|
||||
if (response) {
|
||||
faviconPreview = response.result
|
||||
}
|
||||
})
|
||||
|
||||
async function uploadLogo(file) {
|
||||
let response = {}
|
||||
try {
|
||||
let data = new FormData()
|
||||
data.append("file", file)
|
||||
response = await API.uploadLogo(data)
|
||||
} catch (error) {
|
||||
notifications.error("Error uploading logo")
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function uploadFavicon(file) {
|
||||
let response = {}
|
||||
try {
|
||||
let data = new FormData()
|
||||
data.append("file", file)
|
||||
response = await API.uploadFavicon(data)
|
||||
} catch (error) {
|
||||
notifications.error("Error uploading favicon")
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving = true
|
||||
|
||||
if (logoFile) {
|
||||
const logoResp = await uploadLogo(logoFile)
|
||||
if (logoResp.url) {
|
||||
config = {
|
||||
...config,
|
||||
logoUrl: logoResp.url,
|
||||
}
|
||||
logoFile = null
|
||||
logoPreview = null
|
||||
}
|
||||
}
|
||||
|
||||
if (faviconFile) {
|
||||
const faviconResp = await uploadFavicon(faviconFile)
|
||||
if (faviconResp.url) {
|
||||
config = {
|
||||
...config,
|
||||
faviconUrl: faviconResp.url,
|
||||
}
|
||||
faviconFile = null
|
||||
faviconPreview = null
|
||||
}
|
||||
}
|
||||
|
||||
// Trim
|
||||
const userStrings = [
|
||||
"metaTitle",
|
||||
"platformTitle",
|
||||
"loginButton",
|
||||
"loginHeading",
|
||||
"metaDescription",
|
||||
"metaImageUrl",
|
||||
]
|
||||
|
||||
const trimmed = userStrings.reduce((acc, fieldName) => {
|
||||
acc[fieldName] = config[fieldName] ? config[fieldName].trim() : undefined
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...trimmed,
|
||||
}
|
||||
|
||||
try {
|
||||
// Update settings
|
||||
await organisation.save(config)
|
||||
await organisation.init()
|
||||
notifications.success("Branding settings updated")
|
||||
} catch (e) {
|
||||
console.error("Branding updated failed", e)
|
||||
notifications.error("Branding updated failed")
|
||||
}
|
||||
updated = false
|
||||
saving = false
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await organisation.init()
|
||||
|
||||
config = {
|
||||
faviconUrl: $organisation.faviconUrl,
|
||||
logoUrl: $organisation.logoUrl,
|
||||
platformTitle: $organisation.platformTitle,
|
||||
emailBrandingEnabled: $organisation.emailBrandingEnabled,
|
||||
loginHeading: $organisation.loginHeading,
|
||||
loginButton: $organisation.loginButton,
|
||||
testimonialsEnabled: $organisation.testimonialsEnabled,
|
||||
metaDescription: $organisation.metaDescription,
|
||||
metaImageUrl: $organisation.metaImageUrl,
|
||||
metaTitle: $organisation.metaTitle,
|
||||
}
|
||||
mounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $auth.isAdmin && mounted}
|
||||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<div class="title">
|
||||
<Heading size="M">Branding</Heading>
|
||||
{#if !isCloud && !brandingEnabled}
|
||||
<Tags>
|
||||
<Tag icon="LockClosed">Business</Tag>
|
||||
</Tags>
|
||||
{/if}
|
||||
{#if isCloud && !brandingEnabled}
|
||||
<Tags>
|
||||
<Tag icon="LockClosed">Pro</Tag>
|
||||
</Tags>
|
||||
{/if}
|
||||
</div>
|
||||
<Body>Remove all Budibase branding and use your own.</Body>
|
||||
</Layout>
|
||||
<Divider />
|
||||
<div class="branding fields">
|
||||
<div class="field">
|
||||
<Label size="L">Logo</Label>
|
||||
<File
|
||||
title="Upload image"
|
||||
handleFileTooLarge={() => {
|
||||
notifications.warn("File too large. 20mb limit")
|
||||
}}
|
||||
extensions={imageExtensions}
|
||||
previewUrl={logoPreview || logo?.url}
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
if (e.detail) {
|
||||
logoFile = e.detail
|
||||
logoPreview = null
|
||||
} else {
|
||||
logoFile = null
|
||||
clone.logoUrl = ""
|
||||
}
|
||||
config = clone
|
||||
}}
|
||||
value={logoFile || logo}
|
||||
disabled={!brandingEnabled || saving}
|
||||
allowClear={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Favicon</Label>
|
||||
<File
|
||||
title="Upload image"
|
||||
handleFileTooLarge={() => {
|
||||
notifications.warn("File too large. 20mb limit")
|
||||
}}
|
||||
extensions={faviconExtensions}
|
||||
previewUrl={faviconPreview || favicon?.url}
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
if (e.detail) {
|
||||
faviconFile = e.detail
|
||||
faviconPreview = null
|
||||
} else {
|
||||
clone.faviconUrl = ""
|
||||
}
|
||||
config = clone
|
||||
}}
|
||||
value={faviconFile || favicon}
|
||||
disabled={!brandingEnabled || saving}
|
||||
allowClear={true}
|
||||
/>
|
||||
</div>
|
||||
{#if !isCloud}
|
||||
<div class="field">
|
||||
<Label size="L">Title</Label>
|
||||
<Input
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.platformTitle = e.detail ? e.detail : ""
|
||||
config = clone
|
||||
}}
|
||||
value={config.platformTitle || ""}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Toggle
|
||||
text={"Remove Budibase brand from emails"}
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.emailBrandingEnabled = !e.detail
|
||||
config = clone
|
||||
}}
|
||||
value={!config.emailBrandingEnabled}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !isCloud}
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">Login page</Heading>
|
||||
<Body />
|
||||
</Layout>
|
||||
<div class="login">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">Header</Label>
|
||||
<Input
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.loginHeading = e.detail ? e.detail : ""
|
||||
config = clone
|
||||
}}
|
||||
value={config.loginHeading || ""}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Button</Label>
|
||||
<Input
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.loginButton = e.detail ? e.detail : ""
|
||||
config = clone
|
||||
}}
|
||||
value={config.loginButton || ""}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
text={"Remove customer testimonials"}
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.testimonialsEnabled = !e.detail
|
||||
config = clone
|
||||
}}
|
||||
value={!config.testimonialsEnabled}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">Application previews</Heading>
|
||||
<Body>Customise the meta tags on your app preview</Body>
|
||||
</Layout>
|
||||
<div class="app-previews">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">Image URL</Label>
|
||||
<Input
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.metaImageUrl = e.detail ? e.detail : ""
|
||||
config = clone
|
||||
}}
|
||||
value={config.metaImageUrl}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label size="L">Title</Label>
|
||||
<Input
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.metaTitle = e.detail ? e.detail : ""
|
||||
config = clone
|
||||
}}
|
||||
value={config.metaTitle}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Label size="L">Description</Label>
|
||||
<TextArea
|
||||
on:change={e => {
|
||||
let clone = { ...config }
|
||||
clone.metaDescription = e.detail ? e.detail : ""
|
||||
config = clone
|
||||
}}
|
||||
value={config.metaDescription}
|
||||
disabled={!brandingEnabled || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
{#if !brandingEnabled}
|
||||
<Button
|
||||
on:click={() => {
|
||||
if (isCloud && $auth?.user?.accountPortalAccess) {
|
||||
window.open($admin.accountPortalUrl + "/portal/upgrade", "_blank")
|
||||
} else if ($auth.isAdmin) {
|
||||
$goto("/builder/portal/account/upgrade")
|
||||
}
|
||||
}}
|
||||
secondary
|
||||
disabled={saving}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
{/if}
|
||||
<Button on:click={saveConfig} cta disabled={saving || !updated || !init}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.branding,
|
||||
.login {
|
||||
width: 70%;
|
||||
max-width: 70%;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-m);
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 80px auto;
|
||||
grid-gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -7,12 +7,10 @@
|
|||
Divider,
|
||||
Label,
|
||||
Input,
|
||||
Dropzone,
|
||||
notifications,
|
||||
Toggle,
|
||||
} from "@budibase/bbui"
|
||||
import { auth, organisation, admin } from "stores/portal"
|
||||
import { API } from "api"
|
||||
import { writable } from "svelte/store"
|
||||
import { redirect } from "@roxi/routify"
|
||||
|
||||
|
@ -28,32 +26,14 @@
|
|||
company: $organisation.company,
|
||||
platformUrl: $organisation.platformUrl,
|
||||
analyticsEnabled: $organisation.analyticsEnabled,
|
||||
logo: $organisation.logoUrl
|
||||
? { url: $organisation.logoUrl, type: "image", name: "Logo" }
|
||||
: null,
|
||||
})
|
||||
let loading = false
|
||||
|
||||
async function uploadLogo(file) {
|
||||
try {
|
||||
let data = new FormData()
|
||||
data.append("file", file)
|
||||
await API.uploadLogo(data)
|
||||
} catch (error) {
|
||||
notifications.error("Error uploading logo")
|
||||
}
|
||||
}
|
||||
let loading = false
|
||||
|
||||
async function saveConfig() {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
// Upload logo if required
|
||||
if ($values.logo && !$values.logo.url) {
|
||||
await uploadLogo($values.logo)
|
||||
await organisation.init()
|
||||
}
|
||||
|
||||
const config = {
|
||||
isSSOEnforced: $values.isSSOEnforced,
|
||||
company: $values.company ?? "",
|
||||
|
@ -61,11 +41,6 @@
|
|||
analyticsEnabled: $values.analyticsEnabled,
|
||||
}
|
||||
|
||||
// Remove logo if required
|
||||
if (!$values.logo) {
|
||||
config.logoUrl = ""
|
||||
}
|
||||
|
||||
// Update settings
|
||||
await organisation.save(config)
|
||||
} catch (error) {
|
||||
|
@ -87,21 +62,7 @@
|
|||
<Label size="L">Org. name</Label>
|
||||
<Input thin bind:value={$values.company} />
|
||||
</div>
|
||||
<div class="field logo">
|
||||
<Label size="L">Logo</Label>
|
||||
<div class="file">
|
||||
<Dropzone
|
||||
value={[$values.logo]}
|
||||
on:change={e => {
|
||||
if (!e.detail || e.detail.length === 0) {
|
||||
$values.logo = null
|
||||
} else {
|
||||
$values.logo = e.detail[0]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !$admin.cloud}
|
||||
<div class="field">
|
||||
<Label
|
||||
|
@ -137,10 +98,4 @@
|
|||
grid-gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
.file {
|
||||
max-width: 30ch;
|
||||
}
|
||||
.logo {
|
||||
align-items: start;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,6 +22,18 @@ export function createTablesStore() {
|
|||
}))
|
||||
}
|
||||
|
||||
const fetchTable = async tableId => {
|
||||
const table = await API.fetchTableDefinition(tableId)
|
||||
|
||||
store.update(state => {
|
||||
const indexToUpdate = state.list.findIndex(t => t._id === table._id)
|
||||
state.list[indexToUpdate] = table
|
||||
return {
|
||||
...state,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const select = tableId => {
|
||||
store.update(state => ({
|
||||
...state,
|
||||
|
@ -126,6 +138,7 @@ export function createTablesStore() {
|
|||
return {
|
||||
subscribe: derivedStore.subscribe,
|
||||
fetch,
|
||||
fetchTable,
|
||||
init: fetch,
|
||||
select,
|
||||
save,
|
||||
|
|
|
@ -53,6 +53,7 @@ export function createAdminStore() {
|
|||
store.disableAccountPortal = environment.disableAccountPortal
|
||||
store.accountPortalUrl = environment.accountPortalUrl
|
||||
store.isDev = environment.isDev
|
||||
store.baseUrl = environment.baseUrl
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
|
|
@ -12,9 +12,12 @@ export const createLicensingStore = () => {
|
|||
// the top level license
|
||||
license: undefined,
|
||||
isFreePlan: true,
|
||||
isEnterprisePlan: true,
|
||||
isBusinessPlan: true,
|
||||
// features
|
||||
groupsEnabled: false,
|
||||
backupsEnabled: false,
|
||||
brandingEnabled: false,
|
||||
// the currently used quotas from the db
|
||||
quotaUsage: undefined,
|
||||
// derived quota metrics for percentages used
|
||||
|
@ -53,7 +56,10 @@ export const createLicensingStore = () => {
|
|||
},
|
||||
setLicense: () => {
|
||||
const license = get(auth).user.license
|
||||
const isFreePlan = license?.plan.type === Constants.PlanType.FREE
|
||||
const planType = license?.plan.type
|
||||
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
|
||||
const isFreePlan = planType === Constants.PlanType.FREE
|
||||
const isBusinessPlan = planType === Constants.PlanType.BUSINESS
|
||||
const groupsEnabled = license.features.includes(
|
||||
Constants.Features.USER_GROUPS
|
||||
)
|
||||
|
@ -66,7 +72,9 @@ export const createLicensingStore = () => {
|
|||
const enforceableSSO = license.features.includes(
|
||||
Constants.Features.ENFORCEABLE_SSO
|
||||
)
|
||||
|
||||
const brandingEnabled = license.features.includes(
|
||||
Constants.Features.BRANDING
|
||||
)
|
||||
const auditLogsEnabled = license.features.includes(
|
||||
Constants.Features.AUDIT_LOGS
|
||||
)
|
||||
|
@ -74,9 +82,12 @@ export const createLicensingStore = () => {
|
|||
return {
|
||||
...state,
|
||||
license,
|
||||
isEnterprisePlan,
|
||||
isFreePlan,
|
||||
isBusinessPlan,
|
||||
groupsEnabled,
|
||||
backupsEnabled,
|
||||
brandingEnabled,
|
||||
environmentVariablesEnabled,
|
||||
auditLogsEnabled,
|
||||
enforceableSSO,
|
||||
|
|
|
@ -50,6 +50,10 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
|
|||
title: "Organisation",
|
||||
href: "/builder/portal/settings/organisation",
|
||||
},
|
||||
{
|
||||
title: "Branding",
|
||||
href: "/builder/portal/settings/branding",
|
||||
},
|
||||
{
|
||||
title: "Environment",
|
||||
href: "/builder/portal/settings/environment",
|
||||
|
|
|
@ -6,10 +6,20 @@ import _ from "lodash"
|
|||
const DEFAULT_CONFIG = {
|
||||
platformUrl: "",
|
||||
logoUrl: undefined,
|
||||
faviconUrl: undefined,
|
||||
emailBrandingEnabled: true,
|
||||
testimonialsEnabled: true,
|
||||
platformTitle: "Budibase",
|
||||
loginHeading: undefined,
|
||||
loginButton: undefined,
|
||||
metaDescription: undefined,
|
||||
metaImageUrl: undefined,
|
||||
metaTitle: undefined,
|
||||
docsUrl: undefined,
|
||||
company: "Budibase",
|
||||
oidc: undefined,
|
||||
google: undefined,
|
||||
googleDatasourceConfigured: undefined,
|
||||
oidcCallbackUrl: "",
|
||||
googleCallbackUrl: "",
|
||||
isSSOEnforced: false,
|
||||
|
@ -30,6 +40,7 @@ export function createOrganisationStore() {
|
|||
const storeConfig = _.cloneDeep(get(store))
|
||||
delete storeConfig.oidc
|
||||
delete storeConfig.google
|
||||
delete storeConfig.googleDatasourceConfigured
|
||||
delete storeConfig.oidcCallbackUrl
|
||||
delete storeConfig.googleCallbackUrl
|
||||
await API.saveConfig({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/cli",
|
||||
"version": "2.4.43",
|
||||
"version": "2.4.44-alpha.1",
|
||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
@ -29,9 +29,9 @@
|
|||
"outputPath": "build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/backend-core": "^2.4.43",
|
||||
"@budibase/string-templates": "^2.4.43",
|
||||
"@budibase/types": "^2.4.43",
|
||||
"@budibase/backend-core": "2.4.44-alpha.1",
|
||||
"@budibase/string-templates": "2.4.44-alpha.1",
|
||||
"@budibase/types": "2.4.44-alpha.1",
|
||||
"axios": "0.21.2",
|
||||
"chalk": "4.1.0",
|
||||
"cli-progress": "3.11.2",
|
||||
|
|
|
@ -239,9 +239,9 @@
|
|||
"@hapi/hoek" "^9.0.0"
|
||||
|
||||
"@sideway/formula@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
|
||||
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
|
||||
integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
|
||||
|
||||
"@sideway/pinpoint@^2.0.0":
|
||||
version "2.0.0"
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/client",
|
||||
"version": "2.4.43",
|
||||
"version": "2.4.44-alpha.1",
|
||||
"license": "MPL-2.0",
|
||||
"module": "dist/budibase-client.js",
|
||||
"main": "dist/budibase-client.js",
|
||||
|
@ -19,11 +19,11 @@
|
|||
"dev:builder": "rollup -cw"
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^2.4.43",
|
||||
"@budibase/frontend-core": "^2.4.43",
|
||||
"@budibase/shared-core": "^2.4.43",
|
||||
"@budibase/string-templates": "^2.4.43",
|
||||
"@budibase/types": "^2.4.43",
|
||||
"@budibase/bbui": "2.4.44-alpha.1",
|
||||
"@budibase/frontend-core": "2.4.44-alpha.1",
|
||||
"@budibase/shared-core": "2.4.44-alpha.1",
|
||||
"@budibase/string-templates": "2.4.44-alpha.1",
|
||||
"@budibase/types": "2.4.44-alpha.1",
|
||||
"@spectrum-css/button": "^3.0.3",
|
||||
"@spectrum-css/card": "^3.0.3",
|
||||
"@spectrum-css/divider": "^1.0.3",
|
||||
|
|
|
@ -24,7 +24,6 @@
|
|||
// to render this part of the block, taking advantage of binding enrichment
|
||||
$: id = `${block.id}-${context ?? rand}`
|
||||
$: instance = {
|
||||
_blockElementHasChildren: $$slots?.default ?? false,
|
||||
_component: `@budibase/standard-components/${type}`,
|
||||
_id: id,
|
||||
_instanceName: name || type[0].toUpperCase() + type.slice(1),
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
// Provide contexts
|
||||
setContext("sdk", SDK)
|
||||
setContext("component", writable({}))
|
||||
setContext("component", writable({ id: null, ancestors: [] }))
|
||||
setContext("context", createContextStore())
|
||||
|
||||
let dataLoaded = false
|
||||
|
|
|
@ -26,21 +26,20 @@
|
|||
} from "stores"
|
||||
import { Helpers } from "@budibase/bbui"
|
||||
import { getActiveConditions, reduceConditionActions } from "utils/conditions"
|
||||
import Placeholder from "components/app/Placeholder.svelte"
|
||||
import EmptyPlaceholder from "components/app/EmptyPlaceholder.svelte"
|
||||
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
|
||||
import ComponentPlaceholder from "components/app/ComponentPlaceholder.svelte"
|
||||
import Skeleton from "components/app/Skeleton.svelte"
|
||||
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
|
||||
import { BudibasePrefix } from "../stores/components.js"
|
||||
|
||||
export let instance = {}
|
||||
export let isLayout = false
|
||||
export let isScreen = false
|
||||
export let isBlock = false
|
||||
export let parent = null
|
||||
|
||||
// Get parent contexts
|
||||
const context = getContext("context")
|
||||
const loading = getContext("loading")
|
||||
const insideScreenslot = !!getContext("screenslot")
|
||||
const component = getContext("component")
|
||||
|
||||
// Create component context
|
||||
const store = writable({})
|
||||
|
@ -122,6 +121,12 @@
|
|||
$: showEmptyState = definition?.showEmptyState !== false
|
||||
$: hasMissingRequiredSettings = missingRequiredSettings?.length > 0
|
||||
$: editable = !!definition?.editable && !hasMissingRequiredSettings
|
||||
$: requiredAncestors = definition?.requiredAncestors || []
|
||||
$: missingRequiredAncestors = requiredAncestors.filter(
|
||||
ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`)
|
||||
)
|
||||
$: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0
|
||||
$: errorState = hasMissingRequiredSettings || hasMissingRequiredAncestors
|
||||
|
||||
// Interactive components can be selected, dragged and highlighted inside
|
||||
// the builder preview
|
||||
|
@ -172,15 +177,6 @@
|
|||
$: pad = pad || (interactive && hasChildren && inDndPath)
|
||||
$: $dndIsDragging, (pad = false)
|
||||
|
||||
// Determine whether we should render a skeleton loader for this component
|
||||
$: showSkeleton =
|
||||
$loading &&
|
||||
definition?.name !== "Screenslot" &&
|
||||
children.length === 0 &&
|
||||
!instance._blockElementHasChildren &&
|
||||
!definition?.block &&
|
||||
definition?.skeleton !== false
|
||||
|
||||
// Update component context
|
||||
$: store.set({
|
||||
id,
|
||||
|
@ -194,6 +190,7 @@
|
|||
custom: customCSS,
|
||||
id,
|
||||
empty: emptyState,
|
||||
selected,
|
||||
interactive,
|
||||
draggable,
|
||||
editable,
|
||||
|
@ -204,7 +201,9 @@
|
|||
name,
|
||||
editing,
|
||||
type: instance._component,
|
||||
missingRequiredSettings,
|
||||
errorState,
|
||||
parent: id,
|
||||
ancestors: [...$component?.ancestors, instance._component],
|
||||
})
|
||||
|
||||
const initialise = (instance, force = false) => {
|
||||
|
@ -493,6 +492,7 @@
|
|||
getDataContext: () => get(context),
|
||||
reload: () => initialise(instance, true),
|
||||
setEphemeralStyles: styles => (ephemeralStyles = styles),
|
||||
state: store,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -507,12 +507,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
{#if showSkeleton}
|
||||
<Skeleton
|
||||
height={initialSettings?.height || definition?.size?.height || 0}
|
||||
width={initialSettings?.width || definition?.size?.width || 0}
|
||||
/>
|
||||
{:else if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden}
|
||||
{#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden}
|
||||
<!-- The ID is used as a class because getElementsByClassName is O(1) -->
|
||||
<!-- and the performance matters for the selection indicators -->
|
||||
<div
|
||||
|
@ -525,28 +520,34 @@
|
|||
class:pad
|
||||
class:parent={hasChildren}
|
||||
class:block={isBlock}
|
||||
class:error={errorState}
|
||||
data-id={id}
|
||||
data-name={name}
|
||||
data-icon={icon}
|
||||
data-parent={parent}
|
||||
data-parent={$component.id}
|
||||
>
|
||||
<svelte:component this={constructor} bind:this={ref} {...initialSettings}>
|
||||
{#if hasMissingRequiredSettings}
|
||||
<ComponentPlaceholder />
|
||||
{:else if children.length}
|
||||
{#each children as child (child._id)}
|
||||
<svelte:self instance={child} parent={id} />
|
||||
{/each}
|
||||
{:else if emptyState}
|
||||
{#if isScreen}
|
||||
<ScreenPlaceholder />
|
||||
{:else}
|
||||
<Placeholder />
|
||||
{#if errorState}
|
||||
<ComponentErrorState
|
||||
{missingRequiredSettings}
|
||||
{missingRequiredAncestors}
|
||||
/>
|
||||
{:else}
|
||||
<svelte:component this={constructor} bind:this={ref} {...initialSettings}>
|
||||
{#if children.length}
|
||||
{#each children as child (child._id)}
|
||||
<svelte:self instance={child} />
|
||||
{/each}
|
||||
{:else if emptyState}
|
||||
{#if isScreen}
|
||||
<ScreenPlaceholder />
|
||||
{:else}
|
||||
<EmptyPlaceholder />
|
||||
{/if}
|
||||
{:else if isBlock}
|
||||
<slot />
|
||||
{/if}
|
||||
{:else if isBlock}
|
||||
<slot />
|
||||
{/if}
|
||||
</svelte:component>
|
||||
</svelte:component>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script>
|
||||
import { writable } from "svelte/store"
|
||||
import { setContext, getContext, onMount } from "svelte"
|
||||
import Router, { querystring } from "svelte-spa-router"
|
||||
import { routeStore, stateStore } from "stores"
|
||||
|
@ -10,9 +9,6 @@
|
|||
const component = getContext("component")
|
||||
setContext("screenslot", true)
|
||||
|
||||
const loading = writable(false)
|
||||
setContext("loading", loading)
|
||||
|
||||
// Only wrap this as an array to take advantage of svelte keying,
|
||||
// to ensure the svelte-spa-router is fully remounted when route config
|
||||
// changes
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { builderStore } from "stores"
|
||||
|
||||
const component = getContext("component")
|
||||
|
||||
$: requiredSetting = $component.missingRequiredSettings?.[0]
|
||||
</script>
|
||||
|
||||
{#if $builderStore.inBuilder && requiredSetting}
|
||||
<div class="component-placeholder">
|
||||
<span>
|
||||
Add the <mark>{requiredSetting.label}</mark> setting to start using your component
|
||||
-
|
||||
</span>
|
||||
<span
|
||||
class="spectrum-Link"
|
||||
on:click={() => {
|
||||
builderStore.actions.highlightSetting(requiredSetting.key)
|
||||
}}
|
||||
>
|
||||
Show me
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.component-placeholder {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
.component-placeholder mark {
|
||||
background-color: var(--spectrum-global-color-gray-400);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.component-placeholder .spectrum-Link {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,6 @@
|
|||
<script>
|
||||
import { writable } from "svelte/store"
|
||||
import { setContext, getContext } from "svelte"
|
||||
import { Pagination } from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
import { Pagination, ProgressCircle } from "@budibase/bbui"
|
||||
import { fetchData, LuceneUtils } from "@budibase/frontend-core"
|
||||
|
||||
export let dataSource
|
||||
|
@ -14,11 +13,6 @@
|
|||
const { styleable, Provider, ActionTypes, API } = getContext("sdk")
|
||||
const component = getContext("component")
|
||||
|
||||
// Update loading state
|
||||
const parentLoading = getContext("loading")
|
||||
const loading = writable(true)
|
||||
setContext("loading", loading)
|
||||
|
||||
// We need to manage our lucene query manually as we want to allow components
|
||||
// to extend it
|
||||
let queryExtensions = {}
|
||||
|
@ -26,8 +20,8 @@
|
|||
$: query = extendQuery(defaultQuery, queryExtensions)
|
||||
|
||||
// Fetch data and refresh when needed
|
||||
$: fetch = createFetch(dataSource, $parentLoading)
|
||||
$: updateFetch({
|
||||
$: fetch = createFetch(dataSource)
|
||||
$: fetch.update({
|
||||
query,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
|
@ -35,9 +29,6 @@
|
|||
paginate,
|
||||
})
|
||||
|
||||
// Keep loading context updated
|
||||
$: loading.set($parentLoading || !$fetch.loaded)
|
||||
|
||||
// Build our action context
|
||||
$: actions = [
|
||||
{
|
||||
|
@ -89,18 +80,7 @@
|
|||
limit,
|
||||
}
|
||||
|
||||
const createFetch = (datasource, parentLoading) => {
|
||||
// Return a dummy fetch if parent is still loading. We do this so that we
|
||||
// can still properly subscribe to a valid fetch object and check all
|
||||
// properties, but we want to avoid fetching the real data until all parents
|
||||
// have finished loading.
|
||||
// This logic is only needed due to skeleton loaders, as previously we
|
||||
// simply blocked component rendering until data was ready.
|
||||
if (parentLoading) {
|
||||
return fetchData({ API })
|
||||
}
|
||||
|
||||
// Otherwise return the real thing
|
||||
const createFetch = datasource => {
|
||||
return fetchData({
|
||||
API,
|
||||
datasource,
|
||||
|
@ -114,14 +94,6 @@
|
|||
})
|
||||
}
|
||||
|
||||
const updateFetch = opts => {
|
||||
// Only update fetch if parents have stopped loading. Otherwise we will
|
||||
// trigger a fetch of the real data before parents are ready.
|
||||
if (!$parentLoading) {
|
||||
fetch.update(opts)
|
||||
}
|
||||
}
|
||||
|
||||
const addQueryExtension = (key, extension) => {
|
||||
if (!key || !extension) {
|
||||
return
|
||||
|
@ -155,17 +127,23 @@
|
|||
|
||||
<div use:styleable={$component.styles} class="container">
|
||||
<Provider {actions} data={dataContext}>
|
||||
<slot />
|
||||
{#if paginate && $fetch.supportsPagination}
|
||||
<div class="pagination">
|
||||
<Pagination
|
||||
page={$fetch.pageNumber + 1}
|
||||
hasPrevPage={$fetch.hasPrevPage}
|
||||
hasNextPage={$fetch.hasNextPage}
|
||||
goToPrevPage={fetch.prevPage}
|
||||
goToNextPage={fetch.nextPage}
|
||||
/>
|
||||
{#if !$fetch.loaded}
|
||||
<div class="loading">
|
||||
<ProgressCircle />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{#if paginate && $fetch.supportsPagination}
|
||||
<div class="pagination">
|
||||
<Pagination
|
||||
page={$fetch.pageNumber + 1}
|
||||
hasPrevPage={$fetch.hasPrevPage}
|
||||
hasNextPage={$fetch.hasNextPage}
|
||||
goToPrevPage={fetch.prevPage}
|
||||
goToNextPage={fetch.nextPage}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Provider>
|
||||
</div>
|
||||
|
@ -177,6 +155,13 @@
|
|||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
45
packages/client/src/components/app/EmptyPlaceholder.svelte
Normal file
45
packages/client/src/components/app/EmptyPlaceholder.svelte
Normal file
|
@ -0,0 +1,45 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
|
||||
const component = getContext("component")
|
||||
const { builderStore, componentStore } = getContext("sdk")
|
||||
|
||||
$: definition = componentStore.actions.getComponentDefinition($component.type)
|
||||
</script>
|
||||
|
||||
{#if $builderStore.inBuilder}
|
||||
<div class="component-placeholder">
|
||||
<Icon name="Help" color="var(--spectrum-global-color-blue-600)" />
|
||||
<span
|
||||
class="spectrum-Link"
|
||||
on:click={() => {
|
||||
builderStore.actions.requestAddComponent()
|
||||
}}
|
||||
>
|
||||
Add components inside your {definition?.name || $component.type}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.component-placeholder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
font-size: var(--font-size-s);
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
/* Common styles for all error states to use */
|
||||
.component-placeholder :global(mark) {
|
||||
background-color: var(--spectrum-global-color-gray-400);
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.component-placeholder :global(.spectrum-Link) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -12,25 +12,22 @@
|
|||
|
||||
const { Provider } = getContext("sdk")
|
||||
const component = getContext("component")
|
||||
const loading = getContext("loading")
|
||||
|
||||
// If the parent DataProvider is loading, fill the rows array with a number of empty objects corresponding to the DataProvider's page size; this allows skeleton loader components to be rendered further down the tree.
|
||||
$: rows = $loading
|
||||
? new Array(dataProvider.limit > 20 ? 20 : dataProvider.limit).fill({})
|
||||
: dataProvider?.rows
|
||||
$: rows = dataProvider?.rows ?? []
|
||||
$: loaded = dataProvider?.loaded ?? true
|
||||
</script>
|
||||
|
||||
<Container {direction} {hAlign} {vAlign} {gap} wrap>
|
||||
{#if $component.empty}
|
||||
<Placeholder />
|
||||
{:else if !$loading && rows.length === 0}
|
||||
<div class="noRows"><i class="ri-list-check-2" />{noRowsMessage}</div>
|
||||
{:else}
|
||||
{:else if rows.length > 0}
|
||||
{#each rows as row, index}
|
||||
<Provider data={{ ...row, index }}>
|
||||
<slot />
|
||||
</Provider>
|
||||
{/each}
|
||||
{:else if loaded && noRowsMessage}
|
||||
<div class="noRows"><i class="ri-list-check-2" />{noRowsMessage}</div>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue