Merge branch 'develop' into BUDI-6785-update-to-node-18-in-production

This commit is contained in:
Adria Navarro 2023-08-29 08:59:17 +02:00 committed by GitHub
commit 673e93af40
197 changed files with 3596 additions and 1776 deletions

View File

@ -69,11 +69,7 @@ jobs:
# Run build all the projects # Run build all the projects
- name: Build - name: Build
run: | run: |
if ${{ env.USE_NX_AFFECTED }}; then yarn build
yarn build --since=${{ env.NX_BASE_BRANCH }}
else
yarn build
fi
# Check the types of the projects built via esbuild # Check the types of the projects built via esbuild
- name: Check types - name: Check types
run: | run: |
@ -198,7 +194,8 @@ jobs:
node-version: 18.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- run: yarn build --scope @budibase/server --scope @budibase/worker --scope @budibase/client - name: Build packages
run: yarn build --scope @budibase/server --scope @budibase/worker --scope @budibase/client --scope @budibase/backend-core
- name: Run tests - name: Run tests
run: | run: |
cd qa-core cd qa-core

View File

@ -0,0 +1,19 @@
name: deploy-featurebranch
on:
pull_request:
branches:
- develop
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main
env:
BRANCH: ${{ github.head_ref }}
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

71
.vscode/launch.json vendored
View File

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

View File

@ -137,7 +137,6 @@ services:
path: /health path: /health
port: 10000 port: 10000
scheme: HTTP scheme: HTTP
enabled: true
periodSeconds: 3 periodSeconds: 3
failureThreshold: 1 failureThreshold: 1
livenessProbe: livenessProbe:
@ -170,7 +169,6 @@ services:
path: /health path: /health
port: 4002 port: 4002
scheme: HTTP scheme: HTTP
enabled: true
periodSeconds: 3 periodSeconds: 3
failureThreshold: 1 failureThreshold: 1
livenessProbe: livenessProbe:
@ -204,7 +202,6 @@ services:
path: /health path: /health
port: 4003 port: 4003
scheme: HTTP scheme: HTTP
enabled: true
periodSeconds: 3 periodSeconds: 3
failureThreshold: 1 failureThreshold: 1
livenessProbe: livenessProbe:
@ -411,14 +408,12 @@ couchdb:
## Ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/#configure-probes ## Ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/#configure-probes
# FOR COUCHDB # FOR COUCHDB
livenessProbe: livenessProbe:
enabled: true
failureThreshold: 3 failureThreshold: 3
initialDelaySeconds: 0 initialDelaySeconds: 0
periodSeconds: 10 periodSeconds: 10
successThreshold: 1 successThreshold: 1
timeoutSeconds: 1 timeoutSeconds: 1
readinessProbe: readinessProbe:
enabled: true
failureThreshold: 3 failureThreshold: 3
initialDelaySeconds: 0 initialDelaySeconds: 0
periodSeconds: 10 periodSeconds: 10

View File

@ -5,11 +5,11 @@ ENV COUCHDB_PASSWORD admin
EXPOSE 5984 EXPOSE 5984
RUN apt-get update && apt-get install -y --no-install-recommends software-properties-common wget unzip curl && \ RUN apt-get update && apt-get install -y --no-install-recommends software-properties-common wget unzip curl && \
wget -qO - https://adoptopenjdk.jfrog.io/adoptopenjdk/api/gpg/key/public | apt-key add - && \ wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | sudo apt-key add - && \
apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \ apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \
apt-add-repository 'deb http://archive.debian.org/debian stretch-backports main' && \ apt-add-repository 'deb http://archive.debian.org/debian stretch-backports main' && \
apt-add-repository --yes https://adoptopenjdk.jfrog.io/adoptopenjdk/deb/ && \ apt-add-repository 'deb https://packages.adoptium.net/artifactory/deb bullseye main' && \
apt-get update && apt-get install -y --no-install-recommends adoptopenjdk-8-hotspot && \ apt-get update && apt-get install -y --no-install-recommends temurin-8-jdk && \
rm -rf /var/lib/apt/lists/ rm -rf /var/lib/apt/lists/
# setup clouseau # setup clouseau

View File

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

View File

@ -27,6 +27,7 @@ services:
BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL} BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL}
BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD} BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD}
PLUGINS_DIR: ${PLUGINS_DIR} PLUGINS_DIR: ${PLUGINS_DIR}
OFFLINE_MODE: ${OFFLINE_MODE}
depends_on: depends_on:
- worker-service - worker-service
- redis-service - redis-service
@ -54,6 +55,7 @@ services:
INTERNAL_API_KEY: ${INTERNAL_API_KEY} INTERNAL_API_KEY: ${INTERNAL_API_KEY}
REDIS_URL: redis-service:6379 REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
OFFLINE_MODE: ${OFFLINE_MODE}
depends_on: depends_on:
- redis-service - redis-service
- minio-service - minio-service

View File

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

View File

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

View File

@ -1,7 +1,9 @@
{ {
"version": "2.9.23", "version": "2.9.33-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": ["packages/*"], "packages": [
"packages/*"
],
"useNx": true, "useNx": true,
"command": { "command": {
"publish": { "publish": {
@ -17,4 +19,4 @@
"loadEnvFiles": false "loadEnvFiles": false
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -215,7 +215,7 @@
const nameA = getDisplayName(a) const nameA = getDisplayName(a)
const nameB = getDisplayName(b) const nameB = getDisplayName(b)
if (orderA !== orderB) { if (orderA !== orderB) {
return orderA < orderB ? orderA : orderB return orderA < orderB ? a : b
} }
return nameA < nameB ? a : b return nameA < nameB ? a : b
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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