Merge pull request #2134 from Budibase/develop

Develop
This commit is contained in:
Martin McKeaveney 2021-07-27 17:03:32 +01:00 committed by GitHub
commit 2ffb76cc1d
236 changed files with 10814 additions and 2557 deletions

View File

@ -75,6 +75,28 @@
"design" "design"
] ]
}, },
{
"login": "Rory-Powell",
"name": "Rory Powell",
"avatar_url": "https://avatars.githubusercontent.com/u/8755148?v=4",
"profile": "https://github.com/Rory-Powell",
"contributions": [
"code",
"doc",
"test"
]
},
{
"login": "PClmnt",
"name": "Peter Clement",
"avatar_url": "https://avatars.githubusercontent.com/u/5665926?v=4",
"profile": "https://github.com/PClmnt",
"contributions": [
"code",
"doc",
"test"
]
},
{ {
"login": "Conor-Mack", "login": "Conor-Mack",
"name": "Conor_Mack", "name": "Conor_Mack",

View File

@ -3,4 +3,5 @@ public
dist dist
packages/server/builder packages/server/builder
packages/server/coverage packages/server/coverage
packages/server/client
packages/builder/.routify packages/builder/.routify

4
.github/AUTHORS.md vendored
View File

@ -6,4 +6,6 @@ Contributors
* Joe - [@joebudi](https://github.com/joebudi) * Joe - [@joebudi](https://github.com/joebudi)
* Martin McKeaveney - [@shogunpurple](https://github.com/shogunpurple) * Martin McKeaveney - [@shogunpurple](https://github.com/shogunpurple)
* Andrew Kingston - [@aptkingston](https://github.com/aptkingston) * Andrew Kingston - [@aptkingston](https://github.com/aptkingston)
* Michael Drury - [@mike12345567](https://github.com/mike12345567) * Michael Drury - [@mike12345567](https://github.com/mike12345567)
* Peter Clement - [@PClmnt](https://github.com/PClmnt)
* Rory Powell - [@Rory-Powell](https://github.com/Rory-Powell)

View File

@ -126,7 +126,16 @@ To run the budibase server and builder in dev mode (i.e. with live reloading):
This will enable watch mode for both the builder app, server, client library and any component libraries. This will enable watch mode for both the builder app, server, client library and any component libraries.
### 5. Cleanup ### 5. Debugging using VS Code
To debug the budibase server and worker a VS Code launch configuration has been provided.
Visit the debug window and select `Budibase Server` or `Budibase Worker` to debug the respective component.
Alternatively to start both components simultaneously select `Start Budibase`.
In addition to the above, the remaining budibase components may be ran in dev mode using: `yarn dev:noserver`.
### 6. Cleanup
If you wish to delete all the apps created in development and reset the environment then run the following: If you wish to delete all the apps created in development and reset the environment then run the following:

View File

@ -42,15 +42,3 @@ jobs:
name: codecov-umbrella name: codecov-umbrella
verbose: true verbose: true
- run: yarn test:e2e:ci - run: yarn test:e2e:ci
- name: Build and Push Development Docker Image
# Only run on push
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build
yarn build:docker:develop
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}

52
.github/workflows/release-develop.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Budibase Release Staging
on:
push:
branches:
- develop
env:
POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12.x
- run: yarn
- run: yarn bootstrap
- run: yarn lint
- run: yarn build
- run: yarn test
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- name: Publish budibase packages to NPM
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default
git config user.name "Budibase Staging Release Bot"
git config user.email "<>"
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release:develop
- name: Build/release Docker images
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build
yarn build:docker:develop
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}

View File

@ -5,4 +5,5 @@ dist
packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte
packages/server/builder packages/server/builder
packages/server/coverage packages/server/coverage
packages/server/client
packages/builder/.routify packages/builder/.routify

21
.vscode/launch.json vendored
View File

@ -21,6 +21,27 @@
"args": [], "args": [],
"cwd":"C:/code/my-apps", "cwd":"C:/code/my-apps",
"console": "externalTerminal" "console": "externalTerminal"
},
{
"name": "Budibase Server",
"type": "node",
"request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
"args": ["${workspaceFolder}/packages/server/src/index.ts"],
"cwd": "${workspaceFolder}/packages/server"
},
{
"name": "Budibase Worker",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/packages/worker/src/index.js",
"cwd": "${workspaceFolder}/packages/worker"
}
],
"compounds": [
{
"name": "Start Budibase",
"configurations": ["Budibase Server", "Budibase Worker"]
} }
] ]
} }

View File

@ -211,9 +211,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td> <td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td> <td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td>
<td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td> <td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td> <td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a></td>
</tr> </tr>
<tr> <tr>
<td align="center"><a href="https://github.com/PClmnt"><img src="https://avatars.githubusercontent.com/u/5665926?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Peter Clement</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td> <td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td> <td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td> <td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td>

View File

@ -1,5 +1,5 @@
{ {
"version": "0.9.82", "version": "0.9.83-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -23,6 +23,7 @@
"publishdev": "lerna run publishdev", "publishdev": "lerna run publishdev",
"publishnpm": "yarn build && lerna publish --force-publish", "publishnpm": "yarn build && lerna publish --force-publish",
"release": "yarn build && lerna publish patch --yes --force-publish", "release": "yarn build && lerna publish patch --yes --force-publish",
"release:develop": "yarn build && lerna publish prerelease --yes --force-publish --dist-tag develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build", "restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",

3
packages/auth/cache.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
user: require("./src/cache/user"),
}

View File

@ -1,11 +1,16 @@
{ {
"name": "@budibase/auth", "name": "@budibase/auth",
"version": "0.9.82", "version": "0.9.83-alpha.0",
"description": "Authentication middlewares for budibase builder and apps", "description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll"
},
"dependencies": { "dependencies": {
"@techpass/passport-openidconnect": "^0.3.0",
"aws-sdk": "^2.901.0", "aws-sdk": "^2.901.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"ioredis": "^4.27.1", "ioredis": "^4.27.1",
@ -22,8 +27,17 @@
"uuid": "^8.3.2", "uuid": "^8.3.2",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"jest": {
"setupFiles": [
"./scripts/jestSetup.js"
]
},
"devDependencies": { "devDependencies": {
"ioredis-mock": "^5.5.5" "ioredis-mock": "^5.5.5",
"jest": "^26.6.3",
"pouchdb": "^7.2.1",
"pouchdb-adapter-memory": "^7.2.2",
"pouchdb-all-dbs": "^1.0.2"
}, },
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc" "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

@ -0,0 +1,5 @@
const env = require("../src/environment")
env._set("NODE_ENV", "jest")
env._set("JWT_SECRET", "test-jwtsecret")
env._set("LOG_LEVEL", "silent")

View File

@ -0,0 +1 @@
module.exports = require("./src/security/sessions")

21
packages/auth/src/cache/user.js vendored Normal file
View File

@ -0,0 +1,21 @@
const { getDB } = require("../db")
const { StaticDatabases } = require("../db/utils")
const redis = require("../redis/authRedis")
const EXPIRY_SECONDS = 3600
exports.getUser = async userId => {
const client = await redis.getUserClient()
// try cache
let user = await client.get(userId)
if (!user) {
user = await getDB(StaticDatabases.GLOBAL.name).get(userId)
client.store(userId, user, EXPIRY_SECONDS)
}
return user
}
exports.invalidateUser = async userId => {
const client = await redis.getUserClient()
await client.delete(userId)
}

View File

@ -6,6 +6,7 @@ exports.UserStatus = {
exports.Cookies = { exports.Cookies = {
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
Auth: "budibase:auth", Auth: "budibase:auth",
OIDC_CONFIG: "budibase:oidc:config",
} }
exports.Headers = { exports.Headers = {
@ -27,4 +28,6 @@ exports.Configs = {
ACCOUNT: "account", ACCOUNT: "account",
SMTP: "smtp", SMTP: "smtp",
GOOGLE: "google", GOOGLE: "google",
OIDC: "oidc",
OIDC_LOGOS: "logos_oidc",
} }

View File

@ -17,4 +17,8 @@ module.exports = {
MINIO_URL: process.env.MINIO_URL, MINIO_URL: process.env.MINIO_URL,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
isTest, isTest,
_set(key, value) {
process.env[key] = value
module.exports[key] = value
},
} }

View File

@ -2,8 +2,16 @@ const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
const { StaticDatabases } = require("./db/utils") const { StaticDatabases } = require("./db/utils")
const { jwt, local, authenticated, google, auditLog } = require("./middleware") const {
jwt,
local,
authenticated,
google,
oidc,
auditLog,
} = require("./middleware")
const { setDB, getDB } = require("./db") const { setDB, getDB } = require("./db")
const userCache = require("./cache/user")
// Strategies // Strategies
passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new LocalStrategy(local.options, local.authenticate))
@ -44,9 +52,13 @@ module.exports = {
buildAuthMiddleware: authenticated, buildAuthMiddleware: authenticated,
passport, passport,
google, google,
oidc,
jwt: require("jsonwebtoken"), jwt: require("jsonwebtoken"),
auditLog, auditLog,
}, },
cache: {
user: userCache,
},
StaticDatabases, StaticDatabases,
constants: require("./constants"), constants: require("./constants"),
} }

View File

@ -1,7 +1,7 @@
const { Cookies, Headers } = require("../constants") const { Cookies, Headers } = require("../constants")
const database = require("../db")
const { getCookie, clearCookie } = require("../utils") const { getCookie, clearCookie } = require("../utils")
const { StaticDatabases } = require("../db/utils") const { getUser } = require("../cache/user")
const { getSession, updateSessionTTL } = require("../security/sessions")
const env = require("../environment") const env = require("../environment")
const PARAM_REGEX = /\/:(.*?)\//g const PARAM_REGEX = /\/:(.*?)\//g
@ -50,14 +50,27 @@ module.exports = (noAuthPatterns = [], opts) => {
user = null, user = null,
internal = false internal = false
if (authCookie) { if (authCookie) {
try { let error = null
const db = database.getDB(StaticDatabases.GLOBAL.name) const sessionId = authCookie.sessionId,
user = await db.get(authCookie.userId) userId = authCookie.userId
delete user.password const session = await getSession(userId, sessionId)
authenticated = true if (!session) {
} catch (err) { error = "No session found"
// remove the cookie as the use does not exist anymore } else {
try {
user = await getUser(userId)
delete user.password
authenticated = true
} catch (err) {
error = err
}
}
if (error) {
// remove the cookie as the user does not exist anymore
clearCookie(ctx, Cookies.Auth) clearCookie(ctx, Cookies.Auth)
} else {
// make sure we denote that the session is still in use
await updateSessionTTL(session)
} }
} }
const apiKey = ctx.request.headers[Headers.API_KEY] const apiKey = ctx.request.headers[Headers.API_KEY]

View File

@ -1,11 +1,13 @@
const jwt = require("./passport/jwt") const jwt = require("./passport/jwt")
const local = require("./passport/local") const local = require("./passport/local")
const google = require("./passport/google") const google = require("./passport/google")
const oidc = require("./passport/oidc")
const authenticated = require("./authenticated") const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
module.exports = { module.exports = {
google, google,
oidc,
jwt, jwt,
local, local,
authenticated, authenticated,

View File

@ -1,75 +1,25 @@
const env = require("../../environment")
const jwt = require("jsonwebtoken")
const database = require("../../db")
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
const {
StaticDatabases,
generateGlobalUserID,
ViewNames,
} = require("../../db/utils")
async function authenticate(token, tokenSecret, profile, done) { const { authenticateThirdParty } = require("./third-party-common")
// Check the user exists in the instance DB by email
const db = database.getDB(StaticDatabases.GLOBAL.name)
let dbUser async function authenticate(accessToken, refreshToken, profile, done) {
const thirdPartyUser = {
const userId = generateGlobalUserID(profile.id) provider: profile.provider, // should always be 'google'
providerType: "google",
try { userId: profile.id,
// use the google profile id profile: profile,
dbUser = await db.get(userId) email: profile._json.email,
} catch (err) { oauth2: {
const user = { accessToken: accessToken,
_id: userId, refreshToken: refreshToken,
provider: profile.provider, },
roles: {},
...profile._json,
}
// check if an account with the google email address exists locally
const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
key: profile._json.email,
include_docs: true,
})
// Google user already exists by email
if (users.rows.length > 0) {
const existing = users.rows[0].doc
// remove the local account to avoid conflicts
await db.remove(existing._id, existing._rev)
// merge with existing account
user.roles = existing.roles
user.builder = existing.builder
user.admin = existing.admin
const response = await db.post(user)
dbUser = user
dbUser._rev = response.rev
} else {
return done(
new Error(
"email does not yet exist. You must set up your local budibase account first."
),
false
)
}
} }
// authenticate return authenticateThirdParty(
const payload = { thirdPartyUser,
userId: dbUser._id, true, // require local accounts to exist
builder: dbUser.builder, done
email: dbUser.email, )
}
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: "1 day",
})
return done(null, dbUser)
} }
/** /**
@ -100,3 +50,5 @@ exports.strategyFactory = async function (config) {
throw new Error("Error constructing google authentication strategy", err) throw new Error("Error constructing google authentication strategy", err)
} }
} }
// expose for testing
exports.authenticate = authenticate

View File

@ -1,5 +1,6 @@
const { Cookies } = require("../../constants") const { Cookies } = require("../../constants")
const env = require("../../environment") const env = require("../../environment")
const { authError } = require("./utils")
exports.options = { exports.options = {
secretOrKey: env.JWT_SECRET, secretOrKey: env.JWT_SECRET,
@ -12,6 +13,6 @@ exports.authenticate = async function (jwt, done) {
try { try {
return done(null, jwt) return done(null, jwt)
} catch (err) { } catch (err) {
return done(new Error("JWT invalid."), false) return authError(done, "JWT invalid", err)
} }
} }

View File

@ -3,6 +3,9 @@ const { UserStatus } = require("../../constants")
const { compare } = require("../../hashing") const { compare } = require("../../hashing")
const env = require("../../environment") const env = require("../../environment")
const { getGlobalUserByEmail } = require("../../utils") const { getGlobalUserByEmail } = require("../../utils")
const { authError } = require("./utils")
const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions")
const INVALID_ERR = "Invalid Credentials" const INVALID_ERR = "Invalid Credentials"
@ -16,33 +19,36 @@ exports.options = {}
* @returns The authenticated user, or errors if they occur * @returns The authenticated user, or errors if they occur
*/ */
exports.authenticate = async function (email, password, done) { exports.authenticate = async function (email, password, done) {
if (!email) return done(null, false, "Email Required.") if (!email) return authError(done, "Email Required")
if (!password) return done(null, false, "Password Required.") if (!password) return authError(done, "Password Required")
const dbUser = await getGlobalUserByEmail(email) const dbUser = await getGlobalUserByEmail(email)
if (dbUser == null) { if (dbUser == null) {
return done(null, false, { message: "User not found" }) return authError(done, "User not found")
} }
// check that the user is currently inactive, if this is the case throw invalid // check that the user is currently inactive, if this is the case throw invalid
if (dbUser.status === UserStatus.INACTIVE) { if (dbUser.status === UserStatus.INACTIVE) {
return done(null, false, { message: INVALID_ERR }) return authError(done, INVALID_ERR)
} }
// authenticate // authenticate
if (await compare(password, dbUser.password)) { if (await compare(password, dbUser.password)) {
const payload = { const sessionId = newid()
userId: dbUser._id, await createASession(dbUser._id, sessionId)
}
dbUser.token = jwt.sign(payload, env.JWT_SECRET, { dbUser.token = jwt.sign(
expiresIn: "1 day", {
}) userId: dbUser._id,
sessionId,
},
env.JWT_SECRET
)
// Remove users password in payload // Remove users password in payload
delete dbUser.password delete dbUser.password
return done(null, dbUser) return done(null, dbUser)
} else { } else {
done(new Error(INVALID_ERR), false) return authError(done, INVALID_ERR)
} }
} }

View File

@ -0,0 +1,128 @@
const fetch = require("node-fetch")
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common")
/**
* @param {*} issuer The identity provider base URL
* @param {*} sub The user ID
* @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} jwtClaims The parsed id_token claims
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param {*} idToken The id_token - always a JWT
* @param {*} params The response body from requesting an access_token
* @param {*} done The passport callback: err, user, info
*/
async function authenticate(
issuer,
sub,
profile,
jwtClaims,
accessToken,
refreshToken,
idToken,
params,
done
) {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
false, // don't require local accounts to exist
done
)
}
/**
* @param {*} profile The structured profile created by passport using the user info endpoint
* @param {*} jwtClaims The claims returned in the id token
*/
function getEmail(profile, jwtClaims) {
// profile not guaranteed to contain email e.g. github connected azure ad account
if (profile._json.email) {
return profile._json.email
}
// fallback to id token email
if (jwtClaims.email) {
return jwtClaims.email
}
// fallback to id token preferred username
const username = jwtClaims.preferred_username
if (username && validEmail(username)) {
return username
}
throw new Error(
`Could not determine user email from profile ${JSON.stringify(
profile
)} and claims ${JSON.stringify(jwtClaims)}`
)
}
function validEmail(value) {
return (
value &&
!!value.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
)
)
}
/**
* Create an instance of the oidc passport strategy. This wrapper fetches the configuration
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport OIDC Strategy
*/
exports.strategyFactory = async function (config, callbackUrl) {
try {
const { clientID, clientSecret, configUrl } = config
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
throw new Error(
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
)
}
const response = await fetch(configUrl)
if (!response.ok) {
throw new Error(
`Unexpected response when fetching openid-configuration: ${response.statusText}`
)
}
const body = await response.json()
return new OIDCStrategy(
{
issuer: body.issuer,
authorizationURL: body.authorization_endpoint,
tokenURL: body.token_endpoint,
userInfoURL: body.userinfo_endpoint,
clientID: clientID,
clientSecret: clientSecret,
callbackURL: callbackUrl,
},
authenticate
)
} catch (err) {
console.error(err)
throw new Error("Error constructing OIDC authentication strategy", err)
}
}
// expose for testing
exports.authenticate = authenticate

View File

@ -0,0 +1,74 @@
// Mock data
const { data } = require("./utilities/mock-data")
const googleConfig = {
callbackURL: "http://somecallbackurl",
clientID: data.clientID,
clientSecret: data.clientSecret,
}
const profile = {
id: "mockId",
_json: {
email : data.email
},
provider: "google"
}
const user = data.buildThirdPartyUser("google", "google", profile)
describe("google", () => {
describe("strategyFactory", () => {
// mock passport strategy factory
jest.mock("passport-google-oauth")
const mockStrategy = require("passport-google-oauth").OAuth2Strategy
it("should create successfully create a google strategy", async () => {
const google = require("../google")
await google.strategyFactory(googleConfig)
const expectedOptions = {
clientID: googleConfig.clientID,
clientSecret: googleConfig.clientSecret,
callbackURL: googleConfig.callbackURL,
}
expect(mockStrategy).toHaveBeenCalledWith(
expectedOptions,
expect.anything()
)
})
})
describe("authenticate", () => {
afterEach(() => {
jest.clearAllMocks();
});
// mock third party common authentication
jest.mock("../third-party-common")
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
// mock the passport callback
const mockDone = jest.fn()
it("delegates authentication to third party common", async () => {
const google = require("../google")
await google.authenticate(
data.accessToken,
data.refreshToken,
profile,
mockDone
)
expect(authenticateThirdParty).toHaveBeenCalledWith(
user,
true,
mockDone)
})
})
})

View File

@ -0,0 +1,143 @@
// Mock data
const { data } = require("./utilities/mock-data")
const issuer = "mockIssuer"
const sub = "mockSub"
const profile = {
id: "mockId",
_json: {
email : data.email
}
}
let jwtClaims = {}
const idToken = "mockIdToken"
const params = {}
const callbackUrl = "http://somecallbackurl"
// response from .well-known/openid-configuration
const oidcConfigUrlResponse = {
issuer: issuer,
authorization_endpoint: "mockAuthorizationEndpoint",
token_endpoint: "mockTokenEndpoint",
userinfo_endpoint: "mockUserInfoEndpoint"
}
const oidcConfig = {
configUrl: "http://someconfigurl",
clientID: data.clientID,
clientSecret: data.clientSecret,
}
const user = data.buildThirdPartyUser(issuer, "oidc", profile)
describe("oidc", () => {
describe("strategyFactory", () => {
// mock passport strategy factory
jest.mock("@techpass/passport-openidconnect")
const mockStrategy = require("@techpass/passport-openidconnect").Strategy
// mock the request to retrieve the oidc configuration
jest.mock("node-fetch")
const mockFetch = require("node-fetch")
mockFetch.mockReturnValue({
ok: true,
json: () => oidcConfigUrlResponse
})
it("should create successfully create an oidc strategy", async () => {
const oidc = require("../oidc")
await oidc.strategyFactory(oidcConfig, callbackUrl)
expect(mockFetch).toHaveBeenCalledWith(oidcConfig.configUrl)
const expectedOptions = {
issuer: oidcConfigUrlResponse.issuer,
authorizationURL: oidcConfigUrlResponse.authorization_endpoint,
tokenURL: oidcConfigUrlResponse.token_endpoint,
userInfoURL: oidcConfigUrlResponse.userinfo_endpoint,
clientID: oidcConfig.clientID,
clientSecret: oidcConfig.clientSecret,
callbackURL: callbackUrl,
}
expect(mockStrategy).toHaveBeenCalledWith(
expectedOptions,
expect.anything()
)
})
})
describe("authenticate", () => {
afterEach(() => {
jest.clearAllMocks();
});
// mock third party common authentication
jest.mock("../third-party-common")
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
// mock the passport callback
const mockDone = jest.fn()
async function doAuthenticate() {
const oidc = require("../oidc")
await oidc.authenticate(
issuer,
sub,
profile,
jwtClaims,
data.accessToken,
data.refreshToken,
idToken,
params,
mockDone
)
}
async function doTest() {
await doAuthenticate()
expect(authenticateThirdParty).toHaveBeenCalledWith(
user,
false,
mockDone)
}
it("delegates authentication to third party common", async () => {
doTest()
})
it("uses JWT email to get email", async () => {
delete profile._json.email
jwtClaims = {
email : "mock@budibase.com"
}
doTest()
})
it("uses JWT username to get email", async () => {
delete profile._json.email
jwtClaims = {
preferred_username : "mock@budibase.com"
}
doTest()
})
it("uses JWT invalid username to get email", async () => {
delete profile._json.email
jwtClaims = {
preferred_username : "invalidUsername"
}
await expect(doAuthenticate()).rejects.toThrow("Could not determine user email from profile");
})
})
})

View File

@ -0,0 +1,152 @@
// Mock data
require("./utilities/test-config")
const database = require("../../../db")
const { authenticateThirdParty } = require("../third-party-common")
const { data } = require("./utilities/mock-data")
const {
StaticDatabases,
generateGlobalUserID
} = require("../../../db/utils")
const { newid } = require("../../../hashing")
let db
const done = jest.fn()
const getErrorMessage = () => {
return done.mock.calls[0][2].message
}
describe("third party common", () => {
describe("authenticateThirdParty", () => {
let thirdPartyUser
beforeEach(() => {
db = database.getDB(StaticDatabases.GLOBAL.name)
thirdPartyUser = data.buildThirdPartyUser()
})
afterEach(async () => {
jest.clearAllMocks()
await db.destroy()
})
describe("validation", () => {
const testValidation = async (message) => {
await authenticateThirdParty(thirdPartyUser, false, done)
expect(done.mock.calls.length).toBe(1)
expect(getErrorMessage()).toContain(message)
}
it("provider fails", async () => {
delete thirdPartyUser.provider
testValidation("third party user provider required")
})
it("user id fails", async () => {
delete thirdPartyUser.userId
testValidation("third party user id required")
})
it("email fails", async () => {
delete thirdPartyUser.email
testValidation("third party user email required")
})
})
const expectUserIsAuthenticated = () => {
const user = done.mock.calls[0][1]
expect(user).toBeDefined()
expect(user._id).toBeDefined()
expect(user._rev).toBeDefined()
expect(user.token).toBeDefined()
return user
}
const expectUserIsSynced = (user, thirdPartyUser) => {
expect(user.provider).toBe(thirdPartyUser.provider)
expect(user.email).toBe(thirdPartyUser.email)
expect(user.firstName).toBe(thirdPartyUser.profile.name.givenName)
expect(user.lastName).toBe(thirdPartyUser.profile.name.familyName)
expect(user.thirdPartyProfile).toStrictEqual(thirdPartyUser.profile._json)
expect(user.oauth2).toStrictEqual(thirdPartyUser.oauth2)
}
describe("when the user doesn't exist", () => {
describe("when a local account is required", () => {
it("returns an error message", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
expect(done.mock.calls.length).toBe(1)
expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.")
})
})
describe("when a local account isn't required", () => {
it("creates and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, false, done)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expect(user.roles).toStrictEqual({})
})
})
})
describe("when the user exists", () => {
let dbUser
let id
let email
const createUser = async () => {
dbUser = {
_id: id,
email: email,
}
const response = await db.post(dbUser)
dbUser._rev = response.rev
}
const expectUserIsUpdated = (user) => {
// id is unchanged
expect(user._id).toBe(id)
// user is updated
expect(user._rev).not.toBe(dbUser._rev)
}
describe("exists by email", () => {
beforeEach(async () => {
id = generateGlobalUserID(newid()) // random id
email = thirdPartyUser.email // matching email
await createUser()
})
it("syncs and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expectUserIsUpdated(user)
})
})
describe("exists by id", () => {
beforeEach(async () => {
id = generateGlobalUserID(thirdPartyUser.userId) // matching id
email = "test@test.com" // random email
await createUser()
})
it("syncs and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expectUserIsUpdated(user)
})
})
})
})
})

View File

@ -0,0 +1,20 @@
const PouchDB = require("pouchdb")
const allDbs = require("pouchdb-all-dbs")
const env = require("../../../../environment")
let POUCH_DB_DEFAULTS
// should always be test but good to do the sanity check
if (env.isTest()) {
PouchDB.plugin(require("pouchdb-adapter-memory"))
POUCH_DB_DEFAULTS = {
prefix: undefined,
adapter: "memory",
}
}
const Pouch = PouchDB.defaults(POUCH_DB_DEFAULTS)
allDbs(Pouch)
module.exports = Pouch

View File

@ -0,0 +1,54 @@
// Mock Data
const mockClientID = "mockClientID"
const mockClientSecret = "mockClientSecret"
const mockEmail = "mock@budibase.com"
const mockAccessToken = "mockAccessToken"
const mockRefreshToken = "mockRefreshToken"
const mockProvider = "mockProvider"
const mockProviderType = "mockProviderType"
const mockProfile = {
id: "mockId",
name: {
givenName: "mockGivenName",
familyName: "mockFamilyName",
},
_json: {
email: mockEmail,
},
}
const buildOauth2 = (
accessToken = mockAccessToken,
refreshToken = mockRefreshToken
) => ({
accessToken: accessToken,
refreshToken: refreshToken,
})
const buildThirdPartyUser = (
provider = mockProvider,
providerType = mockProviderType,
profile = mockProfile,
email = mockEmail,
oauth2 = buildOauth2()
) => ({
provider: provider,
providerType: providerType,
userId: profile.id,
profile: profile,
email: email,
oauth2: oauth2,
})
exports.data = {
clientID: mockClientID,
clientSecret: mockClientSecret,
email: mockEmail,
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
buildThirdPartyUser,
}

View File

@ -0,0 +1,3 @@
const packageConfiguration = require("../../../../index")
const CouchDB = require("./db")
packageConfiguration.init(CouchDB)

View File

@ -0,0 +1,129 @@
const env = require("../../environment")
const jwt = require("jsonwebtoken")
const database = require("../../db")
const { StaticDatabases, generateGlobalUserID } = require("../../db/utils")
const { authError } = require("./utils")
const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions")
const { getGlobalUserByEmail } = require("../../utils")
/**
* Common authentication logic for third parties. e.g. OAuth, OIDC.
*/
exports.authenticateThirdParty = async function (
thirdPartyUser,
requireLocalAccount = true,
done
) {
if (!thirdPartyUser.provider)
return authError(done, "third party user provider required")
if (!thirdPartyUser.userId)
return authError(done, "third party user id required")
if (!thirdPartyUser.email)
return authError(done, "third party user email required")
const db = database.getDB(StaticDatabases.GLOBAL.name)
let dbUser
// use the third party id
const userId = generateGlobalUserID(thirdPartyUser.userId)
// try to load by id
try {
dbUser = await db.get(userId)
} catch (err) {
// abort when not 404 error
if (!err.status || err.status !== 404) {
return authError(
done,
"Unexpected error when retrieving existing user",
err
)
}
}
// fallback to loading by email
if (!dbUser) {
dbUser = await getGlobalUserByEmail(thirdPartyUser.email)
}
// exit early if there is still no user and auto creation is disabled
if (!dbUser && requireLocalAccount) {
return authError(
done,
"Email does not yet exist. You must set up your local budibase account first."
)
}
// first time creation
if (!dbUser) {
// setup a blank user using the third party id
dbUser = {
_id: userId,
roles: {},
}
}
dbUser = syncUser(dbUser, thirdPartyUser)
// create or sync the user
const response = await db.post(dbUser)
dbUser._rev = response.rev
// authenticate
const sessionId = newid()
await createASession(dbUser._id, sessionId)
dbUser.token = jwt.sign(
{
userId: dbUser._id,
sessionId,
},
env.JWT_SECRET
)
return done(null, dbUser)
}
/**
* @returns a user that has been sync'd with third party information
*/
function syncUser(user, thirdPartyUser) {
// provider
user.provider = thirdPartyUser.provider
user.providerType = thirdPartyUser.providerType
// email
user.email = thirdPartyUser.email
if (thirdPartyUser.profile) {
const profile = thirdPartyUser.profile
if (profile.name) {
const name = profile.name
// first name
if (name.givenName) {
user.firstName = name.givenName
}
// last name
if (name.familyName) {
user.lastName = name.familyName
}
}
// profile
user.thirdPartyProfile = {
...profile._json,
}
}
// oauth tokens for future use
if (thirdPartyUser.oauth2) {
user.oauth2 = {
...thirdPartyUser.oauth2,
}
}
return user
}

View File

@ -0,0 +1,14 @@
/**
* Utility to handle authentication errors.
*
* @param {*} done The passport callback.
* @param {*} message Message that will be returned in the response body
* @param {*} err (Optional) error that will be logged
*/
exports.authError = function (done, message, err = null) {
return done(
err,
null, // never return a user
{ message: message }
)
}

View File

@ -22,11 +22,13 @@ const CONTENT_TYPE_MAP = {
html: "text/html", html: "text/html",
css: "text/css", css: "text/css",
js: "application/javascript", js: "application/javascript",
json: "application/json",
} }
const STRING_CONTENT_TYPES = [ const STRING_CONTENT_TYPES = [
CONTENT_TYPE_MAP.html, CONTENT_TYPE_MAP.html,
CONTENT_TYPE_MAP.css, CONTENT_TYPE_MAP.css,
CONTENT_TYPE_MAP.js, CONTENT_TYPE_MAP.js,
CONTENT_TYPE_MAP.json,
] ]
// does normal sanitization and then swaps dev apps to apps // does normal sanitization and then swaps dev apps to apps

View File

@ -0,0 +1,29 @@
const Client = require("./index")
const utils = require("./utils")
let userClient, sessionClient
async function init() {
userClient = await new Client(utils.Databases.USER_CACHE).init()
sessionClient = await new Client(utils.Databases.SESSIONS).init()
}
process.on("exit", async () => {
if (userClient) await userClient.finish()
if (sessionClient) await sessionClient.finish()
})
module.exports = {
getUserClient: async () => {
if (!userClient) {
await init()
}
return userClient
},
getSessionClient: async () => {
if (!sessionClient) {
await init()
}
return sessionClient
},
}

View File

@ -1,7 +1,12 @@
const env = require("../environment") const env = require("../environment")
// ioredis mock is all in memory // ioredis mock is all in memory
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis") const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis")
const { addDbPrefix, removeDbPrefix, getRedisOptions } = require("./utils") const {
addDbPrefix,
removeDbPrefix,
getRedisOptions,
SEPARATOR,
} = require("./utils")
const RETRY_PERIOD_MS = 2000 const RETRY_PERIOD_MS = 2000
const STARTUP_TIMEOUT_MS = 5000 const STARTUP_TIMEOUT_MS = 5000
@ -143,14 +148,15 @@ class RedisWrapper {
CLIENT.disconnect() CLIENT.disconnect()
} }
async scan() { async scan(key = "") {
const db = this._db const db = this._db
key = `${db}${SEPARATOR}${key}`
let stream let stream
if (CLUSTERED) { if (CLUSTERED) {
let node = CLIENT.nodes("master") let node = CLIENT.nodes("master")
stream = node[0].scanStream({ match: db + "-*", count: 100 }) stream = node[0].scanStream({ match: key + "*", count: 100 })
} else { } else {
stream = CLIENT.scanStream({ match: db + "-*", count: 100 }) stream = CLIENT.scanStream({ match: key + "*", count: 100 })
} }
return promisifyStream(stream) return promisifyStream(stream)
} }
@ -182,6 +188,12 @@ class RedisWrapper {
} }
} }
async setExpiry(key, expirySeconds) {
const db = this._db
const prefixedKey = addDbPrefix(db, key)
await CLIENT.expire(prefixedKey, expirySeconds)
}
async delete(key) { async delete(key) {
const db = this._db const db = this._db
await CLIENT.del(addDbPrefix(db, key)) await CLIENT.del(addDbPrefix(db, key))

View File

@ -11,8 +11,12 @@ exports.Databases = {
INVITATIONS: "invitation", INVITATIONS: "invitation",
DEV_LOCKS: "devLocks", DEV_LOCKS: "devLocks",
DEBOUNCE: "debounce", DEBOUNCE: "debounce",
SESSIONS: "session",
USER_CACHE: "users",
} }
exports.SEPARATOR = SEPARATOR
exports.getRedisOptions = (clustered = false) => { exports.getRedisOptions = (clustered = false) => {
const [host, port] = REDIS_URL.split(":") const [host, port] = REDIS_URL.split(":")
const opts = { const opts = {

View File

@ -0,0 +1,69 @@
const redis = require("../redis/authRedis")
const EXPIRY_SECONDS = 86400
async function getSessionsForUser(userId) {
const client = await redis.getSessionClient()
const sessions = await client.scan(userId)
return sessions.map(session => session.value)
}
function makeSessionID(userId, sessionId) {
return `${userId}/${sessionId}`
}
exports.createASession = async (userId, sessionId) => {
const client = await redis.getSessionClient()
const session = {
createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(),
sessionId,
userId,
}
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
}
exports.invalidateSessions = async (userId, sessionId = null) => {
let sessions = []
if (sessionId) {
sessions.push({ key: makeSessionID(userId, sessionId) })
} else {
sessions = await getSessionsForUser(userId)
}
const client = await redis.getSessionClient()
const promises = []
for (let session of sessions) {
promises.push(client.delete(session.key))
}
await Promise.all(promises)
}
exports.updateSessionTTL = async session => {
const client = await redis.getSessionClient()
const key = makeSessionID(session.userId, session.sessionId)
session.lastAccessedAt = new Date().toISOString()
await client.store(key, session, EXPIRY_SECONDS)
}
exports.endSession = async (userId, sessionId) => {
const client = await redis.getSessionClient()
await client.delete(makeSessionID(userId, sessionId))
}
exports.getUserSessions = getSessionsForUser
exports.getSession = async (userId, sessionId) => {
try {
const client = await redis.getSessionClient()
return client.get(makeSessionID(userId, sessionId))
} catch (err) {
// if can't get session don't error, just don't return anything
return null
}
}
exports.getAllSessions = async () => {
const client = await redis.getSessionClient()
const sessions = await client.scan()
return sessions.map(session => session.value)
}

View File

@ -65,23 +65,18 @@ exports.getCookie = (ctx, name) => {
} }
/** /**
* Store a cookie for the request, has a hardcoded expiry. * Store a cookie for the request - it will not expire.
* @param {object} ctx The request which is to be manipulated. * @param {object} ctx The request which is to be manipulated.
* @param {string} name The name of the cookie to set. * @param {string} name The name of the cookie to set.
* @param {string|object} value The value of cookie which will be set. * @param {string|object} value The value of cookie which will be set.
*/ */
exports.setCookie = (ctx, value, name = "builder") => { exports.setCookie = (ctx, value, name = "builder") => {
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!value) { if (!value) {
ctx.cookies.set(name) ctx.cookies.set(name)
} else { } else {
value = jwt.sign(value, options.secretOrKey, { value = jwt.sign(value, options.secretOrKey)
expiresIn: "1 day",
})
ctx.cookies.set(name, value, { ctx.cookies.set(name, value, {
expires, maxAge: Number.MAX_SAFE_INTEGER,
path: "/", path: "/",
httpOnly: false, httpOnly: false,
overwrite: true, overwrite: true,

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.9.82", "version": "0.9.83-alpha.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -9,10 +9,10 @@
export let value export let value
export let size = "M" export let size = "M"
export let spectrumTheme
let open = false let open = false
$: color = value || "transparent"
$: customValue = getCustomValue(value) $: customValue = getCustomValue(value)
$: checkColor = getCheckColor(value) $: checkColor = getCheckColor(value)
@ -21,7 +21,8 @@
{ {
label: "Grays", label: "Grays",
colors: [ colors: [
"white", "gray-50",
"gray-75",
"gray-100", "gray-100",
"gray-200", "gray-200",
"gray-300", "gray-300",
@ -31,7 +32,6 @@
"gray-700", "gray-700",
"gray-800", "gray-800",
"gray-900", "gray-900",
"black",
], ],
}, },
{ {
@ -86,7 +86,7 @@
return value return value
} }
let found = false let found = false
const comparisonValue = value.substring(35, value.length - 1) const comparisonValue = value.substring(28, value.length - 1)
for (let category of categories) { for (let category of categories) {
found = category.colors.includes(comparisonValue) found = category.colors.includes(comparisonValue)
if (found) { if (found) {
@ -102,17 +102,19 @@
const getCheckColor = value => { const getCheckColor = value => {
return /^.*(white|(gray-(50|75|100|200|300|400|500)))\)$/.test(value) return /^.*(white|(gray-(50|75|100|200|300|400|500)))\)$/.test(value)
? "black" ? "var(--spectrum-global-color-gray-900)"
: "white" : "var(--spectrum-global-color-gray-50)"
} }
</script> </script>
<div class="container"> <div class="container">
<div <div class="preview size--{size || 'M'}" on:click={() => (open = true)}>
class="preview size--{size || 'M'}" <div
style="background: {color};" class="fill {spectrumTheme || ''}"
on:click={() => (open = true)} style={value ? `background: ${value};` : ""}
/> class:placeholder={!value}
/>
</div>
{#if open} {#if open}
<div <div
use:clickOutside={() => (open = false)} use:clickOutside={() => (open = false)}
@ -126,15 +128,19 @@
{#each category.colors as color} {#each category.colors as color}
<div <div
on:click={() => { on:click={() => {
onChange(`var(--spectrum-global-color-static-${color})`) onChange(`var(--spectrum-global-color-${color})`)
}} }}
class="color" class="color"
style="background: var(--spectrum-global-color-static-{color}); color: {checkColor};"
title={prettyPrint(color)} title={prettyPrint(color)}
> >
{#if value === `var(--spectrum-global-color-static-${color})`} <div
<Icon name="Checkmark" size="S" /> class="fill {spectrumTheme || ''}"
{/if} style="background: var(--spectrum-global-color-{color}); color: {checkColor};"
>
{#if value === `var(--spectrum-global-color-${color})`}
<Icon name="Checkmark" size="S" />
{/if}
</div>
</div> </div>
{/each} {/each}
</div> </div>
@ -170,12 +176,43 @@
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 100%; border-radius: 100%;
position: relative;
transition: border-color 130ms ease-in-out; transition: border-color 130ms ease-in-out;
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300); box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-400);
} }
.preview:hover { .preview:hover {
cursor: pointer; cursor: pointer;
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300); box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-400);
}
.fill {
width: 100%;
height: 100%;
border-radius: 100%;
position: absolute;
top: 0;
left: 0;
display: grid;
place-items: center;
}
.fill.placeholder {
background-position: 0 0, 10px 10px;
background-size: 20px 20px;
background-image: linear-gradient(
45deg,
#eee 25%,
transparent 25%,
transparent 75%,
#eee 75%,
#eee 100%
),
linear-gradient(
45deg,
#eee 25%,
white 25%,
white 75%,
#eee 75%,
#eee 100%
);
} }
.size--S { .size--S {
width: 20px; width: 20px;
@ -219,8 +256,7 @@
width: 16px; width: 16px;
border-radius: 100%; border-radius: 100%;
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300); box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
display: grid; position: relative;
place-items: center;
} }
.color:hover { .color:hover {
cursor: pointer; cursor: pointer;
@ -236,4 +272,8 @@
.category--custom .heading { .category--custom .heading {
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.spectrum-wrapper {
background-color: transparent;
}
</style> </style>

View File

@ -1,7 +1,7 @@
<script> <script>
import { slide } from "svelte/transition" import { slide } from "svelte/transition"
import Portal from "svelte-portal" import Portal from "svelte-portal"
import ActionButton from "../ActionButton/ActionButton.svelte" import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte" import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte" import Heading from "../Typography/Heading.svelte"
@ -38,13 +38,13 @@
<header> <header>
<div class="text"> <div class="text">
<Heading size="XS">{title}</Heading> <Heading size="XS">{title}</Heading>
<Body size="XXS"> <Body size="S">
<slot name="description" /> <slot name="description" />
</Body> </Body>
</div> </div>
<div class="buttons"> <div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" /> <slot name="buttons" />
<ActionButton quiet icon="Close" on:click={hide} />
</div> </div>
</header> </header>
<slot name="body" /> <slot name="body" />
@ -59,7 +59,7 @@
left: 260px; left: 260px;
width: calc(100% - 520px); width: calc(100% - 520px);
background: var(--background); background: var(--background);
border: var(--border-light); border-top: var(--border-light);
z-index: 2; z-index: 2;
} }
@ -68,17 +68,15 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: var(--border-light); border-bottom: var(--border-light);
padding: var(--spectrum-alias-item-padding-s) 0; padding: var(--spacing-l) var(--spacing-xl);
} gap: var(--spacing-xl);
header :global(*) + :global(*) {
margin: 0 var(--spectrum-alias-grid-baseline);
} }
.text { .text {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: flex-start; justify-content: center;
align-items: center; align-items: flex-start;
margin-left: var(--spectrum-alias-item-padding-s); gap: var(--spacing-xs);
} }
</style> </style>

View File

@ -19,7 +19,7 @@
.container { .container {
height: 100%; height: 100%;
display: grid; display: grid;
grid-template-columns: 290px 1fr; grid-template-columns: 320px 1fr;
} }
.no-sidebar { .no-sidebar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -27,12 +27,15 @@
.sidebar { .sidebar {
border-right: var(--border-light); border-right: var(--border-light);
overflow: auto; overflow: auto;
padding: var(--spacing-xl);
scrollbar-width: none;
} }
.sidebar::-webkit-scrollbar { .sidebar::-webkit-scrollbar {
display: none; display: none;
} }
.main { .main {
font-family: var(--font-sans); font-family: var(--font-sans);
padding: var(--spacing-xl);
} }
.main :global(textarea) { .main :global(textarea) {
min-height: 200px; min-height: 200px;

View File

@ -145,4 +145,7 @@
height: 100vh; height: 100vh;
z-index: 999; z-index: 999;
} }
:global(.flatpickr-calendar) {
font-family: "Source Sans Pro", sans-serif;
}
</style> </style>

View File

@ -10,6 +10,7 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let fieldText = "" export let fieldText = ""
export let fieldIcon = ""
export let isPlaceholder = false export let isPlaceholder = false
export let placeholderOption = null export let placeholderOption = null
export let options = [] export let options = []
@ -17,11 +18,11 @@
export let onSelectOption = () => {} export let onSelectOption = () => {}
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = () => null
export let open = false export let open = false
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onClick = () => { const onClick = () => {
dispatch("click") dispatch("click")
@ -42,6 +43,12 @@
aria-haspopup="listbox" aria-haspopup="listbox"
on:mousedown={onClick} on:mousedown={onClick}
> >
{#if fieldIcon}
<span class="icon-Placeholder-Padding">
<img src={fieldIcon} alt="icon" width="20" height="15" />
</span>
{/if}
<span <span
class="spectrum-Picker-label" class="spectrum-Picker-label"
class:is-placeholder={isPlaceholder} class:is-placeholder={isPlaceholder}
@ -104,6 +111,16 @@
tabindex="0" tabindex="0"
on:click={() => onSelectOption(getOptionValue(option, idx))} on:click={() => onSelectOption(getOptionValue(option, idx))}
> >
{#if getOptionIcon(option, idx)}
<span class="icon-Padding">
<img
src={getOptionIcon(option, idx)}
alt="icon"
width="20"
height="15"
/>
</span>
{/if}
<span class="spectrum-Menu-itemLabel" <span class="spectrum-Menu-itemLabel"
>{getOptionLabel(option, idx)}</span >{getOptionLabel(option, idx)}</span
> >
@ -148,4 +165,12 @@
.spectrum-Picker-label.auto-width.is-placeholder { .spectrum-Picker-label.auto-width.is-placeholder {
padding-right: 2px; padding-right: 2px;
} }
.icon-Padding {
padding-right: 10px;
}
.icon-Placeholder-Padding {
padding-top: 5px;
padding-right: 10px;
}
</style> </style>

View File

@ -10,6 +10,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = () => null
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -17,6 +18,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
$: fieldText = getFieldText(value, options, placeholder) $: fieldText = getFieldText(value, options, placeholder)
$: fieldIcon = getFieldIcon(value, options, placeholder)
const getFieldText = (value, options, placeholder) => { const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value // Always use placeholder if no value
@ -36,6 +38,17 @@
return index !== -1 ? getOptionLabel(options[index], index) : value return index !== -1 ? getOptionLabel(options[index], index) : value
} }
const getFieldIcon = (value, options) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex(
(option, idx) => getOptionValue(option, idx) === value
)
return index !== -1 ? getOptionIcon(options[index], index) : null
}
const selectOption = value => { const selectOption = value => {
dispatch("change", value) dispatch("change", value)
open = false open = false
@ -55,6 +68,8 @@
{autoWidth} {autoWidth}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon}
{fieldIcon}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder} placeholderOption={placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}

View File

@ -13,6 +13,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionIcon = option => option?.icon
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -21,6 +22,7 @@
value = e.detail value = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
const extractProperty = (value, property) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
@ -41,6 +43,7 @@
{autoWidth} {autoWidth}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View File

@ -115,7 +115,6 @@
} }
.spectrum-Modal { .spectrum-Modal {
background: var(--background);
overflow: visible; overflow: visible;
max-height: none; max-height: none;
margin: 40px 0; margin: 40px 0;

View File

@ -88,7 +88,6 @@
display: grid; display: grid;
position: relative; position: relative;
gap: var(--spacing-xl); gap: var(--spacing-xl);
color: var(--ink);
} }
.spectrum-Dialog-content { .spectrum-Dialog-content {
@ -106,13 +105,8 @@
position: absolute; position: absolute;
top: 15px; top: 15px;
right: 15px; right: 15px;
color: var(--ink);
font-size: var(--font-size-m); font-size: var(--font-size-m);
} }
.close-icon:hover {
color: var(--grey-6);
cursor: pointer;
}
.close-icon :global(svg) { .close-icon :global(svg) {
margin-right: 0; margin-right: 0;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -29,7 +29,7 @@ context("Create Bindings", () => {
// The builder preview pages don't have a real URL, so all we can do // The builder preview pages don't have a real URL, so all we can do
// is check that we were able to bind to the property, and that the // is check that we were able to bind to the property, and that the
// component exists on the page // component exists on the page
cy.getComponent(componentId).should("have.text", "Placeholder text") cy.getComponent(componentId).should("have.text", "New Paragraph")
}) })
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "0.9.82", "version": "0.9.83-alpha.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.82", "@budibase/bbui": "^0.9.83-alpha.0",
"@budibase/client": "^0.9.82", "@budibase/client": "^0.9.83-alpha.0",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^0.9.82", "@budibase/string-templates": "^0.9.83-alpha.0",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -8,7 +8,6 @@ import {
selectedComponent, selectedComponent,
selectedAccessRole, selectedAccessRole,
} from "builderStore" } from "builderStore"
// Backendstores
import { import {
datasources, datasources,
integrations, integrations,
@ -33,6 +32,10 @@ const INITIAL_FRONTEND_STATE = {
layouts: [], layouts: [],
screens: [], screens: [],
components: [], components: [],
clientFeatures: {
spectrumThemes: false,
intelligentLoading: false,
},
currentFrontEndType: "none", currentFrontEndType: "none",
selectedScreenId: "", selectedScreenId: "",
selectedLayoutId: "", selectedLayoutId: "",
@ -43,6 +46,7 @@ const INITIAL_FRONTEND_STATE = {
appId: "", appId: "",
routes: {}, routes: {},
clientLibPath: "", clientLibPath: "",
theme: "",
} }
export const getFrontendStore = () => { export const getFrontendStore = () => {
@ -56,16 +60,23 @@ export const getFrontendStore = () => {
...state, ...state,
libraries: application.componentLibraries, libraries: application.componentLibraries,
components, components,
clientFeatures: {
...state.clientFeatures,
...components.features,
},
name: application.name, name: application.name,
description: application.description, description: application.description,
appId: application.appId, appId: application.appId,
url: application.url, url: application.url,
layouts, layouts,
screens, screens,
theme: application.theme,
hasAppPackage: true, hasAppPackage: true,
appInstance: application.instance, appInstance: application.instance,
clientLibPath, clientLibPath,
previousTopNavPath: {}, previousTopNavPath: {},
version: application.version,
revertableVersion: application.revertableVersion,
})) }))
await hostingStore.actions.fetch() await hostingStore.actions.fetch()
@ -79,6 +90,20 @@ export const getFrontendStore = () => {
database.set(application.instance) database.set(application.instance)
tables.init() tables.init()
}, },
theme: {
save: async theme => {
const appId = get(store).appId
const response = await api.put(`/api/applications/${appId}`, { theme })
if (response.status === 200) {
store.update(state => {
state.theme = theme
return state
})
} else {
throw new Error("Error updating theme")
}
},
},
routing: { routing: {
fetch: async () => { fetch: async () => {
const response = await api.get("/api/routing") const response = await api.get("/api/routing")
@ -199,6 +224,11 @@ export const getFrontendStore = () => {
const response = await api.post(`/api/layouts`, layoutToSave) const response = await api.post(`/api/layouts`, layoutToSave)
const savedLayout = await response.json() const savedLayout = await response.json()
// Abort if saving failed
if (response.status !== 200) {
return
}
store.update(state => { store.update(state => {
const layoutIdx = state.layouts.findIndex( const layoutIdx = state.layouts.findIndex(
stateLayout => stateLayout._id === savedLayout._id stateLayout => stateLayout._id === savedLayout._id
@ -316,16 +346,6 @@ export const getFrontendStore = () => {
create: async (componentName, presetProps) => { create: async (componentName, presetProps) => {
const selected = get(selectedComponent) const selected = get(selectedComponent)
const asset = get(currentAsset) const asset = get(currentAsset)
const state = get(store)
// Only allow one screen slot, and in the layout
if (componentName.endsWith("screenslot")) {
const isLayout = state.currentFrontEndType === FrontendTypes.LAYOUT
const slot = findComponentType(asset.props, componentName)
if (!isLayout || slot != null) {
return
}
}
// Create new component // Create new component
const componentInstance = store.actions.components.createInstance( const componentInstance = store.actions.components.createInstance(
@ -510,6 +530,11 @@ export const getFrontendStore = () => {
selected._styles = { normal: {}, hover: {}, active: {} } selected._styles = { normal: {}, hover: {}, active: {} }
await store.actions.preview.saveSelected() await store.actions.preview.saveSelected()
}, },
updateConditions: async conditions => {
const selected = get(selectedComponent)
selected._conditions = conditions
await store.actions.preview.saveSelected()
},
updateProp: async (name, value) => { updateProp: async (name, value) => {
let component = get(selectedComponent) let component = get(selectedComponent)
if (!name || !component) { if (!name || !component) {

View File

@ -38,8 +38,6 @@ const createScreen = table => {
.instanceName("Form") .instanceName("Form")
.customProps({ .customProps({
actionType: "Create", actionType: "Create",
theme: "spectrum--lightest",
size: "spectrum--medium",
dataSource: { dataSource: {
label: table.name, label: table.name,
tableId: table._id, tableId: table._id,

View File

@ -8,7 +8,6 @@ import {
makeTitleContainer, makeTitleContainer,
makeSaveButton, makeSaveButton,
makeMainForm, makeMainForm,
spectrumColor,
makeDatasourceFormComponents, makeDatasourceFormComponents,
} from "./utils/commonComponents" } from "./utils/commonComponents"
@ -26,36 +25,13 @@ export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE"
export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`) export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`)
function generateTitleContainer(table, title, formId, repeaterId) { function generateTitleContainer(table, title, formId, repeaterId) {
// have to override style for this, its missing margin const saveButton = makeSaveButton(table, formId)
const saveButton = makeSaveButton(table, formId).normalStyle({
background: "#000000",
"border-width": "0",
"border-style": "None",
color: "#fff",
"font-weight": "600",
"font-size": "14px",
})
const deleteButton = new Component("@budibase/standard-components/button") const deleteButton = new Component("@budibase/standard-components/button")
.normalStyle({
background: "transparent",
"border-width": "0",
"border-style": "None",
color: "#9e9e9e",
"font-weight": "600",
"font-size": "14px",
"margin-right": "8px",
"margin-left": "16px",
})
.hoverStyle({
background: "transparent",
color: "#4285f4",
})
.customStyle(spectrumColor(700))
.text("Delete") .text("Delete")
.customProps({ .customProps({
className: "", type: "secondary",
disabled: false, quiet: true,
size: "M",
onClick: [ onClick: [
{ {
parameters: { parameters: {
@ -76,7 +52,19 @@ function generateTitleContainer(table, title, formId, repeaterId) {
}) })
.instanceName("Delete Button") .instanceName("Delete Button")
return makeTitleContainer(title).addChild(deleteButton).addChild(saveButton) const buttons = new Component("@budibase/standard-components/container")
.instanceName("Button Container")
.customProps({
direction: "row",
hAlign: "right",
vAlign: "middle",
size: "shrink",
gap: "M",
})
.addChild(deleteButton)
.addChild(saveButton)
return makeTitleContainer(title).addChild(buttons)
} }
const createScreen = table => { const createScreen = table => {
@ -98,7 +86,7 @@ const createScreen = table => {
valueType: "Binding", valueType: "Binding",
}, },
], ],
limit: 1, limit: table.type === "external" ? undefined : 1,
paginate: false, paginate: false,
}) })

View File

@ -19,21 +19,10 @@ export const rowListUrl = table => sanitizeUrl(`/${table.name}`)
function generateTitleContainer(table) { function generateTitleContainer(table) {
const newButton = new Component("@budibase/standard-components/button") const newButton = new Component("@budibase/standard-components/button")
.normalStyle({
background: "#000000",
"border-width": "0",
"border-style": "None",
color: "#fff",
"font-weight": "600",
"font-size": "14px",
})
.hoverStyle({
background: "#4285f4",
})
.text("Create New") .text("Create New")
.customProps({ .customProps({
className: "", size: "M",
disabled: false, type: "primary",
onClick: [ onClick: [
{ {
parameters: { parameters: {
@ -46,12 +35,6 @@ function generateTitleContainer(table) {
.instanceName("New Button") .instanceName("New Button")
const heading = new Component("@budibase/standard-components/heading") const heading = new Component("@budibase/standard-components/heading")
.normalStyle({
margin: "0px",
flex: "1 1 auto",
"text-transform": "capitalize",
})
.type("h2")
.instanceName("Title") .instanceName("Title")
.text(table.name) .text(table.name)
.customProps({ .customProps({
@ -60,14 +43,12 @@ function generateTitleContainer(table) {
}) })
return new Component("@budibase/standard-components/container") return new Component("@budibase/standard-components/container")
.normalStyle({
"margin-bottom": "32px",
})
.customProps({ .customProps({
direction: "row", direction: "row",
hAlign: "stretch", hAlign: "stretch",
vAlign: "middle", vAlign: "middle",
size: "shrink", size: "shrink",
gap: "M",
}) })
.instanceName("Title Container") .instanceName("Title Container")
.addChild(heading) .addChild(heading)
@ -91,68 +72,35 @@ const createScreen = table => {
const spectrumTable = new Component("@budibase/standard-components/table") const spectrumTable = new Component("@budibase/standard-components/table")
.customProps({ .customProps({
dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`, dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`,
theme: "spectrum--lightest",
showAutoColumns: false, showAutoColumns: false,
quiet: true, quiet: false,
size: "spectrum--medium",
rowCount: 8, rowCount: 8,
}) })
.instanceName(`${table.name} Table`) .instanceName(`${table.name} Table`)
const safeTableId = makePropSafe(spectrumTable._json._id) const safeTableId = makePropSafe(spectrumTable._json._id)
const safeRowId = makePropSafe("_id") const safeRowId = makePropSafe("_id")
const viewButton = new Component("@budibase/standard-components/button") const viewLink = new Component("@budibase/standard-components/link")
.customProps({ .customProps({
text: "View", text: "View",
onClick: [ url: `${rowListUrl(table)}/{{ ${safeTableId}.${safeRowId} }}`,
{ size: "S",
"##eventHandlerType": "Navigate To", color: "var(--spectrum-global-color-gray-600)",
parameters: { align: "left",
url: `${rowListUrl(table)}/{{ ${safeTableId}.${safeRowId} }}`,
},
},
],
}) })
.instanceName("View Button")
.normalStyle({ .normalStyle({
background: "transparent", ["margin-left"]: "16px",
"font-weight": "600", ["margin-right"]: "16px",
color: "#888",
"border-width": "0",
})
.hoverStyle({
color: "#4285f4",
}) })
.instanceName("View Link")
spectrumTable.addChild(viewButton) spectrumTable.addChild(viewLink)
provider.addChild(spectrumTable) provider.addChild(spectrumTable)
const mainContainer = new Component("@budibase/standard-components/container")
.normalStyle({
background: "white",
"border-radius": "0.5rem",
"box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
"border-width": "2px",
"border-color": "rgba(0, 0, 0, 0.1)",
"border-style": "None",
"padding-top": "48px",
"padding-bottom": "48px",
"padding-right": "48px",
"padding-left": "48px",
})
.customProps({
direction: "column",
hAlign: "stretch",
vAlign: "top",
size: "shrink",
})
.instanceName("Container")
.addChild(generateTitleContainer(table))
.addChild(provider)
return new Screen() return new Screen()
.route(rowListUrl(table)) .route(rowListUrl(table))
.instanceName(`${table.name} - List`) .instanceName(`${table.name} - List`)
.addChild(mainContainer) .addChild(generateTitleContainer(table))
.addChild(provider)
.json() .json()
} }

View File

@ -8,23 +8,16 @@ export function spectrumColor(number) {
// God knows why. It seems to think optional chaining further down the // God knows why. It seems to think optional chaining further down the
// file is invalid if the word g-l-o-b-a-l is found - hence the reason this // file is invalid if the word g-l-o-b-a-l is found - hence the reason this
// statement is split into parts. // statement is split into parts.
return "color: var(--spectrum-glo" + `bal-color-gray-${number});` return "var(--spectrum-glo" + `bal-color-gray-${number})`
} }
export function makeLinkComponent(tableName) { export function makeLinkComponent(tableName) {
return new Component("@budibase/standard-components/link") return new Component("@budibase/standard-components/link")
.normalStyle({
color: "#757575",
"text-transform": "capitalize",
})
.hoverStyle({
color: "#4285f4",
})
.customStyle(spectrumColor(700))
.text(tableName) .text(tableName)
.customProps({ .customProps({
url: `/${tableName.toLowerCase()}`, url: `/${tableName.toLowerCase()}`,
openInNewTab: false, openInNewTab: false,
color: spectrumColor(700),
size: "S", size: "S",
align: "left", align: "left",
}) })
@ -33,19 +26,12 @@ export function makeLinkComponent(tableName) {
export function makeMainForm() { export function makeMainForm() {
return new Component("@budibase/standard-components/form") return new Component("@budibase/standard-components/form")
.normalStyle({ .normalStyle({
width: "700px", width: "600px",
padding: "0px",
"border-radius": "0.5rem",
"box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
"padding-top": "48px",
"padding-bottom": "48px",
"padding-right": "48px",
"padding-left": "48px",
}) })
.instanceName("Form") .instanceName("Form")
} }
export function makeBreadcrumbContainer(tableName, text, capitalise = false) { export function makeBreadcrumbContainer(tableName, text) {
const link = makeLinkComponent(tableName).instanceName("Back Link") const link = makeLinkComponent(tableName).instanceName("Back Link")
const arrowText = new Component("@budibase/standard-components/text") const arrowText = new Component("@budibase/standard-components/text")
@ -53,42 +39,27 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
.normalStyle({ .normalStyle({
"margin-right": "4px", "margin-right": "4px",
"margin-left": "4px", "margin-left": "4px",
"margin-top": "0px",
"margin-bottom": "0px",
}) })
.customStyle(spectrumColor(700))
.text(">") .text(">")
.instanceName("Arrow") .instanceName("Arrow")
.customProps({ .customProps({
color: spectrumColor(700),
size: "S", size: "S",
align: "left", align: "left",
}) })
const textStyling = {
color: "#000000",
"margin-top": "0px",
"margin-bottom": "0px",
}
if (capitalise) {
textStyling["text-transform"] = "capitalize"
}
const identifierText = new Component("@budibase/standard-components/text") const identifierText = new Component("@budibase/standard-components/text")
.type("none")
.normalStyle(textStyling)
.customStyle(spectrumColor(700))
.text(text) .text(text)
.instanceName("Identifier") .instanceName("Identifier")
.customProps({ .customProps({
color: spectrumColor(700),
size: "S", size: "S",
align: "left", align: "left",
}) })
return new Component("@budibase/standard-components/container") return new Component("@budibase/standard-components/container")
.normalStyle({
"font-size": "14px",
color: "#757575",
})
.customProps({ .customProps({
gap: "N",
direction: "row", direction: "row",
hAlign: "left", hAlign: "left",
vAlign: "middle", vAlign: "middle",
@ -102,22 +73,10 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
export function makeSaveButton(table, formId) { export function makeSaveButton(table, formId) {
return new Component("@budibase/standard-components/button") return new Component("@budibase/standard-components/button")
.normalStyle({
background: "#000000",
"border-width": "0",
"border-style": "None",
color: "#fff",
"font-weight": "600",
"font-size": "14px",
"margin-left": "16px",
})
.hoverStyle({
background: "#4285f4",
})
.text("Save") .text("Save")
.customProps({ .customProps({
className: "", type: "primary",
disabled: false, size: "M",
onClick: [ onClick: [
{ {
"##eventHandlerType": "Validate Form", "##eventHandlerType": "Validate Form",
@ -145,12 +104,6 @@ export function makeSaveButton(table, formId) {
export function makeTitleContainer(title) { export function makeTitleContainer(title) {
const heading = new Component("@budibase/standard-components/heading") const heading = new Component("@budibase/standard-components/heading")
.normalStyle({
margin: "0px",
flex: "1 1 auto",
})
.customStyle(spectrumColor(900))
.type("h2")
.instanceName("Title") .instanceName("Title")
.text(title) .text(title)
.customProps({ .customProps({
@ -168,6 +121,7 @@ export function makeTitleContainer(title) {
hAlign: "stretch", hAlign: "stretch",
vAlign: "middle", vAlign: "middle",
size: "shrink", size: "shrink",
gap: "M",
}) })
.instanceName("Title Container") .instanceName("Title Container")
.addChild(heading) .addChild(heading)

View File

@ -69,8 +69,9 @@
<Input type="password" bind:value={block.inputs[key]} /> <Input type="password" bind:value={block.inputs[key]} />
{:else if value.customType === "email"} {:else if value.customType === "email"}
<DrawerBindableInput <DrawerBindableInput
title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={"email"} type="email"
value={block.inputs[key]} value={block.inputs[key]}
on:change={e => (block.inputs[key] = e.detail)} on:change={e => (block.inputs[key] = e.detail)}
{bindings} {bindings}
@ -102,6 +103,7 @@
</CodeEditorModal> </CodeEditorModal>
{:else if value.type === "string" || value.type === "number"} {:else if value.type === "string" || value.type === "number"}
<DrawerBindableInput <DrawerBindableInput
title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
value={block.inputs[key]} value={block.inputs[key]}
@ -127,6 +129,7 @@
.block-field { .block-field {
display: grid; display: grid;
grid-gap: 5px;
} }
.block-label { .block-label {

View File

@ -24,7 +24,7 @@
name: $views.selected?.name, name: $views.selected?.name,
} }
$: type = $tables.selected?.type $: type = $tables.selected?.type
$: isInternal = type === "internal" $: isInternal = type !== "external"
// Fetch rows for specified table // Fetch rows for specified table
$: { $: {
@ -72,9 +72,7 @@
{#if isUsersTable} {#if isUsersTable}
<EditRolesButton /> <EditRolesButton />
{/if} {/if}
{#if isInternal} <HideAutocolumnButton bind:hideAutocolumns />
<HideAutocolumnButton bind:hideAutocolumns />
{/if}
<!-- always have the export last --> <!-- always have the export last -->
<ExportButton view={$tables.selected?._id} /> <ExportButton view={$tables.selected?._id} />
{/if} {/if}

View File

@ -53,7 +53,7 @@
let deletion let deletion
$: tableOptions = $tables.list.filter( $: tableOptions = $tables.list.filter(
table => table._id !== $tables.draft._id table => table._id !== $tables.draft._id && table.type !== "external"
) )
$: required = !!field?.constraints?.presence || primaryDisplay $: required = !!field?.constraints?.presence || primaryDisplay
$: uneditable = $: uneditable =
@ -172,11 +172,6 @@
alt: `Many ${table.name} rows → many ${linkTable.name} rows`, alt: `Many ${table.name} rows → many ${linkTable.name} rows`,
value: RelationshipTypes.MANY_TO_MANY, value: RelationshipTypes.MANY_TO_MANY,
}, },
{
name: `One ${linkName} row → many ${thisName} rows`,
alt: `One ${linkTable.name} rows → many ${table.name} rows`,
value: RelationshipTypes.ONE_TO_MANY,
},
{ {
name: `One ${thisName} row → many ${linkName} rows`, name: `One ${thisName} row → many ${linkName} rows`,
alt: `One ${table.name} rows → many ${linkTable.name} rows`, alt: `One ${table.name} rows → many ${linkTable.name} rows`,

View File

@ -6,8 +6,10 @@
import ErrorsBox from "components/common/ErrorsBox.svelte" import ErrorsBox from "components/common/ErrorsBox.svelte"
import { roles } from "stores/backend" import { roles } from "stores/backend"
const BASE_ROLE = { _id: "", inherits: "BASIC", permissionId: "Read/Write" }
let basePermissions = [] let basePermissions = []
let selectedRole = {} let selectedRole = BASE_ROLE
let errors = [] let errors = []
let builtInRoles = ["Admin", "Power", "Basic", "Public"] let builtInRoles = ["Admin", "Power", "Basic", "Public"]
// Don't allow editing of public role // Don't allow editing of public role
@ -15,6 +17,11 @@
$: selectedRoleId = selectedRole._id $: selectedRoleId = selectedRole._id
$: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId) $: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId)
$: isCreating = selectedRoleId == null || selectedRoleId === "" $: isCreating = selectedRoleId == null || selectedRoleId === ""
$: valid =
selectedRole.name &&
selectedRole.inherits &&
selectedRole.permissionId &&
!builtInRoles.includes(selectedRole.name)
const fetchBasePermissions = async () => { const fetchBasePermissions = async () => {
const permissionsResponse = await api.get("/api/permission/builtin") const permissionsResponse = await api.get("/api/permission/builtin")
@ -32,7 +39,7 @@
permissionId: role.permissionId ?? "", permissionId: role.permissionId ?? "",
} }
} else { } else {
selectedRole = { _id: "", inherits: "", permissionId: "" } selectedRole = BASE_ROLE
} }
errors = [] errors = []
} }
@ -88,6 +95,7 @@
title="Edit Roles" title="Edit Roles"
confirmText={isCreating ? "Create" : "Save"} confirmText={isCreating ? "Create" : "Save"}
onConfirm={saveRole} onConfirm={saveRole}
disabled={!valid}
> >
{#if errors.length} {#if errors.length}
<ErrorsBox {errors} /> <ErrorsBox {errors} />
@ -115,7 +123,7 @@
options={otherRoles} options={otherRoles}
getOptionValue={role => role._id} getOptionValue={role => role._id}
getOptionLabel={role => role.name} getOptionLabel={role => role.name}
placeholder="None" disabled={builtInRoles.includes(selectedRole.name)}
/> />
<Select <Select
label="Base Permissions" label="Base Permissions"
@ -123,7 +131,7 @@
options={basePermissions} options={basePermissions}
getOptionValue={x => x._id} getOptionValue={x => x._id}
getOptionLabel={x => x.name} getOptionLabel={x => x.name}
placeholder="Choose permissions" disabled={builtInRoles.includes(selectedRole.name)}
/> />
{/if} {/if}
<div slot="footer"> <div slot="footer">

View File

@ -5,14 +5,17 @@
import ICONS from "../icons" import ICONS from "../icons"
export let integration = {} export let integration = {}
let integrations = [] let integrations = []
const INTERNAL = "BUDIBASE"
async function fetchIntegrations() { async function fetchIntegrations() {
const response = await api.get("/api/integrations") const response = await api.get("/api/integrations")
const json = await response.json() const json = await response.json()
integrations = json integrations = {
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
...json,
}
return json return json
} }
@ -21,7 +24,7 @@
// build the schema // build the schema
const schema = {} const schema = {}
for (let key in selected.datasource) { for (let key of Object.keys(selected.datasource)) {
schema[key] = selected.datasource[key].default schema[key] = selected.datasource[key].default
} }
@ -39,7 +42,7 @@
<section> <section>
<div class="integration-list"> <div class="integration-list">
{#each Object.keys(integrations) as integrationType} {#each Object.entries(integrations) as [integrationType, schema]}
<div <div
class="integration hoverable" class="integration hoverable"
class:selected={integration.type === integrationType} class:selected={integration.type === integrationType}
@ -50,7 +53,7 @@
height="50" height="50"
width="50" width="50"
/> />
<Body size="XS">{integrationType}</Body> <Body size="XS">{schema.name || integrationType}</Body>
</div> </div>
{/each} {/each}
</div> </div>

View File

@ -2,15 +2,21 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { datasources } from "stores/backend" import { datasources } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { Input, Label, ModalContent } from "@budibase/bbui" import { Input, Label, ModalContent, Modal, Context } from "@budibase/bbui"
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte" import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import analytics from "analytics" import analytics from "analytics"
import { getContext } from "svelte"
let error = "" const modalContext = getContext(Context.Modal)
let tableModal
let name let name
let error = ""
let integration let integration
$: checkOpenModal(integration && integration.type === "BUDIBASE")
function checkValid(evt) { function checkValid(evt) {
const datasourceName = evt.target.value const datasourceName = evt.target.value
if ( if (
@ -22,6 +28,12 @@
error = "" error = ""
} }
function checkOpenModal(isInternal) {
if (isInternal) {
tableModal.show()
}
}
async function saveDatasource() { async function saveDatasource() {
const { type, plus, ...config } = integration const { type, plus, ...config } = integration
@ -40,6 +52,9 @@
} }
</script> </script>
<Modal bind:this={tableModal} on:hide={modalContext.hide}>
<CreateTableModal bind:name />
</Modal>
<ModalContent <ModalContent
title="Create Datasource" title="Create Datasource"
size="L" size="L"

View File

@ -6,9 +6,14 @@
import EditViewPopover from "./popovers/EditViewPopover.svelte" import EditViewPopover from "./popovers/EditViewPopover.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
export let sourceId export let sourceId
$: selectedView = $views.selected && $views.selected.name $: selectedView = $views.selected && $views.selected.name
$: sortedTables = $tables.list
.filter(table => table.sourceId === sourceId)
.sort(alphabetical)
function selectTable(table) { function selectTable(table) {
tables.select(table) tables.select(table)
@ -33,7 +38,7 @@
{#if $database?._id} {#if $database?._id}
<div class="hierarchy-items-container"> <div class="hierarchy-items-container">
{#each $tables.list.filter(table => table.sourceId === sourceId) as table, idx} {#each sortedTables as table, idx}
<NavItem <NavItem
indentLevel={1} indentLevel={1}
border={idx > 0} border={idx > 0}
@ -46,7 +51,7 @@
<EditTablePopover {table} /> <EditTablePopover {table} />
{/if} {/if}
</NavItem> </NavItem>
{#each Object.keys(table.views || {}) as viewName, idx (idx)} {#each [...Object.keys(table.views || {})].sort() as viewName, idx (idx)}
<NavItem <NavItem
indentLevel={2} indentLevel={2}
icon="Remove" icon="Remove"

View File

@ -1,5 +1,5 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto, url } from "@roxi/routify"
import { store } from "builderStore" import { store } from "builderStore"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -27,7 +27,7 @@
$: tableNames = $tables.list.map(table => table.name) $: tableNames = $tables.list.map(table => table.name)
let name export let name
let dataImport let dataImport
let error = "" let error = ""
let createAutoscreens = true let createAutoscreens = true
@ -91,7 +91,11 @@
} }
// Navigate to new table // Navigate to new table
$goto(`../../table/${table._id}`) const currentUrl = $url()
const path = currentUrl.endsWith("data")
? `./table/${table._id}`
: `../../table/${table._id}`
$goto(path)
} }
</script> </script>

View File

@ -12,7 +12,7 @@
apps: "Create your first app", apps: "Create your first app",
smtp: "Set up email", smtp: "Set up email",
adminUser: "Create your first user", adminUser: "Create your first user",
oauth: "Set up OAuth", sso: "Set up single sign-on",
} }
</script> </script>

View File

@ -1,20 +1,9 @@
<script> <script>
import groupBy from "lodash/fp/groupBy" import groupBy from "lodash/fp/groupBy"
import { import { Search, TextArea, DrawerContent } from "@budibase/bbui"
Search,
TextArea,
Heading,
Label,
DrawerContent,
Layout,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { isValid } from "@budibase/string-templates" import { isValid } from "@budibase/string-templates"
import { import { readableToRuntimeBinding } from "builderStore/dataBinding"
getBindableProperties,
readableToRuntimeBinding,
} from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import { handlebarsCompletions } from "constants/completions" import { handlebarsCompletions } from "constants/completions"
import { addToText } from "./utils" import { addToText } from "./utils"
@ -22,44 +11,36 @@
export let bindableProperties export let bindableProperties
export let value = "" export let value = ""
export let bindingDrawer export let valid
export let valid = true
let originalValue = value
let helpers = handlebarsCompletions() let helpers = handlebarsCompletions()
let getCaretPosition let getCaretPosition
let search = "" let search = ""
$: value && checkValid() $: valid = isValid(readableToRuntimeBinding(bindableProperties, value))
$: bindableProperties = getBindableProperties( $: dispatch("change", value)
$currentAsset, $: ({ context } = groupBy("type", bindableProperties))
$store.selectedComponentId
)
$: dispatch("update", value)
$: ({ instance, context } = groupBy("type", bindableProperties))
$: searchRgx = new RegExp(search, "ig") $: searchRgx = new RegExp(search, "ig")
$: filteredColumns = context?.filter(context => {
function checkValid() { return context.readableBinding.match(searchRgx)
// TODO: need to convert the value to the runtime binding })
const runtimeBinding = readableToRuntimeBinding(bindableProperties, value) $: filteredHelpers = helpers?.filter(helper => {
valid = isValid(runtimeBinding) return helper.label.match(searchRgx) || helper.description.match(searchRgx)
} })
export function cancel() {
dispatch("update", originalValue)
bindingDrawer.close()
}
</script> </script>
<DrawerContent> <DrawerContent>
<svelte:fragment slot="sidebar"> <svelte:fragment slot="sidebar">
<Layout> <div class="container">
<Search placeholder="Search" bind:value={search} /> <section>
{#if context} <div class="heading">Search</div>
<Search placeholder="Search" bind:value={search} />
</section>
{#if filteredColumns?.length}
<section> <section>
<Heading size="XS">Columns</Heading> <div class="heading">Columns</div>
<ul> <ul>
{#each context.filter( context => context.readableBinding.match(searchRgx) ) as { readableBinding }} {#each filteredColumns as { readableBinding }}
<li <li
on:click={() => { on:click={() => {
value = addToText(value, getCaretPosition(), readableBinding) value = addToText(value, getCaretPosition(), readableBinding)
@ -71,39 +52,29 @@
</ul> </ul>
</section> </section>
{/if} {/if}
{#if instance} {#if filteredHelpers?.length}
<section> <section>
<Heading size="XS">Components</Heading> <div class="heading">Helpers</div>
<ul> <ul>
{#each instance.filter( instance => instance.readableBinding.match(searchRgx) ) as { readableBinding }} {#each filteredHelpers as helper}
<li on:click={() => addToText(readableBinding)}> <li
{readableBinding} on:click={() => {
value = addToText(value, getCaretPosition(), helper.text)
}}
>
<div class="helper">
<div class="helper__name">{helper.displayText}</div>
<div class="helper__description">
{@html helper.description}
</div>
<pre class="helper__example">{helper.example || ''}</pre>
</div>
</li> </li>
{/each} {/each}
</ul> </ul>
</section> </section>
{/if} {/if}
<section> </div>
<Heading size="XS">Helpers</Heading>
<ul>
{#each helpers.filter(helper => helper.label.match(searchRgx) || helper.description.match(searchRgx)) as helper}
<li
on:click={() => {
value = addToText(value, getCaretPosition(), helper.text)
}}
>
<div>
<Label extraSmall>{helper.displayText}</Label>
<div class="description">
{@html helper.description}
</div>
<pre>{helper.example || ''}</pre>
</div>
</li>
{/each}
</ul>
</section>
</Layout>
</svelte:fragment> </svelte:fragment>
<div class="main"> <div class="main">
<TextArea <TextArea
@ -122,50 +93,78 @@
</DrawerContent> </DrawerContent>
<style> <style>
.main {
padding: var(--spacing-m);
}
.main :global(textarea) { .main :global(textarea) {
min-height: 150px !important; min-height: 150px !important;
} }
.container {
margin: calc(-1 * var(--spacing-xl));
}
.heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
}
section { section {
display: grid; padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
grid-gap: var(--spacing-s); }
section:not(:first-child) {
border-top: var(--border-light);
} }
ul { ul {
list-style: none; list-style: none;
margin: 0;
padding: 0; padding: 0;
margin: 0;
} }
li { li {
display: flex;
font-family: var(--font-sans);
font-size: var(--font-size-s); font-size: var(--font-size-s);
color: var(--grey-7);
padding: var(--spacing-m); padding: var(--spacing-m);
margin: auto 0px;
align-items: center;
cursor: pointer;
border-top: var(--border-light);
border-width: 1px 0 1px 0;
border-radius: 4px; border-radius: 4px;
border: var(--border-light);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
} }
li:not(:last-of-type) {
pre, margin-bottom: var(--spacing-s);
.description { }
white-space: normal; li :global(*) {
transition: color 130ms ease-in-out;
} }
li:hover { li:hover {
background-color: var(--grey-2); color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-global-color-gray-500);
cursor: pointer; cursor: pointer;
} }
li:hover :global(*) {
color: var(--spectrum-global-color-gray-900) !important;
}
li:active { .helper {
color: var(--blue); display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xs);
}
.helper__name {
font-weight: bold;
}
.helper__description,
.helper__description :global(*) {
color: var(--spectrum-global-color-gray-700);
}
.helper__example {
white-space: normal;
margin: 0.5rem 0 0 0;
font-weight: 700;
}
.helper__description :global(p) {
margin: 0;
} }
.syntax-error { .syntax-error {
@ -173,21 +172,8 @@
color: var(--red); color: var(--red);
font-size: 12px; font-size: 12px;
} }
.syntax-error a { .syntax-error a {
color: var(--red); color: var(--red);
text-decoration: underline; text-decoration: underline;
} }
.description :global(p) {
color: var(--grey-7);
}
.description :global(p:hover) {
color: var(--ink);
}
.description :global(p a) {
color: var(--grey-7);
}
</style> </style>

View File

@ -0,0 +1,97 @@
<script>
import { Icon, Combobox, Drawer, Button } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
import { createEventDispatcher } from "svelte"
export let panel = BindingPanel
export let value = ""
export let bindings = []
export let title = "Bindings"
export let placeholder
export let label
export let disabled = false
export let options
const dispatch = createEventDispatcher()
let bindingDrawer
$: tempValue = Array.isArray(value) ? value : []
$: readableValue = runtimeToReadableBinding(bindings, value)
const handleClose = () => {
onChange(tempValue)
bindingDrawer.hide()
}
const onChange = value => {
dispatch("change", readableToRuntimeBinding(bindings, value))
}
</script>
<div class="control">
<Combobox
{label}
{disabled}
value={readableValue}
on:change={event => onChange(event.detail)}
{placeholder}
{options}
/>
{#if !disabled}
<div class="icon" on:click={bindingDrawer.show}>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
<Drawer bind:this={bindingDrawer} {title}>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
<svelte:component
this={panel}
slot="body"
value={readableValue}
close={handleClose}
on:update={event => (tempValue = event.detail)}
bindableProperties={bindings}
/>
</Drawer>
<style>
.control {
flex: 1;
position: relative;
}
.icon {
right: 31px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-right: 1px solid var(--spectrum-alias-border-color);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
</style>

View File

@ -17,10 +17,11 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
$: tempValue = Array.isArray(value) ? value : [] let valid = true
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
const handleClose = () => { const saveBinding = () => {
onChange(tempValue) onChange(tempValue)
bindingDrawer.hide() bindingDrawer.hide()
} }
@ -48,13 +49,15 @@
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={handleClose}>Save</Button> <Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
Save
</Button>
<svelte:component <svelte:component
this={panel} this={panel}
slot="body" slot="body"
bind:valid
value={readableValue} value={readableValue}
close={handleClose} on:change={event => (tempValue = event.detail)}
on:update={event => (tempValue = event.detail)}
bindableProperties={bindings} bindableProperties={bindings}
/> />
</Drawer> </Drawer>

View File

@ -6,7 +6,6 @@
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let panel = ServerBindingPanel export let panel = ServerBindingPanel
export let value = "" export let value = ""
@ -16,12 +15,11 @@
export let placeholder export let placeholder
export let label export let label
const dispatch = createEventDispatcher()
let bindingModal let bindingModal
let validity = true let valid = true
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue $: tempValue = readableValue
$: invalid = !validity
const saveBinding = () => { const saveBinding = () => {
onChange(tempValue) onChange(tempValue)
@ -38,7 +36,7 @@
{label} {label}
{thin} {thin}
value={readableValue} value={readableValue}
on:change={event => onChange(event.target.value)} on:change={event => onChange(event.detail)}
{placeholder} {placeholder}
/> />
<div class="icon" on:click={bindingModal.show}> <div class="icon" on:click={bindingModal.show}>
@ -46,23 +44,20 @@
</div> </div>
</div> </div>
<Modal bind:this={bindingModal}> <Modal bind:this={bindingModal}>
<ModalContent <ModalContent {title} onConfirm={saveBinding} disabled={!valid} size="XL">
{title}
onConfirm={saveBinding}
bind:disabled={invalid}
size="XL"
>
<Body extraSmall grey> <Body extraSmall grey>
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</Body> </Body>
<svelte:component <div class="panel-wrapper">
this={panel} <svelte:component
serverSide this={panel}
value={readableValue} serverSide
bind:validity value={readableValue}
on:update={event => (tempValue = event.detail)} bind:valid
bindableProperties={bindings} on:change={e => (tempValue = e.detail)}
/> bindableProperties={bindings}
/>
</div>
</ModalContent> </ModalContent>
</Modal> </Modal>
@ -100,4 +95,9 @@
background-color: var(--spectrum-global-color-gray-50); background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover); border-color: var(--spectrum-alias-border-color-hover);
} }
.panel-wrapper {
border: var(--border-light);
border-radius: 4px;
}
</style> </style>

View File

@ -1,12 +1,6 @@
<script> <script>
import groupBy from "lodash/fp/groupBy" import groupBy from "lodash/fp/groupBy"
import { import { Search, TextArea, DrawerContent } from "@budibase/bbui"
Input,
TextArea,
Heading,
Layout,
DrawerContent,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { isValid } from "@budibase/string-templates" import { isValid } from "@budibase/string-templates"
import { handlebarsCompletions } from "constants/completions" import { handlebarsCompletions } from "constants/completions"
@ -16,83 +10,91 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let bindableProperties = [] export let bindableProperties = []
export let validity = true export let valid = true
export let value = "" export let value = ""
let hasReadable = bindableProperties[0].readableBinding != null
let helpers = handlebarsCompletions() let helpers = handlebarsCompletions()
let getCaretPosition let getCaretPosition
let search = "" let search = ""
$: categories = Object.entries(groupBy("category", bindableProperties)) $: categories = Object.entries(groupBy("category", bindableProperties))
$: value && checkValid() $: valid = isValid(readableToRuntimeBinding(bindableProperties, value))
$: dispatch("update", value) $: dispatch("change", value)
$: searchRgx = new RegExp(search, "ig") $: searchRgx = new RegExp(search, "ig")
$: filteredCategories = categories.map(([categoryName, bindings]) => {
function checkValid() { const filteredBindings = bindings.filter(binding => {
if (hasReadable) { return binding.label.match(searchRgx)
const runtime = readableToRuntimeBinding(bindableProperties, value) })
validity = isValid(runtime) return [categoryName, filteredBindings]
} else { })
validity = isValid(value) $: filteredHelpers = helpers?.filter(helper => {
} return helper.label.match(searchRgx) || helper.description.match(searchRgx)
} })
</script> </script>
<DrawerContent> <DrawerContent>
<div slot="sidebar" class="list"> <svelte:fragment slot="sidebar">
<Layout> <div class="container">
<div class="section"> <section>
<Heading size="S">Available bindings</Heading> <div class="heading">Search</div>
<Input extraThin placeholder="Search" bind:value={search} /> <Search placeholder="Search" bind:value={search} />
</div> </section>
<div class="section"> {#each filteredCategories as [categoryName, bindings]}
{#each categories as [categoryName, bindings]} {#if bindings.length}
<Heading size="XS">{categoryName}</Heading> <section>
{#each bindings.filter( binding => binding.label.match(searchRgx) ) as binding} <div class="heading">{categoryName}</div>
<div <ul>
class="binding" {#each bindings as binding}
on:click={() => { <li
value = addToText(value, getCaretPosition(), binding) on:click={() => {
}} value = addToText(value, getCaretPosition(), binding)
> }}
<span class="binding__label">{binding.label}</span> >
<span class="binding__type">{binding.type}</span> <span class="binding__label">{binding.label}</span>
<br /> <span class="binding__type">{binding.type}</span>
<div class="binding__description"> {#if binding.description}
{binding.description || ""} <br />
</div> <div class="binding__description">
</div> {binding.description || ""}
{/each} </div>
{/each} {/if}
</div> </li>
<div class="section"> {/each}
<Heading size="XS">Helpers</Heading> </ul>
{#each helpers.filter(helper => helper.label.match(searchRgx) || helper.description.match(searchRgx)) as helper} </section>
<div {/if}
class="binding" {/each}
on:click={() => { {#if filteredHelpers?.length}
value = addToText(value, getCaretPosition(), helper.text) <section>
}} <div class="heading">Helpers</div>
> <ul>
<span class="binding__label">{helper.label}</span> {#each filteredHelpers as helper}
<br /> <li
<div class="binding__description"> on:click={() => {
{@html helper.description || ""} value = addToText(value, getCaretPosition(), helper.text)
</div> }}
<pre>{helper.example || ""}</pre> >
</div> <div class="helper">
{/each} <div class="helper__name">{helper.displayText}</div>
</div> <div class="helper__description">
</Layout> {@html helper.description}
</div> </div>
<div class="text"> <pre class="helper__example">{helper.example || ''}</pre>
</div>
</li>
{/each}
</ul>
</section>
{/if}
</div>
</svelte:fragment>
<div class="main">
<TextArea <TextArea
bind:getCaretPosition bind:getCaretPosition
bind:value bind:value
placeholder="Add text, or click the objects on the left to add them to the textbox." placeholder="Add text, or click the objects on the left to add them to the textbox."
/> />
{#if !validity} {#if !valid}
<p class="syntax-error"> <p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a> <a href="https://handlebarsjs.com/guide/">here</a>
@ -103,70 +105,105 @@
</DrawerContent> </DrawerContent>
<style> <style>
.list { .main :global(textarea) {
grid-gap: var(--spacing-s);
border-right: var(--border-light);
overflow-y: auto;
}
.section {
display: grid;
grid-gap: var(--spacing-s);
}
.text {
padding: var(--spacing-l);
font-family: var(--font-sans);
}
.text :global(textarea) {
min-height: 150px !important; min-height: 150px !important;
} }
.text :global(p) {
.container {
margin: calc(-1 * var(--spacing-xl));
}
.heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
}
section {
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
}
section:not(:first-child) {
border-top: var(--border-light);
}
ul {
list-style: none;
padding: 0;
margin: 0; margin: 0;
} }
.binding { li {
font-size: 12px; font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
border: var(--border-light); border: var(--border-light);
border-width: 1px 0 0 0; transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
padding: var(--spacing-m) 0; border-color 130ms ease-in-out;
margin: auto 0; }
align-items: center; li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-global-color-gray-500);
cursor: pointer; cursor: pointer;
} }
.binding:hover { li:hover :global(*) {
background-color: var(--grey-2); color: var(--spectrum-global-color-gray-900) !important;
cursor: pointer;
} }
.helper {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xs);
}
.helper__name {
font-weight: bold;
}
.helper__description,
.helper__description :global(*) {
color: var(--spectrum-global-color-gray-700);
}
.helper__example {
white-space: normal;
margin: 0.5rem 0 0 0;
font-weight: 700;
}
.helper__description :global(p) {
margin: 0;
}
.syntax-error {
padding-top: var(--spacing-m);
color: var(--red);
font-size: 12px;
}
.syntax-error a {
color: var(--red);
text-decoration: underline;
}
.binding__label { .binding__label {
font-weight: 600; font-weight: 600;
text-transform: capitalize; text-transform: capitalize;
} }
.binding__description { .binding__description {
color: var(--grey-8); color: var(--spectrum-global-color-gray-700);
margin-top: 2px; margin: 0.5rem 0 0 0;
white-space: normal; white-space: normal;
} }
pre {
white-space: normal;
}
.binding__type { .binding__type {
font-family: monospace; font-family: monospace;
background-color: var(--grey-2); background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-m); border-radius: var(--border-radius-s);
padding: 2px; padding: 2px 4px;
margin-left: 2px; margin-left: 2px;
font-weight: 600; font-weight: 600;
} }
.syntax-error {
color: var(--red);
font-size: 12px;
}
.syntax-error a {
color: var(--red);
text-decoration: underline;
}
</style> </style>

View File

@ -1,8 +1,6 @@
<script> <script>
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { Button, Modal, notifications, ModalContent } from "@budibase/bbui" import { Button, Modal, notifications, ModalContent } from "@budibase/bbui"
import FeedbackIframe from "../feedback/FeedbackIframe.svelte"
import { store } from "builderStore"
import api from "builderStore/api" import api from "builderStore/api"
import analytics from "analytics" import analytics from "analytics"
@ -19,8 +17,6 @@
let poll let poll
let publishModal let publishModal
$: appId = $store.appId
async function deployApp() { async function deployApp() {
try { try {
const response = await api.post("/api/deploy") const response = await api.post("/api/deploy")
@ -99,9 +95,7 @@
size="L" size="L"
showConfirmButton={false} showConfirmButton={false}
showCancelButton={false} showCancelButton={false}
> />
<FeedbackIframe on:finished={feedbackModal.hide} />
</ModalContent>
</Modal> </Modal>
<Modal bind:this={publishModal}> <Modal bind:this={publishModal}>
<ModalContent <ModalContent

View File

@ -0,0 +1,120 @@
<script>
import {
Icon,
Modal,
notifications,
ModalContent,
Body,
Button,
} from "@budibase/bbui"
import { store } from "builderStore"
import api from "builderStore/api"
import clientPackage from "@budibase/client/package.json"
let updateModal
$: appId = $store.appId
$: updateAvailable = clientPackage.version !== $store.version
$: revertAvailable = $store.revertableVersion != null
const refreshAppPackage = async () => {
const applicationPkg = await api.get(
`/api/applications/${appId}/appPackage`
)
const pkg = await applicationPkg.json()
if (applicationPkg.ok) {
await store.actions.initialise(pkg)
} else {
throw new Error(pkg)
}
}
const update = async () => {
try {
const response = await api.post(
`/api/applications/${appId}/client/update`
)
const json = await response.json()
if (response.status !== 200) {
throw json.message
}
// Don't wait for the async refresh, since this causes modal flashing
refreshAppPackage()
notifications.success(
`App updated successfully to version ${clientPackage.version}`
)
} catch (err) {
notifications.error(`Error updating app: ${err}`)
}
}
const revert = async () => {
try {
const revertableVersion = $store.revertableVersion
const response = await api.post(
`/api/applications/${appId}/client/revert`
)
const json = await response.json()
if (response.status !== 200) {
throw json.message
}
// Don't wait for the async refresh, since this causes modal flashing
refreshAppPackage()
notifications.success(
`App reverted successfully to version ${revertableVersion}`
)
} catch (err) {
notifications.error(`Error reverting app: ${err}`)
}
updateModal.hide()
}
</script>
<div class="icon-wrapper" class:highlight={updateAvailable}>
<Icon name="Refresh" hoverable on:click={updateModal.show} />
</div>
<Modal bind:this={updateModal}>
<ModalContent
title="App version"
confirmText="Update"
cancelText={updateAvailable ? "Cancel" : "Close"}
onConfirm={update}
showConfirmButton={updateAvailable}
>
<div slot="footer">
{#if revertAvailable}
<Button quiet secondary on:click={revert}>Revert</Button>
{/if}
</div>
{#if updateAvailable}
<Body size="S">
This app is currently using version <b>{$store.version}</b>, but version
<b>{clientPackage.version}</b> is available. Updates can contain new features,
performance improvements and bug fixes.
</Body>
{:else}
<Body size="S">
This app is currently using version <b>{$store.version}</b> which is the
latest version available.
</Body>
{/if}
{#if revertAvailable}
<Body size="S">
You can revert this app to version
<b>{$store.revertableVersion}</b>
if you're experiencing issues with the current version.
</Body>
{/if}
</ModalContent>
</Modal>
<style>
.icon-wrapper {
display: contents;
}
.icon-wrapper.highlight :global(svg) {
color: var(--spectrum-global-color-blue-600);
}
</style>

View File

@ -0,0 +1,38 @@
<script>
import { Select } from "@budibase/bbui"
import { store } from "builderStore"
const themeOptions = [
{
label: "Lightest",
value: "spectrum--lightest",
},
{
label: "Light",
value: "spectrum--light",
},
{
label: "Dark",
value: "spectrum--dark",
},
{
label: "Darkest",
value: "spectrum--darkest",
},
]
</script>
<div>
<Select
value={$store.theme || "spectrum--light"}
options={themeOptions}
placeholder={null}
on:change={e => store.actions.theme.save(e.detail)}
/>
</div>
<style>
div {
padding-right: 8px;
}
</style>

View File

@ -48,6 +48,7 @@
screen, screen,
selectedComponentId, selectedComponentId,
previewType: $store.currentFrontEndType, previewType: $store.currentFrontEndType,
theme: $store.theme,
} }
// Saving pages and screens to the DB causes them to have _revs. // Saving pages and screens to the DB causes them to have _revs.
@ -74,19 +75,16 @@
iframe.contentWindow.addEventListener( iframe.contentWindow.addEventListener(
"ready", "ready",
() => { () => {
loading = false // Display preview immediately if the intelligent loading feature
// is not supported
if (!$store.clientFeatures.intelligentLoading) {
loading = false
}
refreshContent(strippedJson) refreshContent(strippedJson)
}, },
{ once: true } { once: true }
) )
// Use iframe loading event to support old client versions
iframe.contentWindow.addEventListener(
"iframe-loaded",
() => (loading = false),
{ once: true }
)
// Catch any app errors // Catch any app errors
iframe.contentWindow.addEventListener( iframe.contentWindow.addEventListener(
"error", "error",
@ -108,7 +106,9 @@
idToDelete = data.id idToDelete = data.id
confirmDeleteDialog.show() confirmDeleteDialog.show()
} else if (type === "preview-loaded") { } else if (type === "preview-loaded") {
// loading = false // Wait for this event to show the client library if intelligent
// loading is supported
loading = false
} else { } else {
console.warning(`Client sent unknown event type: ${type}`) console.warning(`Client sent unknown event type: ${type}`)
} }

View File

@ -27,9 +27,7 @@
"name": "Card", "name": "Card",
"icon": "Card", "icon": "Card",
"children": [ "children": [
"stackedlist", "spectrumcard",
"card",
"cardhorizontal",
"cardstat" "cardstat"
] ]
}, },
@ -57,13 +55,6 @@
"icon", "icon",
"embed" "embed"
] ]
},
{
"name": "Other",
"icon": "More",
"children": [
"screenslot"
]
} }
] ]

View File

@ -48,7 +48,14 @@ export default `
} }
// Extract data from message // Extract data from message
const { selectedComponentId, layout, screen, previewType, appId } = JSON.parse(event.data) const {
selectedComponentId,
layout,
screen,
previewType,
appId,
theme
} = JSON.parse(event.data)
// Set some flags so the app knows we're in the builder // Set some flags so the app knows we're in the builder
window["##BUDIBASE_IN_BUILDER##"] = true window["##BUDIBASE_IN_BUILDER##"] = true
@ -58,6 +65,7 @@ export default `
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
window["##BUDIBASE_PREVIEW_ID##"] = Math.random() window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
window["##BUDIBASE_PREVIEW_TYPE##"] = previewType window["##BUDIBASE_PREVIEW_TYPE##"] = previewType
window["##BUDIBASE_PREVIEW_THEME##"] = theme
// Initialise app // Initialise app
try { try {

View File

@ -65,52 +65,56 @@
} }
</script> </script>
<ActionMenu> {#if definition?.editable !== false}
<div slot="control" class="icon"> <ActionMenu>
<Icon size="S" hoverable name="MoreSmallList" /> <div slot="control" class="icon">
</div> <Icon size="S" hoverable name="MoreSmallList" />
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem> </div>
<MenuItem noClose icon="ChevronUp" on:click={moveUpComponent}> <MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>
Move up Delete
</MenuItem> </MenuItem>
<MenuItem noClose icon="ChevronDown" on:click={moveDownComponent}> <MenuItem noClose icon="ChevronUp" on:click={moveUpComponent}>
Move down Move up
</MenuItem> </MenuItem>
<MenuItem noClose icon="Duplicate" on:click={duplicateComponent}> <MenuItem noClose icon="ChevronDown" on:click={moveDownComponent}>
Duplicate Move down
</MenuItem> </MenuItem>
<MenuItem icon="Cut" on:click={() => storeComponentForCopy(true)}> <MenuItem noClose icon="Duplicate" on:click={duplicateComponent}>
Cut Duplicate
</MenuItem> </MenuItem>
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}> <MenuItem icon="Cut" on:click={() => storeComponentForCopy(true)}>
Copy Cut
</MenuItem> </MenuItem>
<MenuItem <MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
icon="LayersBringToFront" Copy
on:click={() => pasteComponent("above")} </MenuItem>
disabled={noPaste} <MenuItem
> icon="LayersBringToFront"
Paste above on:click={() => pasteComponent("above")}
</MenuItem> disabled={noPaste}
<MenuItem >
icon="LayersSendToBack" Paste above
on:click={() => pasteComponent("below")} </MenuItem>
disabled={noPaste} <MenuItem
> icon="LayersSendToBack"
Paste below on:click={() => pasteComponent("below")}
</MenuItem> disabled={noPaste}
<MenuItem >
icon="ShowOneLayer" Paste below
on:click={() => pasteComponent("inside")} </MenuItem>
disabled={noPaste || noChildrenAllowed} <MenuItem
> icon="ShowOneLayer"
Paste inside on:click={() => pasteComponent("inside")}
</MenuItem> disabled={noPaste || noChildrenAllowed}
</ActionMenu> >
<ConfirmDialog Paste inside
bind:this={confirmDeleteDialog} </MenuItem>
title="Confirm Deletion" </ActionMenu>
body={`Are you sure you wish to delete this '${definition?.name}' component?`} <ConfirmDialog
okText="Delete Component" bind:this={confirmDeleteDialog}
onOk={deleteComponent} title="Confirm Deletion"
/> body={`Are you sure you wish to delete this '${definition?.name}' component?`}
okText="Delete Component"
onOk={deleteComponent}
/>
{/if}

View File

@ -3,6 +3,7 @@
import { DropEffect, DropPosition } from "./dragDropStore" import { DropEffect, DropPosition } from "./dragDropStore"
import ComponentDropdownMenu from "../ComponentDropdownMenu.svelte" import ComponentDropdownMenu from "../ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import { capitalise } from "helpers"
export let components = [] export let components = []
export let currentComponent export let currentComponent
@ -10,8 +11,6 @@
export let level = 0 export let level = 0
export let dragDropStore export let dragDropStore
const isScreenslot = name => name?.endsWith("screenslot")
const selectComponent = component => { const selectComponent = component => {
store.actions.components.select(component) store.actions.components.select(component)
} }
@ -42,6 +41,16 @@
return false return false
} }
const getComponentText = component => {
if (component._instanceName) {
return component._instanceName
}
const type =
component._component.replace("@budibase/standard-components/", "") ||
"component"
return capitalise(type)
}
</script> </script>
<ul> <ul>
@ -63,9 +72,7 @@
on:dragstart={dragstart(component)} on:dragstart={dragstart(component)}
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:drop={dragDropStore.actions.drop} on:drop={dragDropStore.actions.drop}
text={isScreenslot(component._component) text={getComponentText(component)}
? "Screenslot"
: component._instanceName}
withArrow withArrow
indentLevel={level + 1} indentLevel={level + 1}
selected={$store.selectedComponentId === component._id} selected={$store.selectedComponentId === component._id}

View File

@ -1,36 +1,12 @@
<script> <script>
import { isEmpty } from "lodash/fp" import { isEmpty } from "lodash/fp"
import { import { Input, DetailSummary } from "@budibase/bbui"
Checkbox,
Input,
Select,
DetailSummary,
ColorPicker,
} from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import PropertyControl from "./PropertyControls/PropertyControl.svelte" import PropertyControl from "./PropertyControls/PropertyControl.svelte"
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte" import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
import RoleSelect from "./PropertyControls/RoleSelect.svelte" import RoleSelect from "./PropertyControls/RoleSelect.svelte"
import TableSelect from "./PropertyControls/TableSelect.svelte"
import DataSourceSelect from "./PropertyControls/DataSourceSelect.svelte"
import DataProviderSelect from "./PropertyControls/DataProviderSelect.svelte"
import FieldSelect from "./PropertyControls/FieldSelect.svelte"
import MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte"
import SchemaSelect from "./PropertyControls/SchemaSelect.svelte"
import SectionSelect from "./PropertyControls/SectionSelect.svelte"
import NavigationEditor from "./PropertyControls/NavigationEditor/NavigationEditor.svelte"
import EventsEditor from "./PropertyControls/EventsEditor"
import FilterEditor from "./PropertyControls/FilterEditor/FilterEditor.svelte"
import { IconSelect } from "./PropertyControls/IconSelect"
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
import NumberFieldSelect from "./PropertyControls/NumberFieldSelect.svelte"
import OptionsFieldSelect from "./PropertyControls/OptionsFieldSelect.svelte"
import BooleanFieldSelect from "./PropertyControls/BooleanFieldSelect.svelte"
import LongFormFieldSelect from "./PropertyControls/LongFormFieldSelect.svelte"
import DateTimeFieldSelect from "./PropertyControls/DateTimeFieldSelect.svelte"
import AttachmentFieldSelect from "./PropertyControls/AttachmentFieldSelect.svelte"
import RelationshipFieldSelect from "./PropertyControls/RelationshipFieldSelect.svelte"
import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte" import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte"
import { getComponentForSettingType } from "./PropertyControls/componentSettings"
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
@ -49,39 +25,9 @@
$: assetDefinition = isLayout ? layoutDefinition : screenDefinition $: assetDefinition = isLayout ? layoutDefinition : screenDefinition
const updateProp = store.actions.components.updateProp const updateProp = store.actions.components.updateProp
const controlMap = {
text: Input,
select: Select,
dataSource: DataSourceSelect,
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Input,
event: EventsEditor,
table: TableSelect,
color: ColorPicker,
icon: IconSelect,
field: FieldSelect,
multifield: MultiFieldSelect,
schema: SchemaSelect,
section: SectionSelect,
navigation: NavigationEditor,
filter: FilterEditor,
"field/string": StringFieldSelect,
"field/number": NumberFieldSelect,
"field/options": OptionsFieldSelect,
"field/boolean": BooleanFieldSelect,
"field/longform": LongFormFieldSelect,
"field/datetime": DateTimeFieldSelect,
"field/attachment": AttachmentFieldSelect,
"field/link": RelationshipFieldSelect,
}
const getControl = type => {
return controlMap[type]
}
const canRenderControl = setting => { const canRenderControl = setting => {
const control = getControl(setting?.type) const control = getComponentForSettingType(setting?.type)
if (!control) { if (!control) {
return false return false
} }
@ -104,11 +50,11 @@
/> />
{/if} {/if}
{#if settings && settings.length > 0} {#if settings && settings.length > 0}
{#each settings as setting (`${componentInstance._id}-${setting.key}`)} {#each settings as setting}
{#if canRenderControl(setting)} {#if canRenderControl(setting)}
<PropertyControl <PropertyControl
type={setting.type} type={setting.type}
control={getControl(setting.type)} control={getComponentForSettingType(setting.type)}
label={setting.label} label={setting.label}
key={setting.key} key={setting.key}
value={componentInstance[setting.key] ?? value={componentInstance[setting.key] ??

View File

@ -0,0 +1,36 @@
<script>
import { DetailSummary, ActionButton, Drawer, Button } from "@budibase/bbui"
import { store } from "builderStore"
import ConditionalUIDrawer from "./PropertyControls/ConditionalUIDrawer.svelte"
export let componentInstance
let tempValue
let drawer
const openDrawer = () => {
tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? []))
drawer.show()
}
const save = () => {
store.actions.components.updateConditions(tempValue)
drawer.hide()
}
</script>
<DetailSummary
name={`Conditions${componentInstance?._conditions ? " *" : ""}`}
collapsible={false}
>
<div>
<ActionButton on:click={openDrawer}>Configure conditions</ActionButton>
</div>
</DetailSummary>
<Drawer bind:this={drawer} title="Conditions">
<svelte:fragment slot="description">
Show, hide and update components in response to conditions being met.
</svelte:fragment>
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} />
</Drawer>

View File

@ -35,17 +35,19 @@
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton> <ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
</div> </div>
</DetailSummary> </DetailSummary>
<Drawer bind:this={drawer} title="Custom CSS"> {#key componentInstance?._id}
<Button cta slot="buttons" on:click={save}>Save</Button> <Drawer bind:this={drawer} title="Custom CSS">
<DrawerContent slot="body"> <Button cta slot="buttons" on:click={save}>Save</Button>
<div class="content"> <DrawerContent slot="body">
<Layout gap="S"> <div class="content">
<Body size="S">Custom CSS overrides all other component styles.</Body> <Layout gap="S" noPadding>
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." /> <Body size="S">Custom CSS overrides all other component styles.</Body>
</Layout> <TextArea bind:value={tempValue} placeholder="Enter some CSS..." />
</div> </Layout>
</DrawerContent> </div>
</Drawer> </DrawerContent>
</Drawer>
{/key}
<style> <style>
.content { .content {

View File

@ -5,6 +5,7 @@
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte"
$: componentInstance = $selectedComponent $: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition( $: componentDefinition = store.actions.components.getDefinition(
@ -15,10 +16,13 @@
<Tabs selected="Settings" noPadding> <Tabs selected="Settings" noPadding>
<Tab title="Settings"> <Tab title="Settings">
<div class="container"> <div class="container">
<ScreenSettingsSection {componentInstance} {componentDefinition} /> {#key componentInstance?._id}
<ComponentSettingsSection {componentInstance} {componentDefinition} /> <ScreenSettingsSection {componentInstance} {componentDefinition} />
<DesignSection {componentInstance} {componentDefinition} /> <ComponentSettingsSection {componentInstance} {componentDefinition} />
<CustomStylesSection {componentInstance} {componentDefinition} /> <DesignSection {componentInstance} {componentDefinition} />
<CustomStylesSection {componentInstance} {componentDefinition} />
<ConditionalUISection {componentInstance} {componentDefinition} />
{/key}
</div> </div>
</Tab> </Tab>
</Tabs> </Tabs>

View File

@ -1,42 +1,8 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { ColorPicker } from "@budibase/bbui"
import Colorpicker from "@budibase/colorpicker" import { store } from "builderStore"
const dispatch = createEventDispatcher()
export let value export let value
const WAIT = 150
function throttle(callback, wait, immediate = false) {
let timeout = null
let initialCall = true
return function () {
const callNow = immediate && initialCall
const next = () => {
callback.apply(this, arguments)
timeout = null
}
if (callNow) {
initialCall = false
next()
}
if (!timeout) {
timeout = setTimeout(next, wait)
}
}
}
const onChange = throttle(
e => {
dispatch("change", e.detail)
},
WAIT,
true
)
</script> </script>
<Colorpicker value={value || "#C4C4C4"} on:change={onChange} /> <ColorPicker {value} on:change spectrumTheme={$store.theme} />

View File

@ -0,0 +1,290 @@
<script>
import {
Button,
Body,
Icon,
DrawerContent,
Layout,
Select,
DatePicker,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene"
import { getBindableProperties } from "builderStore/dataBinding"
import { currentAsset, selectedComponent, store } from "builderStore"
import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte"
export let conditions = []
const flipDurationMs = 150
const actionOptions = [
{
label: "Hide component",
value: "hide",
},
{
label: "Show component",
value: "show",
},
{
label: "Update setting",
value: "update",
},
]
const valueTypeOptions = [
{
value: "string",
label: "Binding",
},
{
value: "number",
label: "Number",
},
{
value: "datetime",
label: "Date",
},
{
value: "boolean",
label: "Boolean",
},
]
let dragDisabled = true
$: definition = store.actions.components.getDefinition(
$selectedComponent?._component
)
$: settings = (definition?.settings ?? []).map(setting => {
return {
label: setting.label,
value: setting.key,
}
})
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: conditions.forEach(link => {
if (!link.id) {
link.id = generate()
}
})
const getSettingDefinition = key => {
return definition?.settings?.find(setting => {
return setting.key === key
})
}
const getComponentForSetting = key => {
const settingDefinition = getSettingDefinition(key)
return getComponentForSettingType(settingDefinition?.type || "text")
}
const addCondition = () => {
conditions = [
...conditions,
{
valueType: "string",
id: generate(),
action: "hide",
operator: OperatorOptions.Equals.value,
},
]
}
const removeCondition = id => {
conditions = conditions.filter(link => link.id !== id)
}
const handleFinalize = e => {
updateConditions(e)
dragDisabled = true
}
const updateConditions = e => {
conditions = e.detail.items
}
const getOperatorOptions = condition => {
return getValidOperatorsForType(condition.valueType)
}
const onOperatorChange = (condition, newOperator) => {
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
condition.noValue = noValueOptions.includes(newOperator)
if (condition.noValue) {
condition.referenceValue = null
condition.valueType = "string"
}
}
const onValueTypeChange = (condition, newType) => {
condition.referenceValue = null
// Ensure a valid operator is set
const validOperators = getValidOperatorsForType(newType).map(x => x.value)
if (!validOperators.includes(condition.operator)) {
condition.operator = validOperators[0] ?? OperatorOptions.Equals.value
onOperatorChange(condition, condition.operator)
}
}
</script>
<DrawerContent>
<div class="container">
<Layout noPadding>
{#if conditions?.length}
<div
class="conditions"
use:dndzone={{
items: conditions,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled,
}}
on:finalize={handleFinalize}
on:consider={updateConditions}
>
{#each conditions as condition (condition.id)}
<div
class="condition"
class:update={condition.action === "update"}
animate:flip={{ duration: flipDurationMs }}
>
<div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" />
</div>
<Select
placeholder={null}
options={actionOptions}
bind:value={condition.action}
/>
{#if condition.action === "update"}
<Select options={settings} bind:value={condition.setting} />
<div>TO</div>
{#if getSettingDefinition(condition.setting)}
<PropertyControl
type={getSettingDefinition(condition.setting).type}
control={getComponentForSetting(condition.setting)}
key={getSettingDefinition(condition.setting).key}
value={condition.settingValue}
componentInstance={$selectedComponent}
onChange={val => (condition.settingValue = val)}
props={{
options: getSettingDefinition(condition.setting).options,
placeholder: getSettingDefinition(condition.setting)
.placeholder,
}}
/>
{:else}
<Select disabled placeholder=" " />
{/if}
{/if}
<div>IF</div>
<DrawerBindableInput
bindings={bindableProperties}
placeholder="Value"
value={condition.newValue}
on:change={e => (condition.newValue = e.detail)}
/>
<Select
placeholder={null}
options={getOperatorOptions(condition)}
bind:value={condition.operator}
on:change={e => onOperatorChange(condition, e.detail)}
/>
<Select
disabled={condition.noValue}
options={valueTypeOptions}
bind:value={condition.valueType}
placeholder={null}
on:change={e => onValueTypeChange(condition, e.detail)}
/>
{#if ["string", "number"].includes(condition.valueType)}
<DrawerBindableInput
disabled={condition.noValue}
bindings={bindableProperties}
placeholder="Value"
value={condition.referenceValue}
on:change={e => (condition.referenceValue = e.detail)}
/>
{:else if condition.valueType === "datetime"}
<DatePicker
placeholder="Value"
disabled={condition.noValue}
bind:value={condition.referenceValue}
/>
{:else if condition.valueType === "boolean"}
<Select
placeholder="Value"
disabled={condition.noValue}
options={["True", "False"]}
bind:value={condition.referenceValue}
/>
{/if}
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeCondition(condition.id)}
/>
</div>
{/each}
</div>
{:else}
<Body size="S">Add your first condition to get started.</Body>
{/if}
<div>
<Button secondary icon="Add" on:click={addCondition}>
Add condition
</Button>
</div>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.conditions {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.condition {
gap: var(--spacing-l);
display: grid;
align-items: center;
grid-template-columns: auto 1fr auto 1fr 1fr 1fr 1fr auto;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.condition.update {
grid-template-columns: auto 1fr 1fr auto 1fr auto 1fr 1fr 1fr 1fr auto;
}
.condition:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.handle {
display: grid;
place-items: center;
}
</style>

View File

@ -132,7 +132,7 @@
Save Save
</Button> </Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<Layout> <Layout noPadding>
{#if value.parameters.length > 0} {#if value.parameters.length > 0}
<ParameterBuilder <ParameterBuilder
bind:customParams={value.queryParams} bind:customParams={value.queryParams}

View File

@ -73,54 +73,49 @@
</script> </script>
<DrawerContent> <DrawerContent>
<div class="actions-list" slot="sidebar"> <Layout noPadding gap="S" slot="sidebar">
<Layout> {#if actions && actions.length > 0}
<ActionMenu> <div
<Button slot="control" secondary>Add Action</Button> class="actions"
{#each actionTypes as actionType} use:dndzone={{
<MenuItem on:click={addAction(actionType)}> items: actions,
{actionType.name} flipDurationMs,
</MenuItem> dropTargetStyle: { outline: "none" },
{/each} }}
</ActionMenu> on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
{#if actions && actions.length > 0} >
<div {#each actions as action, index (action.id)}
class="action-dnd-container" <div
use:dndzone={{ class="action-container"
items: actions, animate:flip={{ duration: flipDurationMs }}
flipDurationMs, class:selected={action === selectedAction}
dropTargetStyle: { outline: "none" }, on:click={selectAction(action)}
}} >
on:consider={handleDndConsider} <Icon name="DragHandle" size="XL" />
on:finalize={handleDndFinalize} <div class="action-header">
> {index + 1}.&nbsp;{action[EVENT_TYPE_KEY]}
{#each actions as action, index (action.id)}
<div
class="action-container"
animate:flip={{ duration: flipDurationMs }}
>
<div
class="action-header"
class:selected={action === selectedAction}
on:click={selectAction(action)}
>
{index + 1}.
{action[EVENT_TYPE_KEY]}
</div>
<div
on:click={() => deleteAction(index)}
style="margin-left: auto;"
>
<Icon size="S" hoverable name="Close" />
</div>
</div> </div>
{/each} <Icon
</div> name="Close"
{/if} hoverable
</Layout> size="S"
</div> on:click={() => deleteAction(index)}
<Layout> />
</div>
{/each}
</div>
{/if}
<ActionMenu>
<Button slot="control" secondary>Add Action</Button>
{#each actionTypes as actionType}
<MenuItem on:click={addAction(actionType)}>
{actionType.name}
</MenuItem>
{/each}
</ActionMenu>
</Layout>
<Layout noPadding>
{#if selectedAction} {#if selectedAction}
<div class="selected-action-container"> <div class="selected-action-container">
<svelte:component <svelte:component
@ -133,32 +128,41 @@
</DrawerContent> </DrawerContent>
<style> <style>
.action-header { .actions {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: center; justify-content: flex-start;
margin-top: var(--spacing-s); align-items: stretch;
gap: var(--spacing-s);
} }
.action-header { .action-header {
margin-bottom: var(--spacing-m); color: var(--spectrum-global-color-gray-700);
font-size: var(--font-size-s);
color: var(--grey-7);
font-weight: 600;
}
.action-header:hover, flex: 1 1 auto;
.action-header.selected {
cursor: pointer;
color: var(--ink);
} }
.action-container { .action-container {
border-bottom: 1px solid var(--grey-1); background-color: var(--background);
padding: var(--spacing-s) var(--spacing-m);
border-radius: 4px;
border: var(--border-light);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
gap: var(--spacing-m);
display: flex; display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center; align-items: center;
} }
.action-container:last-child { .action-container:hover,
border-bottom: none; .action-container.selected {
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-global-color-gray-500);
cursor: pointer;
}
.action-container:hover .action-header,
.action-container.selected .action-header {
color: var(--spectrum-global-color-gray-900);
} }
</style> </style>

View File

@ -51,7 +51,7 @@
} }
</script> </script>
<ActionButton on:click={drawer.show}>Define Actions</ActionButton> <ActionButton on:click={drawer.show}>Define actions</ActionButton>
<Drawer bind:this={drawer} title={"Actions"}> <Drawer bind:this={drawer} title={"Actions"}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Define what actions to run. Define what actions to run.

View File

@ -25,7 +25,7 @@
} }
</script> </script>
<Layout gap="XS"> <Layout gap="XS" noPadding>
<Select <Select
label="Datasource" label="Datasource"
bind:value={parameters.datasourceId} bind:value={parameters.datasourceId}

View File

@ -38,11 +38,11 @@
} }
</script> </script>
<ActionButton on:click={drawer.show}>Define Filters</ActionButton> <ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} title="Filtering"> <Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button> <Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<Layout> <Layout noPadding>
<Body size="S"> <Body size="S">
{#if !numFilters} {#if !numFilters}
Add your first filter column. Add your first filter column.

View File

@ -11,44 +11,11 @@
import { getBindableProperties } from "builderStore/dataBinding" import { getBindableProperties } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene"
export let schemaFields export let schemaFields
export let value export let value
const OperatorOptions = {
Equals: {
value: "equal",
label: "Equals",
},
NotEquals: {
value: "notEqual",
label: "Not equals",
},
Empty: {
value: "empty",
label: "Is empty",
},
NotEmpty: {
value: "notEmpty",
label: "Is not empty",
},
StartsWith: {
value: "string",
label: "Starts with",
},
Like: {
value: "fuzzy",
label: "Like",
},
MoreThan: {
value: "rangeLow",
label: "More than",
},
LessThan: {
value: "rangeHigh",
label: "Less than",
},
}
const BannedTypes = ["link", "attachment"] const BannedTypes = ["link", "attachment"]
$: bindableProperties = getBindableProperties( $: bindableProperties = getBindableProperties(
$currentAsset, $currentAsset,
@ -75,61 +42,16 @@
value = value.filter(field => field.id !== id) value = value.filter(field => field.id !== id)
} }
const getValidOperatorsForType = type => {
const Op = OperatorOptions
if (type === "string") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
}
return []
}
const onFieldChange = (expression, field) => { const onFieldChange = (expression, field) => {
// Update the field type // Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type expression.type = schemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set // Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type) const validOperators = getValidOperatorsForType(expression.type).map(
x => x.value
)
if (!validOperators.includes(expression.operator)) { if (!validOperators.includes(expression.operator)) {
expression.operator = expression.operator = validOperators[0] ?? OperatorOptions.Equals.value
validOperators[0]?.value ?? OperatorOptions.Equals.value
onOperatorChange(expression, expression.operator) onOperatorChange(expression, expression.operator)
} }
} }

View File

@ -107,7 +107,7 @@
loading = false loading = false
} }
$: displayValue = value ? value.substring(3) : "Pick Icon" $: displayValue = value ? value.substring(3) : "Pick icon"
$: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage) $: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage)
$: pageEndIdx = maxIconsPerPage * currentPage $: pageEndIdx = maxIconsPerPage * currentPage

View File

@ -15,13 +15,13 @@
export let links = [] export let links = []
const flipDurationMs = 150 const flipDurationMs = 150
let dragDisabled = true
$: links.forEach(link => { $: links.forEach(link => {
if (!link.id) { if (!link.id) {
link.id = generate() link.id = generate()
} }
}) })
$: urlOptions = $store.screens $: urlOptions = $store.screens
.map(screen => screen.routing?.route) .map(screen => screen.routing?.route)
.filter(x => x != null) .filter(x => x != null)
@ -37,11 +37,16 @@
const updateLinks = e => { const updateLinks = e => {
links = e.detail.items links = e.detail.items
} }
const handleFinalize = e => {
updateLinks(e)
dragDisabled = true
}
</script> </script>
<DrawerContent> <DrawerContent>
<div class="container"> <div class="container">
<Layout> <Layout noPadding gap="S">
{#if links?.length} {#if links?.length}
<div <div
class="links" class="links"
@ -49,13 +54,21 @@
items: links, items: links,
flipDurationMs, flipDurationMs,
dropTargetStyle: { outline: "none" }, dropTargetStyle: { outline: "none" },
dragDisabled,
}} }}
on:finalize={updateLinks} on:finalize={handleFinalize}
on:consider={updateLinks} on:consider={updateLinks}
> >
{#each links as link (link.id)} {#each links as link (link.id)}
<div class="link" animate:flip={{ duration: flipDurationMs }}> <div class="link" animate:flip={{ duration: flipDurationMs }}>
<Icon name="DragHandle" size="XL" /> <div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" />
</div>
<Input bind:value={link.text} placeholder="Text" /> <Input bind:value={link.text} placeholder="Text" />
<Combobox <Combobox
bind:value={link.url} bind:value={link.url}
@ -72,7 +85,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<div class="button-container"> <div>
<Button secondary icon="Add" on:click={addLink}>Add Link</Button> <Button secondary icon="Add" on:click={addLink}>Add Link</Button>
</div> </div>
</Layout> </Layout>
@ -83,16 +96,16 @@
.container { .container {
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
margin: var(--spacing-m) auto; margin: 0 auto;
} }
.links { .links {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
gap: var(--spacing-m);
} }
.link { .link {
padding: 4px 8px;
gap: var(--spacing-l); gap: var(--spacing-l);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -108,7 +121,8 @@
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
} }
.button-container { .handle {
margin-left: var(--spacing-l); display: grid;
place-items: center;
} }
</style> </style>

View File

@ -20,7 +20,6 @@
export let onChange = () => {} export let onChange = () => {}
let bindingDrawer let bindingDrawer
let temporaryBindableValue = value
let anchor let anchor
let valid let valid
@ -29,10 +28,11 @@
$store.selectedComponentId $store.selectedComponentId
) )
$: safeValue = getSafeValue(value, props.defaultValue, bindableProperties) $: safeValue = getSafeValue(value, props.defaultValue, bindableProperties)
$: tempValue = safeValue
$: replaceBindings = val => readableToRuntimeBinding(bindableProperties, val) $: replaceBindings = val => readableToRuntimeBinding(bindableProperties, val)
const handleClose = () => { const handleClose = () => {
handleChange(temporaryBindableValue) handleChange(tempValue)
bindingDrawer.hide() bindingDrawer.hide()
} }
@ -70,7 +70,7 @@
</script> </script>
<div class="property-control" bind:this={anchor} data-cy={`setting-${key}`}> <div class="property-control" bind:this={anchor} data-cy={`setting-${key}`}>
{#if type !== "boolean"} {#if type !== "boolean" && label}
<div class="label"> <div class="label">
<Label>{label}</Label> <Label>{label}</Label>
</div> </div>
@ -107,8 +107,7 @@
slot="body" slot="body"
bind:valid bind:valid
value={safeValue} value={safeValue}
close={handleClose} on:change={e => (tempValue = e.detail)}
on:update={e => (temporaryBindableValue = e.detail)}
{bindableProperties} {bindableProperties}
/> />
</Drawer> </Drawer>

View File

@ -0,0 +1,12 @@
<script>
import { store } from "builderStore"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
export let value
$: urlOptions = $store.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
</script>
<DrawerBindableCombobox {value} on:change options={urlOptions} />

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