commit
2ffb76cc1d
|
@ -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",
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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 }}
|
|
||||||
|
|
||||||
|
|
|
@ -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 }}
|
|
@ -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,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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "0.9.82",
|
"version": "0.9.83-alpha.0",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
user: require("./src/cache/user"),
|
||||||
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require("./src/security/sessions")
|
|
@ -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)
|
||||||
|
}
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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");
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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
|
|
@ -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,
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
const packageConfiguration = require("../../../../index")
|
||||||
|
const CouchDB = require("./db")
|
||||||
|
packageConfiguration.init(CouchDB)
|
|
@ -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
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
|
@ -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))
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 |
|
@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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`,
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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] ??
|
||||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
|
@ -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}
|
||||||
|
|
|
@ -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}. {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>
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue