commit
b992b52aba
|
@ -162,6 +162,7 @@
|
|||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mslourens",
|
||||
"name": "Maurits Lourens",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1907152?v=4",
|
||||
|
|
|
@ -69,6 +69,28 @@ jobs:
|
|||
env:
|
||||
KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}'
|
||||
|
||||
- name: Re roll app-service
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
||||
with:
|
||||
args: rollout restart deployment app-service -n budibase
|
||||
|
||||
- name: Re roll proxy-service
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
||||
with:
|
||||
args: rollout restart deployment proxy-service -n budibase
|
||||
|
||||
- name: Re roll worker-service
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
||||
with:
|
||||
args: rollout restart deployment worker-service -n budibase
|
||||
|
||||
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@v4.0.0
|
||||
with:
|
||||
|
|
|
@ -18,8 +18,9 @@ on:
|
|||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Posthog token used by ui at build time
|
||||
POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F
|
||||
# Posthog token used by ui at build time
|
||||
# disable unless needed for testing
|
||||
# POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F
|
||||
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
FEATURE_PREVIEW_URL: https://budirelease.live
|
||||
|
@ -119,6 +120,27 @@ jobs:
|
|||
]
|
||||
env:
|
||||
KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}'
|
||||
|
||||
- name: Re roll app-service
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
||||
with:
|
||||
args: rollout restart deployment app-service -n budibase
|
||||
|
||||
- name: Re roll proxy-service
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
||||
with:
|
||||
args: rollout restart deployment proxy-service -n budibase
|
||||
|
||||
- name: Re roll worker-service
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
||||
with:
|
||||
args: rollout restart deployment worker-service -n budibase
|
||||
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@v4.0.0
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: Budibase Smoke Test
|
||||
name: Budibase Nightly Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
@ -6,7 +6,7 @@ on:
|
|||
- cron: "0 5 * * *" # every day at 5AM
|
||||
|
||||
jobs:
|
||||
release:
|
||||
nightly:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
@ -43,6 +43,18 @@ jobs:
|
|||
name: Test Reports
|
||||
path: packages/builder/cypress/reports/testReport.html
|
||||
|
||||
# TODO: enable once running in QA test env
|
||||
# - 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: Upload test results HTML
|
||||
# uses: aws-actions/configure-aws-credentials@v1
|
||||
# run: aws s3 cp packages/builder/cypress/reports/testReport.html s3://{{ secrets.BUDI_QA_REPORTS_BUCKET_NAME }}/$GITHUB_RUN_ID/index.html
|
||||
|
||||
- name: Cypress Discord Notify
|
||||
run: yarn test:e2e:ci:notify
|
||||
env:
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"svelteSortOrder": "options-scripts-markup-styles"
|
||||
}
|
||||
|
|
|
@ -65,6 +65,10 @@ http {
|
|||
proxy_pass http://{{ address }}:4001;
|
||||
}
|
||||
|
||||
location /preview {
|
||||
proxy_pass http://{{ address }}:4001;
|
||||
}
|
||||
|
||||
location /builder {
|
||||
proxy_pass http://{{ address }}:3000;
|
||||
rewrite ^/builder(.*)$ /builder/$1 break;
|
||||
|
|
|
@ -88,6 +88,10 @@ http {
|
|||
proxy_pass http://$apps:4002;
|
||||
}
|
||||
|
||||
location /preview {
|
||||
proxy_pass http://$apps:4002;
|
||||
}
|
||||
|
||||
location = / {
|
||||
proxy_pass http://$apps:4002;
|
||||
}
|
||||
|
@ -97,6 +101,7 @@ http {
|
|||
proxy_pass http://$watchtower:8080;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
location ~ ^/(builder|app_) {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
|
|
@ -3,15 +3,18 @@
|
|||
echo ${TARGETBUILD} > /buildtarget.txt
|
||||
if [[ "${TARGETBUILD}" = "aas" ]]; then
|
||||
# Azure AppService uses /home for persisent data & SSH on port 2222
|
||||
mkdir -p /home/{search,minio,couch}
|
||||
mkdir -p /home/couch/{dbs,views}
|
||||
chown -R couchdb:couchdb /home/couch/
|
||||
DATA_DIR=/home
|
||||
mkdir -p $DATA_DIR/{search,minio,couchdb}
|
||||
mkdir -p $DATA_DIR/couchdb/{dbs,views}
|
||||
chown -R couchdb:couchdb $DATA_DIR/couchdb/
|
||||
apt update
|
||||
apt-get install -y openssh-server
|
||||
sed -i 's#dir=/opt/couchdb/data/search#dir=/home/search#' /opt/clouseau/clouseau.ini
|
||||
sed -i 's#/minio/minio server /minio &#/minio/minio server /home/minio &#' /runner.sh
|
||||
sed -i 's#database_dir = ./data#database_dir = /home/couch/dbs#' /opt/couchdb/etc/default.ini
|
||||
sed -i 's#view_index_dir = ./data#view_index_dir = /home/couch/views#' /opt/couchdb/etc/default.ini
|
||||
sed -i "s/#Port 22/Port 2222/" /etc/ssh/sshd_config
|
||||
/etc/init.d/ssh restart
|
||||
fi
|
||||
sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini
|
||||
sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini
|
||||
else
|
||||
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
|
||||
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
|
||||
|
||||
fi
|
|
@ -20,10 +20,10 @@ RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh
|
|||
|
||||
FROM couchdb:3.2.1
|
||||
# TARGETARCH can be amd64 or arm e.g. docker build --build-arg TARGETARCH=amd64
|
||||
ARG TARGETARCH amd64
|
||||
ARG TARGETARCH=amd64
|
||||
#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service)
|
||||
# e.g. docker build --build-arg TARGETBUILD=aas ....
|
||||
ARG TARGETBUILD single
|
||||
ARG TARGETBUILD=single
|
||||
ENV TARGETBUILD $TARGETBUILD
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
@ -35,6 +35,7 @@ ENV \
|
|||
BUDIBASE_ENVIRONMENT=PRODUCTION \
|
||||
CLUSTER_PORT=80 \
|
||||
# CUSTOM_DOMAIN=budi001.custom.com \
|
||||
DATA_DIR=/data \
|
||||
DEPLOYMENT_ENVIRONMENT=docker \
|
||||
MINIO_URL=http://localhost:9000 \
|
||||
POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU \
|
||||
|
@ -114,6 +115,7 @@ RUN chmod +x ./healthcheck.sh
|
|||
ADD hosting/scripts/build-target-paths.sh .
|
||||
RUN chmod +x ./build-target-paths.sh
|
||||
|
||||
# Script below sets the path for storing data based on $DATA_DIR
|
||||
# For Azure App Service install SSH & point data locations to /home
|
||||
RUN /build-target-paths.sh
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ name=clouseau@127.0.0.1
|
|||
cookie=monster
|
||||
|
||||
; the path where you would like to store the search index files
|
||||
dir=/data/search
|
||||
dir=DATA_DIR/search
|
||||
|
||||
; the number of search indexes that can be open simultaneously
|
||||
max_indexes_open=500
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
; CouchDB Configuration Settings
|
||||
|
||||
[couchdb]
|
||||
database_dir = /data/couch/dbs
|
||||
view_index_dir = /data/couch/views
|
||||
database_dir = DATA_DIR/couchdb/dbs
|
||||
view_index_dir = DATA_DIR/couchdb/views
|
||||
|
|
|
@ -3,6 +3,11 @@ healthy=true
|
|||
|
||||
if [ -f "/data/.env" ]; then
|
||||
export $(cat /data/.env | xargs)
|
||||
elif [ -f "/home/.env" ]; then
|
||||
export $(cat /home/.env | xargs)
|
||||
else
|
||||
echo "No .env file found"
|
||||
healthy=false
|
||||
fi
|
||||
|
||||
if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
#!/bin/bash
|
||||
declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD")
|
||||
if [ -f "/data/.env" ]; then
|
||||
export $(cat /data/.env | xargs)
|
||||
declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD")
|
||||
|
||||
# Azure App Service customisations
|
||||
if [[ "${TARGETBUILD}" = "aas" ]]; then
|
||||
DATA_DIR=/home
|
||||
/etc/init.d/ssh start
|
||||
else
|
||||
DATA_DIR=${DATA_DIR:-/data}
|
||||
fi
|
||||
|
||||
if [ -f "${DATA_DIR}/.env" ]; then
|
||||
export $(cat ${DATA_DIR}/.env | xargs)
|
||||
fi
|
||||
# first randomise any unset environment variables
|
||||
for ENV_VAR in "${ENV_VARS[@]}"
|
||||
|
@ -14,21 +23,26 @@ done
|
|||
if [[ -z "${COUCH_DB_URL}" ]]; then
|
||||
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984
|
||||
fi
|
||||
if [ ! -f "/data/.env" ]; then
|
||||
touch /data/.env
|
||||
if [ ! -f "${DATA_DIR}/.env" ]; then
|
||||
touch ${DATA_DIR}/.env
|
||||
for ENV_VAR in "${ENV_VARS[@]}"
|
||||
do
|
||||
temp=$(eval "echo \$$ENV_VAR")
|
||||
echo "$ENV_VAR=$temp" >> /data/.env
|
||||
echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env
|
||||
done
|
||||
echo "COUCH_DB_URL=${COUCH_DB_URL}" >> ${DATA_DIR}/.env
|
||||
fi
|
||||
|
||||
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984
|
||||
|
||||
# make these directories in runner, incase of mount
|
||||
mkdir -p /data/couch/{dbs,views} /home/couch/{dbs,views}
|
||||
chown -R couchdb:couchdb /data/couch /home/couch
|
||||
mkdir -p ${DATA_DIR}/couchdb/{dbs,views}
|
||||
mkdir -p ${DATA_DIR}/minio
|
||||
mkdir -p ${DATA_DIR}/search
|
||||
chown -R couchdb:couchdb ${DATA_DIR}/couchdb
|
||||
redis-server --requirepass $REDIS_PASSWORD &
|
||||
/opt/clouseau/bin/clouseau &
|
||||
/minio/minio server /data/minio &
|
||||
/minio/minio server ${DATA_DIR}/minio &
|
||||
/docker-entrypoint.sh /opt/couchdb/bin/couchdb &
|
||||
/etc/init.d/nginx restart
|
||||
if [[ ! -z "${CUSTOM_DOMAIN}" ]]; then
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "1.2.58",
|
||||
"version": "1.2.59-alpha.0",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/backend-core",
|
||||
"version": "1.2.58",
|
||||
"version": "1.2.59-alpha.0",
|
||||
"description": "Budibase backend core libraries used in server and worker",
|
||||
"main": "dist/src/index.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
|
@ -20,13 +20,14 @@
|
|||
"test:watch": "jest --watchAll"
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/types": "^1.2.58",
|
||||
"@budibase/types": "1.2.59-alpha.0",
|
||||
"@techpass/passport-openidconnect": "0.3.2",
|
||||
"aws-sdk": "2.1030.0",
|
||||
"bcrypt": "5.0.1",
|
||||
"dotenv": "16.0.1",
|
||||
"emitter-listener": "1.1.2",
|
||||
"ioredis": "4.28.0",
|
||||
"joi": "17.6.0",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"koa-passport": "4.1.4",
|
||||
"lodash": "4.17.21",
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
const passport = require("koa-passport")
|
||||
const LocalStrategy = require("passport-local").Strategy
|
||||
const JwtStrategy = require("passport-jwt").Strategy
|
||||
const { getGlobalDB } = require("./tenancy")
|
||||
import { getGlobalDB } from "./tenancy"
|
||||
const refresh = require("passport-oauth2-refresh")
|
||||
const { Configs } = require("./constants")
|
||||
const { getScopedConfig } = require("./db/utils")
|
||||
const {
|
||||
import { Configs } from "./constants"
|
||||
import { getScopedConfig } from "./db/utils"
|
||||
import {
|
||||
jwt,
|
||||
local,
|
||||
authenticated,
|
||||
|
@ -13,7 +13,6 @@ const {
|
|||
oidc,
|
||||
auditLog,
|
||||
tenancy,
|
||||
appTenancy,
|
||||
authError,
|
||||
ssoCallbackUrl,
|
||||
csrf,
|
||||
|
@ -22,32 +21,36 @@ const {
|
|||
builderOnly,
|
||||
builderOrAdmin,
|
||||
joiValidator,
|
||||
} = require("./middleware")
|
||||
|
||||
const { invalidateUser } = require("./cache/user")
|
||||
} from "./middleware"
|
||||
import { invalidateUser } from "./cache/user"
|
||||
import { User } from "@budibase/types"
|
||||
|
||||
// Strategies
|
||||
passport.use(new LocalStrategy(local.options, local.authenticate))
|
||||
passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
|
||||
|
||||
passport.serializeUser((user, done) => done(null, user))
|
||||
passport.serializeUser((user: User, done: any) => done(null, user))
|
||||
|
||||
passport.deserializeUser(async (user, done) => {
|
||||
passport.deserializeUser(async (user: User, done: any) => {
|
||||
const db = getGlobalDB()
|
||||
|
||||
try {
|
||||
const user = await db.get(user._id)
|
||||
return done(null, user)
|
||||
const dbUser = await db.get(user._id)
|
||||
return done(null, dbUser)
|
||||
} catch (err) {
|
||||
console.error(`User not found`, err)
|
||||
return done(null, false, { message: "User not found" })
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
|
||||
async function refreshOIDCAccessToken(
|
||||
db: any,
|
||||
chosenConfig: any,
|
||||
refreshToken: string
|
||||
) {
|
||||
const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig)
|
||||
let enrichedConfig
|
||||
let strategy
|
||||
let enrichedConfig: any
|
||||
let strategy: any
|
||||
|
||||
try {
|
||||
enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl)
|
||||
|
@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
|
|||
refresh.requestNewAccessToken(
|
||||
Configs.OIDC,
|
||||
refreshToken,
|
||||
(err, accessToken, refreshToken, params) => {
|
||||
(err: any, accessToken: string, refreshToken: any, params: any) => {
|
||||
resolve({ err, accessToken, refreshToken, params })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshGoogleAccessToken(db, config, refreshToken) {
|
||||
async function refreshGoogleAccessToken(
|
||||
db: any,
|
||||
config: any,
|
||||
refreshToken: any
|
||||
) {
|
||||
let callbackUrl = await google.getCallbackUrl(db, config)
|
||||
|
||||
let strategy
|
||||
try {
|
||||
strategy = await google.strategyFactory(config, callbackUrl)
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
throw new Error("Error constructing OIDC refresh strategy", err)
|
||||
throw new Error(
|
||||
`Error constructing OIDC refresh strategy: message=${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
refresh.use(strategy)
|
||||
|
@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) {
|
|||
refresh.requestNewAccessToken(
|
||||
Configs.GOOGLE,
|
||||
refreshToken,
|
||||
(err, accessToken, refreshToken, params) => {
|
||||
(err: any, accessToken: string, refreshToken: string, params: any) => {
|
||||
resolve({ err, accessToken, refreshToken, params })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshOAuthToken(refreshToken, configType, configId) {
|
||||
async function refreshOAuthToken(
|
||||
refreshToken: string,
|
||||
configType: string,
|
||||
configId: string
|
||||
) {
|
||||
const db = getGlobalDB()
|
||||
|
||||
const config = await getScopedConfig(db, {
|
||||
|
@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
|
|||
let refreshResponse
|
||||
if (configType === Configs.OIDC) {
|
||||
// configId - retrieved from cookie.
|
||||
chosenConfig = config.configs.filter(c => c.uuid === configId)[0]
|
||||
chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0]
|
||||
if (!chosenConfig) {
|
||||
throw new Error("Invalid OIDC configuration")
|
||||
}
|
||||
|
@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
|
|||
return refreshResponse
|
||||
}
|
||||
|
||||
async function updateUserOAuth(userId, oAuthConfig) {
|
||||
async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
||||
const details = {
|
||||
accessToken: oAuthConfig.accessToken,
|
||||
refreshToken: oAuthConfig.refreshToken,
|
||||
|
@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export = {
|
||||
buildAuthMiddleware: authenticated,
|
||||
passport,
|
||||
google,
|
||||
oidc,
|
||||
jwt: require("jsonwebtoken"),
|
||||
buildTenancyMiddleware: tenancy,
|
||||
buildAppTenancyMiddleware: appTenancy,
|
||||
auditLog,
|
||||
authError,
|
||||
buildCsrfMiddleware: csrf,
|
|
@ -18,6 +18,7 @@ export enum ViewName {
|
|||
LINK = "by_link",
|
||||
ROUTING = "screen_routes",
|
||||
AUTOMATION_LOGS = "automation_logs",
|
||||
ACCOUNT_BY_EMAIL = "account_by_email",
|
||||
}
|
||||
|
||||
export const DeprecatedViews = {
|
||||
|
@ -41,6 +42,7 @@ export enum DocumentType {
|
|||
MIGRATIONS = "migrations",
|
||||
DEV_INFO = "devinfo",
|
||||
AUTOMATION_LOG = "log_au",
|
||||
ACCOUNT_METADATA = "acc_metadata",
|
||||
}
|
||||
|
||||
export const StaticDatabases = {
|
||||
|
|
|
@ -233,6 +233,10 @@ export async function getAllApps({ dev, all, idsOnly, efficient }: any = {}) {
|
|||
}
|
||||
let dbs = await getAllDbs({ efficient })
|
||||
const appDbNames = dbs.filter((dbName: any) => {
|
||||
if (env.isTest() && !dbName) {
|
||||
return false
|
||||
}
|
||||
|
||||
const split = dbName.split(SEPARATOR)
|
||||
// it is an app, check the tenantId
|
||||
if (split[0] === DocumentType.APP) {
|
||||
|
|
|
@ -5,6 +5,8 @@ const {
|
|||
SEPARATOR,
|
||||
} = require("./utils")
|
||||
const { getGlobalDB } = require("../tenancy")
|
||||
const { StaticDatabases } = require("./constants")
|
||||
const { doWithDB } = require("./")
|
||||
|
||||
const DESIGN_DB = "_design/database"
|
||||
|
||||
|
@ -56,6 +58,31 @@ exports.createNewUserEmailView = async () => {
|
|||
await db.put(designDoc)
|
||||
}
|
||||
|
||||
exports.createAccountEmailView = async () => {
|
||||
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
|
||||
let designDoc
|
||||
try {
|
||||
designDoc = await db.get(DESIGN_DB)
|
||||
} catch (err) {
|
||||
// no design doc, make one
|
||||
designDoc = DesignDoc()
|
||||
}
|
||||
const view = {
|
||||
// if using variables in a map function need to inject them before use
|
||||
map: `function(doc) {
|
||||
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
|
||||
emit(doc.email.toLowerCase(), doc._id)
|
||||
}
|
||||
}`,
|
||||
}
|
||||
designDoc.views = {
|
||||
...designDoc.views,
|
||||
[ViewName.ACCOUNT_BY_EMAIL]: view,
|
||||
}
|
||||
await db.put(designDoc)
|
||||
})
|
||||
}
|
||||
|
||||
exports.createUserAppView = async () => {
|
||||
const db = getGlobalDB()
|
||||
let designDoc
|
||||
|
@ -128,6 +155,39 @@ exports.createUserBuildersView = async () => {
|
|||
await db.put(designDoc)
|
||||
}
|
||||
|
||||
exports.queryView = async (viewName, params, db, CreateFuncByName) => {
|
||||
try {
|
||||
let response = (await db.query(`database/${viewName}`, params)).rows
|
||||
response = response.map(resp =>
|
||||
params.include_docs ? resp.doc : resp.value
|
||||
)
|
||||
if (params.arrayResponse) {
|
||||
return response
|
||||
} else {
|
||||
return response.length <= 1 ? response[0] : response
|
||||
}
|
||||
} catch (err) {
|
||||
if (err != null && err.name === "not_found") {
|
||||
const createFunc = CreateFuncByName[viewName]
|
||||
await removeDeprecated(db, viewName)
|
||||
await createFunc()
|
||||
return exports.queryView(viewName, params, db, CreateFuncByName)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.queryPlatformView = async (viewName, params) => {
|
||||
const CreateFuncByName = {
|
||||
[ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView,
|
||||
}
|
||||
|
||||
return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
|
||||
return exports.queryView(viewName, params, db, CreateFuncByName)
|
||||
})
|
||||
}
|
||||
|
||||
exports.queryGlobalView = async (viewName, params, db = null) => {
|
||||
const CreateFuncByName = {
|
||||
[ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView,
|
||||
|
@ -139,20 +199,5 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
|
|||
if (!db) {
|
||||
db = getGlobalDB()
|
||||
}
|
||||
try {
|
||||
let response = (await db.query(`database/${viewName}`, params)).rows
|
||||
response = response.map(resp =>
|
||||
params.include_docs ? resp.doc : resp.value
|
||||
)
|
||||
return response.length <= 1 ? response[0] : response
|
||||
} catch (err) {
|
||||
if (err != null && err.name === "not_found") {
|
||||
const createFunc = CreateFuncByName[viewName]
|
||||
await removeDeprecated(db, viewName)
|
||||
await createFunc()
|
||||
return exports.queryGlobalView(viewName, params)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return exports.queryView(viewName, params, db, CreateFuncByName)
|
||||
}
|
||||
|
|
|
@ -8,4 +8,5 @@ import { processors } from "./processors"
|
|||
|
||||
export const shutdown = () => {
|
||||
processors.shutdown()
|
||||
console.log("Events shutdown")
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import constants from "./constants"
|
|||
import * as dbConstants from "./db/constants"
|
||||
import logging from "./logging"
|
||||
import pino from "./pino"
|
||||
import * as middleware from "./middleware"
|
||||
|
||||
// mimic the outer package exports
|
||||
import * as db from "./pkg/db"
|
||||
|
@ -57,6 +58,7 @@ const core = {
|
|||
roles,
|
||||
...pino,
|
||||
...errorClasses,
|
||||
middleware,
|
||||
}
|
||||
|
||||
export = core
|
||||
|
|
|
@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
|
|||
* The tenancy modules should not be used here and it should be assumed that the tenancy context
|
||||
* has not yet been populated.
|
||||
*/
|
||||
module.exports = (
|
||||
export = (
|
||||
noAuthPatterns = [],
|
||||
opts: { publicAllowed: boolean; populateUser?: Function } = {
|
||||
publicAllowed: false,
|
||||
|
|
|
@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly")
|
|||
const builderOrAdmin = require("./builderOrAdmin")
|
||||
const builderOnly = require("./builderOnly")
|
||||
const joiValidator = require("./joi-validator")
|
||||
module.exports = {
|
||||
|
||||
const pkg = {
|
||||
google,
|
||||
oidc,
|
||||
jwt,
|
||||
|
@ -33,3 +34,5 @@ module.exports = {
|
|||
builderOrAdmin,
|
||||
joiValidator,
|
||||
}
|
||||
|
||||
export = pkg
|
|
@ -1,3 +1,5 @@
|
|||
const Joi = require("joi")
|
||||
|
||||
function validate(schema, property) {
|
||||
// Return a Koa middleware function
|
||||
return (ctx, next) => {
|
||||
|
@ -10,6 +12,15 @@ function validate(schema, property) {
|
|||
} else if (ctx.request[property] != null) {
|
||||
params = ctx.request[property]
|
||||
}
|
||||
|
||||
// not all schemas have the append property e.g. array schemas
|
||||
if (schema.append) {
|
||||
schema = schema.append({
|
||||
createdAt: Joi.any().optional(),
|
||||
updatedAt: Joi.any().optional(),
|
||||
})
|
||||
}
|
||||
|
||||
const { error } = schema.validate(params)
|
||||
if (error) {
|
||||
ctx.throw(400, `Invalid ${property} - ${error.message}`)
|
||||
|
|
|
@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid")
|
|||
const { logWarn } = require("../logging")
|
||||
const env = require("../environment")
|
||||
|
||||
interface Session {
|
||||
key: string
|
||||
userId: string
|
||||
interface CreateSession {
|
||||
sessionId: string
|
||||
lastAccessedAt: string
|
||||
createdAt: string
|
||||
tenantId: string
|
||||
csrfToken?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type SessionKey = { key: string }[]
|
||||
interface Session extends CreateSession {
|
||||
userId: string
|
||||
lastAccessedAt: string
|
||||
createdAt: string
|
||||
// make optional attributes required
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
interface SessionKey {
|
||||
key: string
|
||||
}
|
||||
|
||||
interface ScannedSession {
|
||||
value: Session
|
||||
}
|
||||
|
||||
// a week in seconds
|
||||
const EXPIRY_SECONDS = 86400 * 7
|
||||
|
@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) {
|
|||
return `${userId}/${sessionId}`
|
||||
}
|
||||
|
||||
export async function getSessionsForUser(userId: string) {
|
||||
export async function getSessionsForUser(userId: string): Promise<Session[]> {
|
||||
if (!userId) {
|
||||
console.trace("Cannot get sessions for undefined userId")
|
||||
return []
|
||||
}
|
||||
const client = await redis.getSessionClient()
|
||||
const sessions = await client.scan(userId)
|
||||
return sessions.map((session: Session) => session.value)
|
||||
const sessions: ScannedSession[] = await client.scan(userId)
|
||||
return sessions.map(session => session.value)
|
||||
}
|
||||
|
||||
export async function invalidateSessions(
|
||||
|
@ -39,33 +49,32 @@ export async function invalidateSessions(
|
|||
try {
|
||||
const reason = opts?.reason || "unknown"
|
||||
let sessionIds: string[] = opts.sessionIds || []
|
||||
let sessions: SessionKey
|
||||
let sessionKeys: SessionKey[]
|
||||
|
||||
// If no sessionIds, get all the sessions for the user
|
||||
if (sessionIds.length === 0) {
|
||||
sessions = await getSessionsForUser(userId)
|
||||
sessions.forEach(
|
||||
(session: any) =>
|
||||
(session.key = makeSessionID(session.userId, session.sessionId))
|
||||
)
|
||||
const sessions = await getSessionsForUser(userId)
|
||||
sessionKeys = sessions.map(session => ({
|
||||
key: makeSessionID(session.userId, session.sessionId),
|
||||
}))
|
||||
} else {
|
||||
// use the passed array of sessionIds
|
||||
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]
|
||||
sessions = sessionIds.map((sessionId: string) => ({
|
||||
sessionKeys = sessionIds.map(sessionId => ({
|
||||
key: makeSessionID(userId, sessionId),
|
||||
}))
|
||||
}
|
||||
|
||||
if (sessions && sessions.length > 0) {
|
||||
if (sessionKeys && sessionKeys.length > 0) {
|
||||
const client = await redis.getSessionClient()
|
||||
const promises = []
|
||||
for (let session of sessions) {
|
||||
promises.push(client.delete(session.key))
|
||||
for (let sessionKey of sessionKeys) {
|
||||
promises.push(client.delete(sessionKey.key))
|
||||
}
|
||||
if (!env.isTest()) {
|
||||
logWarn(
|
||||
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions
|
||||
.map(session => session.key)
|
||||
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys
|
||||
.map(sessionKey => sessionKey.key)
|
||||
.join(", ")}`
|
||||
)
|
||||
}
|
||||
|
@ -76,22 +85,26 @@ export async function invalidateSessions(
|
|||
}
|
||||
}
|
||||
|
||||
export async function createASession(userId: string, session: Session) {
|
||||
export async function createASession(
|
||||
userId: string,
|
||||
createSession: CreateSession
|
||||
) {
|
||||
// invalidate all other sessions
|
||||
await invalidateSessions(userId, { reason: "creation" })
|
||||
|
||||
const client = await redis.getSessionClient()
|
||||
const sessionId = session.sessionId
|
||||
if (!session.csrfToken) {
|
||||
session.csrfToken = uuidv4()
|
||||
}
|
||||
session = {
|
||||
...session,
|
||||
const sessionId = createSession.sessionId
|
||||
const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4()
|
||||
const key = makeSessionID(userId, sessionId)
|
||||
|
||||
const session: Session = {
|
||||
...createSession,
|
||||
csrfToken,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
userId,
|
||||
}
|
||||
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
|
||||
await client.store(key, session, EXPIRY_SECONDS)
|
||||
}
|
||||
|
||||
export async function updateSessionTTL(session: Session) {
|
||||
|
@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) {
|
|||
await client.delete(makeSessionID(userId, sessionId))
|
||||
}
|
||||
|
||||
export async function getSession(userId: string, sessionId: string) {
|
||||
export async function getSession(
|
||||
userId: string,
|
||||
sessionId: string
|
||||
): Promise<Session> {
|
||||
if (!userId || !sessionId) {
|
||||
throw new Error(`Invalid session details - ${userId} - ${sessionId}`)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ const { UNICODE_MAX } = require("./db/constants")
|
|||
* Given an email address this will use a view to search through
|
||||
* all the users to find one with this email address.
|
||||
* @param {string} email the email to lookup the user by.
|
||||
* @return {Promise<object|null>}
|
||||
*/
|
||||
exports.getGlobalUserByEmail = async email => {
|
||||
if (email == null) {
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export const getAccount = jest.fn()
|
||||
export const getAccountByTenantId = jest.fn()
|
||||
|
||||
jest.mock("../../../src/cloud/accounts", () => ({
|
||||
getAccount,
|
||||
getAccountByTenantId,
|
||||
}))
|
|
@ -1,2 +0,0 @@
|
|||
exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
|
||||
exports.MOCK_DATE_TIMESTAMP = 1577836800000
|
|
@ -0,0 +1,2 @@
|
|||
export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
|
||||
export const MOCK_DATE_TIMESTAMP = 1577836800000
|
|
@ -1,9 +0,0 @@
|
|||
const posthog = require("./posthog")
|
||||
const events = require("./events")
|
||||
const date = require("./date")
|
||||
|
||||
module.exports = {
|
||||
posthog,
|
||||
date,
|
||||
events,
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import "./posthog"
|
||||
import "./events"
|
||||
export * as accounts from "./accounts"
|
||||
export * as date from "./date"
|
|
@ -291,6 +291,18 @@
|
|||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
|
||||
integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==
|
||||
|
||||
"@hapi/topo@^5.0.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012"
|
||||
integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.0.0"
|
||||
|
||||
"@istanbuljs/load-nyc-config@^1.0.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
|
||||
|
@ -539,6 +551,23 @@
|
|||
koa "^2.13.4"
|
||||
node-mocks-http "^1.5.8"
|
||||
|
||||
"@sideway/address@^4.1.3":
|
||||
version "4.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0"
|
||||
integrity sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.0.0"
|
||||
|
||||
"@sideway/formula@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
|
||||
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
|
||||
|
||||
"@sideway/pinpoint@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df"
|
||||
integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==
|
||||
|
||||
"@sindresorhus/is@^0.14.0":
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
|
||||
|
@ -3193,6 +3222,17 @@ jmespath@0.15.0:
|
|||
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
|
||||
integrity sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w==
|
||||
|
||||
joi@17.6.0:
|
||||
version "17.6.0"
|
||||
resolved "https://registry.yarnpkg.com/joi/-/joi-17.6.0.tgz#0bb54f2f006c09a96e75ce687957bd04290054b2"
|
||||
integrity sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.0.0"
|
||||
"@hapi/topo" "^5.0.0"
|
||||
"@sideway/address" "^4.1.3"
|
||||
"@sideway/formula" "^3.0.0"
|
||||
"@sideway/pinpoint" "^2.0.0"
|
||||
|
||||
join-component@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/join-component/-/join-component-1.1.0.tgz#b8417b750661a392bee2c2537c68b2a9d4977cd5"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@budibase/bbui",
|
||||
"description": "A UI solution used in the different Budibase projects.",
|
||||
"version": "1.2.58",
|
||||
"version": "1.2.59-alpha.0",
|
||||
"license": "MPL-2.0",
|
||||
"svelte": "src/index.js",
|
||||
"module": "dist/bbui.es.js",
|
||||
|
@ -38,7 +38,7 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"@adobe/spectrum-css-workflow-icons": "^1.2.1",
|
||||
"@budibase/string-templates": "^1.2.58",
|
||||
"@budibase/string-templates": "1.2.59-alpha.0",
|
||||
"@spectrum-css/actionbutton": "^1.0.1",
|
||||
"@spectrum-css/actiongroup": "^1.0.1",
|
||||
"@spectrum-css/avatar": "^3.0.2",
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
export let appendTo = undefined
|
||||
export let timeOnly = false
|
||||
export let ignoreTimezones = false
|
||||
export let time24hr = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const flatpickrId = `${uuid()}-wrapper`
|
||||
|
@ -37,6 +38,7 @@
|
|||
enableTime: timeOnly || enableTime || false,
|
||||
noCalendar: timeOnly || false,
|
||||
altInput: true,
|
||||
time_24hr: time24hr || false,
|
||||
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
|
||||
wrap: true,
|
||||
appendTo,
|
||||
|
@ -49,6 +51,12 @@
|
|||
},
|
||||
}
|
||||
|
||||
$: redrawOptions = {
|
||||
timeOnly,
|
||||
enableTime,
|
||||
time24hr,
|
||||
}
|
||||
|
||||
const handleChange = event => {
|
||||
const [dates] = event.detail
|
||||
const noTimezone = enableTime && !timeOnly && ignoreTimezones
|
||||
|
@ -149,7 +157,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#key timeOnly}
|
||||
{#key redrawOptions}
|
||||
<Flatpickr
|
||||
bind:flatpickr
|
||||
value={parseDate(value)}
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
export let disabled = false
|
||||
export let fileSizeLimit = BYTES_IN_MB * 20
|
||||
export let processFiles = null
|
||||
export let deleteAttachments = null
|
||||
export let handleFileTooLarge = null
|
||||
export let handleTooManyFiles = null
|
||||
export let gallery = true
|
||||
|
@ -94,6 +95,11 @@
|
|||
"change",
|
||||
value.filter((x, idx) => idx !== selectedImageIdx)
|
||||
)
|
||||
if (deleteAttachments) {
|
||||
await deleteAttachments(
|
||||
value.filter((x, idx) => idx === selectedImageIdx).map(item => item.key)
|
||||
)
|
||||
}
|
||||
selectedImageIdx = 0
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
$: toggleOption = makeToggleOption(selectedLookupMap, value)
|
||||
|
||||
const getFieldText = (value, map, placeholder) => {
|
||||
if (value?.length) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (!map) {
|
||||
return ""
|
||||
}
|
||||
|
@ -36,7 +36,7 @@
|
|||
|
||||
const getSelectedLookupMap = value => {
|
||||
let map = {}
|
||||
if (value?.length) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
value.forEach(option => {
|
||||
if (option) {
|
||||
map[option] = true
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
export let disabled = false
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let getOptionTitle = option => option
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => dispatch("change", e.target.value)
|
||||
|
@ -19,7 +20,7 @@
|
|||
{#if options && Array.isArray(options)}
|
||||
{#each options as option}
|
||||
<div
|
||||
title={getOptionLabel(option)}
|
||||
title={getOptionTitle(option)}
|
||||
class="spectrum-Radio spectrum-FieldGroup-item spectrum-Radio--emphasized"
|
||||
class:is-invalid={!!error}
|
||||
>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
export let error = null
|
||||
export let enableTime = true
|
||||
export let timeOnly = false
|
||||
export let time24hr = false
|
||||
export let placeholder = null
|
||||
export let appendTo = undefined
|
||||
export let ignoreTimezones = false
|
||||
|
@ -30,6 +31,7 @@
|
|||
{placeholder}
|
||||
{enableTime}
|
||||
{timeOnly}
|
||||
{time24hr}
|
||||
{appendTo}
|
||||
{ignoreTimezones}
|
||||
on:change={onChange}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
export let error = null
|
||||
export let fileSizeLimit = undefined
|
||||
export let processFiles = undefined
|
||||
export let deleteAttachments = undefined
|
||||
export let handleFileTooLarge = undefined
|
||||
export let handleTooManyFiles = undefined
|
||||
export let gallery = true
|
||||
|
@ -30,6 +31,7 @@
|
|||
{value}
|
||||
{fileSizeLimit}
|
||||
{processFiles}
|
||||
{deleteAttachments}
|
||||
{handleFileTooLarge}
|
||||
{handleTooManyFiles}
|
||||
{gallery}
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
export let direction = "vertical"
|
||||
export let getOptionLabel = option => extractProperty(option, "label")
|
||||
export let getOptionValue = option => extractProperty(option, "value")
|
||||
export let getOptionTitle = option => extractProperty(option, "label")
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
|
@ -35,6 +36,7 @@
|
|||
{direction}
|
||||
{getOptionLabel}
|
||||
{getOptionValue}
|
||||
{getOptionTitle}
|
||||
on:change={onChange}
|
||||
/>
|
||||
</Field>
|
||||
|
|
|
@ -83,4 +83,9 @@
|
|||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spectrum-Icon--sizeXS {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const actionMenu = getContext("actionMenu")
|
||||
|
@ -8,6 +9,22 @@
|
|||
export let icon = undefined
|
||||
export let disabled = undefined
|
||||
export let noClose = false
|
||||
export let keyBind = undefined
|
||||
|
||||
$: keys = getKeys(keyBind)
|
||||
|
||||
const getKeys = keyBind => {
|
||||
let keys = keyBind?.split("+") || []
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (
|
||||
keys[i].toLowerCase() === "ctrl" &&
|
||||
navigator.platform.startsWith("Mac")
|
||||
) {
|
||||
keys[i] = "⌘"
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (actionMenu && !noClose) {
|
||||
|
@ -26,20 +43,54 @@
|
|||
tabindex="0"
|
||||
>
|
||||
{#if icon}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-Icon--sizeS spectrum-Menu-itemIcon"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
aria-label={icon}
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||
</svg>
|
||||
<div class="icon">
|
||||
<Icon name={icon} size="S" />
|
||||
</div>
|
||||
{/if}
|
||||
<span class="spectrum-Menu-itemLabel"><slot /></span>
|
||||
{#if keys?.length}
|
||||
<div class="keys">
|
||||
{#each keys as key}
|
||||
<div class="key">
|
||||
{#if key.startsWith("!")}
|
||||
<Icon size="XS" name={key.split("!")[1]} />
|
||||
{:else}
|
||||
{key}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.spectrum-Menu-itemIcon {
|
||||
.icon {
|
||||
align-self: center;
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
.keys {
|
||||
margin-left: 30px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.key {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background-color: var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px;
|
||||
min-width: 12px;
|
||||
height: 16px;
|
||||
text-align: center;
|
||||
margin: -1px 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.is-disabled .key {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
let visible = fixed || inline
|
||||
let modal
|
||||
|
||||
$: dispatch(visible ? "show" : "hide")
|
||||
|
||||
export function show() {
|
||||
|
@ -41,12 +43,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function focusFirstInput(node) {
|
||||
async function focusModal(node) {
|
||||
await tick()
|
||||
|
||||
// Try to focus first input
|
||||
const inputs = node.querySelectorAll("input")
|
||||
if (inputs?.length) {
|
||||
await tick()
|
||||
inputs[0].focus()
|
||||
}
|
||||
|
||||
// Otherwise try to focus confirmation button
|
||||
else if (modal) {
|
||||
const confirm = modal.querySelector(".confirm-wrap .spectrum-Button")
|
||||
if (confirm) {
|
||||
confirm.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContext(Context.Modal, { show, hide, cancel })
|
||||
|
@ -56,7 +68,7 @@
|
|||
|
||||
{#if inline}
|
||||
{#if visible}
|
||||
<div use:focusFirstInput class="spectrum-Modal inline is-open">
|
||||
<div use:focusModal bind:this={modal} class="spectrum-Modal inline is-open">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -70,17 +82,18 @@
|
|||
-->
|
||||
<Portal target=".modal-container">
|
||||
{#if visible}
|
||||
<div
|
||||
class="spectrum-Underlay is-open"
|
||||
in:fade={{ duration: 200 }}
|
||||
out:fade|local={{ duration: 200 }}
|
||||
on:mousedown|self={cancel}
|
||||
>
|
||||
<div class="spectrum-Underlay is-open" on:mousedown|self={cancel}>
|
||||
<div
|
||||
class="background"
|
||||
in:fade={{ duration: 200 }}
|
||||
out:fade|local={{ duration: 200 }}
|
||||
/>
|
||||
<div class="modal-wrapper" on:mousedown|self={cancel}>
|
||||
<div class="modal-inner-wrapper" on:mousedown|self={cancel}>
|
||||
<slot name="outside" />
|
||||
<div
|
||||
use:focusFirstInput
|
||||
use:focusModal
|
||||
bind:this={modal}
|
||||
class="spectrum-Modal is-open"
|
||||
in:fly={{ y: 30, duration: 200 }}
|
||||
out:fly|local={{ y: 30, duration: 200 }}
|
||||
|
@ -103,7 +116,17 @@
|
|||
z-index: 999;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
background: transparent;
|
||||
}
|
||||
.background {
|
||||
background: var(--modal-background, rgba(0, 0, 0, 0.75));
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-wrapper {
|
||||
|
|
|
@ -63,7 +63,7 @@
|
|||
|
||||
<style>
|
||||
.spectrum-Popover {
|
||||
min-width: var(--spectrum-global-dimension-size-2000) !important;
|
||||
min-width: var(--spectrum-global-dimension-size-2000);
|
||||
}
|
||||
.spectrum-Popover.is-open.spectrum-Popover--withTip {
|
||||
margin-top: var(--spacing-xs);
|
||||
|
|
|
@ -23,7 +23,7 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.get(interact.SPECTRUM_ICON).click({ force: true })
|
||||
})
|
||||
cy.get(interact.SPECTRUM_MENU).within(() => {
|
||||
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force Password Reset").click({ force: true })
|
||||
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force password reset").click({ force: true })
|
||||
})
|
||||
|
||||
cy.get(interact.SPECTRUM_DIALOG_GRID)
|
||||
|
@ -41,10 +41,25 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).eq(i).type("test")
|
||||
}
|
||||
cy.get(interact.SPECTRUM_BUTTON).contains("Reset your password").click({ force: true })
|
||||
//cy.logoutNoAppGrid()
|
||||
})
|
||||
|
||||
it("should verify Standard Portal", () => {
|
||||
// Development access should be disabled (Admin access is already disabled)
|
||||
cy.login()
|
||||
cy.setUserRole("bbuser", "App User")
|
||||
bbUserLogin()
|
||||
|
||||
// Verify Standard Portal
|
||||
cy.get(interact.SPECTRUM_SIDENAV).should('not.exist') // No config sections
|
||||
cy.get(interact.CREATE_APP_BUTTON).should('not.exist') // No create app button
|
||||
cy.get(".app").should('not.exist') // No apps -> no roles assigned to user
|
||||
cy.get(interact.CONTAINER).should('contain', bbUserEmail) // Message containing users email
|
||||
|
||||
cy.logoutNoAppGrid()
|
||||
})
|
||||
|
||||
xit("should verify Admin Portal", () => {
|
||||
it("should verify Admin Portal", () => {
|
||||
cy.login()
|
||||
// Configure user role
|
||||
cy.setUserRole("bbuser", "Admin")
|
||||
|
@ -86,21 +101,6 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.logOut()
|
||||
})
|
||||
|
||||
it("should verify Standard Portal", () => {
|
||||
// Development access should be disabled (Admin access is already disabled)
|
||||
cy.login()
|
||||
cy.setUserRole("bbuser", "App User")
|
||||
bbUserLogin()
|
||||
|
||||
// Verify Standard Portal
|
||||
cy.get(interact.SPECTRUM_SIDENAV).should('not.exist') // No config sections
|
||||
cy.get(interact.CREATE_APP_BUTTON).should('not.exist') // No create app button
|
||||
cy.get(".app").should('not.exist') // No apps -> no roles assigned to user
|
||||
cy.get(interact.CONTAINER).should('contain', bbUserEmail) // Message containing users email
|
||||
|
||||
cy.logoutNoAppGrid()
|
||||
})
|
||||
|
||||
const bbUserLogin = () => {
|
||||
// Login as bbuser
|
||||
cy.logOut()
|
||||
|
|
|
@ -0,0 +1,178 @@
|
|||
import filterTests from "../../support/filterTests"
|
||||
// const interact = require("../support/interact")
|
||||
|
||||
filterTests(["smoke", "all"], () => {
|
||||
context("Auth Configuration", () => {
|
||||
before(() => {
|
||||
cy.login()
|
||||
})
|
||||
|
||||
after(() => {
|
||||
cy.get(".spectrum-SideNav li").contains("Auth").click()
|
||||
cy.location().should(loc => {
|
||||
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
|
||||
})
|
||||
|
||||
cy.get("[data-cy=new-scope-input]").clear()
|
||||
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
cy.get("[data-cy=oidc-active]").click()
|
||||
|
||||
cy.get("[data-cy=oidc-active]").should('not.be.checked')
|
||||
|
||||
cy.intercept("POST", "/api/global/configs").as("updateAuth")
|
||||
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
|
||||
cy.wait("@updateAuth")
|
||||
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
|
||||
|
||||
cy.get(".spectrum-Toast-content")
|
||||
.contains("Settings saved")
|
||||
.should("be.visible")
|
||||
})
|
||||
|
||||
it("Should allow updating of the OIDC config", () => {
|
||||
cy.get(".spectrum-SideNav li").contains("Auth").click()
|
||||
cy.location().should(loc => {
|
||||
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
|
||||
})
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
cy.get(".spectrum-Toast .spectrum-ClearButton").click()
|
||||
|
||||
cy.get("input[data-cy=configUrl]").type("http://budi-auth.com/v2")
|
||||
cy.get("input[data-cy=clientID]").type("34ac6a13-f24a-4b52-c70d-fa544ffd11b2")
|
||||
cy.get("input[data-cy=clientSecret]").type("12A8Q~4nS_DWhOOJ2vWIRsNyDVsdtXPD.Zxa9df_")
|
||||
|
||||
cy.get("button[data-cy=oidc-save]").should("not.be.disabled");
|
||||
|
||||
cy.intercept("POST", "/api/global/configs").as("updateAuth")
|
||||
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
|
||||
cy.wait("@updateAuth")
|
||||
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
|
||||
|
||||
cy.get(".spectrum-Toast-content")
|
||||
.contains("Settings saved")
|
||||
.should("be.visible")
|
||||
})
|
||||
|
||||
it("Should display default scopes in advanced config.", () => {
|
||||
cy.get(".spectrum-SideNav li").contains("Auth").click()
|
||||
cy.location().should(loc => {
|
||||
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
|
||||
})
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("openid")
|
||||
cy.get(".spectrum-Tags-item").contains("openid").find(".spectrum-ClearButton").should("not.exist")
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("offline_access")
|
||||
cy.get(".spectrum-Tags-item").contains("email")
|
||||
cy.get(".spectrum-Tags-item").contains("profile")
|
||||
})
|
||||
|
||||
it("Add a new scopes", () => {
|
||||
cy.get(".spectrum-SideNav li").contains("Auth").click()
|
||||
cy.location().should(loc => {
|
||||
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
|
||||
})
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
|
||||
cy.get("[data-cy=new-scope-input]").type("Sample{enter}")
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 5)
|
||||
cy.get(".spectrum-Tags-item").contains("Sample")
|
||||
|
||||
cy.get(".auth-form input.spectrum-Textfield-input").type("Another ")
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 6)
|
||||
cy.get(".spectrum-Tags-item").contains("Another")
|
||||
|
||||
cy.get("button[data-cy=oidc-save]").should("not.be.disabled");
|
||||
|
||||
cy.intercept("POST", "/api/global/configs").as("updateAuth")
|
||||
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
|
||||
cy.wait("@updateAuth")
|
||||
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
|
||||
|
||||
cy.reload()
|
||||
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("openid")
|
||||
cy.get(".spectrum-Tags-item").contains("offline_access")
|
||||
cy.get(".spectrum-Tags-item").contains("email")
|
||||
cy.get(".spectrum-Tags-item").contains("profile")
|
||||
cy.get(".spectrum-Tags-item").contains("Sample")
|
||||
cy.get(".spectrum-Tags-item").contains("Another")
|
||||
})
|
||||
|
||||
it("Should allow the removal of auth scopes", () => {
|
||||
cy.get(".spectrum-SideNav li").contains("Auth").click()
|
||||
cy.location().should(loc => {
|
||||
expect(loc.pathname).to.eq("/builder/portal/manage/auth")
|
||||
})
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("offline_access").parent().find(".spectrum-ClearButton").click()
|
||||
cy.get(".spectrum-Tags-item").contains("profile").parent().find(".spectrum-ClearButton").click()
|
||||
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("offline_access").should("not.exist")
|
||||
cy.get(".spectrum-Tags-item").contains("profile").should("not.exist")
|
||||
|
||||
cy.get("button[data-cy=oidc-save]").should("not.be.disabled");
|
||||
|
||||
cy.intercept("POST", "/api/global/configs").as("updateAuth")
|
||||
cy.get("button[data-cy=oidc-save]").contains("Save").click({force: true})
|
||||
cy.wait("@updateAuth")
|
||||
cy.get("@updateAuth").its("response.statusCode").should("eq", 200)
|
||||
|
||||
cy.get(".spectrum-Toast-content")
|
||||
.contains("Settings saved")
|
||||
.should("be.visible")
|
||||
|
||||
cy.reload()
|
||||
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("offline_access").should("not.exist")
|
||||
cy.get(".spectrum-Tags-item").contains("profile").should("not.exist")
|
||||
})
|
||||
|
||||
it("Should allow auth scopes to be reset to the core defaults.", () => {
|
||||
cy.get(".spectrum-SideNav li").contains("Auth").click()
|
||||
|
||||
cy.get("div.content").scrollTo("bottom")
|
||||
|
||||
cy.get("[data-cy=restore-oidc-default-scopes]").click({force: true})
|
||||
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
|
||||
cy.get(".spectrum-Tags-item").contains("openid")
|
||||
cy.get(".spectrum-Tags-item").contains("offline_access")
|
||||
cy.get(".spectrum-Tags-item").contains("email")
|
||||
cy.get(".spectrum-Tags-item").contains("profile")
|
||||
})
|
||||
|
||||
it("Should not allow invalid characters in the auth scopes", () => {
|
||||
cy.get("[data-cy=new-scope-input]").type("thisIsInvalid\\{enter}")
|
||||
cy.get(".spectrum-Form-itemField .error").contains("Auth scopes cannot contain spaces, double quotes or backslashes")
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
|
||||
cy.get("[data-cy=new-scope-input]").clear()
|
||||
|
||||
cy.get("[data-cy=new-scope-input]").type("alsoInvalid\"{enter}")
|
||||
cy.get(".spectrum-Form-itemField .error").contains("Auth scopes cannot contain spaces, double quotes or backslashes")
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
|
||||
cy.get("[data-cy=new-scope-input]").clear()
|
||||
})
|
||||
|
||||
it("Should not allow duplicate auth scopes", () => {
|
||||
cy.get("[data-cy=new-scope-input]").type("offline_access{enter}")
|
||||
cy.get(".spectrum-Form-itemField .error").contains("Auth scope already exists")
|
||||
cy.get(".spectrum-Tags").find(".spectrum-Tags-item").its("length").should("eq", 4)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
|
@ -17,7 +17,7 @@ filterTests(["smoke", "all"], () => {
|
|||
|
||||
it("should confirm App User role for a New User", () => {
|
||||
cy.contains("bbuser").click()
|
||||
cy.get(".spectrum-Form-itemField").eq(2).should('contain', 'App User')
|
||||
cy.get(".spectrum-Form-itemField").eq(3).should('contain', 'App User')
|
||||
|
||||
// User should not have app access
|
||||
cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "No apps")
|
||||
|
@ -166,12 +166,12 @@ filterTests(["smoke", "all"], () => {
|
|||
|
||||
it("Should edit user details within user details page", () => {
|
||||
// Add First name
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
|
||||
cy.wait(500)
|
||||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).wait(500).clear().click().type("bb")
|
||||
})
|
||||
// Add Last name
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
|
||||
cy.wait(500)
|
||||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).click().wait(500).clear().type("test")
|
||||
})
|
||||
|
@ -180,10 +180,10 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.reload()
|
||||
|
||||
// Confirm details have been saved
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
|
||||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', "bb")
|
||||
})
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => {
|
||||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).should('have.value', "test")
|
||||
})
|
||||
})
|
||||
|
@ -193,13 +193,14 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.get(interact.SPECTRUM_ICON).click({ force: true })
|
||||
})
|
||||
cy.get(interact.SPECTRUM_MENU).within(() => {
|
||||
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force Password Reset").click({ force: true })
|
||||
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Force password reset").click({ force: true })
|
||||
})
|
||||
|
||||
// Reset password modal
|
||||
cy.get(interact.SPECTRUM_DIALOG_GRID)
|
||||
.find(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').as('pwd')
|
||||
cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").click({ force: true })
|
||||
cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").should('not.exist')
|
||||
|
||||
// Logout, then login with new password
|
||||
cy.logOut()
|
||||
|
@ -214,6 +215,7 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.get(interact.SPECTRUM_BUTTON).contains("Reset your password").click({ force: true })
|
||||
|
||||
// Confirm user logged in afer password change
|
||||
cy.login("bbuser@test.com", "test")
|
||||
cy.get(".avatar > .icon").click({ force: true })
|
||||
|
||||
cy.get(".spectrum-Menu-item").contains("Update user information").click({ force: true })
|
||||
|
|
|
@ -19,10 +19,10 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.contains("Users").click()
|
||||
cy.contains("test@test.com").click()
|
||||
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(0).within(() => {
|
||||
cy.get(interact.FIELD, { timeout: 1000 }).eq(1).within(() => {
|
||||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', fname)
|
||||
})
|
||||
cy.get(interact.FIELD).eq(1).within(() => {
|
||||
cy.get(interact.FIELD).eq(2).within(() => {
|
||||
cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', lname)
|
||||
})
|
||||
})
|
||||
|
@ -72,7 +72,7 @@ filterTests(["smoke", "all"], () => {
|
|||
})
|
||||
|
||||
// Logout & in with new password
|
||||
cy.logOut()
|
||||
//cy.logOut()
|
||||
cy.login("test@test.com", "newpwd")
|
||||
})
|
||||
|
||||
|
@ -90,7 +90,6 @@ filterTests(["smoke", "all"], () => {
|
|||
cy.get(interact.SPECTRUM_MENU_ITEM).contains("Open developer mode").click({ force: true })
|
||||
cy.get(interact.SPECTRUM_SIDENAV).should('exist') // config sections available
|
||||
cy.get(interact.CREATE_APP_BUTTON).should('exist') // create app button available
|
||||
cy.get(interact.APP_TABLE).should('exist') // App table available
|
||||
})
|
||||
|
||||
after(() => {
|
||||
|
|
|
@ -266,7 +266,7 @@ filterTests(["all"], () => {
|
|||
cy.reload()
|
||||
cy.log("Current deployment version: " + clientPackage.version)
|
||||
|
||||
cy.get(".version-status a", { timeout: 1000 }).contains("Update").click()
|
||||
cy.get(".version-status a", { timeout: 5000 }).contains("Update").click()
|
||||
cy.get(".spectrum-Tabs-item.is-selected").contains("Settings")
|
||||
|
||||
cy.get(".version-section .page-action button")
|
||||
|
|
|
@ -102,7 +102,7 @@ filterTests(['all'], () => {
|
|||
|
||||
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 6000 })
|
||||
cy.wait(500)
|
||||
cy.get(interact.APP_TABLE_STATUS, { timeout: 1000 }).eq(0).contains("Unpublished")
|
||||
cy.get(interact.APP_TABLE_STATUS, { timeout: 10000 }).eq(0).contains("Unpublished")
|
||||
|
||||
})
|
||||
})
|
||||
|
|
|
@ -94,6 +94,7 @@ filterTests(['smoke', 'all'], () => {
|
|||
})
|
||||
|
||||
it("should create the first application from scratch with a default name", () => {
|
||||
cy.updateUserInformation("", "")
|
||||
cy.createApp("", false)
|
||||
cy.applicationInAppTable("My app")
|
||||
cy.deleteApp("My app")
|
||||
|
|
|
@ -48,7 +48,7 @@ filterTests(["smoke", "all"], () => {
|
|||
|
||||
it("deletes a row", () => {
|
||||
cy.get(interact.SPECTRUM_CHECKBOX_INPUT).check({ force: true })
|
||||
cy.contains("Delete 1 row(s)").click()
|
||||
cy.contains("Delete 1 row").click()
|
||||
cy.get(interact.SPECTRUM_MODAL).contains("Delete").click()
|
||||
cy.contains("RoverUpdated").should("not.exist")
|
||||
})
|
||||
|
|
|
@ -175,7 +175,10 @@ filterTests(["all"], () => {
|
|||
cy.get("@query").its("response.statusCode").should("eq", 200)
|
||||
cy.get("@query").its("response.body").should("not.be.empty")
|
||||
// Save query
|
||||
cy.intercept("POST", "**/queries").as("saveQuery")
|
||||
cy.get(".spectrum-Button").contains("Save Query").click({ force: true })
|
||||
cy.wait("@saveQuery")
|
||||
cy.get("@saveQuery").its("response.statusCode").should("eq", 200)
|
||||
cy.get(".nav-item").should("contain", queryName)
|
||||
})
|
||||
|
||||
|
|
|
@ -252,7 +252,8 @@ filterTests(["all"], () => {
|
|||
.contains("Delete Query")
|
||||
.click({ force: true })
|
||||
// Confirm deletion
|
||||
cy.reload({ timeout: 5000 })
|
||||
cy.reload()
|
||||
cy.get(".nav-item", { timeout: 30000 }).contains(datasource).click({ force: true })
|
||||
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename)
|
||||
})
|
||||
|
||||
|
|
|
@ -48,6 +48,7 @@ filterTests(['smoke', 'all'], () => {
|
|||
cy.get(interact.AREA_LABEL_REVERT).click({ force: true })
|
||||
})
|
||||
cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => {
|
||||
cy.get("input").type("Cypress Tests")
|
||||
// Click Revert
|
||||
cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true })
|
||||
cy.wait(2000) // Wait for app to finish reverting
|
||||
|
|
|
@ -128,7 +128,9 @@ Cypress.Commands.add("updateUserInformation", (firstName, lastName) => {
|
|||
.should("have.value", lastName)
|
||||
.blur()
|
||||
}
|
||||
cy.get("button").contains("Update information").click({ force: true })
|
||||
cy.get(".confirm-wrap").within(() => {
|
||||
cy.get("button").contains("Update information").click({ force: true })
|
||||
})
|
||||
cy.get(".spectrum-Dialog-grid").should("not.exist")
|
||||
})
|
||||
})
|
||||
|
@ -140,14 +142,14 @@ Cypress.Commands.add("setUserRole", (user, role) => {
|
|||
// Set Role
|
||||
cy.wait(500)
|
||||
cy.get(".spectrum-Form-itemField")
|
||||
.eq(2)
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get(".spectrum-Picker-label").click({ force: true })
|
||||
})
|
||||
cy.get(".spectrum-Menu").within(() => {
|
||||
cy.get(".spectrum-Menu-itemLabel").contains(role).click({ force: true })
|
||||
})
|
||||
cy.get(".spectrum-Form-itemField").eq(2).should("contain", role)
|
||||
cy.get(".spectrum-Form-itemField").eq(3).should("contain", role)
|
||||
})
|
||||
|
||||
// APPLICATIONS
|
||||
|
@ -162,7 +164,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => {
|
|||
typeof addDefaultTable != "boolean" ? true : addDefaultTable
|
||||
|
||||
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 })
|
||||
cy.wait(1000)
|
||||
cy.url({ timeout: 30000 }).should("include", "/apps")
|
||||
cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ force: true })
|
||||
|
||||
// If apps already exist
|
||||
|
@ -432,6 +434,7 @@ Cypress.Commands.add("createAppFromScratch", appName => {
|
|||
|
||||
// TABLES
|
||||
Cypress.Commands.add("createTable", (tableName, initialTable) => {
|
||||
// Creates an internal Budibase DB table
|
||||
if (!initialTable) {
|
||||
cy.navigateToDataSection()
|
||||
cy.get(`[data-cy="new-table"]`, { timeout: 2000 }).click()
|
||||
|
@ -445,6 +448,7 @@ Cypress.Commands.add("createTable", (tableName, initialTable) => {
|
|||
.contains("Continue")
|
||||
.click({ force: true })
|
||||
})
|
||||
cy.get(".spectrum-Modal").contains("Create Table", { timeout: 10000 })
|
||||
cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => {
|
||||
cy.get("input", { timeout: 2000 }).first().type(tableName).blur()
|
||||
cy.get(".spectrum-ButtonGroup").contains("Create").click()
|
||||
|
@ -735,8 +739,15 @@ Cypress.Commands.add("deleteAllScreens", () => {
|
|||
Cypress.Commands.add("navigateToFrontend", () => {
|
||||
// Clicks on Design tab and then the Home nav item
|
||||
cy.wait(500)
|
||||
cy.intercept("**/preview").as("preview")
|
||||
cy.contains("Design").click()
|
||||
cy.get(".spectrum-Search", { timeout: 2000 }).type("/")
|
||||
cy.wait("@preview")
|
||||
cy.get("@preview").then(res => {
|
||||
if (res.statusCode != 200) {
|
||||
cy.reload()
|
||||
}
|
||||
})
|
||||
cy.get(".spectrum-Search", { timeout: 20000 }).type("/")
|
||||
cy.get(".nav-item", { timeout: 2000 }).contains("home").click({ force: true })
|
||||
})
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/builder",
|
||||
"version": "1.2.58",
|
||||
"version": "1.2.59-alpha.0",
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
@ -13,11 +13,11 @@
|
|||
"cy:setup:ci": "node ./cypress/setup.js",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
"cy:run:ci": "cypress run --headed --browser chrome --spec cypress/integration/createApp.spec.js",
|
||||
"cy:run:ci": "cypress run --headed --browser chrome --spec cypress/integration/createApp.spec.js",
|
||||
"cy:run:ci:record": "xvfb-run cypress run --headed --browser chrome --record",
|
||||
"cy:test": "start-server-and-test cy:setup http://localhost:4100/builder cy:run",
|
||||
"cy:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci",
|
||||
"cy:ci:record": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci:record && npm run cy:ci:report",
|
||||
"cy:ci:record": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci:record; npm run cy:ci:report",
|
||||
"cy:ci:report": "mochawesome-merge cypress/reports/*.json > cypress/reports/testReport.json && marge cypress/reports/testReport.json --reportDir cypress/reports --inline",
|
||||
"cy:ci:notify": "node scripts/cypressResultsWebhook",
|
||||
"cy:debug": "start-server-and-test cy:setup http://localhost:4100/builder cy:open",
|
||||
|
@ -69,10 +69,10 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^1.2.58",
|
||||
"@budibase/client": "^1.2.58",
|
||||
"@budibase/frontend-core": "^1.2.58",
|
||||
"@budibase/string-templates": "^1.2.58",
|
||||
"@budibase/bbui": "1.2.59-alpha.0",
|
||||
"@budibase/client": "1.2.59-alpha.0",
|
||||
"@budibase/frontend-core": "1.2.59-alpha.0",
|
||||
"@budibase/string-templates": "1.2.59-alpha.0",
|
||||
"@sentry/browser": "5.19.1",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
|
|
|
@ -5,7 +5,6 @@ const path = require("path")
|
|||
const fs = require("fs")
|
||||
|
||||
const WEBHOOK_URL = process.env.CYPRESS_WEBHOOK_URL
|
||||
const OUTCOME = process.env.CYPRESS_OUTCOME
|
||||
const DASHBOARD_URL = process.env.CYPRESS_DASHBOARD_URL
|
||||
const GIT_SHA = process.env.GITHUB_SHA
|
||||
const GITHUB_ACTIONS_RUN_URL = process.env.GITHUB_ACTIONS_RUN_URL
|
||||
|
@ -35,6 +34,8 @@ async function discordCypressResultsNotification(report) {
|
|||
skipped,
|
||||
} = report.stats
|
||||
|
||||
const OUTCOME = failures > 0 ? "failure" : "success"
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -114,7 +115,7 @@ async function discordCypressResultsNotification(report) {
|
|||
}
|
||||
const response = await fetch(WEBHOOK_URL, options)
|
||||
|
||||
if (response.status >= 400) {
|
||||
if (response.status >= 201) {
|
||||
const text = await response.text()
|
||||
console.error(
|
||||
`Error sending discord webhook. \nStatus: ${response.status}. \nResponse Body: ${text}. \nRequest Body: ${options.body}`
|
||||
|
|
|
@ -19,7 +19,6 @@ import {
|
|||
makeComponentUnique,
|
||||
} from "../componentUtils"
|
||||
import { Helpers } from "@budibase/bbui"
|
||||
import { DefaultAppTheme, LAYOUT_NAMES } from "../../constants"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
|
||||
const INITIAL_FRONTEND_STATE = {
|
||||
|
@ -40,6 +39,7 @@ const INITIAL_FRONTEND_STATE = {
|
|||
devicePreview: false,
|
||||
messagePassing: false,
|
||||
continueIfAction: false,
|
||||
showNotificationAction: false,
|
||||
},
|
||||
errors: [],
|
||||
hasAppPackage: false,
|
||||
|
@ -124,35 +124,6 @@ export const getFrontendStore = () => {
|
|||
await integrations.init()
|
||||
await queries.init()
|
||||
await tables.init()
|
||||
|
||||
// Add navigation settings to old apps
|
||||
if (!application.navigation) {
|
||||
const layout = layouts.find(x => x._id === LAYOUT_NAMES.MASTER.PRIVATE)
|
||||
const customTheme = application.customTheme
|
||||
let navigationSettings = {
|
||||
navigation: "Top",
|
||||
title: application.name,
|
||||
navWidth: "Large",
|
||||
navBackground:
|
||||
customTheme?.navBackground || DefaultAppTheme.navBackground,
|
||||
navTextColor:
|
||||
customTheme?.navTextColor || DefaultAppTheme.navTextColor,
|
||||
}
|
||||
if (layout) {
|
||||
navigationSettings.hideLogo = layout.props.hideLogo
|
||||
navigationSettings.hideTitle = layout.props.hideTitle
|
||||
navigationSettings.title = layout.props.title || application.name
|
||||
navigationSettings.logoUrl = layout.props.logoUrl
|
||||
navigationSettings.links = layout.props.links
|
||||
navigationSettings.navigation = layout.props.navigation || "Top"
|
||||
navigationSettings.sticky = layout.props.sticky
|
||||
navigationSettings.navWidth = layout.props.width || "Large"
|
||||
if (navigationSettings.navigation === "None") {
|
||||
navigationSettings.navigation = "Top"
|
||||
}
|
||||
}
|
||||
await store.actions.navigation.save(navigationSettings)
|
||||
}
|
||||
},
|
||||
theme: {
|
||||
save: async theme => {
|
||||
|
@ -534,7 +505,16 @@ export const getFrontendStore = () => {
|
|||
if (!component) {
|
||||
return
|
||||
}
|
||||
let parentId
|
||||
|
||||
// Determine the next component to select after deletion
|
||||
const state = get(store)
|
||||
let nextSelectedComponentId
|
||||
if (state.selectedComponentId === component._id) {
|
||||
nextSelectedComponentId = store.actions.components.getNext()
|
||||
if (!nextSelectedComponentId) {
|
||||
nextSelectedComponentId = store.actions.components.getPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
// Patch screen
|
||||
await store.actions.screens.patch(screen => {
|
||||
|
@ -549,17 +529,18 @@ export const getFrontendStore = () => {
|
|||
if (!parent) {
|
||||
return false
|
||||
}
|
||||
parentId = parent._id
|
||||
parent._children = parent._children.filter(
|
||||
child => child._id !== component._id
|
||||
)
|
||||
})
|
||||
|
||||
// Select the deleted component's parent
|
||||
store.update(state => {
|
||||
state.selectedComponentId = parentId
|
||||
return state
|
||||
})
|
||||
// Update selected component if required
|
||||
if (nextSelectedComponentId) {
|
||||
store.update(state => {
|
||||
state.selectedComponentId = nextSelectedComponentId
|
||||
return state
|
||||
})
|
||||
}
|
||||
},
|
||||
copy: (component, cut = false, selectParent = true) => {
|
||||
// Update store with copied component
|
||||
|
@ -618,6 +599,16 @@ export const getFrontendStore = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Check inside is valid
|
||||
if (mode === "inside") {
|
||||
const definition = store.actions.components.getDefinition(
|
||||
targetComponent._component
|
||||
)
|
||||
if (!definition.hasChildren) {
|
||||
mode = "below"
|
||||
}
|
||||
}
|
||||
|
||||
// Paste new component
|
||||
if (mode === "inside") {
|
||||
// Paste inside target component if chosen
|
||||
|
@ -654,46 +645,193 @@ export const getFrontendStore = () => {
|
|||
return state
|
||||
})
|
||||
},
|
||||
getPrevious: () => {
|
||||
const state = get(store)
|
||||
const componentId = state.selectedComponentId
|
||||
const screen = get(selectedScreen)
|
||||
const parent = findComponentParent(screen.props, componentId)
|
||||
|
||||
// Check we aren't right at the top of the tree
|
||||
const index = parent?._children.findIndex(x => x._id === componentId)
|
||||
if (!parent || componentId === screen.props._id) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If we have siblings above us, choose the sibling or a descendant
|
||||
if (index > 0) {
|
||||
// If sibling before us accepts children, select a descendant
|
||||
const previousSibling = parent._children[index - 1]
|
||||
if (previousSibling._children?.length) {
|
||||
let target = previousSibling
|
||||
while (target._children?.length) {
|
||||
target = target._children[target._children.length - 1]
|
||||
}
|
||||
return target._id
|
||||
}
|
||||
|
||||
// Otherwise just select sibling
|
||||
return previousSibling._id
|
||||
}
|
||||
|
||||
// If no siblings above us, select the parent
|
||||
return parent._id
|
||||
},
|
||||
getNext: () => {
|
||||
const component = get(selectedComponent)
|
||||
const componentId = component?._id
|
||||
const screen = get(selectedScreen)
|
||||
const parent = findComponentParent(screen.props, componentId)
|
||||
const index = parent?._children.findIndex(x => x._id === componentId)
|
||||
|
||||
// If we have children, select first child
|
||||
if (component._children?.length) {
|
||||
return component._children[0]._id
|
||||
} else if (!parent) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Otherwise select the next sibling if we have one
|
||||
if (index < parent._children.length - 1) {
|
||||
const nextSibling = parent._children[index + 1]
|
||||
return nextSibling._id
|
||||
}
|
||||
|
||||
// Last child, select our parents next sibling
|
||||
let target = parent
|
||||
let targetParent = findComponentParent(screen.props, target._id)
|
||||
let targetIndex = targetParent?._children.findIndex(
|
||||
child => child._id === target._id
|
||||
)
|
||||
while (
|
||||
targetParent != null &&
|
||||
targetIndex === targetParent._children?.length - 1
|
||||
) {
|
||||
target = targetParent
|
||||
targetParent = findComponentParent(screen.props, target._id)
|
||||
targetIndex = targetParent?._children.findIndex(
|
||||
child => child._id === target._id
|
||||
)
|
||||
}
|
||||
if (targetParent) {
|
||||
return targetParent._children[targetIndex + 1]._id
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
selectPrevious: () => {
|
||||
const previousId = store.actions.components.getPrevious()
|
||||
if (previousId) {
|
||||
store.update(state => {
|
||||
state.selectedComponentId = previousId
|
||||
return state
|
||||
})
|
||||
}
|
||||
},
|
||||
selectNext: () => {
|
||||
const nextId = store.actions.components.getNext()
|
||||
if (nextId) {
|
||||
store.update(state => {
|
||||
state.selectedComponentId = nextId
|
||||
return state
|
||||
})
|
||||
}
|
||||
},
|
||||
moveUp: async component => {
|
||||
await store.actions.screens.patch(screen => {
|
||||
const componentId = component?._id
|
||||
const parent = findComponentParent(screen.props, componentId)
|
||||
if (!parent?._children?.length) {
|
||||
return false
|
||||
|
||||
// Check we aren't right at the top of the tree
|
||||
const index = parent?._children.findIndex(x => x._id === componentId)
|
||||
if (!parent || (index === 0 && parent._id === screen.props._id)) {
|
||||
return
|
||||
}
|
||||
const currentIndex = parent._children.findIndex(
|
||||
child => child._id === componentId
|
||||
)
|
||||
if (currentIndex === 0) {
|
||||
return false
|
||||
}
|
||||
const originalComponent = cloneDeep(parent._children[currentIndex])
|
||||
const newChildren = parent._children.filter(
|
||||
|
||||
// Copy original component and remove it from the parent
|
||||
const originalComponent = cloneDeep(parent._children[index])
|
||||
parent._children = parent._children.filter(
|
||||
component => component._id !== componentId
|
||||
)
|
||||
newChildren.splice(currentIndex - 1, 0, originalComponent)
|
||||
parent._children = newChildren
|
||||
|
||||
// If we have siblings above us, move up
|
||||
if (index > 0) {
|
||||
// If sibling before us accepts children, move to last child of
|
||||
// sibling
|
||||
const previousSibling = parent._children[index - 1]
|
||||
const definition = store.actions.components.getDefinition(
|
||||
previousSibling._component
|
||||
)
|
||||
if (definition.hasChildren) {
|
||||
previousSibling._children.push(originalComponent)
|
||||
}
|
||||
|
||||
// Otherwise just move component above sibling
|
||||
else {
|
||||
parent._children.splice(index - 1, 0, originalComponent)
|
||||
}
|
||||
}
|
||||
|
||||
// If no siblings above us, go above the parent as long as it isn't
|
||||
// the screen
|
||||
else if (parent._id !== screen.props._id) {
|
||||
const grandParent = findComponentParent(screen.props, parent._id)
|
||||
const parentIndex = grandParent._children.findIndex(
|
||||
child => child._id === parent._id
|
||||
)
|
||||
grandParent._children.splice(parentIndex, 0, originalComponent)
|
||||
}
|
||||
})
|
||||
},
|
||||
moveDown: async component => {
|
||||
await store.actions.screens.patch(screen => {
|
||||
const componentId = component?._id
|
||||
const parent = findComponentParent(screen.props, componentId)
|
||||
|
||||
// Sanity check parent is found
|
||||
if (!parent?._children?.length) {
|
||||
return false
|
||||
}
|
||||
const currentIndex = parent._children.findIndex(
|
||||
child => child._id === componentId
|
||||
)
|
||||
if (currentIndex === parent._children.length - 1) {
|
||||
return false
|
||||
|
||||
// Check we aren't right at the bottom of the tree
|
||||
const index = parent._children.findIndex(x => x._id === componentId)
|
||||
if (
|
||||
index === parent._children.length - 1 &&
|
||||
parent._id === screen.props._id
|
||||
) {
|
||||
return
|
||||
}
|
||||
const originalComponent = cloneDeep(parent._children[currentIndex])
|
||||
const newChildren = parent._children.filter(
|
||||
|
||||
// Copy the original component and remove from parent
|
||||
const originalComponent = cloneDeep(parent._children[index])
|
||||
parent._children = parent._children.filter(
|
||||
component => component._id !== componentId
|
||||
)
|
||||
newChildren.splice(currentIndex + 1, 0, originalComponent)
|
||||
parent._children = newChildren
|
||||
|
||||
// Move below the next sibling if we are not the last sibling
|
||||
if (index < parent._children.length) {
|
||||
// If the next sibling has children, become the first child
|
||||
const nextSibling = parent._children[index]
|
||||
const definition = store.actions.components.getDefinition(
|
||||
nextSibling._component
|
||||
)
|
||||
if (definition.hasChildren) {
|
||||
nextSibling._children.splice(0, 0, originalComponent)
|
||||
}
|
||||
|
||||
// Otherwise move below next sibling
|
||||
else {
|
||||
parent._children.splice(index + 1, 0, originalComponent)
|
||||
}
|
||||
}
|
||||
|
||||
// Last child, so move below our parent
|
||||
else {
|
||||
const grandParent = findComponentParent(screen.props, parent._id)
|
||||
const parentIndex = grandParent._children.findIndex(
|
||||
child => child._id === parent._id
|
||||
)
|
||||
grandParent._children.splice(parentIndex + 1, 0, originalComponent)
|
||||
}
|
||||
})
|
||||
},
|
||||
updateStyle: async (name, value) => {
|
||||
|
|
|
@ -162,7 +162,7 @@
|
|||
width="28px"
|
||||
height="28px"
|
||||
class="spectrum-Icon"
|
||||
style="color:grey;"
|
||||
style="color:var(--spectrum-global-color-gray-700);"
|
||||
focusable="false"
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-Reuse" />
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
width="28px"
|
||||
height="28px"
|
||||
class="spectrum-Icon"
|
||||
style="color:grey;"
|
||||
style="color:var(--spectrum-global-color-gray-700);"
|
||||
focusable="false"
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-{block.icon}" />
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
</script>
|
||||
|
||||
<div class="automations-list">
|
||||
{#each $automationStore.automations as automation, idx}
|
||||
{#each $automationStore.automations.sort(aut => aut.name) as automation, idx}
|
||||
<NavItem
|
||||
border={idx > 0}
|
||||
icon="ShareAndroid"
|
||||
|
|
|
@ -167,6 +167,7 @@
|
|||
{/if}
|
||||
<HideAutocolumnButton bind:hideAutocolumns />
|
||||
<ImportButton
|
||||
disabled={$tables.selected?._id === "ta_users"}
|
||||
tableId={$tables.selected?._id}
|
||||
on:updaterows={onUpdateRows}
|
||||
/>
|
||||
|
|
|
@ -3,11 +3,12 @@
|
|||
import ImportModal from "../modals/ImportModal.svelte"
|
||||
|
||||
export let tableId
|
||||
export let disabled
|
||||
|
||||
let modal
|
||||
</script>
|
||||
|
||||
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show}>
|
||||
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show} {disabled}>
|
||||
Import
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
|
|
|
@ -467,6 +467,7 @@
|
|||
options={relationshipOptions}
|
||||
getOptionLabel={option => option.name}
|
||||
getOptionValue={option => option.value}
|
||||
getOptionTitle={option => option.alt}
|
||||
/>
|
||||
{/if}
|
||||
<Input
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
Modal,
|
||||
notifications,
|
||||
ProgressCircle,
|
||||
Layout,
|
||||
Body,
|
||||
} from "@budibase/bbui"
|
||||
import { auth, apps } from "stores/portal"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
|
@ -72,62 +74,67 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:this={appLockModal}>
|
||||
<ModalContent
|
||||
title={lockedByHeading}
|
||||
dataCy={"app-lock-modal"}
|
||||
showConfirmButton={false}
|
||||
showCancelButton={false}
|
||||
>
|
||||
<p>
|
||||
Apps are locked to prevent work from being lost from overlapping changes
|
||||
between your team.
|
||||
</p>
|
||||
|
||||
{#if lockedByYou && getExpiryDuration(app) > 0}
|
||||
<span class="lock-expiry-body">
|
||||
{processStringSync(
|
||||
"This lock will expire in {{ duration time 'millisecond' }} from now.",
|
||||
{
|
||||
time: getExpiryDuration(app),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="lock-modal-actions">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
secondary
|
||||
quiet={lockedBy && lockedByYou}
|
||||
disabled={processing}
|
||||
on:click={() => {
|
||||
appLockModal.hide()
|
||||
}}
|
||||
>
|
||||
<span class="cancel"
|
||||
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
|
||||
>
|
||||
</Button>
|
||||
{#if lockedByYou}
|
||||
<Button
|
||||
secondary
|
||||
disabled={processing}
|
||||
on:click={() => {
|
||||
releaseLock()
|
||||
appLockModal.hide()
|
||||
}}
|
||||
>
|
||||
{#if processing}
|
||||
<ProgressCircle overBackground={true} size="S" />
|
||||
{:else}
|
||||
<span class="unlock">Release Lock</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{#key app}
|
||||
<div>
|
||||
<Modal bind:this={appLockModal}>
|
||||
<ModalContent
|
||||
title={lockedByHeading}
|
||||
dataCy={"app-lock-modal"}
|
||||
showConfirmButton={false}
|
||||
showCancelButton={false}
|
||||
>
|
||||
<Layout noPadding>
|
||||
<Body size="S">
|
||||
Apps are locked to prevent work from being lost from overlapping
|
||||
changes between your team.
|
||||
</Body>
|
||||
{#if lockedByYou && getExpiryDuration(app) > 0}
|
||||
<span class="lock-expiry-body">
|
||||
{processStringSync(
|
||||
"This lock will expire in {{ duration time 'millisecond' }} from now. This lock will expire in This lock will expire in ",
|
||||
{
|
||||
time: getExpiryDuration(app),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="lock-modal-actions">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
secondary
|
||||
quiet={lockedBy && lockedByYou}
|
||||
disabled={processing}
|
||||
on:click={() => {
|
||||
appLockModal.hide()
|
||||
}}
|
||||
>
|
||||
<span class="cancel"
|
||||
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
|
||||
>
|
||||
</Button>
|
||||
{#if lockedByYou}
|
||||
<Button
|
||||
secondary
|
||||
disabled={processing}
|
||||
on:click={() => {
|
||||
releaseLock()
|
||||
appLockModal.hide()
|
||||
}}
|
||||
>
|
||||
{#if processing}
|
||||
<ProgressCircle overBackground={true} size="S" />
|
||||
{:else}
|
||||
<span class="unlock">Release Lock</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</Layout>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<style>
|
||||
.lock-modal-actions {
|
||||
|
|
|
@ -27,6 +27,14 @@
|
|||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAttachments(fileList) {
|
||||
try {
|
||||
return await API.deleteBuilderAttachments(fileList)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dropzone
|
||||
|
@ -34,5 +42,6 @@
|
|||
{label}
|
||||
{...$$restProps}
|
||||
{processFiles}
|
||||
{deleteAttachments}
|
||||
{handleFileTooLarge}
|
||||
/>
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
export let scrollable = false
|
||||
export let highlighted = false
|
||||
export let rightAlignIcon = false
|
||||
export let id
|
||||
|
||||
const scrollApi = getContext("scroll")
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -58,6 +59,7 @@
|
|||
on:click={onClick}
|
||||
ondragover="return false"
|
||||
ondragenter="return false"
|
||||
{id}
|
||||
>
|
||||
<div class="nav-item-content" bind:this={contentRef}>
|
||||
{#if withArrow}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
Tab,
|
||||
Body,
|
||||
Layout,
|
||||
Button,
|
||||
} from "@budibase/bbui"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
import {
|
||||
|
@ -15,10 +16,15 @@
|
|||
decodeJSBinding,
|
||||
encodeJSBinding,
|
||||
} from "@budibase/string-templates"
|
||||
import { readableToRuntimeBinding } from "builderStore/dataBinding"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "builderStore/dataBinding"
|
||||
import { handlebarsCompletions } from "constants/completions"
|
||||
import { addHBSBinding, addJSBinding } from "./utils"
|
||||
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
|
||||
import { convertToJS } from "@budibase/string-templates"
|
||||
import { admin } from "stores/portal"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -62,15 +68,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Adds a HBS helper to the expression
|
||||
const addHelper = helper => {
|
||||
hbsValue = addHBSBinding(hbsValue, getCaretPosition(), helper.text)
|
||||
updateValue(hbsValue)
|
||||
// Adds a JS/HBS helper to the expression
|
||||
const addHelper = (helper, js) => {
|
||||
let tempVal
|
||||
const pos = getCaretPosition()
|
||||
if (js) {
|
||||
const decoded = decodeJSBinding(jsValue)
|
||||
tempVal = jsValue = encodeJSBinding(
|
||||
addJSBinding(decoded, pos, helper.text, { helper: true })
|
||||
)
|
||||
} else {
|
||||
tempVal = hbsValue = addHBSBinding(hbsValue, pos, helper.text)
|
||||
}
|
||||
updateValue(tempVal)
|
||||
}
|
||||
|
||||
// Adds a data binding to the expression
|
||||
const addBinding = binding => {
|
||||
if (usingJS) {
|
||||
const addBinding = (binding, { forceJS } = {}) => {
|
||||
if (usingJS || forceJS) {
|
||||
let js = decodeJSBinding(jsValue)
|
||||
js = addJSBinding(js, getCaretPosition(), binding.readableBinding)
|
||||
jsValue = encodeJSBinding(js)
|
||||
|
@ -100,6 +115,26 @@
|
|||
updateValue(jsValue)
|
||||
}
|
||||
|
||||
const convert = () => {
|
||||
const runtime = readableToRuntimeBinding(bindings, hbsValue)
|
||||
const runtimeJs = encodeJSBinding(convertToJS(runtime))
|
||||
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
|
||||
hbsValue = null
|
||||
mode = "JavaScript"
|
||||
addBinding("", { forceJS: true })
|
||||
}
|
||||
|
||||
const getHelperExample = (helper, js) => {
|
||||
let example = helper.example || ""
|
||||
if (js) {
|
||||
example = convertToJS(example).split("\n")[0].split("= ")[1]
|
||||
if (example === "null;") {
|
||||
example = ""
|
||||
}
|
||||
}
|
||||
return example || ""
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
valid = isValid(readableToRuntimeBinding(bindings, value))
|
||||
})
|
||||
|
@ -135,18 +170,21 @@
|
|||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if filteredHelpers?.length && !usingJS}
|
||||
{#if filteredHelpers?.length}
|
||||
<section>
|
||||
<div class="heading">Helpers</div>
|
||||
<ul>
|
||||
{#each filteredHelpers as helper}
|
||||
<li on:click={() => addHelper(helper)}>
|
||||
<li on:click={() => addHelper(helper, usingJS)}>
|
||||
<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>
|
||||
<pre class="helper__example">{getHelperExample(
|
||||
helper,
|
||||
usingJS
|
||||
)}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
|
@ -172,6 +210,11 @@
|
|||
for more details.
|
||||
</p>
|
||||
{/if}
|
||||
{#if $admin.isDev}
|
||||
<div class="convert">
|
||||
<Button secondary on:click={convert}>Convert to JS</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Tab>
|
||||
{#if allowJS}
|
||||
|
@ -306,4 +349,8 @@
|
|||
color: var(--red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.convert {
|
||||
padding-top: var(--spacing-m);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -18,10 +18,14 @@ export function addHBSBinding(value, caretPos, binding) {
|
|||
return value
|
||||
}
|
||||
|
||||
export function addJSBinding(value, caretPos, binding) {
|
||||
export function addJSBinding(value, caretPos, binding, { helper } = {}) {
|
||||
binding = typeof binding === "string" ? binding : binding.path
|
||||
value = value == null ? "" : value
|
||||
binding = `$("${binding}")`
|
||||
if (!helper) {
|
||||
binding = `$("${binding}")`
|
||||
} else {
|
||||
binding = `helper.${binding}()`
|
||||
}
|
||||
if (caretPos.start) {
|
||||
value =
|
||||
value.substring(0, caretPos.start) +
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
<script>
|
||||
import { Select, Label, Checkbox } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
|
||||
export let parameters
|
||||
export let bindings = []
|
||||
|
||||
const types = [
|
||||
{
|
||||
label: "Success",
|
||||
value: "success",
|
||||
},
|
||||
{
|
||||
label: "Warning",
|
||||
value: "warning",
|
||||
},
|
||||
{
|
||||
label: "Error",
|
||||
value: "error",
|
||||
},
|
||||
{
|
||||
label: "Info",
|
||||
value: "info",
|
||||
},
|
||||
]
|
||||
|
||||
onMount(() => {
|
||||
if (!parameters.type) {
|
||||
parameters.type = "success"
|
||||
}
|
||||
if (parameters.autoDismiss == null) {
|
||||
parameters.autoDismiss = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Label>Type</Label>
|
||||
<Select bind:value={parameters.type} options={types} placeholder={null} />
|
||||
<Label>Message</Label>
|
||||
<DrawerBindableInput
|
||||
{bindings}
|
||||
value={parameters.message}
|
||||
on:change={e => (parameters.message = e.detail)}
|
||||
/>
|
||||
<Label />
|
||||
<Checkbox text="Auto dismiss" bind:value={parameters.autoDismiss} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 60px 1fr;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
|
@ -15,3 +15,4 @@ export { default as S3Upload } from "./S3Upload.svelte"
|
|||
export { default as ExportData } from "./ExportData.svelte"
|
||||
export { default as ContinueIf } from "./ContinueIf.svelte"
|
||||
export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte"
|
||||
export { default as ShowNotification } from "./ShowNotification.svelte"
|
||||
|
|
|
@ -110,6 +110,12 @@
|
|||
"type": "logic",
|
||||
"component": "ContinueIf",
|
||||
"dependsOnFeature": "continueIfAction"
|
||||
},
|
||||
{
|
||||
"name": "Show Notification",
|
||||
"type": "application",
|
||||
"component": "ShowNotification",
|
||||
"dependsOnFeature": "showNotificationAction"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -3,29 +3,41 @@
|
|||
Body,
|
||||
Button,
|
||||
Combobox,
|
||||
Multiselect,
|
||||
DatePicker,
|
||||
DrawerContent,
|
||||
Icon,
|
||||
Input,
|
||||
Layout,
|
||||
Select,
|
||||
Label,
|
||||
} from "@budibase/bbui"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
|
||||
import { generate } from "shortid"
|
||||
import { LuceneUtils, Constants } from "@budibase/frontend-core"
|
||||
import { getFields } from "helpers/searchFields"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let schemaFields
|
||||
export let filters = []
|
||||
export let bindings = []
|
||||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
export let allOr = false
|
||||
|
||||
$: dispatch("change", filters)
|
||||
$: enrichedSchemaFields = getFields(schemaFields || [])
|
||||
$: fieldOptions = enrichedSchemaFields.map(field => field.name) || []
|
||||
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
|
||||
|
||||
let behaviourValue
|
||||
const behaviourOptions = [
|
||||
{ value: "and", label: "Match all of the following filters" },
|
||||
{ value: "or", label: "Match any of the following filters" },
|
||||
]
|
||||
const addFilter = () => {
|
||||
filters = [
|
||||
...filters,
|
||||
|
@ -69,7 +81,7 @@
|
|||
}
|
||||
|
||||
// if changed to an array, change default value to empty array
|
||||
const idx = filters.findIndex(x => x.field === field)
|
||||
const idx = filters.findIndex(x => x.id === expression.id)
|
||||
if (expression.type === "array") {
|
||||
filters[idx].value = []
|
||||
} else {
|
||||
|
@ -86,12 +98,26 @@
|
|||
if (expression.noValue) {
|
||||
expression.value = null
|
||||
}
|
||||
if (
|
||||
operator === Constants.OperatorOptions.In.value &&
|
||||
!Array.isArray(expression.value)
|
||||
) {
|
||||
if (expression.value) {
|
||||
expression.value = [expression.value]
|
||||
} else {
|
||||
expression.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getFieldOptions = field => {
|
||||
const schema = enrichedSchemaFields.find(x => x.name === field)
|
||||
return schema?.constraints?.inclusion || []
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
behaviourValue = allOr ? "or" : "and"
|
||||
})
|
||||
</script>
|
||||
|
||||
<DrawerContent>
|
||||
|
@ -107,79 +133,101 @@
|
|||
</Body>
|
||||
{#if filters?.length}
|
||||
<div class="fields">
|
||||
{#each filters as filter, idx}
|
||||
<Select
|
||||
bind:value={filter.field}
|
||||
options={fieldOptions}
|
||||
on:change={e => onFieldChange(filter, e.detail)}
|
||||
placeholder="Column"
|
||||
/>
|
||||
<Select
|
||||
disabled={!filter.field}
|
||||
options={LuceneUtils.getValidOperatorsForType(filter.type)}
|
||||
bind:value={filter.operator}
|
||||
on:change={e => onOperatorChange(filter, e.detail)}
|
||||
placeholder={null}
|
||||
/>
|
||||
<Select
|
||||
disabled={filter.noValue || !filter.field}
|
||||
options={valueTypeOptions}
|
||||
bind:value={filter.valueType}
|
||||
placeholder={null}
|
||||
/>
|
||||
{#if filter.valueType === "Binding"}
|
||||
<DrawerBindableInput
|
||||
disabled={filter.noValue}
|
||||
title={`Value for "${filter.field}"`}
|
||||
value={filter.value}
|
||||
placeholder="Value"
|
||||
{panel}
|
||||
{bindings}
|
||||
on:change={event => (filter.value = event.detail)}
|
||||
<Select
|
||||
label="Behaviour"
|
||||
value={behaviourValue}
|
||||
options={behaviourOptions}
|
||||
getOptionLabel={opt => opt.label}
|
||||
getOptionValue={opt => opt.value}
|
||||
on:change={e => (allOr = e.detail === "or")}
|
||||
placeholder={null}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="filter-label">
|
||||
<Label>Filters</Label>
|
||||
</div>
|
||||
<div class="fields">
|
||||
{#each filters as filter, idx}
|
||||
<Select
|
||||
bind:value={filter.field}
|
||||
options={fieldOptions}
|
||||
on:change={e => onFieldChange(filter, e.detail)}
|
||||
placeholder="Column"
|
||||
/>
|
||||
{:else if ["string", "longform", "number", "formula"].includes(filter.type)}
|
||||
<Input disabled={filter.noValue} bind:value={filter.value} />
|
||||
{:else if ["options", "array"].includes(filter.type)}
|
||||
<Combobox
|
||||
disabled={filter.noValue}
|
||||
options={getFieldOptions(filter.field)}
|
||||
bind:value={filter.value}
|
||||
<Select
|
||||
disabled={!filter.field}
|
||||
options={LuceneUtils.getValidOperatorsForType(filter.type)}
|
||||
bind:value={filter.operator}
|
||||
on:change={e => onOperatorChange(filter, e.detail)}
|
||||
placeholder={null}
|
||||
/>
|
||||
{:else if filter.type === "boolean"}
|
||||
<Combobox
|
||||
disabled={filter.noValue}
|
||||
options={[
|
||||
{ label: "True", value: "true" },
|
||||
{ label: "False", value: "false" },
|
||||
]}
|
||||
bind:value={filter.value}
|
||||
<Select
|
||||
disabled={filter.noValue || !filter.field}
|
||||
options={valueTypeOptions}
|
||||
bind:value={filter.valueType}
|
||||
placeholder={null}
|
||||
/>
|
||||
{:else if filter.type === "datetime"}
|
||||
<DatePicker
|
||||
disabled={filter.noValue}
|
||||
enableTime={!getSchema(filter).dateOnly}
|
||||
timeOnly={getSchema(filter).timeOnly}
|
||||
bind:value={filter.value}
|
||||
{#if filter.valueType === "Binding"}
|
||||
<DrawerBindableInput
|
||||
disabled={filter.noValue}
|
||||
title={`Value for "${filter.field}"`}
|
||||
value={filter.value}
|
||||
placeholder="Value"
|
||||
{panel}
|
||||
{bindings}
|
||||
on:change={event => (filter.value = event.detail)}
|
||||
/>
|
||||
{:else if ["string", "longform", "number", "formula"].includes(filter.type)}
|
||||
<Input disabled={filter.noValue} bind:value={filter.value} />
|
||||
{:else if filter.type === "array" || (filter.type === "options" && filter.operator === "oneOf")}
|
||||
<Multiselect
|
||||
disabled={filter.noValue}
|
||||
options={getFieldOptions(filter.field)}
|
||||
bind:value={filter.value}
|
||||
/>
|
||||
{:else if filter.type === "options"}
|
||||
<Combobox
|
||||
disabled={filter.noValue}
|
||||
options={getFieldOptions(filter.field)}
|
||||
bind:value={filter.value}
|
||||
/>
|
||||
{:else if filter.type === "boolean"}
|
||||
<Combobox
|
||||
disabled={filter.noValue}
|
||||
options={[
|
||||
{ label: "True", value: "true" },
|
||||
{ label: "False", value: "false" },
|
||||
]}
|
||||
bind:value={filter.value}
|
||||
/>
|
||||
{:else if filter.type === "datetime"}
|
||||
<DatePicker
|
||||
disabled={filter.noValue}
|
||||
enableTime={!getSchema(filter).dateOnly}
|
||||
timeOnly={getSchema(filter).timeOnly}
|
||||
bind:value={filter.value}
|
||||
/>
|
||||
{:else}
|
||||
<DrawerBindableInput disabled />
|
||||
{/if}
|
||||
<Icon
|
||||
name="Duplicate"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => duplicateFilter(filter.id)}
|
||||
/>
|
||||
{:else}
|
||||
<DrawerBindableInput disabled />
|
||||
{/if}
|
||||
<Icon
|
||||
name="Duplicate"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => duplicateFilter(filter.id)}
|
||||
/>
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => removeFilter(filter.id)}
|
||||
/>
|
||||
{/each}
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => removeFilter(filter.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="bottom">
|
||||
<Button icon="AddCircle" size="M" secondary on:click={addFilter}>
|
||||
Add filter
|
||||
</Button>
|
||||
|
@ -202,4 +250,14 @@
|
|||
align-items: center;
|
||||
grid-template-columns: 1fr 120px 120px 1fr auto auto;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,21 +8,73 @@
|
|||
import FilterDrawer from "./FilterDrawer.svelte"
|
||||
import { currentAsset } from "builderStore"
|
||||
|
||||
const QUERY_START_REGEX = /\d[0-9]*:/g
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let value = []
|
||||
export let componentInstance
|
||||
export let bindings = []
|
||||
|
||||
let drawer
|
||||
let tempValue = value || []
|
||||
let drawer,
|
||||
toSaveFilters = null,
|
||||
allOr,
|
||||
initialAllOr
|
||||
|
||||
$: initialFilters = correctFilters(value || [])
|
||||
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance)
|
||||
$: schema = getSchemaForDatasource($currentAsset, dataSource)?.schema
|
||||
$: schemaFields = Object.values(schema || {})
|
||||
|
||||
const saveFilter = async () => {
|
||||
dispatch("change", tempValue)
|
||||
function addNumbering(filters) {
|
||||
let count = 1
|
||||
for (let value of filters) {
|
||||
if (value.field && value.field?.match(QUERY_START_REGEX) == null) {
|
||||
value.field = `${count++}:${value.field}`
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
function correctFilters(filters) {
|
||||
const corrected = []
|
||||
for (let filter of filters) {
|
||||
let field = filter.field
|
||||
if (filter.operator === "allOr") {
|
||||
initialAllOr = allOr = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
typeof filter.field === "string" &&
|
||||
filter.field.match(QUERY_START_REGEX) != null
|
||||
) {
|
||||
const parts = field.split(":")
|
||||
const number = parts[0]
|
||||
// it's the new format, remove number
|
||||
if (!isNaN(parseInt(number))) {
|
||||
parts.shift()
|
||||
field = parts.join(":")
|
||||
}
|
||||
}
|
||||
corrected.push({
|
||||
...filter,
|
||||
field,
|
||||
})
|
||||
}
|
||||
return corrected
|
||||
}
|
||||
|
||||
async function saveFilter() {
|
||||
if (!toSaveFilters && allOr !== initialAllOr) {
|
||||
toSaveFilters = initialFilters
|
||||
}
|
||||
const filters = toSaveFilters?.filter(filter => filter.operator !== "allOr")
|
||||
if (allOr && filters) {
|
||||
filters.push({ operator: "allOr" })
|
||||
}
|
||||
// only save if anything was updated
|
||||
if (filters) {
|
||||
dispatch("change", addNumbering(filters))
|
||||
}
|
||||
notifications.success("Filters saved.")
|
||||
drawer.hide()
|
||||
}
|
||||
|
@ -33,8 +85,12 @@
|
|||
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
|
||||
<FilterDrawer
|
||||
slot="body"
|
||||
bind:filters={tempValue}
|
||||
filters={initialFilters}
|
||||
{bindings}
|
||||
{schemaFields}
|
||||
bind:allOr
|
||||
on:change={event => {
|
||||
toSaveFilters = event.detail
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="desktop">
|
||||
<AppLockModal {app} buttonSize="M" />
|
||||
<span><AppLockModal {app} buttonSize="M" /></span>
|
||||
</div>
|
||||
<div class="desktop">
|
||||
<div class="app-status">
|
||||
|
|
|
@ -55,13 +55,16 @@
|
|||
let saveId, url
|
||||
let response, schema, enabledHeaders
|
||||
let authConfigId
|
||||
let dynamicVariables, addVariableModal, varBinding
|
||||
let dynamicVariables, addVariableModal, varBinding, globalDynamicBindings
|
||||
let restBindings = getRestBindings()
|
||||
|
||||
$: staticVariables = datasource?.config?.staticVariables || {}
|
||||
|
||||
$: customRequestBindings = toBindingsArray(requestBindings, "Binding")
|
||||
$: dynamicRequestBindings = toBindingsArray(dynamicVariables, "Dynamic")
|
||||
$: globalDynamicRequestBindings = toBindingsArray(
|
||||
globalDynamicBindings,
|
||||
"Dynamic"
|
||||
)
|
||||
$: dataSourceStaticBindings = toBindingsArray(
|
||||
staticVariables,
|
||||
"Datasource.Static"
|
||||
|
@ -70,7 +73,7 @@
|
|||
$: mergedBindings = [
|
||||
...restBindings,
|
||||
...customRequestBindings,
|
||||
...dynamicRequestBindings,
|
||||
...globalDynamicRequestBindings,
|
||||
...dataSourceStaticBindings,
|
||||
]
|
||||
|
||||
|
@ -231,11 +234,11 @@
|
|||
]
|
||||
|
||||
// convert dynamic variables list to simple key/val object
|
||||
const getDynamicVariables = (datasource, queryId) => {
|
||||
const getDynamicVariables = (datasource, queryId, matchFn) => {
|
||||
const variablesList = datasource?.config?.dynamicVariables
|
||||
if (variablesList && variablesList.length > 0) {
|
||||
const filtered = queryId
|
||||
? variablesList.filter(variable => variable.queryId === queryId)
|
||||
? variablesList.filter(variable => matchFn(variable, queryId))
|
||||
: variablesList
|
||||
return filtered.reduce(
|
||||
(acc, next) => ({ ...acc, [next.name]: next.value }),
|
||||
|
@ -367,12 +370,21 @@
|
|||
if (query && !query.fields.pagination) {
|
||||
query.fields.pagination = {}
|
||||
}
|
||||
dynamicVariables = getDynamicVariables(datasource, query._id)
|
||||
dynamicVariables = getDynamicVariables(
|
||||
datasource,
|
||||
query._id,
|
||||
(variable, queryId) => variable.queryId === queryId
|
||||
)
|
||||
globalDynamicBindings = getDynamicVariables(
|
||||
datasource,
|
||||
query._id,
|
||||
(variable, queryId) => variable.queryId !== queryId
|
||||
)
|
||||
|
||||
prettifyQueryRequestBody(
|
||||
query,
|
||||
requestBindings,
|
||||
dynamicVariables,
|
||||
globalDynamicBindings,
|
||||
staticVariables,
|
||||
restBindings
|
||||
)
|
||||
|
@ -437,7 +449,7 @@
|
|||
valuePlaceholder="Default"
|
||||
bindings={[
|
||||
...restBindings,
|
||||
...dynamicRequestBindings,
|
||||
...globalDynamicRequestBindings,
|
||||
...dataSourceStaticBindings,
|
||||
]}
|
||||
bindingDrawerLeft="260px"
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
selectedLayout,
|
||||
currentAsset,
|
||||
} from "builderStore"
|
||||
import iframeTemplate from "./iframeTemplate"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import {
|
||||
ProgressCircle,
|
||||
|
@ -40,12 +39,6 @@
|
|||
BUDIBASE: "type",
|
||||
}
|
||||
|
||||
// Construct iframe template
|
||||
$: template = iframeTemplate.replace(
|
||||
/\{\{ CLIENT_LIB_PATH }}/,
|
||||
$store.clientLibPath
|
||||
)
|
||||
|
||||
const placeholderScreen = new Screen()
|
||||
.name("Screen Placeholder")
|
||||
.route("/")
|
||||
|
@ -151,7 +144,11 @@
|
|||
} else if (type === "update-prop") {
|
||||
await store.actions.components.updateSetting(data.prop, data.value)
|
||||
} else if (type === "delete-component" && data.id) {
|
||||
// Legacy type, can be deleted in future
|
||||
confirmDeleteComponent(data.id)
|
||||
} else if (type === "key-down") {
|
||||
const { key, ctrlKey } = data
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey }))
|
||||
} else if (type === "duplicate-component" && data.id) {
|
||||
const rootComponent = get(currentAsset).props
|
||||
const component = findComponent(rootComponent, data.id)
|
||||
|
@ -293,7 +290,7 @@
|
|||
<iframe
|
||||
title="componentPreview"
|
||||
bind:this={iframe}
|
||||
srcdoc={template}
|
||||
src="/preview"
|
||||
class:hidden={loading || error}
|
||||
class:tablet={$store.previewDevice === "tablet"}
|
||||
class:mobile={$store.previewDevice === "mobile"}
|
||||
|
|
|
@ -1,104 +0,0 @@
|
|||
export default `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
body {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src='{{ CLIENT_LIB_PATH }}'></script>
|
||||
<script>
|
||||
function receiveMessage(event) {
|
||||
if (!event.data) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse received message
|
||||
// If parsing fails, just ignore and wait for the next message
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(event.data)
|
||||
} catch (error) {
|
||||
console.error("Client received invalid JSON")
|
||||
// Ignore
|
||||
}
|
||||
if (!parsed || !parsed.isBudibaseEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract data from message
|
||||
const {
|
||||
selectedComponentId,
|
||||
layout,
|
||||
screen,
|
||||
appId,
|
||||
theme,
|
||||
customTheme,
|
||||
previewDevice,
|
||||
navigation,
|
||||
hiddenComponentIds
|
||||
} = parsed
|
||||
|
||||
// Set some flags so the app knows we're in the builder
|
||||
window["##BUDIBASE_IN_BUILDER##"] = true
|
||||
window["##BUDIBASE_APP_ID##"] = appId
|
||||
window["##BUDIBASE_PREVIEW_LAYOUT##"] = layout
|
||||
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
|
||||
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
|
||||
window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
|
||||
window["##BUDIBASE_PREVIEW_THEME##"] = theme
|
||||
window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"] = customTheme
|
||||
window["##BUDIBASE_PREVIEW_DEVICE##"] = previewDevice
|
||||
window["##BUDIBASE_PREVIEW_NAVIGATION##"] = navigation
|
||||
window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds
|
||||
|
||||
// Initialise app
|
||||
try {
|
||||
if (window.loadBudibase) {
|
||||
window.loadBudibase()
|
||||
document.documentElement.classList.add("loaded")
|
||||
} else {
|
||||
throw "The client library couldn't be loaded"
|
||||
}
|
||||
} catch (error) {
|
||||
window.parent.postMessage({ type: "error", error })
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", receiveMessage)
|
||||
window.parent.postMessage({ type: "ready" })
|
||||
</script>
|
||||
</head>
|
||||
<body/>
|
||||
</html>
|
||||
`
|
|
@ -1,117 +1,79 @@
|
|||
<script>
|
||||
import { store } from "builderStore"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
|
||||
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
|
||||
|
||||
export let component
|
||||
|
||||
let confirmDeleteDialog
|
||||
|
||||
$: definition = store.actions.components.getDefinition(component?._component)
|
||||
$: noChildrenAllowed = !component || !definition?.hasChildren
|
||||
$: noPaste = !$store.componentToPaste
|
||||
|
||||
// "editable" has been repurposed for inline text editing.
|
||||
// It remains here for legacy compatibility.
|
||||
// Future components should define "static": true for indicate they should
|
||||
// not show a context menu.
|
||||
$: showMenu = definition?.editable !== false && definition?.static !== true
|
||||
|
||||
const moveUpComponent = async () => {
|
||||
try {
|
||||
await store.actions.components.moveUp(component)
|
||||
} catch (error) {
|
||||
notifications.error("Error moving component up")
|
||||
}
|
||||
}
|
||||
|
||||
const moveDownComponent = async () => {
|
||||
try {
|
||||
await store.actions.components.moveDown(component)
|
||||
} catch (error) {
|
||||
notifications.error("Error moving component down")
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateComponent = () => {
|
||||
storeComponentForCopy(false)
|
||||
pasteComponent("below")
|
||||
}
|
||||
|
||||
const deleteComponent = async () => {
|
||||
try {
|
||||
await store.actions.components.delete(component)
|
||||
} catch (error) {
|
||||
notifications.error("Error deleting component")
|
||||
}
|
||||
}
|
||||
|
||||
const storeComponentForCopy = (cut = false) => {
|
||||
store.actions.components.copy(component, cut)
|
||||
}
|
||||
|
||||
const pasteComponent = mode => {
|
||||
try {
|
||||
store.actions.components.paste(component, mode)
|
||||
} catch (error) {
|
||||
notifications.error("Error saving component")
|
||||
}
|
||||
const keyboardEvent = (key, ctrlKey = false) => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("component-menu", {
|
||||
detail: {
|
||||
key,
|
||||
ctrlKey,
|
||||
id: component?._id,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showMenu}
|
||||
<ActionMenu>
|
||||
<div slot="control" class="icon">
|
||||
<Icon size="S" hoverable name="MoreSmallList" />
|
||||
</div>
|
||||
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<MenuItem noClose icon="ChevronUp" on:click={moveUpComponent}>
|
||||
Move up
|
||||
</MenuItem>
|
||||
<MenuItem noClose icon="ChevronDown" on:click={moveDownComponent}>
|
||||
Move down
|
||||
</MenuItem>
|
||||
<MenuItem noClose icon="Duplicate" on:click={duplicateComponent}>
|
||||
Duplicate
|
||||
</MenuItem>
|
||||
<MenuItem icon="Cut" on:click={() => storeComponentForCopy(true)}>
|
||||
Cut
|
||||
</MenuItem>
|
||||
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
|
||||
Copy
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="LayersBringToFront"
|
||||
on:click={() => pasteComponent("above")}
|
||||
disabled={noPaste}
|
||||
>
|
||||
Paste above
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="LayersSendToBack"
|
||||
on:click={() => pasteComponent("below")}
|
||||
disabled={noPaste}
|
||||
>
|
||||
Paste below
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="ShowOneLayer"
|
||||
on:click={() => pasteComponent("inside")}
|
||||
disabled={noPaste || noChildrenAllowed}
|
||||
>
|
||||
Paste inside
|
||||
</MenuItem>
|
||||
</ActionMenu>
|
||||
<ConfirmDialog
|
||||
bind:this={confirmDeleteDialog}
|
||||
title="Confirm Deletion"
|
||||
body={`Are you sure you wish to delete this '${definition?.name}' component?`}
|
||||
okText="Delete Component"
|
||||
onOk={deleteComponent}
|
||||
/>
|
||||
{/if}
|
||||
<ActionMenu>
|
||||
<div slot="control" class="icon">
|
||||
<Icon size="S" hoverable name="MoreSmallList" />
|
||||
</div>
|
||||
<MenuItem
|
||||
icon="Delete"
|
||||
keyBind="!BackAndroid"
|
||||
on:click={() => keyboardEvent("Delete")}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="ChevronUp"
|
||||
keyBind="Ctrl+!ArrowUp"
|
||||
on:click={() => keyboardEvent("ArrowUp", true)}
|
||||
>
|
||||
Move up
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="ChevronDown"
|
||||
keyBind="Ctrl+!ArrowDown"
|
||||
on:click={() => keyboardEvent("ArrowDown", true)}
|
||||
>
|
||||
Move down
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Duplicate"
|
||||
keyBind="Ctrl+D"
|
||||
on:click={() => keyboardEvent("d", true)}
|
||||
>
|
||||
Duplicate
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Cut"
|
||||
keyBind="Ctrl+X"
|
||||
on:click={() => keyboardEvent("x", true)}
|
||||
>
|
||||
Cut
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
keyBind="Ctrl+C"
|
||||
on:click={() => keyboardEvent("c", true)}
|
||||
>
|
||||
Copy
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="LayersSendToBack"
|
||||
keyBind="Ctrl+V"
|
||||
on:click={() => keyboardEvent("v", true)}
|
||||
disabled={noPaste}
|
||||
>
|
||||
Paste
|
||||
</MenuItem>
|
||||
</ActionMenu>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { selectedComponent, selectedScreen, store } from "builderStore"
|
||||
import { findComponent } from "builderStore/componentUtils"
|
||||
import { goto, isActive } from "@roxi/routify"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
|
||||
let confirmDeleteDialog
|
||||
let componentToDelete
|
||||
|
||||
const keyHandlers = {
|
||||
["^ArrowUp"]: async component => {
|
||||
await store.actions.components.moveUp(component)
|
||||
},
|
||||
["^ArrowDown"]: async component => {
|
||||
await store.actions.components.moveDown(component)
|
||||
},
|
||||
["^c"]: component => {
|
||||
store.actions.components.copy(component, false)
|
||||
},
|
||||
["^x"]: component => {
|
||||
store.actions.components.copy(component, true)
|
||||
},
|
||||
["^v"]: async component => {
|
||||
await store.actions.components.paste(component, "inside")
|
||||
},
|
||||
["^d"]: async component => {
|
||||
store.actions.components.copy(component)
|
||||
await store.actions.components.paste(component, "below")
|
||||
},
|
||||
["^Enter"]: () => {
|
||||
$goto("./new")
|
||||
},
|
||||
["Delete"]: component => {
|
||||
// Don't show confirmation for the screen itself
|
||||
if (component?._id === $selectedScreen.props._id) {
|
||||
return false
|
||||
}
|
||||
componentToDelete = component
|
||||
confirmDeleteDialog.show()
|
||||
},
|
||||
["ArrowUp"]: () => {
|
||||
store.actions.components.selectPrevious()
|
||||
},
|
||||
["ArrowDown"]: () => {
|
||||
store.actions.components.selectNext()
|
||||
},
|
||||
["Escape"]: () => {
|
||||
if (!$isActive("/new")) {
|
||||
return false
|
||||
}
|
||||
$goto("./")
|
||||
},
|
||||
}
|
||||
|
||||
const handleKeyAction = async (component, key, ctrlKey = false) => {
|
||||
if (!component || !key) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
// Delete and backspace are the same
|
||||
if (key === "Backspace") {
|
||||
key = "Delete"
|
||||
}
|
||||
// Prefix key with a caret for ctrl modifier
|
||||
if (ctrlKey) {
|
||||
key = "^" + key
|
||||
}
|
||||
const handler = keyHandlers[key]
|
||||
if (!handler) {
|
||||
return false
|
||||
}
|
||||
return handler(component)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error handling key press")
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = async e => {
|
||||
// Ignore repeating events
|
||||
if (e.repeat) {
|
||||
return
|
||||
}
|
||||
// Ignore events when typing
|
||||
const activeTag = document.activeElement?.tagName.toLowerCase()
|
||||
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") {
|
||||
return
|
||||
}
|
||||
// Key events are always for the selected component
|
||||
return handleKeyAction($selectedComponent, e.key, e.ctrlKey || e.metaKey)
|
||||
}
|
||||
|
||||
const handleComponentMenu = async e => {
|
||||
// Menu events can be for any component
|
||||
const { id, key, ctrlKey } = e.detail
|
||||
const component = findComponent($selectedScreen.props, id)
|
||||
return await handleKeyAction(component, key, ctrlKey)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyPress)
|
||||
document.addEventListener("component-menu", handleComponentMenu)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyPress)
|
||||
document.removeEventListener("component-menu", handleComponentMenu)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={confirmDeleteDialog}
|
||||
title="Confirm Deletion"
|
||||
body={`Are you sure you want to delete "${componentToDelete?._instanceName}"?`}
|
||||
okText="Delete Component"
|
||||
onOk={() => store.actions.components.delete(componentToDelete)}
|
||||
/>
|
|
@ -6,55 +6,11 @@
|
|||
import { store, selectedScreen } from "builderStore"
|
||||
import NavItem from "components/common/NavItem.svelte"
|
||||
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
|
||||
import { setContext } from "svelte"
|
||||
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
|
||||
import { DropPosition } from "./dndStore"
|
||||
import { notifications, Button } from "@budibase/bbui"
|
||||
|
||||
let scrollRef
|
||||
|
||||
const scrollTo = bounds => {
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
const sidebarWidth = 259
|
||||
const navItemHeight = 32
|
||||
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
|
||||
let scrollBounds = scrollRef.getBoundingClientRect()
|
||||
let newOffsets = {}
|
||||
|
||||
// Calculate left offset
|
||||
const offsetX = bounds.left + bounds.width + scrollLeft - 36
|
||||
if (offsetX > sidebarWidth) {
|
||||
newOffsets.left = offsetX - sidebarWidth
|
||||
} else {
|
||||
newOffsets.left = 0
|
||||
}
|
||||
if (newOffsets.left === scrollLeft) {
|
||||
delete newOffsets.left
|
||||
}
|
||||
|
||||
// Calculate top offset
|
||||
const offsetY = bounds.top - scrollBounds?.top + scrollTop
|
||||
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
|
||||
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
|
||||
} else if (offsetY < scrollTop + navItemHeight) {
|
||||
newOffsets.top = offsetY - navItemHeight
|
||||
} else {
|
||||
delete newOffsets.top
|
||||
}
|
||||
|
||||
// Skip if offset is unchanged
|
||||
if (newOffsets.left == null && newOffsets.top == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Smoothly scroll to the offset
|
||||
scrollRef.scroll({
|
||||
...newOffsets,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
|
||||
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
|
||||
|
||||
const onDrop = async () => {
|
||||
try {
|
||||
|
@ -64,25 +20,15 @@
|
|||
notifications.error("Error saving component")
|
||||
}
|
||||
}
|
||||
|
||||
// Set scroll context so components can invoke scrolling when selected
|
||||
setContext("scroll", {
|
||||
scrollTo,
|
||||
})
|
||||
</script>
|
||||
|
||||
<Panel title="Components" showExpandIcon borderRight>
|
||||
<div class="add-component">
|
||||
<Button on:click={() => $goto("./new")} cta>Add component</Button>
|
||||
</div>
|
||||
<div class="nav-items-container" bind:this={scrollRef}>
|
||||
<ComponentScrollWrapper>
|
||||
<ul>
|
||||
<li
|
||||
on:click={() => {
|
||||
$store.selectedComponentId = $selectedScreen?.props._id
|
||||
}}
|
||||
id={`component-${$selectedScreen?.props._id}`}
|
||||
>
|
||||
<li>
|
||||
<NavItem
|
||||
text="Screen"
|
||||
indentLevel={0}
|
||||
|
@ -91,6 +37,10 @@
|
|||
scrollable
|
||||
icon="WebPage"
|
||||
on:drop={onDrop}
|
||||
on:click={() => {
|
||||
$store.selectedComponentId = $selectedScreen?.props._id
|
||||
}}
|
||||
id={`component-${$selectedScreen?.props._id}`}
|
||||
>
|
||||
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
|
||||
</NavItem>
|
||||
|
@ -114,8 +64,9 @@
|
|||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ComponentScrollWrapper>
|
||||
</Panel>
|
||||
<ComponentKeyHandler />
|
||||
|
||||
<style>
|
||||
.add-component {
|
||||
|
@ -125,12 +76,6 @@
|
|||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.nav-items-container {
|
||||
padding: var(--spacing-xl) 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
height: 0;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
<script>
|
||||
import { setContext } from "svelte"
|
||||
import { dndStore } from "./dndStore"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
|
||||
let scrollRef
|
||||
|
||||
const scrollTo = bounds => {
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
const sidebarWidth = 259
|
||||
const navItemHeight = 32
|
||||
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
|
||||
let scrollBounds = scrollRef.getBoundingClientRect()
|
||||
let newOffsets = {}
|
||||
|
||||
// Calculate left offset
|
||||
const offsetX = bounds.left + bounds.width + scrollLeft - 36
|
||||
if (offsetX > sidebarWidth) {
|
||||
newOffsets.left = offsetX - sidebarWidth
|
||||
} else {
|
||||
newOffsets.left = 0
|
||||
}
|
||||
if (newOffsets.left === scrollLeft) {
|
||||
delete newOffsets.left
|
||||
}
|
||||
|
||||
// Calculate top offset
|
||||
const offsetY = bounds.top - scrollBounds?.top + scrollTop
|
||||
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
|
||||
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
|
||||
} else if (offsetY < scrollTop + navItemHeight) {
|
||||
newOffsets.top = offsetY - navItemHeight
|
||||
} else {
|
||||
delete newOffsets.top
|
||||
}
|
||||
|
||||
// Skip if offset is unchanged
|
||||
if (newOffsets.left == null && newOffsets.top == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Smoothly scroll to the offset
|
||||
scrollRef.scroll({
|
||||
...newOffsets,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
|
||||
// Set scroll context so components can invoke scrolling when selected
|
||||
setContext("scroll", {
|
||||
scrollTo,
|
||||
})
|
||||
|
||||
const onDrop = async () => {
|
||||
try {
|
||||
await dndStore.actions.drop()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error saving component")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={scrollRef}
|
||||
on:drop={onDrop}
|
||||
ondragover="return false"
|
||||
ondragenter="return false"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
padding: var(--spacing-xl) 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
|
@ -68,7 +68,8 @@
|
|||
closedNodes = closedNodes
|
||||
}
|
||||
|
||||
const onDrop = async () => {
|
||||
const onDrop = async e => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await dndStore.actions.drop()
|
||||
} catch (error) {
|
||||
|
|
|
@ -31,15 +31,20 @@
|
|||
<div slot="control" class="icon">
|
||||
<Icon size="S" hoverable name="MoreSmallList" />
|
||||
</div>
|
||||
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
keyBind="Ctrl+C"
|
||||
on:click={() => storeComponentForCopy(false)}
|
||||
>
|
||||
Copy
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="ShowOneLayer"
|
||||
icon="LayersSendToBack"
|
||||
keyBind="Ctrl+V"
|
||||
on:click={() => pasteComponent("inside")}
|
||||
disabled={noPaste}
|
||||
>
|
||||
Paste inside
|
||||
Paste
|
||||
</MenuItem>
|
||||
</ActionMenu>
|
||||
{/if}
|
||||
|
|
|
@ -36,7 +36,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
const canRenderControl = setting => {
|
||||
const canRenderControl = (setting, isScreen) => {
|
||||
// Prevent rendering on click setting for screens
|
||||
if (setting?.type === "event" && isScreen) {
|
||||
return false
|
||||
}
|
||||
|
||||
const control = getComponentForSetting(setting)
|
||||
if (!control) {
|
||||
return false
|
||||
|
@ -87,7 +92,7 @@
|
|||
/>
|
||||
{/if}
|
||||
{#each section.settings as setting (setting.key)}
|
||||
{#if canRenderControl(setting)}
|
||||
{#if canRenderControl(setting, isScreen)}
|
||||
<PropertyControl
|
||||
type={setting.type}
|
||||
control={getComponentForSetting(setting)}
|
||||
|
|
|
@ -1,20 +1,11 @@
|
|||
<script>
|
||||
import { notifications, Slider, Icon } from "@budibase/bbui"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
const ThemeOptions = [
|
||||
"spectrum--darkest",
|
||||
"spectrum--dark",
|
||||
"spectrum--light",
|
||||
"spectrum--lightest",
|
||||
]
|
||||
|
||||
$: themeIndex = ThemeOptions.indexOf($store.theme) ?? 2
|
||||
|
||||
const onChangeTheme = async e => {
|
||||
const onChangeTheme = async theme => {
|
||||
try {
|
||||
const theme = ThemeOptions[e.detail] ?? ThemeOptions[2]
|
||||
await store.actions.theme.save(theme)
|
||||
await store.actions.theme.save(`spectrum--${theme}`)
|
||||
} catch (error) {
|
||||
notifications.error("Error updating theme")
|
||||
}
|
||||
|
@ -22,26 +13,52 @@
|
|||
</script>
|
||||
|
||||
<div class="container">
|
||||
<Icon name="Moon" />
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={themeIndex}
|
||||
on:change={onChangeTheme}
|
||||
/>
|
||||
<Icon name="Light" />
|
||||
{#each Constants.Themes as theme}
|
||||
<div
|
||||
class="theme"
|
||||
class:selected={`spectrum--${theme.class}` === $store.theme}
|
||||
on:click={() => onChangeTheme(theme.class)}
|
||||
>
|
||||
<div
|
||||
style="background: {theme.preview}"
|
||||
class="color spectrum--{theme.class}"
|
||||
/>
|
||||
{theme.name}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
.color {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50px;
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.theme {
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
gap: var(--spacing-xl);
|
||||
transition: background 130ms ease-out;
|
||||
font-weight: 600;
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
div :global(.spectrum-Form-item) {
|
||||
flex: 1 1 auto;
|
||||
.theme:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme.selected,
|
||||
.theme:hover {
|
||||
background: var(--spectrum-global-color-gray-50);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { Slider, Button } from "@budibase/bbui"
|
||||
|
||||
export let customTheme
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const options = ["0", "4px", "8px", "16px"]
|
||||
|
||||
$: index = options.indexOf(customTheme.buttonBorderRadius) ?? 2
|
||||
|
||||
const onChange = async e => {
|
||||
dispatch("change", options[e.detail])
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<Slider min={0} max={3} step={1} value={index} on:change={onChange} />
|
||||
<div class="button" style="--radius: {customTheme.buttonBorderRadius};">
|
||||
<Button primary newStyles>Button</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
.container :global(.spectrum-Form-item) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.button :global(.spectrum-Button) {
|
||||
border-radius: var(--radius) !important;
|
||||
}
|
||||
</style>
|
|
@ -1,35 +1,11 @@
|
|||
<script>
|
||||
import Panel from "components/design/Panel.svelte"
|
||||
import {
|
||||
Layout,
|
||||
Label,
|
||||
ColorPicker,
|
||||
Button,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { Layout, Label, ColorPicker, notifications } from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import { get } from "svelte/store"
|
||||
import { DefaultAppTheme } from "constants"
|
||||
import AppThemeSelect from "./AppThemeSelect.svelte"
|
||||
|
||||
const ButtonBorderRadiusOptions = [
|
||||
{
|
||||
label: "Square",
|
||||
value: "0",
|
||||
},
|
||||
{
|
||||
label: "Soft edge",
|
||||
value: "4px",
|
||||
},
|
||||
{
|
||||
label: "Curved",
|
||||
value: "8px",
|
||||
},
|
||||
{
|
||||
label: "Round",
|
||||
value: "16px",
|
||||
},
|
||||
]
|
||||
import ButtonRoundnessSelect from "./ButtonRoundnessSelect.svelte"
|
||||
|
||||
$: customTheme = $store.customTheme || {}
|
||||
|
||||
|
@ -52,22 +28,11 @@
|
|||
<AppThemeSelect />
|
||||
</Layout>
|
||||
<Layout noPadding gap="XS">
|
||||
<Label>Buttons</Label>
|
||||
<div class="buttons">
|
||||
{#each ButtonBorderRadiusOptions as option}
|
||||
<div
|
||||
class:active={customTheme.buttonBorderRadius === option.value}
|
||||
style={`--radius: ${option.value}`}
|
||||
>
|
||||
<Button
|
||||
secondary
|
||||
on:click={() => update("buttonBorderRadius", option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Label>Button roundness</Label>
|
||||
<ButtonRoundnessSelect
|
||||
{customTheme}
|
||||
on:change={e => update("buttonBorderRadius", e.detail)}
|
||||
/>
|
||||
</Layout>
|
||||
<Layout noPadding gap="XS">
|
||||
<Label>Accent color</Label>
|
||||
|
@ -88,29 +53,3 @@
|
|||
</Layout>
|
||||
</Layout>
|
||||
</Panel>
|
||||
|
||||
<style>
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 100px;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.buttons > div {
|
||||
display: contents;
|
||||
}
|
||||
.buttons > div :global(.spectrum-Button) {
|
||||
border-radius: var(--radius) !important;
|
||||
border-width: 1px;
|
||||
border-color: var(--spectrum-global-color-gray-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
.buttons > div:hover :global(.spectrum-Button) {
|
||||
background: var(--spectrum-global-color-gray-700);
|
||||
border-color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
.buttons > div.active :global(.spectrum-Button) {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
border-color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
<script>
|
||||
import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui"
|
||||
import { goto, params } from "@roxi/routify"
|
||||
import { users } from "stores/portal"
|
||||
import { users, organisation } from "stores/portal"
|
||||
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
|
||||
import Logo from "assets/bb-emblem.svg"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
const inviteCode = $params["?code"]
|
||||
let password, error
|
||||
|
||||
$: company = $organisation.company || "Budibase"
|
||||
|
||||
async function acceptInvite() {
|
||||
try {
|
||||
await users.acceptInvite(inviteCode, password)
|
||||
|
@ -17,16 +20,24 @@
|
|||
notifications.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await organisation.init()
|
||||
} catch (error) {
|
||||
notifications.error("Error getting org config")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<Layout>
|
||||
<img src={Logo} alt="logo" />
|
||||
<img alt="logo" src={$organisation.logoUrl || Logo} />
|
||||
<Layout gap="XS" justifyItems="center" noPadding>
|
||||
<Heading size="M">Accept Invitation</Heading>
|
||||
<Heading size="M">Invitation to {company}</Heading>
|
||||
<Body textAlign="center" size="M">
|
||||
Please enter a password to set up your user.
|
||||
Please enter a password to get started.
|
||||
</Body>
|
||||
</Layout>
|
||||
<PasswordRepeatInput bind:error bind:password />
|
||||
|
@ -46,7 +57,7 @@
|
|||
}
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
width: 260px;
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
|
|
@ -18,6 +18,8 @@
|
|||
Body,
|
||||
Select,
|
||||
Toggle,
|
||||
Tag,
|
||||
Tags,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { API } from "api"
|
||||
|
@ -29,6 +31,8 @@
|
|||
OIDC: "oidc",
|
||||
}
|
||||
|
||||
const HasSpacesRegex = /[\\"\s]/
|
||||
|
||||
// Some older google configs contain a manually specified value - retain the functionality to edit the field
|
||||
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
|
||||
$: googleCallbackUrl = undefined
|
||||
|
@ -145,7 +149,6 @@
|
|||
|
||||
async function save(docs) {
|
||||
let calls = []
|
||||
|
||||
// Only if the user has provided an image, upload it
|
||||
if (image) {
|
||||
let data = new FormData()
|
||||
|
@ -157,7 +160,6 @@
|
|||
})
|
||||
)
|
||||
}
|
||||
|
||||
docs.forEach(element => {
|
||||
// Delete unsupported fields
|
||||
delete element.createdAt
|
||||
|
@ -199,7 +201,6 @@
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (calls.length) {
|
||||
Promise.all(calls)
|
||||
.then(data => {
|
||||
|
@ -215,6 +216,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
let defaultScopes = ["profile", "email", "offline_access"]
|
||||
|
||||
const refreshScopes = idx => {
|
||||
providers.oidc.config.configs[idx]["scopes"] =
|
||||
providers.oidc.config.configs[idx]["scopes"]
|
||||
}
|
||||
|
||||
let scopesFields = [
|
||||
{
|
||||
editing: true,
|
||||
inputText: null,
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await organisation.init()
|
||||
|
@ -276,7 +292,7 @@
|
|||
if (!oidcDoc?._id) {
|
||||
providers.oidc = {
|
||||
type: ConfigTypes.OIDC,
|
||||
config: { configs: [{ activated: true }] },
|
||||
config: { configs: [{ activated: true, scopes: defaultScopes }] },
|
||||
}
|
||||
} else {
|
||||
originalOidcDoc = cloneDeep(oidcDoc)
|
||||
|
@ -345,6 +361,7 @@
|
|||
size="s"
|
||||
cta
|
||||
on:click={() => save([providers.oidc])}
|
||||
dataCy={"oidc-save"}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
@ -362,6 +379,7 @@
|
|||
bind:value={providers.oidc.config.configs[0][field.name]}
|
||||
readonly={field.readonly}
|
||||
placeholder={field.placeholder}
|
||||
dataCy={field.name}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
@ -392,15 +410,132 @@
|
|||
<div class="form-row">
|
||||
<Label size="L">Activated</Label>
|
||||
<Toggle
|
||||
dataCy={"oidc-active"}
|
||||
text=""
|
||||
bind:value={providers.oidc.config.configs[0].activated}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
<span class="advanced-config">
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="XS">
|
||||
<div class="auth-scopes">
|
||||
<div>Advanced</div>
|
||||
<Button
|
||||
secondary
|
||||
newStyles
|
||||
size="S"
|
||||
on:click={() => {
|
||||
providers.oidc.config.configs[0]["scopes"] = [...defaultScopes]
|
||||
}}
|
||||
dataCy={"restore-oidc-default-scopes"}
|
||||
>
|
||||
Restore Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</Heading>
|
||||
<Body size="S">
|
||||
Changes to your authentication scopes will only take effect when you
|
||||
next log in. Please refer to your vendor documentation before
|
||||
modification.
|
||||
</Body>
|
||||
|
||||
<div class="auth-form">
|
||||
<span class="add-new">
|
||||
<Label size="L">{"Auth Scopes"}</Label>
|
||||
<Input
|
||||
dataCy={"new-scope-input"}
|
||||
error={scopesFields[0].error}
|
||||
placeholder={"New Scope"}
|
||||
bind:value={scopesFields[0].inputText}
|
||||
on:keyup={e => {
|
||||
if (!scopesFields[0].inputText) {
|
||||
scopesFields[0].error = null
|
||||
}
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.keyCode === 13 ||
|
||||
e.code == "Space" ||
|
||||
e.keyCode == 32
|
||||
) {
|
||||
let scopes = providers.oidc.config.configs[0]["scopes"]
|
||||
? providers.oidc.config.configs[0]["scopes"]
|
||||
: [...defaultScopes]
|
||||
|
||||
let update = scopesFields[0].inputText.trim()
|
||||
|
||||
if (HasSpacesRegex.test(update)) {
|
||||
scopesFields[0].error =
|
||||
"Auth scopes cannot contain spaces, double quotes or backslashes"
|
||||
return
|
||||
} else if (scopes.indexOf(update) > -1) {
|
||||
scopesFields[0].error = "Auth scope already exists"
|
||||
return
|
||||
} else if (!update.length) {
|
||||
scopesFields[0].inputText = null
|
||||
scopesFields[0].error = null
|
||||
return
|
||||
} else {
|
||||
scopesFields[0].error = null
|
||||
scopes.push(update)
|
||||
providers.oidc.config.configs[0]["scopes"] = scopes
|
||||
scopesFields[0].inputText = null
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<div class="tag-wrap">
|
||||
<span />
|
||||
<Tags>
|
||||
<Tag closable={false}>openid</Tag>
|
||||
{#each providers.oidc.config.configs[0]["scopes"] || [...defaultScopes] as tag, idx}
|
||||
<Tag
|
||||
closable={scopesFields[0].editing}
|
||||
on:click={() => {
|
||||
let idxScopes = providers.oidc.config.configs[0]["scopes"]
|
||||
if (idxScopes.length == 1) {
|
||||
idxScopes.pop()
|
||||
} else {
|
||||
idxScopes.splice(idx, 1)
|
||||
refreshScopes(0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
{/each}
|
||||
</Tags>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</span>
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.auth-scopes {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.advanced-config :global(.spectrum-Tags-item) {
|
||||
margin-left: 0px;
|
||||
margin-top: var(--spacing-m);
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
|
||||
.auth-form > * {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-l);
|
||||
grid-template-columns: 100px 1fr;
|
||||
}
|
||||
|
||||
.advanced-config .auth-form .tag-wrap {
|
||||
padding: 0px 5px 5px 0px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
|
|
|
@ -44,7 +44,11 @@
|
|||
]
|
||||
}
|
||||
|
||||
function validateInput(email, index) {
|
||||
function validateInput(input, index) {
|
||||
if (input.email) {
|
||||
input.email = input.email.trim()
|
||||
}
|
||||
const email = input.email
|
||||
if (email) {
|
||||
const res = emailValidator(email)
|
||||
if (res === true) {
|
||||
|
@ -61,7 +65,7 @@
|
|||
const onConfirm = () => {
|
||||
let valid = true
|
||||
userData.forEach((input, index) => {
|
||||
valid = validateInput(input.email, index) && valid
|
||||
valid = validateInput(input, index) && valid
|
||||
})
|
||||
if (!valid) {
|
||||
return false
|
||||
|
@ -95,7 +99,7 @@
|
|||
bind:dropdownValue={input.role}
|
||||
options={Constants.BudibaseRoleOptions}
|
||||
error={input.error}
|
||||
on:blur={() => validateInput(input.email, index)}
|
||||
on:blur={() => validateInput(input, index)}
|
||||
/>
|
||||
</div>
|
||||
<div class="icon">
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
<script>
|
||||
import { Body, ModalContent, Table } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let userData
|
||||
export let deleteUsersResponse
|
||||
|
||||
let successCount
|
||||
let failureCount
|
||||
let title
|
||||
let unsuccessfulUsers
|
||||
let message
|
||||
|
||||
const setTitle = () => {
|
||||
if (successCount) {
|
||||
title = `${successCount} users deleted`
|
||||
} else {
|
||||
title = "Oops!"
|
||||
}
|
||||
}
|
||||
|
||||
const setMessage = () => {
|
||||
if (successCount) {
|
||||
message = "However there was a problem deleting some users."
|
||||
} else {
|
||||
message = "There was a problem deleting some users."
|
||||
}
|
||||
}
|
||||
|
||||
const setUsers = () => {
|
||||
unsuccessfulUsers = deleteUsersResponse.unsuccessful.map(user => {
|
||||
return {
|
||||
email: user.email,
|
||||
reason: user.reason,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
successCount = deleteUsersResponse.successful.length
|
||||
failureCount = deleteUsersResponse.unsuccessful.length
|
||||
setTitle()
|
||||
setMessage()
|
||||
setUsers()
|
||||
})
|
||||
|
||||
const schema = {
|
||||
email: {},
|
||||
reason: {},
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
size="M"
|
||||
{title}
|
||||
confirmText="Close"
|
||||
showCloseIcon={false}
|
||||
showCancelButton={false}
|
||||
>
|
||||
<Body size="XS">
|
||||
{message}
|
||||
</Body>
|
||||
<Table
|
||||
{schema}
|
||||
data={unsuccessfulUsers}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
</style>
|
|
@ -62,7 +62,7 @@
|
|||
csvString = e.target.result
|
||||
files = fileArray
|
||||
|
||||
userEmails = csvString.split("\n")
|
||||
userEmails = csvString.split(/\r?\n/)
|
||||
})
|
||||
reader.readAsText(fileArray[0])
|
||||
}
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
<script>
|
||||
import { Body, ModalContent, Table } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let inviteUsersResponse
|
||||
|
||||
let hasSuccess
|
||||
let hasFailure
|
||||
let title
|
||||
let failureMessage
|
||||
|
||||
let unsuccessfulUsers
|
||||
|
||||
const setTitle = () => {
|
||||
if (hasSuccess) {
|
||||
title = "Users invited!"
|
||||
} else if (hasFailure) {
|
||||
title = "Oops!"
|
||||
}
|
||||
}
|
||||
|
||||
const setFailureMessage = () => {
|
||||
if (hasSuccess) {
|
||||
failureMessage = "However there was a problem inviting some users."
|
||||
} else {
|
||||
failureMessage = "There was a problem inviting users."
|
||||
}
|
||||
}
|
||||
|
||||
const setUsers = () => {
|
||||
unsuccessfulUsers = inviteUsersResponse.unsuccessful.map(user => {
|
||||
return {
|
||||
email: user.email,
|
||||
reason: user.reason,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
hasSuccess = inviteUsersResponse.successful.length
|
||||
hasFailure = inviteUsersResponse.unsuccessful.length
|
||||
setTitle()
|
||||
setFailureMessage()
|
||||
setUsers()
|
||||
})
|
||||
|
||||
const failedSchema = {
|
||||
email: {},
|
||||
reason: {},
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent showCancelButton={false} {title} confirmText="Done">
|
||||
{#if hasSuccess}
|
||||
<Body size="XS">
|
||||
Your users should now receive an email invite to get access to their
|
||||
Budibase account
|
||||
</Body>
|
||||
{/if}
|
||||
{#if hasFailure}
|
||||
<Body size="XS">
|
||||
{failureMessage}
|
||||
</Body>
|
||||
<Table
|
||||
schema={failedSchema}
|
||||
data={unsuccessfulUsers}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
/>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
</style>
|
|
@ -2,24 +2,78 @@
|
|||
import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
|
||||
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
|
||||
import { parseToCsv } from "helpers/data/utils"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let userData
|
||||
export let createUsersResponse
|
||||
|
||||
$: mappedData = userData.map(user => {
|
||||
return {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
let hasSuccess
|
||||
let hasFailure
|
||||
let title
|
||||
let failureMessage
|
||||
|
||||
let userDataIndex
|
||||
let successfulUsers
|
||||
let unsuccessfulUsers
|
||||
|
||||
const setTitle = () => {
|
||||
if (hasSuccess) {
|
||||
title = "Users created!"
|
||||
} else if (hasFailure) {
|
||||
title = "Oops!"
|
||||
}
|
||||
}
|
||||
|
||||
const setFailureMessage = () => {
|
||||
if (hasSuccess) {
|
||||
failureMessage = "However there was a problem creating some users."
|
||||
} else {
|
||||
failureMessage = "There was a problem creating some users."
|
||||
}
|
||||
}
|
||||
|
||||
const setUsers = () => {
|
||||
userDataIndex = userData.reduce((prev, current) => {
|
||||
prev[current.email] = current
|
||||
return prev
|
||||
}, {})
|
||||
|
||||
successfulUsers = createUsersResponse.successful.map(user => {
|
||||
return {
|
||||
email: user.email,
|
||||
password: userDataIndex[user.email].password,
|
||||
}
|
||||
})
|
||||
|
||||
unsuccessfulUsers = createUsersResponse.unsuccessful.map(user => {
|
||||
return {
|
||||
email: user.email,
|
||||
reason: user.reason,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
hasSuccess = createUsersResponse.successful.length
|
||||
hasFailure = createUsersResponse.unsuccessful.length
|
||||
setTitle()
|
||||
setFailureMessage()
|
||||
setUsers()
|
||||
})
|
||||
|
||||
const schema = {
|
||||
const successSchema = {
|
||||
email: {},
|
||||
password: {},
|
||||
}
|
||||
|
||||
const failedSchema = {
|
||||
email: {},
|
||||
reason: {},
|
||||
}
|
||||
|
||||
const downloadCsvFile = () => {
|
||||
const fileName = "passwords.csv"
|
||||
const content = parseToCsv(["email", "password"], mappedData)
|
||||
const content = parseToCsv(["email", "password"], successfulUsers)
|
||||
|
||||
download(fileName, content)
|
||||
}
|
||||
|
@ -42,36 +96,52 @@
|
|||
</script>
|
||||
|
||||
<ModalContent
|
||||
size="S"
|
||||
title="Accounts created!"
|
||||
size="M"
|
||||
{title}
|
||||
confirmText="Done"
|
||||
showCancelButton={false}
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body size="XS">
|
||||
All your new users can be accessed through the autogenerated passwords. Take
|
||||
note of these passwords or download the CSV file.
|
||||
</Body>
|
||||
{#if hasFailure}
|
||||
<Body size="XS">
|
||||
{failureMessage}
|
||||
</Body>
|
||||
<Table
|
||||
schema={failedSchema}
|
||||
data={unsuccessfulUsers}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
/>
|
||||
{/if}
|
||||
{#if hasSuccess}
|
||||
<Body size="XS">
|
||||
All your new users can be accessed through the autogenerated passwords.
|
||||
Take note of these passwords or download the CSV file.
|
||||
</Body>
|
||||
|
||||
<div class="container" on:click={downloadCsvFile}>
|
||||
<div class="inner">
|
||||
<Icon name="Download" />
|
||||
<div class="container" on:click={downloadCsvFile}>
|
||||
<div class="inner">
|
||||
<Icon name="Download" />
|
||||
|
||||
<div style="margin-left: var(--spacing-m)">
|
||||
<Body size="XS">Passwords CSV</Body>
|
||||
<div style="margin-left: var(--spacing-m)">
|
||||
<Body size="XS">Passwords CSV</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
{schema}
|
||||
data={mappedData}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]}
|
||||
/>
|
||||
<Table
|
||||
schema={successSchema}
|
||||
data={successfulUsers}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[
|
||||
{ column: "password", component: PasswordCopyRenderer },
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
Table,
|
||||
Layout,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Search,
|
||||
notifications,
|
||||
Pagination,
|
||||
|
@ -23,6 +22,8 @@
|
|||
import { goto } from "@roxi/routify"
|
||||
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
|
||||
import PasswordModal from "./_components/PasswordModal.svelte"
|
||||
import InvitedModal from "./_components/InvitedModal.svelte"
|
||||
import DeletionFailureModal from "./_components/DeletionFailureModal.svelte"
|
||||
import ImportUsersModal from "./_components/ImportUsersModal.svelte"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { get } from "svelte/store"
|
||||
|
@ -33,7 +34,8 @@
|
|||
inviteConfirmationModal,
|
||||
onboardingTypeModal,
|
||||
passwordModal,
|
||||
importUsersModal
|
||||
importUsersModal,
|
||||
deletionFailureModal
|
||||
let pageInfo = createPaginationStore()
|
||||
let prevEmail = undefined,
|
||||
searchEmail = undefined
|
||||
|
@ -55,6 +57,9 @@
|
|||
apps: {},
|
||||
}
|
||||
$: userData = []
|
||||
$: createUsersResponse = { successful: [], unsuccessful: [] }
|
||||
$: deleteUsersResponse = { successful: [], unsuccessful: [] }
|
||||
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, searchEmail)
|
||||
$: {
|
||||
|
@ -92,8 +97,7 @@
|
|||
admin: user.role === Constants.BudibaseRoles.Admin,
|
||||
}))
|
||||
try {
|
||||
const res = await users.invite(payload)
|
||||
notifications.success(res.message)
|
||||
inviteUsersResponse = await users.invite(payload)
|
||||
inviteConfirmationModal.show()
|
||||
} catch (error) {
|
||||
notifications.error("Error inviting user")
|
||||
|
@ -116,8 +120,9 @@
|
|||
newUsers.push(user)
|
||||
}
|
||||
|
||||
if (!newUsers.length)
|
||||
if (!newUsers.length) {
|
||||
notifications.info("Duplicated! There is no new users to add.")
|
||||
}
|
||||
return { ...userData, users: newUsers }
|
||||
}
|
||||
|
||||
|
@ -139,12 +144,14 @@
|
|||
userData = await removingDuplicities({ groups, users })
|
||||
if (!userData.users.length) return
|
||||
|
||||
return createUser()
|
||||
return createUsers()
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
async function createUsers() {
|
||||
try {
|
||||
await users.create(await removingDuplicities(userData))
|
||||
createUsersResponse = await users.create(
|
||||
await removingDuplicities(userData)
|
||||
)
|
||||
notifications.success("Successfully created user")
|
||||
await groups.actions.init()
|
||||
passwordModal.show()
|
||||
|
@ -157,7 +164,7 @@
|
|||
if (onboardingType === "emailOnboarding") {
|
||||
createUserFlow()
|
||||
} else {
|
||||
await createUser()
|
||||
await createUsers()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -176,8 +183,15 @@
|
|||
notifications.error("You cannot delete yourself")
|
||||
return
|
||||
}
|
||||
await users.bulkDelete(ids)
|
||||
notifications.success(`Successfully deleted ${selectedRows.length} rows`)
|
||||
deleteUsersResponse = await users.bulkDelete(ids)
|
||||
if (deleteUsersResponse.unsuccessful?.length) {
|
||||
deletionFailureModal.show()
|
||||
} else {
|
||||
notifications.success(
|
||||
`Successfully deleted ${selectedRows.length} users`
|
||||
)
|
||||
}
|
||||
|
||||
selectedRows = []
|
||||
await fetchUsers(page, searchEmail)
|
||||
} catch (error) {
|
||||
|
@ -267,16 +281,7 @@
|
|||
</Modal>
|
||||
|
||||
<Modal bind:this={inviteConfirmationModal}>
|
||||
<ModalContent
|
||||
showCancelButton={false}
|
||||
title="Invites sent!"
|
||||
confirmText="Done"
|
||||
>
|
||||
<Body size="S"
|
||||
>Your users should now recieve an email invite to get access to their
|
||||
Budibase account</Body
|
||||
></ModalContent
|
||||
>
|
||||
<InvitedModal {inviteUsersResponse} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={onboardingTypeModal}>
|
||||
|
@ -284,7 +289,11 @@
|
|||
</Modal>
|
||||
|
||||
<Modal bind:this={passwordModal}>
|
||||
<PasswordModal userData={userData.users} />
|
||||
<PasswordModal {createUsersResponse} userData={userData.users} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={deletionFailureModal}>
|
||||
<DeletionFailureModal {deleteUsersResponse} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={importUsersModal}>
|
||||
|
|
|
@ -63,10 +63,14 @@ export function createUsersStore() {
|
|||
|
||||
return body
|
||||
})
|
||||
await API.createUsers({ users: mappedUsers, groups: data.groups })
|
||||
const response = await API.createUsers({
|
||||
users: mappedUsers,
|
||||
groups: data.groups,
|
||||
})
|
||||
|
||||
// re-search from first page
|
||||
await search()
|
||||
return response
|
||||
}
|
||||
|
||||
async function del(id) {
|
||||
|
@ -79,7 +83,7 @@ export function createUsersStore() {
|
|||
}
|
||||
|
||||
async function bulkDelete(userIds) {
|
||||
await API.deleteUsers(userIds)
|
||||
return API.deleteUsers(userIds)
|
||||
}
|
||||
|
||||
async function save(user) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/cli",
|
||||
"version": "1.2.58",
|
||||
"version": "1.2.59-alpha.0",
|
||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
|
@ -26,7 +26,7 @@
|
|||
"outputPath": "build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/backend-core": "1.1.32-alpha.6",
|
||||
"@budibase/backend-core": "1.2.58-alpha.7",
|
||||
"axios": "0.21.2",
|
||||
"chalk": "4.1.0",
|
||||
"cli-progress": "3.11.2",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue