Merge remote-tracking branch 'origin/master' into feature/multistep-form-block

This commit is contained in:
Dean 2023-11-30 11:17:44 +00:00
commit 1091ba0c86
97 changed files with 2424 additions and 866 deletions

View File

@ -99,11 +99,6 @@ jobs:
else else
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
fi fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
name: codecov-umbrella
verbose: true
test-worker: test-worker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -129,12 +124,6 @@ jobs:
yarn test --scope=@budibase/worker yarn test --scope=@budibase/worker
fi fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
test-server: test-server:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -159,12 +148,6 @@ jobs:
yarn test --scope=@budibase/server yarn test --scope=@budibase/server
fi fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
test-pro: test-pro:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'

View File

@ -7,8 +7,8 @@ metadata:
kubernetes.io/ingress.class: alb kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/success-codes: 200,301 alb.ingress.kubernetes.io/success-codes: '200'
alb.ingress.kubernetes.io/healthcheck-path: / alb.ingress.kubernetes.io/healthcheck-path: '/health'
{{- if .Values.ingress.certificateArn }} {{- if .Values.ingress.certificateArn }}
alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}' alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]' alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'

View File

@ -29,7 +29,6 @@ WORKDIR /opt/couchdb
ADD couch/vm.args couch/local.ini ./etc/ ADD couch/vm.args couch/local.ini ./etc/
WORKDIR / WORKDIR /
ADD build-target-paths.sh .
ADD runner.sh ./bbcouch-runner.sh ADD runner.sh ./bbcouch-runner.sh
RUN chmod +x ./bbcouch-runner.sh /opt/clouseau/bin/clouseau ./build-target-paths.sh RUN chmod +x ./bbcouch-runner.sh /opt/clouseau/bin/clouseau
CMD ["./bbcouch-runner.sh"] CMD ["./bbcouch-runner.sh"]

View File

@ -1,24 +0,0 @@
#!/bin/bash
echo ${TARGETBUILD} > /buildtarget.txt
if [[ "${TARGETBUILD}" = "aas" ]]; then
# Azure AppService uses /home for persistent data & SSH on port 2222
DATA_DIR="${DATA_DIR:-/home}"
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true
mkdir -p $DATA_DIR/{search,minio,couch}
mkdir -p $DATA_DIR/couch/{dbs,views}
chown -R couchdb:couchdb $DATA_DIR/couch/
apt update
apt-get install -y openssh-server
echo "root:Docker!" | chpasswd
mkdir -p /tmp
chmod +x /tmp/ssh_setup.sh \
&& (sleep 1;/tmp/ssh_setup.sh 2>&1 > /dev/null)
cp /etc/sshd_config /etc/ssh/sshd_config
/etc/init.d/ssh restart
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

View File

@ -1,14 +1,73 @@
#!/bin/bash #!/bin/bash
DATA_DIR=${DATA_DIR:-/data} DATA_DIR=${DATA_DIR:-/data}
mkdir -p ${DATA_DIR} mkdir -p ${DATA_DIR}
mkdir -p ${DATA_DIR}/couch/{dbs,views} mkdir -p ${DATA_DIR}/couch/{dbs,views}
mkdir -p ${DATA_DIR}/search mkdir -p ${DATA_DIR}/search
chown -R couchdb:couchdb ${DATA_DIR}/couch chown -R couchdb:couchdb ${DATA_DIR}/couch
/build-target-paths.sh
echo ${TARGETBUILD} > /buildtarget.txt
if [[ "${TARGETBUILD}" = "aas" ]]; then
# Azure AppService uses /home for persistent data & SSH on port 2222
DATA_DIR="${DATA_DIR:-/home}"
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true
mkdir -p $DATA_DIR/{search,minio,couch}
mkdir -p $DATA_DIR/couch/{dbs,views}
chown -R couchdb:couchdb $DATA_DIR/couch/
apt update
apt-get install -y openssh-server
echo "root:Docker!" | chpasswd
mkdir -p /tmp
chmod +x /tmp/ssh_setup.sh \
&& (sleep 1;/tmp/ssh_setup.sh 2>&1 > /dev/null)
cp /etc/sshd_config /etc/ssh/sshd_config
/etc/init.d/ssh restart
sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini
elif [[ "${TARGETBUILD}" = "single" ]]; then
# In the single image build, the Dockerfile specifies /data as a volume
# mount, so we use that for all persistent data.
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
elif [[ -n $KUBERNETES_SERVICE_HOST ]]; then
# In Kubernetes the directory /opt/couchdb/data has a persistent volume
# mount for storing database data.
sed -i "s#DATA_DIR#/opt/couchdb/data#g" /opt/clouseau/clouseau.ini
# We remove the database_dir and view_index_dir settings from the local.ini
# in Kubernetes because it will default to /opt/couchdb/data which is what
# our Helm chart was using prior to us switching to using our own CouchDB
# image.
sed -i "s#^database_dir.*\$##g" /opt/couchdb/etc/local.ini
sed -i "s#^view_index_dir.*\$##g" /opt/couchdb/etc/local.ini
# We remove the -name setting from the vm.args file in Kubernetes because
# it will default to the pod FQDN, which is what's required for clustering
# to work.
sed -i "s/^-name .*$//g" /opt/couchdb/etc/vm.args
else
# For all other builds, we use /data for persistent data.
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
fi
# Start Clouseau. Budibase won't function correctly without Clouseau running, it
# powers the search API endpoints which are used to do all sorts, including
# populating app grids.
/opt/clouseau/bin/clouseau > /dev/stdout 2>&1 & /opt/clouseau/bin/clouseau > /dev/stdout 2>&1 &
# Start CouchDB.
/docker-entrypoint.sh /opt/couchdb/bin/couchdb & /docker-entrypoint.sh /opt/couchdb/bin/couchdb &
sleep 10
# Wati for CouchDB to start up.
while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do
echo 'Waiting for CouchDB to start...';
sleep 5;
done
# CouchDB needs the `_users` and `_replicator` databases to exist before it will
# function correctly, so we create them here.
curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_users curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_users
curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_replicator curl -X PUT http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984/_replicator
sleep infinity sleep infinity

View File

@ -6,7 +6,7 @@ services:
app-service: app-service:
build: build:
context: .. context: ..
dockerfile: packages/server/Dockerfile.v2 dockerfile: packages/server/Dockerfile
args: args:
- BUDIBASE_VERSION=0.0.0+dev-docker - BUDIBASE_VERSION=0.0.0+dev-docker
container_name: build-bbapps container_name: build-bbapps
@ -36,7 +36,7 @@ services:
worker-service: worker-service:
build: build:
context: .. context: ..
dockerfile: packages/worker/Dockerfile.v2 dockerfile: packages/worker/Dockerfile
args: args:
- BUDIBASE_VERSION=0.0.0+dev-docker - BUDIBASE_VERSION=0.0.0+dev-docker
container_name: build-bbworker container_name: build-bbworker

View File

@ -1,44 +1,59 @@
FROM node:18-slim as build FROM node:18-slim as build
# install node-gyp dependencies # install node-gyp dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python3 RUN apt-get update && apt-get install -y --no-install-recommends g++ make python3 jq
# add pin script
WORKDIR /
ADD scripts/cleanup.sh ./
RUN chmod +x /cleanup.sh
# build server # copy and install dependencies
WORKDIR /app WORKDIR /app
ADD packages/server . COPY package.json .
COPY yarn.lock . COPY yarn.lock .
RUN yarn install --production=true --network-timeout 1000000 COPY lerna.json .
RUN /cleanup.sh COPY .yarnrc .
# build worker COPY packages/server/package.json packages/server/package.json
WORKDIR /worker COPY packages/worker/package.json packages/worker/package.json
ADD packages/worker . # string-templates does not get bundled during the esbuild process, so we want to use the local version
COPY yarn.lock . COPY packages/string-templates/package.json packages/string-templates/package.json
RUN yarn install --production=true --network-timeout 1000000
RUN /cleanup.sh
FROM budibase/couchdb
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json
RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
# We will never want to sync pro, but the script is still required
RUN echo '' > scripts/syncProPackage.js
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production
# copy the actual code
COPY packages/server/dist packages/server/dist
COPY packages/server/pm2.config.js packages/server/pm2.config.js
COPY packages/server/client packages/server/client
COPY packages/server/builder packages/server/builder
COPY packages/worker/dist packages/worker/dist
COPY packages/worker/pm2.config.js packages/worker/pm2.config.js
COPY packages/string-templates packages/string-templates
FROM budibase/couchdb as runner
ARG TARGETARCH ARG TARGETARCH
ENV TARGETARCH $TARGETARCH ENV TARGETARCH $TARGETARCH
ENV NODE_MAJOR 18
#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service) #TARGETBUILD can be set to single (for single docker image) or aas (for azure app service)
# e.g. docker build --build-arg TARGETBUILD=aas .... # e.g. docker build --build-arg TARGETBUILD=aas ....
ARG TARGETBUILD=single ARG TARGETBUILD=single
ENV TARGETBUILD $TARGETBUILD ENV TARGETBUILD $TARGETBUILD
COPY --from=build /app /app
COPY --from=build /worker /worker
# install base dependencies # install base dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server libaio1
# Install postgres client for pg_dump utils # Install postgres client for pg_dump utils
RUN apt install software-properties-common apt-transport-https gpg -y \ RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \ && curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \ && echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
&& apt update -y \ && apt update -y \
@ -47,14 +62,12 @@ RUN apt install software-properties-common apt-transport-https gpg -y \
# install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx # install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx
WORKDIR /nodejs WORKDIR /nodejs
RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh && \ COPY scripts/install-node.sh ./install.sh
bash /tmp/nodesource_setup.sh && \ RUN chmod +x install.sh && ./install.sh
apt-get install -y --no-install-recommends libaio1 nodejs && \
npm install --global yarn pm2
# setup nginx # setup nginx
ADD hosting/single/nginx/nginx.conf /etc/nginx COPY hosting/single/nginx/nginx.conf /etc/nginx
ADD hosting/single/nginx/nginx-default-site.conf /etc/nginx/sites-enabled/default COPY hosting/single/nginx/nginx-default-site.conf /etc/nginx/sites-enabled/default
RUN mkdir -p /var/log/nginx && \ RUN mkdir -p /var/log/nginx && \
touch /var/log/nginx/error.log && \ touch /var/log/nginx/error.log && \
touch /var/run/nginx.pid && \ touch /var/run/nginx.pid && \
@ -62,29 +75,39 @@ RUN mkdir -p /var/log/nginx && \
WORKDIR / WORKDIR /
RUN mkdir -p scripts/integrations/oracle RUN mkdir -p scripts/integrations/oracle
ADD packages/server/scripts/integrations/oracle scripts/integrations/oracle COPY packages/server/scripts/integrations/oracle scripts/integrations/oracle
RUN /bin/bash -e ./scripts/integrations/oracle/instantclient/linux/install.sh RUN /bin/bash -e ./scripts/integrations/oracle/instantclient/linux/install.sh
# setup minio # setup minio
WORKDIR /minio WORKDIR /minio
ADD scripts/install-minio.sh ./install.sh COPY scripts/install-minio.sh ./install.sh
RUN chmod +x install.sh && ./install.sh RUN chmod +x install.sh && ./install.sh
# setup runner file # setup runner file
WORKDIR / WORKDIR /
ADD hosting/single/runner.sh . COPY hosting/single/runner.sh .
RUN chmod +x ./runner.sh RUN chmod +x ./runner.sh
ADD hosting/single/healthcheck.sh . COPY hosting/single/healthcheck.sh .
RUN chmod +x ./healthcheck.sh RUN chmod +x ./healthcheck.sh
# Script below sets the path for storing data based on $DATA_DIR # Script below sets the path for storing data based on $DATA_DIR
# For Azure App Service install SSH & point data locations to /home # For Azure App Service install SSH & point data locations to /home
ADD hosting/single/ssh/sshd_config /etc/ COPY hosting/single/ssh/sshd_config /etc/
ADD hosting/single/ssh/ssh_setup.sh /tmp COPY hosting/single/ssh/ssh_setup.sh /tmp
RUN /build-target-paths.sh
# setup letsencrypt certificate
RUN apt-get install -y certbot python3-certbot-nginx
COPY hosting/letsencrypt /app/letsencrypt
RUN chmod +x /app/letsencrypt/certificate-request.sh /app/letsencrypt/certificate-renew.sh
COPY --from=build /app/node_modules /node_modules
COPY --from=build /app/package.json /package.json
COPY --from=build /app/packages/server /app
COPY --from=build /app/packages/worker /worker
COPY --from=build /app/packages/string-templates /string-templates
RUN cd /string-templates && yarn link && cd ../app && yarn link @budibase/string-templates && cd ../worker && yarn link @budibase/string-templates
# cleanup cache
RUN yarn cache clean -f
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
@ -92,20 +115,10 @@ EXPOSE 443
EXPOSE 2222 EXPOSE 2222
VOLUME /data VOLUME /data
# setup letsencrypt certificate ARG BUDIBASE_VERSION
RUN apt-get install -y certbot python3-certbot-nginx # Ensuring the version argument is sent
ADD hosting/letsencrypt /app/letsencrypt RUN test -n "$BUDIBASE_VERSION"
RUN chmod +x /app/letsencrypt/certificate-request.sh /app/letsencrypt/certificate-renew.sh ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
# Remove cached files
RUN rm -rf \
/root/.cache \
/root/.npm \
/root/.pip \
/usr/local/share/doc \
/usr/share/doc \
/usr/share/man \
/var/lib/apt/lists/* \
/tmp/*
HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh" HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh"

View File

@ -1,131 +0,0 @@
FROM node:18-slim as build
# install node-gyp dependencies
RUN apt-get update && apt-get install -y --no-install-recommends g++ make python3 jq
# copy and install dependencies
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY lerna.json .
COPY .yarnrc .
COPY packages/server/package.json packages/server/package.json
COPY packages/worker/package.json packages/worker/package.json
# string-templates does not get bundled during the esbuild process, so we want to use the local version
COPY packages/string-templates/package.json packages/string-templates/package.json
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json
RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
# We will never want to sync pro, but the script is still required
RUN echo '' > scripts/syncProPackage.js
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production
# copy the actual code
COPY packages/server/dist packages/server/dist
COPY packages/server/pm2.config.js packages/server/pm2.config.js
COPY packages/server/client packages/server/client
COPY packages/server/builder packages/server/builder
COPY packages/worker/dist packages/worker/dist
COPY packages/worker/pm2.config.js packages/worker/pm2.config.js
COPY packages/string-templates packages/string-templates
FROM budibase/couchdb as runner
ARG TARGETARCH
ENV TARGETARCH $TARGETARCH
ENV NODE_MAJOR 18
#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
ENV TARGETBUILD $TARGETBUILD
# install base dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server libaio1
# Install postgres client for pg_dump utils
RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
&& apt update -y \
&& apt install postgresql-client-15 -y \
&& apt remove software-properties-common apt-transport-https gpg -y
# install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx
WORKDIR /nodejs
COPY scripts/install-node.sh ./install.sh
RUN chmod +x install.sh && ./install.sh
# setup nginx
COPY hosting/single/nginx/nginx.conf /etc/nginx
COPY hosting/single/nginx/nginx-default-site.conf /etc/nginx/sites-enabled/default
RUN mkdir -p /var/log/nginx && \
touch /var/log/nginx/error.log && \
touch /var/run/nginx.pid && \
usermod -a -G tty www-data
WORKDIR /
RUN mkdir -p scripts/integrations/oracle
COPY packages/server/scripts/integrations/oracle scripts/integrations/oracle
RUN /bin/bash -e ./scripts/integrations/oracle/instantclient/linux/install.sh
# setup minio
WORKDIR /minio
COPY scripts/install-minio.sh ./install.sh
RUN chmod +x install.sh && ./install.sh
# setup runner file
WORKDIR /
COPY hosting/single/runner.sh .
RUN chmod +x ./runner.sh
COPY hosting/single/healthcheck.sh .
RUN chmod +x ./healthcheck.sh
# Script below sets the path for storing data based on $DATA_DIR
# For Azure App Service install SSH & point data locations to /home
COPY hosting/single/ssh/sshd_config /etc/
COPY hosting/single/ssh/ssh_setup.sh /tmp
RUN /build-target-paths.sh
# setup letsencrypt certificate
RUN apt-get install -y certbot python3-certbot-nginx
COPY hosting/letsencrypt /app/letsencrypt
RUN chmod +x /app/letsencrypt/certificate-request.sh /app/letsencrypt/certificate-renew.sh
COPY --from=build /app/node_modules /node_modules
COPY --from=build /app/package.json /package.json
COPY --from=build /app/packages/server /app
COPY --from=build /app/packages/worker /worker
COPY --from=build /app/packages/string-templates /string-templates
RUN cd /string-templates && yarn link && cd ../app && yarn link @budibase/string-templates && cd ../worker && yarn link @budibase/string-templates
EXPOSE 80
EXPOSE 443
# Expose port 2222 for SSH on Azure App Service build
EXPOSE 2222
VOLUME /data
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh"
# must set this just before running
ENV NODE_ENV=production
WORKDIR /
CMD ["./runner.sh"]

View File

@ -22,11 +22,11 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
# Azure App Service customisations # Azure App Service customisations
if [[ "${TARGETBUILD}" = "aas" ]]; then if [[ "${TARGETBUILD}" = "aas" ]]; then
DATA_DIR="${DATA_DIR:-/home}" export DATA_DIR="${DATA_DIR:-/home}"
WEBSITES_ENABLE_APP_SERVICE_STORAGE=true WEBSITES_ENABLE_APP_SERVICE_STORAGE=true
/etc/init.d/ssh start /etc/init.d/ssh start
else else
DATA_DIR=${DATA_DIR:-/data} export DATA_DIR=${DATA_DIR:-/data}
fi fi
mkdir -p ${DATA_DIR} mkdir -p ${DATA_DIR}
# Mount NFS or GCP Filestore if env vars exist for it # Mount NFS or GCP Filestore if env vars exist for it

View File

@ -1,5 +1,5 @@
{ {
"version": "2.13.15", "version": "2.13.23",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -0,0 +1 @@
* @Budibase/backend

View File

@ -260,12 +260,12 @@ export async function listAllObjects(bucketName: string, path: string) {
} }
/** /**
* Generate a presigned url with a default TTL of 1 hour * Generate a presigned url with a default TTL of 36 hours
*/ */
export function getPresignedUrl( export function getPresignedUrl(
bucketName: string, bucketName: string,
key: string, key: string,
durationSeconds: number = 3600 durationSeconds: number = 129600
) { ) {
const objectStore = ObjectStore(bucketName, { presigning: true }) const objectStore = ObjectStore(bucketName, { presigning: true })
const params = { const params = {

View File

@ -160,4 +160,5 @@ export function isPermissionLevelHigherThanRead(level: PermissionLevel) {
// utility as a lot of things need simply the builder permission // utility as a lot of things need simply the builder permission
export const BUILDER = PermissionType.BUILDER export const BUILDER = PermissionType.BUILDER
export const CREATOR = PermissionType.CREATOR
export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER

View File

@ -146,12 +146,12 @@ export class UserDB {
static async allUsers() { static async allUsers() {
const db = getGlobalDB() const db = getGlobalDB()
const response = await db.allDocs( const response = await db.allDocs<User>(
dbUtils.getGlobalUserParams(null, { dbUtils.getGlobalUserParams(null, {
include_docs: true, include_docs: true,
}) })
) )
return response.rows.map((row: any) => row.doc) return response.rows.map(row => row.doc!)
} }
static async countUsersByApp(appId: string) { static async countUsersByApp(appId: string) {
@ -209,13 +209,6 @@ export class UserDB {
throw new Error("_id or email is required") throw new Error("_id or email is required")
} }
if (
user.builder?.apps?.length &&
!(await UserDB.features.isAppBuildersEnabled())
) {
throw new Error("Unable to update app builders, please check license")
}
let dbUser: User | undefined let dbUser: User | undefined
if (_id) { if (_id) {
// try to get existing user from db // try to get existing user from db

View File

@ -25,6 +25,7 @@ import {
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import * as context from "../context" import * as context from "../context"
import { isCreator } from "./utils" import { isCreator } from "./utils"
import { UserDB } from "./db"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -336,3 +337,20 @@ export function cleanseUserObject(user: User | ContextUser, base?: User) {
} }
return user return user
} }
export async function addAppBuilder(user: User, appId: string) {
const prodAppId = getProdAppID(appId)
user.builder ??= {}
user.builder.creator = true
user.builder.apps ??= []
user.builder.apps.push(prodAppId)
await UserDB.save(user, { hashPassword: false })
}
export async function removeAppBuilder(user: User, appId: string) {
const prodAppId = getProdAppID(appId)
if (user.builder && user.builder.apps?.includes(prodAppId)) {
user.builder.apps = user.builder.apps.filter(id => id !== prodAppId)
}
await UserDB.save(user, { hashPassword: false })
}

View File

@ -2,7 +2,7 @@
import "@spectrum-css/buttongroup/dist/index-vars.css" import "@spectrum-css/buttongroup/dist/index-vars.css"
export let vertical = false export let vertical = false
export let gap = "" export let gap = "M"
$: gapStyle = $: gapStyle =
gap === "L" gap === "L"

View File

@ -12,11 +12,13 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let options = [] export let options = []
export let footer = null
export let isOptionEnabled = () => true export let isOptionEnabled = () => true
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => extractProperty(option, "subtitle") export let getOptionSubtitle = option => extractProperty(option, "subtitle")
export let getOptionColour = () => null export let getOptionColour = () => null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
@ -100,6 +102,7 @@
{error} {error}
{disabled} {disabled}
{options} {options}
{footer}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionSubtitle} {getOptionSubtitle}

View File

@ -17,7 +17,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => option?.subtitle
export let isOptionSelected = () => false export let isOptionSelected = () => false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -135,7 +135,7 @@
class="spectrum-Textfield-input spectrum-InputGroup-input" class="spectrum-Textfield-input spectrum-InputGroup-input"
/> />
</div> </div>
<div style="width: 30%"> <div style="width: 40%">
<button <button
{id} {id}
class="spectrum-Picker spectrum-Picker--sizeM override-borders" class="spectrum-Picker spectrum-Picker--sizeM override-borders"
@ -157,38 +157,43 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
{#if open}
<div
use:clickOutside={handleOutsideClick}
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
>
<ul class="spectrum-Menu" role="listbox">
{#each options as option, idx}
<li
class="spectrum-Menu-item"
class:is-selected={isOptionSelected(getOptionValue(option, idx))}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onPick(getOptionValue(option, idx))}
>
<span class="spectrum-Menu-itemLabel">
{getOptionLabel(option, idx)}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
</ul>
</div>
{/if}
</div> </div>
{#if open}
<div
use:clickOutside={handleOutsideClick}
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
>
<ul class="spectrum-Menu" role="listbox">
{#each options as option, idx}
<li
class="spectrum-Menu-item"
class:is-selected={isOptionSelected(getOptionValue(option, idx))}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onPick(getOptionValue(option, idx))}
>
<span class="spectrum-Menu-itemLabel">
{getOptionLabel(option, idx)}
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text">
{getOptionSubtitle(option, idx)}
</span>
{/if}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
</ul>
</div>
{/if}
</div> </div>
<style> <style>
@ -196,7 +201,6 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
.spectrum-InputGroup-input { .spectrum-InputGroup-input {
border-right-width: 1px; border-right-width: 1px;
} }
@ -206,7 +210,6 @@
.spectrum-Textfield-input { .spectrum-Textfield-input {
width: 0; width: 0;
} }
.override-borders { .override-borders {
border-top-left-radius: 0px; border-top-left-radius: 0px;
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
@ -215,5 +218,18 @@
max-height: 240px; max-height: 240px;
z-index: 999; z-index: 999;
top: 100%; top: 100%;
width: 100%;
}
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
.spectrum-Menu-checkmark {
align-self: center;
margin-top: 0;
} }
</style> </style>

View File

@ -224,13 +224,12 @@
</span> </span>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text"
>{getOptionSubtitle(option, idx)}</span
>
{/if}
{getOptionLabel(option, idx)} {getOptionLabel(option, idx)}
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text">
{getOptionSubtitle(option, idx)}
</span>
{/if}
</span> </span>
{#if option.tag} {#if option.tag}
<span class="option-tag"> <span class="option-tag">
@ -275,10 +274,9 @@
font-size: 12px; font-size: 12px;
line-height: 15px; line-height: 15px;
font-weight: 500; font-weight: 500;
top: 10px;
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
display: block; display: block;
margin-bottom: var(--spacing-s); margin-top: var(--spacing-s);
} }
.spectrum-Picker-label.auto-width { .spectrum-Picker-label.auto-width {

View File

@ -10,8 +10,9 @@
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let useOptionIconImage = false
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
@ -82,8 +83,9 @@
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon} {getOptionIcon}
{useOptionIconImage}
{getOptionColour} {getOptionColour}
{getOptionSubtitle}
{useOptionIconImage}
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{sort} {sort}

View File

@ -18,6 +18,7 @@
checked={value} checked={value}
{disabled} {disabled}
on:change={onChange} on:change={onChange}
on:click
{id} {id}
type="checkbox" type="checkbox"
class="spectrum-Switch-input" class="spectrum-Switch-input"

View File

@ -20,7 +20,7 @@
let focus = false let focus = false
const updateValue = newValue => { const updateValue = newValue => {
if (readonly) { if (readonly || disabled) {
return return
} }
if (type === "number") { if (type === "number") {
@ -31,14 +31,14 @@
} }
const onFocus = () => { const onFocus = () => {
if (readonly) { if (readonly || disabled) {
return return
} }
focus = true focus = true
} }
const onBlur = event => { const onBlur = event => {
if (readonly) { if (readonly || disabled) {
return return
} }
focus = false focus = false
@ -46,14 +46,14 @@
} }
const onInput = event => { const onInput = event => {
if (readonly || !updateOnChange) { if (readonly || !updateOnChange || disabled) {
return return
} }
updateValue(event.target.value) updateValue(event.target.value)
} }
const updateValueOnEnter = event => { const updateValueOnEnter = event => {
if (readonly) { if (readonly || disabled) {
return return
} }
if (event.key === "Enter") { if (event.key === "Enter") {
@ -69,6 +69,7 @@
} }
onMount(() => { onMount(() => {
if (disabled) return
focus = autofocus focus = autofocus
if (focus) field.focus() if (focus) field.focus()
}) })
@ -108,4 +109,16 @@
.spectrum-Textfield { .spectrum-Textfield {
width: 100%; width: 100%;
} }
input::placeholder {
color: var(--grey-7);
}
input:hover::placeholder {
color: var(--grey-7) !important;
}
input:focus::placeholder {
color: var(--grey-7) !important;
}
</style> </style>

View File

@ -43,6 +43,7 @@
{quiet} {quiet}
{autofocus} {autofocus}
{options} {options}
isOptionSelected={option => option === dropdownValue}
on:change={onChange} on:change={onChange}
on:pick={onPick} on:pick={onPick}
on:click on:click

View File

@ -13,9 +13,10 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => option?.subtitle
export let getOptionIcon = option => option?.icon export let getOptionIcon = option => option?.icon
export let useOptionIconImage = false
export let getOptionColour = option => option?.colour export let getOptionColour = option => option?.colour
export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -58,6 +59,7 @@
{getOptionValue} {getOptionValue}
{getOptionIcon} {getOptionIcon}
{getOptionColour} {getOptionColour}
{getOptionSubtitle}
{useOptionIconImage} {useOptionIconImage}
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}

View File

@ -19,5 +19,5 @@
</script> </script>
<Field {helpText} {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} /> <Switch {error} {disabled} {text} {value} on:change={onChange} on:click />
</Field> </Field>

View File

@ -1,4 +1,5 @@
import { store } from "./index" import { store } from "./index"
import { get } from "svelte/store"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { import {
decodeJSBinding, decodeJSBinding,
@ -238,6 +239,10 @@ export const makeComponentUnique = component => {
} }
export const getComponentText = component => { export const getComponentText = component => {
if (component == null) {
return ""
}
if (component?._instanceName) { if (component?._instanceName) {
return component._instanceName return component._instanceName
} }
@ -246,3 +251,16 @@ export const getComponentText = component => {
"component" "component"
return capitalise(type) return capitalise(type)
} }
export const getComponentName = component => {
if (component == null) {
return ""
}
const components = get(store)?.components || {}
const componentDefinition = components[component._component] || {}
const name =
componentDefinition.friendlyName || componentDefinition.name || ""
return name
}

View File

@ -29,6 +29,12 @@ const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g
const UpdateReferenceAction = {
ADD: "add",
DELETE: "delete",
MOVE: "move",
}
/** /**
* Gets all bindable data context fields and instance fields. * Gets all bindable data context fields and instance fields.
*/ */
@ -1226,3 +1232,81 @@ export const runtimeToReadableBinding = (
"readableBinding" "readableBinding"
) )
} }
/**
* Used to update binding references for automation or action steps
*
* @param obj - The object to be updated
* @param originalIndex - The original index of the step being moved. Not applicable to add/delete.
* @param modifiedIndex - The new index of the step being modified
* @param action - Used to determine if a step is being added, deleted or moved
* @param label - The binding text that describes the steps
*/
export const updateReferencesInObject = ({
obj,
modifiedIndex,
action,
label,
originalIndex,
}) => {
const stepIndexRegex = new RegExp(`{{\\s*${label}\\.(\\d+)\\.`, "g")
const updateActionStep = (str, index, replaceWith) =>
str.replace(`{{ ${label}.${index}.`, `{{ ${label}.${replaceWith}.`)
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = stepIndexRegex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
if (
action === UpdateReferenceAction.ADD &&
referencedStep >= modifiedIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep + 1
)
} else if (
action === UpdateReferenceAction.DELETE &&
referencedStep > modifiedIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep - 1
)
} else if (action === UpdateReferenceAction.MOVE) {
if (referencedStep === originalIndex) {
obj[key] = updateActionStep(obj[key], referencedStep, modifiedIndex)
} else if (
modifiedIndex <= referencedStep &&
modifiedIndex < originalIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep + 1
)
} else if (
modifiedIndex >= referencedStep &&
modifiedIndex > originalIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep - 1
)
}
}
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
updateReferencesInObject({
obj: obj[key],
modifiedIndex,
action,
label,
originalIndex,
})
}
}
}

View File

@ -4,7 +4,7 @@ import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users" import { getUserStore } from "./store/users"
import { getDeploymentStore } from "./store/deployments" import { getDeploymentStore } from "./store/deployments"
import { derived, writable, get } from "svelte/store" import { derived, get } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history" import { createHistoryStore } from "builderStore/store/history"
@ -146,5 +146,3 @@ export const userSelectedResourceMap = derived(userStore, $userStore => {
export const isOnlyUser = derived(userStore, $userStore => { export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2 return $userStore.length < 2
}) })
export const screensHeight = writable("210px")

View File

@ -4,6 +4,7 @@ import { cloneDeep } from "lodash/fp"
import { generate } from "shortid" import { generate } from "shortid"
import { selectedAutomation } from "builderStore" import { selectedAutomation } from "builderStore"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { updateReferencesInObject } from "builderStore/dataBinding"
const initialAutomationState = { const initialAutomationState = {
automations: [], automations: [],
@ -22,34 +23,14 @@ export const getAutomationStore = () => {
return store return store
} }
const updateReferencesInObject = (obj, modifiedIndex, action) => {
const regex = /{{\s*steps\.(\d+)\./g
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = regex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
if (action === "add" && referencedStep >= modifiedIndex) {
obj[key] = obj[key].replace(
`{{ steps.${referencedStep}.`,
`{{ steps.${referencedStep + 1}.`
)
} else if (action === "delete" && referencedStep > modifiedIndex) {
obj[key] = obj[key].replace(
`{{ steps.${referencedStep}.`,
`{{ steps.${referencedStep - 1}.`
)
}
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
updateReferencesInObject(obj[key], modifiedIndex, action)
}
}
}
const updateStepReferences = (steps, modifiedIndex, action) => { const updateStepReferences = (steps, modifiedIndex, action) => {
steps.forEach(step => { steps.forEach(step => {
updateReferencesInObject(step.inputs, modifiedIndex, action) updateReferencesInObject({
obj: step.inputs,
modifiedIndex,
action,
label: "steps",
})
}) })
} }

View File

@ -2,6 +2,7 @@ import { expect, describe, it, vi } from "vitest"
import { import {
runtimeToReadableBinding, runtimeToReadableBinding,
readableToRuntimeBinding, readableToRuntimeBinding,
updateReferencesInObject,
} from "../dataBinding" } from "../dataBinding"
vi.mock("@budibase/frontend-core") vi.mock("@budibase/frontend-core")
@ -84,3 +85,461 @@ describe("readableToRuntimeBinding", () => {
).toEqual(`Hello {{ [user].[firstName] }}! The count is {{ count }}.`) ).toEqual(`Hello {{ [user].[firstName] }}! The count is {{ count }}.`)
}) })
}) })
describe("updateReferencesInObject", () => {
it("should increment steps in sequence on 'add'", () => {
let obj = [
{
id: "a0",
parameters: {
text: "Alpha",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.1.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.1.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.3.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.4.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 0,
action: "add",
label: "actions",
})
expect(obj).toEqual([
{
id: "a0",
parameters: {
text: "Alpha",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.2.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.2.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.4.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.5.row }}",
},
},
])
})
it("should decrement steps in sequence on 'delete'", () => {
let obj = [
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.1.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.3.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.4.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "delete",
label: "actions",
})
expect(obj).toEqual([
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.1.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.2.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
])
})
it("should handle on 'move' to a lower index", () => {
let obj = [
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.0.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.0.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.2.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "move",
label: "actions",
originalIndex: 4,
})
expect(obj).toEqual([
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "b2",
parameters: {
text: "Banana {{ actions.0.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.4.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.0.row }}",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.3.row }}",
},
},
])
})
it("should handle on 'move' to a higher index", () => {
let obj = [
{
id: "b2",
parameters: {
text: "Banana {{ actions.0.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.0.row }}",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.2.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "move",
label: "actions",
originalIndex: 0,
})
expect(obj).toEqual([
{
id: "b2",
parameters: {
text: "Banana {{ actions.2.row }}",
},
},
{
id: "c3",
parameters: {
text: "Carrot {{ actions.2.row }}",
},
},
{
id: "a1",
parameters: {
text: "Apple",
},
},
{
id: "d4",
parameters: {
text: "Dog {{ actions.1.row }}",
},
},
{
id: "e5",
parameters: {
text: "Eagle {{ actions.3.row }}",
},
},
])
})
it("should handle on 'move' of action being referenced, dragged to a higher index", () => {
let obj = [
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.1.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
]
updateReferencesInObject({
obj,
modifiedIndex: 2,
action: "move",
label: "actions",
originalIndex: 1,
})
expect(obj).toEqual([
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.2.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
])
})
it("should handle on 'move' of action being referenced, dragged to a lower index", () => {
let obj = [
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.4.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
]
updateReferencesInObject({
obj,
modifiedIndex: 0,
action: "move",
label: "actions",
originalIndex: 4,
})
expect(obj).toEqual([
{
"##eventHandlerType": "Save Row",
parameters: {
tableId: "ta_bb_employee",
},
id: "aehg5cTmhR",
},
{
"##eventHandlerType": "Validate Form",
id: "cCD0Dwcnq",
},
{
"##eventHandlerType": "Close Screen Modal",
id: "3fbbIOfN0H",
},
{
"##eventHandlerType": "Close Side Panel",
id: "mzkpf86cxo",
},
{
"##eventHandlerType": "Navigate To",
id: "h0uDFeJa8A",
},
{
parameters: {
autoDismiss: true,
type: "success",
message: "{{ actions.0.row }}",
},
"##eventHandlerType": "Show Notification",
id: "JEI5lAyJZ",
},
])
})
})

View File

@ -64,7 +64,7 @@
</span> </span>
{:else if schema.type === "link"} {:else if schema.type === "link"}
<LinkedRowSelector <LinkedRowSelector
bind:linkedRows={value[field]} linkedRows={value[field]}
{schema} {schema}
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}

View File

@ -22,7 +22,7 @@
<Select <Select
on:change={onChange} on:change={onChange}
bind:value bind:value
options={filteredTables.filter(table => table._id !== TableNames.USERS)} options={filteredTables}
getOptionLabel={table => table.name} getOptionLabel={table => table.name}
getOptionValue={table => table._id} getOptionValue={table => table._id}
/> />

View File

@ -70,7 +70,12 @@
options={meta.constraints.inclusion} options={meta.constraints.inclusion}
/> />
{:else if type === "link"} {:else if type === "link"}
<LinkedRowSelector {error} bind:linkedRows={value} schema={meta} /> <LinkedRowSelector
{error}
linkedRows={value}
schema={meta}
on:change={e => (value = e.detail)}
/>
{:else if type === "longform"} {:else if type === "longform"}
{#if meta.useRichText} {#if meta.useRichText}
<RichTextField {error} {label} height="150px" bind:value /> <RichTextField {error} {label} height="150px" bind:value />

View File

@ -56,12 +56,12 @@
/> />
{:else} {:else}
<Multiselect <Multiselect
bind:value={linkedIds} value={linkedIds}
{label} {label}
options={rows} options={rows}
getOptionLabel={getPrettyName} getOptionLabel={getPrettyName}
getOptionValue={row => row._id} getOptionValue={row => row._id}
sort sort
on:change={() => dispatch("change", linkedIds)} on:change
/> />
{/if} {/if}

View File

@ -1,10 +1,11 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { AbsTooltip, Icon } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
export let icon export let icon
export let iconTooltip
export let withArrow = false export let withArrow = false
export let withActions = true export let withActions = true
export let indentLevel = 0 export let indentLevel = 0
@ -77,7 +78,11 @@
{style} {style}
{draggable} {draggable}
> >
<div class="nav-item-content" bind:this={contentRef}> <div
class="nav-item-content"
bind:this={contentRef}
class:right={rightAlignIcon}
>
{#if withArrow} {#if withArrow}
<div <div
class:opened class:opened
@ -98,7 +103,9 @@
</div> </div>
{:else if icon} {:else if icon}
<div class="icon" class:right={rightAlignIcon}> <div class="icon" class:right={rightAlignIcon}>
<Icon color={iconColor} size="S" name={icon} /> <AbsTooltip type="info" position="right" text={iconTooltip}>
<Icon color={iconColor} size="S" name={icon} />
</AbsTooltip>
</div> </div>
{/if} {/if}
<div class="text" title={showTooltip ? text : null}> <div class="text" title={showTooltip ? text : null}>
@ -166,6 +173,11 @@
width: max-content; width: max-content;
position: relative; position: relative;
padding-left: var(--spacing-l); padding-left: var(--spacing-l);
box-sizing: border-box;
}
.nav-item-content.right {
width: 100%;
} }
/* Needed to fully display the actions icon */ /* Needed to fully display the actions icon */
@ -264,6 +276,7 @@
} }
.right { .right {
margin-left: auto;
order: 10; order: 10;
} }
</style> </style>

View File

@ -20,73 +20,91 @@
export let allowedRoles = null export let allowedRoles = null
export let allowCreator = false export let allowCreator = false
export let fancySelect = false export let fancySelect = false
export let labelPrefix = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const RemoveID = "remove" const RemoveID = "remove"
$: enrichLabel = label => (labelPrefix ? `${labelPrefix} ${label}` : label)
$: options = getOptions( $: options = getOptions(
$roles, $roles,
allowPublic, allowPublic,
allowRemove, allowRemove,
allowedRoles, allowedRoles,
allowCreator allowCreator,
enrichLabel
) )
const getOptions = ( const getOptions = (
roles, roles,
allowPublic, allowPublic,
allowRemove, allowRemove,
allowedRoles, allowedRoles,
allowCreator allowCreator,
enrichLabel
) => { ) => {
// Use roles whitelist if specified
if (allowedRoles?.length) { if (allowedRoles?.length) {
const filteredRoles = roles.filter(role => let options = roles
allowedRoles.includes(role._id) .filter(role => allowedRoles.includes(role._id))
) .map(role => ({
return [ name: enrichLabel(role.name),
...filteredRoles, _id: role._id,
...(allowedRoles.includes(Constants.Roles.CREATOR) }))
? [{ _id: Constants.Roles.CREATOR, name: "Creator", enabled: false }] if (allowedRoles.includes(Constants.Roles.CREATOR)) {
: []), options.push({
]
}
let newRoles = [...roles]
if (allowCreator) {
newRoles = [
{
_id: Constants.Roles.CREATOR, _id: Constants.Roles.CREATOR,
name: "Creator", name: "Can edit",
tag: enabled: false,
!$licensing.perAppBuildersEnabled && })
capitalise(Constants.PlanType.BUSINESS), }
}, return options
...newRoles,
]
} }
// Allow all core roles
let options = roles.map(role => ({
name: enrichLabel(role.name),
_id: role._id,
}))
// Add creator if required
if (allowCreator) {
options.unshift({
_id: Constants.Roles.CREATOR,
name: "Can edit",
tag:
!$licensing.perAppBuildersEnabled &&
capitalise(Constants.PlanType.BUSINESS),
})
}
// Add remove option if required
if (allowRemove) { if (allowRemove) {
newRoles = [ options.push({
...newRoles, _id: RemoveID,
{ name: "Remove",
_id: RemoveID, })
name: "Remove",
},
]
} }
if (allowPublic) {
return newRoles // Remove public if not allowed
if (!allowPublic) {
options = options.filter(role => role._id !== Constants.Roles.PUBLIC)
} }
return newRoles.filter(role => role._id !== Constants.Roles.PUBLIC)
return options
} }
const getColor = role => { const getColor = role => {
if (allowRemove && role._id === RemoveID) { // Creator and remove options have no colors
if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) {
return null return null
} }
return RoleUtils.getRoleColour(role._id) return RoleUtils.getRoleColour(role._id)
} }
const getIcon = role => { const getIcon = role => {
if (allowRemove && role._id === RemoveID) { // Only remove option has an icon
if (role._id === RemoveID) {
return "Close" return "Close"
} }
return null return null

View File

@ -0,0 +1,119 @@
const getResizeActions = (
cssProperty,
mouseMoveEventProperty,
elementProperty,
initialValue,
setValue = () => {}
) => {
let element = null
const elementAction = node => {
element = node
if (initialValue != null) {
element.style[cssProperty] = `${initialValue}px`
}
return {
destroy() {
element = null
},
}
}
const dragHandleAction = node => {
let startProperty = null
let startPosition = null
const handleMouseMove = e => {
e.preventDefault() // Prevent highlighting while dragging
const change = e[mouseMoveEventProperty] - startPosition
element.style[cssProperty] = `${startProperty + change}px`
}
const handleMouseUp = e => {
e.preventDefault() // Prevent highlighting while dragging
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
element.style.removeProperty("transition") // remove temporary transition override
for (let item of document.getElementsByTagName("iframe")) {
item.style.removeProperty("pointer-events")
}
setValue(element[elementProperty])
}
const handleMouseDown = e => {
if (e.detail > 1) {
// e.detail is the number of rapid clicks, so e.detail = 2 is
// a double click. We want to prevent default behaviour in
// this case as it highlights nearby selectable elements, which
// then interferes with the resizing mousemove.
// Putting this on the double click handler doesn't seem to
// work, so it must go here.
e.preventDefault()
}
if (
e.target.hasAttribute("disabled") &&
e.target.getAttribute("disabled") !== "false"
) {
return
}
element.style.transition = `${cssProperty} 0ms` // temporarily override any height transitions
// iframes swallow mouseup events if your cursor ends up over it during a drag, so make them
// temporarily non-interactive
for (let item of document.getElementsByTagName("iframe")) {
item.style.pointerEvents = "none"
}
startProperty = element[elementProperty]
startPosition = e[mouseMoveEventProperty]
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
}
const handleDoubleClick = () => {
element.style.removeProperty(cssProperty)
}
node.addEventListener("mousedown", handleMouseDown)
node.addEventListener("dblclick", handleDoubleClick)
return {
destroy() {
node.removeEventListener("mousedown", handleMouseDown)
node.removeEventListener("dblclick", handleDoubleClick)
},
}
}
return [elementAction, dragHandleAction]
}
export const getVerticalResizeActions = (initialValue, setValue = () => {}) => {
return getResizeActions(
"height",
"pageY",
"clientHeight",
initialValue,
setValue
)
}
export const getHorizontalResizeActions = (
initialValue,
setValue = () => {}
) => {
return getResizeActions(
"width",
"pageX",
"clientWidth",
initialValue,
setValue
)
}

View File

@ -1,8 +1,9 @@
<script> <script>
import { Icon, Body } from "@budibase/bbui" import { AbsTooltip, Icon, Body } from "@budibase/bbui"
export let title export let title
export let icon export let icon
export let iconTooltip
export let showAddButton = false export let showAddButton = false
export let showBackButton = false export let showBackButton = false
export let showCloseButton = false export let showCloseButton = false
@ -36,7 +37,9 @@
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} /> <Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
{/if} {/if}
{#if icon} {#if icon}
<Icon name={icon} /> <AbsTooltip type="info" text={iconTooltip}>
<Icon name={icon} />
</AbsTooltip>
{/if} {/if}
<div class="title"> <div class="title">
{#if customTitleContent} {#if customTitleContent}
@ -68,6 +71,7 @@
<style> <style>
.panel { .panel {
min-width: 260px;
width: 260px; width: 260px;
flex: 0 0 260px; flex: 0 0 260px;
background: var(--background); background: var(--background);
@ -85,6 +89,7 @@
border-right: var(--border-light); border-right: var(--border-light);
} }
.panel.wide { .panel.wide {
min-width: 310px;
width: 310px; width: 310px;
flex: 0 0 310px; flex: 0 0 310px;
} }

View File

@ -20,7 +20,7 @@ import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelt
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte" import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte" import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte" import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
import BarButtonList from "./controls/BarButtonList.svelte" import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte" import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
@ -29,6 +29,7 @@ import FormStepConfiguration from "./controls/FormStepConfiguration.svelte"
const componentMap = { const componentMap = {
text: DrawerBindableInput, text: DrawerBindableInput,
plainText: Input,
select: Select, select: Select,
radio: RadioGroup, radio: RadioGroup,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,

View File

@ -15,6 +15,7 @@
getEventContextBindings, getEventContextBindings,
getActionBindings, getActionBindings,
makeStateBinding, makeStateBinding,
updateReferencesInObject,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -30,6 +31,7 @@
let actionQuery let actionQuery
let selectedAction = actions?.length ? actions[0] : null let selectedAction = actions?.length ? actions[0] : null
let originalActionIndex
const setUpdateActions = actions => { const setUpdateActions = actions => {
return actions return actions
@ -115,6 +117,14 @@
if (isSelected) { if (isSelected) {
selectedAction = actions?.length ? actions[0] : null selectedAction = actions?.length ? actions[0] : null
} }
// Update action binding references
updateReferencesInObject({
obj: actions,
modifiedIndex: index,
action: "delete",
label: "actions",
})
} }
const toggleActionList = () => { const toggleActionList = () => {
@ -146,9 +156,29 @@
function handleDndConsider(e) { function handleDndConsider(e) {
actions = e.detail.items actions = e.detail.items
// set the initial index of the action being dragged
if (e.detail.info.trigger === "draggedEntered") {
originalActionIndex = actions.findIndex(
action => action.id === e.detail.info.id
)
}
} }
function handleDndFinalize(e) { function handleDndFinalize(e) {
actions = e.detail.items actions = e.detail.items
// Update action binding references
updateReferencesInObject({
obj: actions,
modifiedIndex: actions.findIndex(
action => action.id === e.detail.info.id
),
action: "move",
label: "actions",
originalIndex: originalActionIndex,
})
originalActionIndex = -1
} }
const getAllBindings = (actionBindings, eventContextBindings, actions) => { const getAllBindings = (actionBindings, eventContextBindings, actions) => {
@ -289,7 +319,7 @@
</Layout> </Layout>
<Layout noPadding> <Layout noPadding>
{#if selectedActionComponent && !showAvailableActions} {#if selectedActionComponent && !showAvailableActions}
{#key selectedAction.id} {#key (selectedAction.id, originalActionIndex)}
<div class="selected-action-container"> <div class="selected-action-container">
<svelte:component <svelte:component
this={selectedActionComponent} this={selectedActionComponent}

View File

@ -55,7 +55,10 @@
size="S" size="S"
name="Close" name="Close"
hoverable hoverable
on:click={() => removeButton(item._id)} on:click={e => {
e.stopPropagation()
removeButton(item._id)
}}
/> />
</div> </div>
</div> </div>

View File

@ -1,10 +0,0 @@
<script>
import ColumnEditor from "./ColumnEditor.svelte"
</script>
<ColumnEditor
{...$$props}
on:change
allowCellEditing={false}
allowReorder={false}
/>

View File

@ -32,11 +32,14 @@
} }
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flipDurationMs = 150
let anchors = {} let anchors = {}
let draggableItems = [] let draggableItems = []
// Used for controlling cursor behaviour in order to limit drag behaviour
// to the drag handle
let inactive = true
const buildDraggable = items => { const buildDraggable = items => {
return items return items
.map(item => { .map(item => {
@ -64,6 +67,7 @@
} }
const handleFinalize = e => { const handleFinalize = e => {
inactive = true
updateRowOrder(e) updateRowOrder(e)
dispatch("change", serialiseUpdate()) dispatch("change", serialiseUpdate())
} }
@ -77,24 +81,36 @@
class="list-wrap" class="list-wrap"
use:dndzone={{ use:dndzone={{
items: draggableItems, items: draggableItems,
flipDurationMs,
dropTargetStyle: { outline: "none" }, dropTargetStyle: { outline: "none" },
dragDisabled: !draggable, dragDisabled: !draggable || inactive,
}} }}
on:finalize={handleFinalize} on:finalize={handleFinalize}
on:consider={updateRowOrder} on:consider={updateRowOrder}
> >
{#each draggableItems as draggable (draggable.id)} {#each draggableItems as draggableItem (draggableItem.id)}
<li <li
on:click={() => {
get(store).actions.select(draggableItem.id)
}}
on:mousedown={() => { on:mousedown={() => {
get(store).actions.select() get(store).actions.select()
}} }}
bind:this={anchors[draggable.id]} bind:this={anchors[draggableItem.id]}
class:highlighted={draggable.id === $store.selected} class:highlighted={draggableItem.id === $store.selected}
> >
<div class="left-content"> <div class="left-content">
{#if showHandle} {#if showHandle}
<div class="handle"> <div
class="handle"
aria-label="drag-handle"
style={!inactive ? "cursor:grabbing" : "cursor:grab"}
on:mousedown={() => {
inactive = false
}}
on:mouseup={() => {
inactive = true
}}
>
<DragHandle /> <DragHandle />
</div> </div>
{/if} {/if}
@ -102,8 +118,8 @@
<div class="right-content"> <div class="right-content">
<svelte:component <svelte:component
this={listType} this={listType}
anchor={anchors[draggable.item._id]} anchor={anchors[draggableItem.item._id]}
item={draggable.item} item={draggableItem.item}
{...listTypeProps} {...listTypeProps}
on:change={onItemChanged} on:change={onItemChanged}
/> />
@ -143,6 +159,7 @@
--spectrum-table-row-background-color-hover, --spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover) var(--spectrum-alias-highlight-hover)
); );
cursor: pointer;
} }
.list-wrap > li:first-child { .list-wrap > li:first-child {
border-top-left-radius: 4px; border-top-left-radius: 4px;
@ -165,6 +182,9 @@
display: flex; display: flex;
height: var(--spectrum-global-dimension-size-150); height: var(--spectrum-global-dimension-size-150);
} }
.handle:hover {
cursor: grab;
}
.handle :global(svg) { .handle :global(svg) {
fill: var(--spectrum-global-color-gray-500); fill: var(--spectrum-global-color-gray-500);
margin-right: var(--spacing-m); margin-right: var(--spacing-m);

View File

@ -156,7 +156,7 @@
<div class="field-configuration"> <div class="field-configuration">
<div class="toggle-all"> <div class="toggle-all">
<span /> <span>Fields</span>
<Toggle <Toggle
on:change={() => { on:change={() => {
let update = fieldList.map(field => ({ let update = fieldList.map(field => ({
@ -186,6 +186,9 @@
</div> </div>
<style> <style>
.field-configuration {
padding-top: 8px;
}
.field-configuration :global(.spectrum-ActionButton) { .field-configuration :global(.spectrum-ActionButton) {
width: 100%; width: 100%;
} }
@ -204,6 +207,5 @@
.toggle-all span { .toggle-all span {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
font-size: 12px; font-size: 12px;
margin-left: calc(var(--spacing-s) - 1px);
} }
</style> </style>

View File

@ -58,7 +58,15 @@
<div class="field-label">{readableText}</div> <div class="field-label">{readableText}</div>
</div> </div>
<div class="list-item-right"> <div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin /> <Toggle
on:change={onToggle(item)}
on:click={e => {
e.stopPropagation()
}}
text=""
value={item.active}
thin
/>
</div> </div>
</div> </div>

View File

@ -0,0 +1,91 @@
<script>
import EditComponentPopover from "../EditComponentPopover.svelte"
import { FieldTypeToComponentMap } from "../FieldConfiguration/utils"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { store } from "builderStore"
export let item
export let anchor
const dispatch = createEventDispatcher()
const onToggle = item => {
return e => {
item.active = e.detail
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
const getIcon = () => {
const component = `@budibase/standard-components/${
FieldTypeToComponentMap[item.columnType]
}`
return store.actions.components.getDefinition(component).icon
}
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditComponentPopover
{anchor}
componentInstance={item}
{parseSettings}
on:change
>
<div slot="header" class="type-icon">
<Icon name={getIcon()} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{item.label || item.field}</div>
</div>
<div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View File

@ -0,0 +1,107 @@
<script>
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import DraggableList from "../DraggableList/DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import FieldSetting from "./FieldSetting.svelte"
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte"
import getColumns from "./getColumns.js"
export let value
export let componentInstance
const dispatch = createEventDispatcher()
let primaryDisplayColumnAnchor
const handleChange = newValues => {
dispatch("change", newValues)
}
const getSchema = (asset, datasource) => {
const schema = getSchemaForDatasource(asset, datasource).schema
// Don't show ID and rev in tables
if (schema) {
delete schema._id
delete schema._rev
}
return schema
}
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: primaryDisplayColumnName = getSchemaForDatasource(
$currentAsset,
datasource
)?.table?.primaryDisplay
$: schema = getSchema(currentAsset, datasource)
$: columns = getColumns({
columns: value,
schema,
primaryDisplayColumnName,
onChange: handleChange,
createComponent: store.actions.components.createInstance,
})
</script>
{#if columns.primary}
<div class="sticky-item">
<div bind:this={primaryDisplayColumnAnchor} class="sticky-item-inner">
<div class="right-content">
<PrimaryColumnFieldSetting
anchor={primaryDisplayColumnAnchor}
item={columns.primary}
on:change={e => columns.update(e.detail)}
/>
</div>
</div>
</div>
{/if}
<DraggableList
on:change={e => columns.updateSortable(e.detail)}
on:itemChange={e => columns.update(e.detail)}
items={columns.sortable}
listItemKey={"_id"}
listType={FieldSetting}
/>
<style>
.right-content {
flex: 1;
min-width: 0;
margin-left: 17.5px;
}
.sticky-item {
list-style-type: none;
margin: 0;
padding: 0;
width: 100%;
border-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
margin-bottom: 10px;
}
.sticky-item-inner {
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
align-items: center;
border-bottom: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
box-sizing: border-box;
border-radius: 4px;
border-bottom: 0;
}
</style>

View File

@ -0,0 +1,100 @@
<script>
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui"
import { setContext } from "svelte"
import { writable } from "svelte/store"
import { FieldTypeToComponentMap } from "../FieldConfiguration/utils"
import { store } from "builderStore"
export let item
export let anchor
let draggableStore = writable({
selected: null,
actions: {
select: id => {
draggableStore.update(state => ({
...state,
selected: id,
}))
},
},
})
setContext("draggable", draggableStore)
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
const getIcon = item => {
const component = `@budibase/standard-components/${
FieldTypeToComponentMap[item.columnType]
}`
return store.actions.components.getDefinition(component)?.icon
}
$: icon = getIcon(item)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditComponentPopover
{anchor}
componentInstance={item}
{parseSettings}
on:change
>
<div slot="header" class="type-icon">
<Icon name={icon} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{item.label || item.field}</div>
</div>
<div title="The display column is always shown first" class="list-item-right">
<Icon name={"Info"} />
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(svg) {
color: var(--grey-5);
padding: 7px 5px 7px 0;
}
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View File

@ -0,0 +1,129 @@
const modernize = columns => {
if (!columns) {
return []
}
// If the first element has no active key then it's safe to assume all elements are in the old format
if (columns?.[0] && columns[0].active === undefined) {
return columns.map(column => ({
label: column.displayName,
field: column.name,
active: true,
}))
}
return columns
}
const removeInvalidAddMissing = (
columns = [],
defaultColumns,
primaryDisplayColumnName
) => {
const defaultColumnNames = defaultColumns.map(column => column.field)
const columnNames = columns.map(column => column.field)
const validColumns = columns.filter(column =>
defaultColumnNames.includes(column.field)
)
let missingColumns = defaultColumns.filter(
defaultColumn => !columnNames.includes(defaultColumn.field)
)
// If the user already has fields selected, any appended missing fields should be disabled by default
if (validColumns.length) {
missingColumns = missingColumns.map(field => ({ ...field, active: false }))
}
const combinedColumns = [...validColumns, ...missingColumns]
// Ensure the primary display column is always visible
const primaryDisplayIndex = combinedColumns.findIndex(
column => column.field === primaryDisplayColumnName
)
if (primaryDisplayIndex > -1) {
combinedColumns[primaryDisplayIndex].active = true
}
return combinedColumns
}
const getDefault = (schema = {}) => {
const defaultValues = Object.values(schema)
.filter(column => !column.nestedJSON)
.map(column => ({
label: column.name,
field: column.name,
active: column.visible ?? true,
order: column.visible ? column.order ?? -1 : Number.MAX_SAFE_INTEGER,
}))
defaultValues.sort((a, b) => a.order - b.order)
return defaultValues
}
const toGridFormat = draggableListColumns => {
return draggableListColumns.map(entry => ({
label: entry.label,
field: entry.field,
active: entry.active,
}))
}
const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
return gridFormatColumns.map(column => {
return createComponent(
"@budibase/standard-components/labelfield",
{
_instanceName: column.field,
active: column.active,
field: column.field,
label: column.label,
columnType: schema[column.field].type,
},
{}
)
})
}
const getColumns = ({
columns,
schema,
primaryDisplayColumnName,
onChange,
createComponent,
}) => {
const validatedColumns = removeInvalidAddMissing(
modernize(columns),
getDefault(schema),
primaryDisplayColumnName
)
const draggableList = toDraggableListFormat(
validatedColumns,
createComponent,
schema
)
const primary = draggableList.find(
entry => entry.field === primaryDisplayColumnName
)
const sortable = draggableList.filter(
entry => entry.field !== primaryDisplayColumnName
)
return {
primary,
sortable,
updateSortable: newDraggableList => {
onChange(toGridFormat(newDraggableList.concat(primary)))
},
update: newEntry => {
const newDraggableList = draggableList.map(entry => {
return newEntry.field === entry.field ? newEntry : entry
})
onChange(toGridFormat(newDraggableList))
},
}
}
export default getColumns

View File

@ -0,0 +1,374 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import getColumns from "./getColumns"
describe("getColumns", () => {
beforeEach(ctx => {
ctx.schema = {
one: { name: "one", visible: false, order: 0, type: "foo" },
two: { name: "two", visible: true, order: 1, type: "foo" },
three: { name: "three", visible: true, order: 2, type: "foo" },
four: { name: "four", visible: false, order: 3, type: "foo" },
five: {
name: "excluded",
visible: true,
order: 4,
type: "foo",
nestedJSON: true,
},
}
ctx.primaryDisplayColumnName = "four"
ctx.onChange = vi.fn()
ctx.createComponent = (componentName, props) => {
return { componentName, ...props }
}
})
describe("nested json fields", () => {
beforeEach(ctx => {
ctx.columns = getColumns({
columns: null,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("does not return nested json fields, as the grid cannot display them", ctx => {
expect(ctx.columns.sortable).not.toContainEqual({
name: "excluded",
visible: true,
order: 4,
type: "foo",
nestedJSON: true,
})
})
})
describe("using the old grid column format", () => {
beforeEach(ctx => {
const oldGridFormatColumns = [
{ displayName: "three label", name: "three" },
{ displayName: "two label", name: "two" },
]
ctx.columns = getColumns({
columns: oldGridFormatColumns,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three label",
},
{
_instanceName: "two",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two label",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
})
describe("default columns", () => {
beforeEach(ctx => {
ctx.columns = getColumns({
columns: undefined,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns all columns, with non-hidden columns automatically selected", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "two",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
it("Unselected columns should be placed at the end", ctx => {
expect(ctx.columns.sortable[2].field).toEqual("one")
})
})
describe("missing columns", () => {
beforeEach(ctx => {
const gridFormatColumns = [
{ label: "three label", field: "three", active: true },
]
ctx.columns = getColumns({
columns: gridFormatColumns,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns all columns, including those missing from the initial data", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three label",
},
{
_instanceName: "two",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
})
describe("invalid columns", () => {
beforeEach(ctx => {
const gridFormatColumns = [
{ label: "three label", field: "three", active: true },
{ label: "some nonsense", field: "some nonsense", active: true },
]
ctx.columns = getColumns({
columns: gridFormatColumns,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns all valid columns, excluding those that aren't valid for the schema", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three label",
},
{
_instanceName: "two",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
})
describe("methods", () => {
beforeEach(ctx => {
const { update, updateSortable } = getColumns({
columns: [],
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
ctx.update = update
ctx.updateSortable = updateSortable
})
describe("update", () => {
beforeEach(ctx => {
ctx.update({
field: "one",
label: "a new label",
active: true,
})
})
it("calls the callback with the updated columns", ctx => {
expect(ctx.onChange).toHaveBeenCalledTimes(1)
expect(ctx.onChange).toHaveBeenCalledWith([
{
field: "two",
label: "two",
active: true,
},
{
field: "three",
label: "three",
active: true,
},
{
field: "one",
label: "a new label",
active: true,
},
{
field: "four",
label: "four",
active: true,
},
])
})
})
describe("updateSortable", () => {
beforeEach(ctx => {
ctx.updateSortable([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three",
},
{
_instanceName: "one",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
{
_instanceName: "two",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
])
})
it("calls the callback with the updated columns", ctx => {
expect(ctx.onChange).toHaveBeenCalledTimes(1)
expect(ctx.onChange).toHaveBeenCalledWith([
{
field: "three",
label: "three",
active: true,
},
{
field: "one",
label: "one",
active: true,
},
{
field: "two",
label: "two",
active: false,
},
{
field: "four",
label: "four",
active: true,
},
])
})
})
})
})

View File

@ -364,7 +364,10 @@
const payload = [ const payload = [
{ {
email: newUserEmail, email: newUserEmail,
builder: { global: creationRoleType === Constants.BudibaseRoles.Admin }, builder: {
global: creationRoleType === Constants.BudibaseRoles.Admin,
creator: creationRoleType === Constants.BudibaseRoles.Creator,
},
admin: { global: creationRoleType === Constants.BudibaseRoles.Admin }, admin: { global: creationRoleType === Constants.BudibaseRoles.Admin },
}, },
] ]
@ -471,10 +474,6 @@
await users.removeAppBuilder(userId, prodAppId) await users.removeAppBuilder(userId, prodAppId)
} }
const addGroupAppBuilder = async groupId => {
await groups.actions.addGroupAppBuilder(groupId, prodAppId)
}
const removeGroupAppBuilder = async groupId => { const removeGroupAppBuilder = async groupId => {
await groups.actions.removeGroupAppBuilder(groupId, prodAppId) await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
} }
@ -495,14 +494,12 @@
} }
const getInviteRoleValue = invite => { const getInviteRoleValue = invite => {
if (invite.info?.admin?.global && invite.info?.builder?.global) { if (
return Constants.Roles.ADMIN (invite.info?.admin?.global && invite.info?.builder?.global) ||
} invite.info?.builder?.apps?.includes(prodAppId)
) {
if (invite.info?.builder?.apps?.includes(prodAppId)) {
return Constants.Roles.CREATOR return Constants.Roles.CREATOR
} }
return invite.info.apps?.[prodAppId] return invite.info.apps?.[prodAppId]
} }
@ -512,7 +509,7 @@
return `This user has been given ${role?.name} access from the ${user.group} group` return `This user has been given ${role?.name} access from the ${user.group} group`
} }
if (user.isAdminOrGlobalBuilder) { if (user.isAdminOrGlobalBuilder) {
return "This user's role grants admin access to all apps" return "Account admins can edit all apps"
} }
return null return null
} }
@ -523,6 +520,18 @@
} }
return user.role return user.role
} }
const checkAppAccess = e => {
// Ensure we don't get into an invalid combo of tenant role and app access
if (
e.detail === Constants.BudibaseRoles.AppUser &&
creationAccessType === Constants.Roles.CREATOR
) {
creationAccessType = Constants.Roles.BASIC
} else if (e.detail === Constants.BudibaseRoles.Admin) {
creationAccessType = Constants.Roles.CREATOR
}
}
</script> </script>
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
@ -650,8 +659,9 @@
autoWidth autoWidth
align="right" align="right"
allowedRoles={user.isAdminOrGlobalBuilder allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN] ? [Constants.Roles.CREATOR]
: null} : null}
labelPrefix="Can use as"
/> />
</div> </div>
</div> </div>
@ -695,19 +705,16 @@
allowRemove={group.role} allowRemove={group.role}
allowPublic={false} allowPublic={false}
quiet={true} quiet={true}
allowCreator={true} allowCreator={group.role === Constants.Roles.CREATOR}
on:change={e => { on:change={e => {
if (e.detail === Constants.Roles.CREATOR) { onUpdateGroup(group, e.detail)
addGroupAppBuilder(group._id)
} else {
onUpdateGroup(group, e.detail)
}
}} }}
on:remove={() => { on:remove={() => {
onUpdateGroup(group) onUpdateGroup(group)
}} }}
autoWidth autoWidth
align="right" align="right"
labelPrefix="Can use as"
/> />
</div> </div>
</div> </div>
@ -753,6 +760,7 @@
allowedRoles={user.isAdminOrGlobalBuilder allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.CREATOR] ? [Constants.Roles.CREATOR]
: null} : null}
labelPrefix="Can use as"
/> />
</div> </div>
</div> </div>
@ -781,7 +789,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<Divider /> <Divider noMargin />
<div class="body"> <div class="body">
<Layout gap="L" noPadding> <Layout gap="L" noPadding>
<div class="user-invite-form"> <div class="user-invite-form">
@ -804,31 +812,34 @@
<FancySelect <FancySelect
bind:value={creationRoleType} bind:value={creationRoleType}
options={sdk.users.isAdmin($auth.user) options={sdk.users.isAdmin($auth.user)
? Constants.BudibaseRoleOptionsNew ? Constants.BudibaseRoleOptions
: Constants.BudibaseRoleOptionsNew.filter( : Constants.BudibaseRoleOptions.filter(
option => option.value !== Constants.BudibaseRoles.Admin option => option.value !== Constants.BudibaseRoles.Admin
)} )}
label="Role" label="Role"
on:change={checkAppAccess}
/> />
{#if creationRoleType !== Constants.BudibaseRoles.Admin} <span class="role-wrap">
<RoleSelect <RoleSelect
placeholder={false} placeholder={false}
bind:value={creationAccessType} bind:value={creationAccessType}
allowPublic={false} allowPublic={false}
allowCreator={true} allowCreator={creationRoleType !==
Constants.BudibaseRoles.AppUser}
quiet={true} quiet={true}
autoWidth autoWidth
align="right" align="right"
fancySelect fancySelect
allowedRoles={creationRoleType === Constants.BudibaseRoles.Admin
? [Constants.Roles.CREATOR]
: null}
footer={getRoleFooter({
isAdminOrGlobalBuilder:
creationRoleType === Constants.BudibaseRoles.Admin,
})}
/> />
{/if} </span>
</FancyForm> </FancyForm>
{#if creationRoleType === Constants.BudibaseRoles.Admin}
<div class="admin-info">
<Icon name="Info" />
Admins will get full access to all apps and settings
</div>
{/if}
<span class="add-user"> <span class="add-user">
<Button <Button
newStyles newStyles
@ -847,6 +858,13 @@
</div> </div>
<style> <style>
.role-wrap :global(.fancy-field:not(:last-of-type)) {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.search :global(input) { .search :global(input) {
padding-left: 0px; padding-left: 0px;
} }
@ -862,16 +880,6 @@
display: grid; display: grid;
} }
.admin-info {
margin-top: var(--spacing-xl);
padding: var(--spacing-l) var(--spacing-l) var(--spacing-l) var(--spacing-l);
display: flex;
align-items: center;
gap: var(--spacing-xl);
height: 30px;
background-color: var(--background-alt);
}
.underlined { .underlined {
text-decoration: underline; text-decoration: underline;
cursor: pointer; cursor: pointer;
@ -889,7 +897,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-s); gap: var(--spacing-s);
width: 400px;
} }
.auth-entity-meta { .auth-entity-meta {
@ -918,7 +925,7 @@
.auth-entity, .auth-entity,
.auth-entity-header { .auth-entity-header {
display: grid; display: grid;
grid-template-columns: 1fr 110px; grid-template-columns: 1fr 180px;
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
@ -949,7 +956,7 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
position: absolute; position: absolute;
width: 400px; width: 440px;
right: 0; right: 0;
height: 100%; height: 100%;
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);

View File

@ -1,7 +1,7 @@
<script> <script>
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { store, selectedComponent, selectedScreen } from "builderStore" import { store, selectedComponent, selectedScreen } from "builderStore"
import { getComponentText } from "builderStore/componentUtils" import { getComponentName } from "builderStore/componentUtils"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
@ -43,17 +43,25 @@
$: id = $selectedComponent?._id $: id = $selectedComponent?._id
$: id, (section = tabs[0]) $: id, (section = tabs[0])
$: componentName = getComponentName(componentInstance)
</script> </script>
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id} {#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft wide> <Panel
{title}
icon={componentDefinition?.icon}
iconTooltip={componentName}
borderLeft
wide
>
<span class="panel-title-content" slot="panel-title-content"> <span class="panel-title-content" slot="panel-title-content">
<input <input
class="input" class="input"
value={title} value={title}
{title} {title}
placeholder={getComponentText(componentInstance)} placeholder={componentName}
on:keypress={e => { on:keypress={e => {
if (e.key.toLowerCase() === "enter") { if (e.key.toLowerCase() === "enter") {
e.target.blur() e.target.blur()

View File

@ -25,6 +25,7 @@
<style> <style>
.app-panel { .app-panel {
min-width: 410px;
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;

View File

@ -12,6 +12,7 @@
import { import {
findComponentPath, findComponentPath,
getComponentText, getComponentText,
getComponentName,
} from "builderStore/componentUtils" } from "builderStore/componentUtils"
import { get } from "svelte/store" import { get } from "svelte/store"
import { dndStore } from "./dndStore" import { dndStore } from "./dndStore"
@ -110,6 +111,7 @@
on:drop={onDrop} on:drop={onDrop}
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
iconTooltip={getComponentName(component)}
withArrow={componentHasChildren(component)} withArrow={componentHasChildren(component)}
indentLevel={level} indentLevel={level}
selected={$store.selectedComponentId === component._id} selected={$store.selectedComponentId === component._id}

View File

@ -1,21 +1,55 @@
<script> <script>
import ScreenList from "./ScreenList/index.svelte" import ScreenList from "./ScreenList/index.svelte"
import ComponentList from "./ComponentList/index.svelte" import ComponentList from "./ComponentList/index.svelte"
import { getHorizontalResizeActions } from "components/common/resizable"
const [resizable, resizableHandle] = getHorizontalResizeActions()
</script> </script>
<div class="panel"> <div class="panel" use:resizable>
<ScreenList /> <div class="content">
<ComponentList /> <ScreenList />
<ComponentList />
</div>
<div class="divider">
<div class="dividerClickExtender" role="separator" use:resizableHandle />
</div>
</div> </div>
<style> <style>
.panel { .panel {
display: flex;
min-width: 270px;
width: 310px; width: 310px;
height: 100%; height: 100%;
border-right: var(--border-light); }
.content {
overflow: hidden;
flex-grow: 1;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--background); background: var(--background);
position: relative; position: relative;
} }
.divider {
position: relative;
height: 100%;
width: 2px;
background: var(--spectrum-global-color-gray-200);
transition: background 130ms ease-out;
}
.divider:hover {
background: var(--spectrum-global-color-gray-300);
cursor: row-resize;
}
.dividerClickExtender {
position: absolute;
cursor: col-resize;
height: 100%;
width: 12px;
}
</style> </style>

View File

@ -1,108 +1,50 @@
<script> <script>
import { Layout } from "@budibase/bbui" import { Layout } from "@budibase/bbui"
import { import { store, sortedScreens, userSelectedResourceMap } from "builderStore"
store,
sortedScreens,
userSelectedResourceMap,
screensHeight,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import RoleIndicator from "./RoleIndicator.svelte" import RoleIndicator from "./RoleIndicator.svelte"
import DropdownMenu from "./DropdownMenu.svelte" import DropdownMenu from "./DropdownMenu.svelte"
import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { getVerticalResizeActions } from "components/common/resizable"
import NavHeader from "components/common/NavHeader.svelte" import NavHeader from "components/common/NavHeader.svelte"
let search = false const [resizable, resizableHandle] = getVerticalResizeActions()
let resizing = false
let searchValue = ""
let container let searching = false
let searchValue = ""
let screensContainer let screensContainer
let scrolling = false let scrolling = false
let previousHeight = null
let dragOffset
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue) $: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const handleOpenSearch = async () => {
$: search ? openSearch() : closeSearch()
const openSearch = async () => {
screensContainer.scroll({ top: 0, behavior: "smooth" }) screensContainer.scroll({ top: 0, behavior: "smooth" })
previousHeight = $screensHeight
$screensHeight = "calc(100% + 1px)"
} }
const closeSearch = async () => { $: {
if (previousHeight) { if (searching) {
// Restore previous height and wait for animation handleOpenSearch()
$screensHeight = previousHeight
previousHeight = null
await sleep(300)
} }
} }
const getFilteredScreens = (screens, search) => { const getFilteredScreens = (screens, searchValue) => {
return screens.filter(screen => { return screens.filter(screen => {
return !search || screen.routing.route.includes(search) return !searchValue || screen.routing.route.includes(searchValue)
}) })
} }
const handleScroll = e => { const handleScroll = e => {
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
const startResizing = e => {
// Reset the height store to match the true height
$screensHeight = `${container.getBoundingClientRect().height}px`
// Store an offset to easily compute new height when moving the mouse
dragOffset = parseInt($screensHeight) - e.clientY
// Add event listeners
resizing = true
document.addEventListener("mousemove", resize)
document.addEventListener("mouseup", stopResizing)
}
const resize = e => {
// Prevent negative heights as this screws with layout
const newHeight = Math.max(0, e.clientY + dragOffset)
if (newHeight == null || isNaN(newHeight)) {
return
}
$screensHeight = `${newHeight}px`
}
const stopResizing = () => {
resizing = false
document.removeEventListener("mousemove", resize)
}
onMount(() => {
// Ensure we aren't stuck at 100% height from leaving while searching
if ($screensHeight == null || isNaN(parseInt($screensHeight))) {
$screensHeight = "210px"
}
})
</script> </script>
<svelte:window /> <div class="screens" class:searching use:resizable>
<div
class="screens"
class:search
class:resizing
style={`height:${$screensHeight};`}
bind:this={container}
>
<div class="header" class:scrolling> <div class="header" class:scrolling>
<NavHeader <NavHeader
title="Screens" title="Screens"
placeholder="Search for screens" placeholder="Search for screens"
bind:value={searchValue} bind:value={searchValue}
bind:search bind:search={searching}
onAdd={() => $goto("../new")} onAdd={() => $goto("../new")}
/> />
</div> </div>
@ -110,6 +52,7 @@
{#if filteredScreens?.length} {#if filteredScreens?.length}
{#each filteredScreens as screen (screen._id)} {#each filteredScreens as screen (screen._id)}
<NavItem <NavItem
scrollable
icon={screen.routing.homeScreen ? "Home" : null} icon={screen.routing.homeScreen ? "Home" : null}
indentLevel={0} indentLevel={0}
selected={$store.selectedScreenId === screen._id} selected={$store.selectedScreenId === screen._id}
@ -135,9 +78,11 @@
</div> </div>
<div <div
role="separator"
disabled={searching}
class="divider" class="divider"
on:mousedown={startResizing} class:disabled={searching}
on:dblclick={() => screensHeight.set("210px")} use:resizableHandle
/> />
</div> </div>
@ -148,14 +93,12 @@
min-height: 147px; min-height: 147px;
max-height: calc(100% - 147px); max-height: calc(100% - 147px);
position: relative; position: relative;
transition: height 300ms ease-out; transition: height 300ms ease-out, max-height 300ms ease-out;
height: 210px;
} }
.screens.search { .screens.searching {
max-height: none; max-height: 100%;
} height: 100% !important;
.screens.resizing {
user-select: none;
cursor: row-resize;
} }
.header { .header {
@ -177,9 +120,6 @@
overflow: auto; overflow: auto;
flex-grow: 1; flex-grow: 1;
} }
.screens.resizing .content {
pointer-events: none;
}
.screens :global(.nav-item) { .screens :global(.nav-item) {
padding-right: 8px !important; padding-right: 8px !important;
@ -217,4 +157,10 @@
.divider:hover:after { .divider:hover:after {
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
} }
.divider.disabled {
cursor: auto;
}
.divider.disabled:after {
background: var(--spectrum-global-color-gray-200);
}
</style> </style>

View File

@ -40,6 +40,7 @@
} }
.content { .content {
width: 100vw;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;

View File

@ -4,8 +4,6 @@
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "builderStore" import { isOnlyUser } from "builderStore"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
let deleteModal let deleteModal
</script> </script>
@ -46,24 +44,22 @@
url={$url("./version")} url={$url("./version")}
active={$isActive("./version")} active={$isActive("./version")}
/> />
{#if sdk.users.isGlobalBuilder($auth.user)} <div class="delete-action">
<div class="delete-action"> <AbsTooltip
<AbsTooltip position={TooltipPosition.Bottom}
position={TooltipPosition.Bottom} text={$isOnlyUser
text={$isOnlyUser ? null
? null : "Unavailable - another user is editing this app"}
: "Unavailable - another user is editing this app"} >
> <SideNavItem
<SideNavItem text="Delete app"
text="Delete app" disabled={!$isOnlyUser}
disabled={!$isOnlyUser} on:click={() => {
on:click={() => { deleteModal.show()
deleteModal.show() }}
}} />
/> </AbsTooltip>
</AbsTooltip> </div>
</div>
{/if}
</SideNav> </SideNav>
<slot /> <slot />
</Content> </Content>

View File

@ -16,7 +16,7 @@
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: isOnboarding = !$apps.length && sdk.users.isGlobalBuilder($auth.user) $: isOnboarding = !$apps.length && sdk.users.hasBuilderPermissions($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {

View File

@ -14,7 +14,7 @@
import PortalSideBar from "./_components/PortalSideBar.svelte" import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = $apps.length != null let loaded = !!$apps?.length
onMount(async () => { onMount(async () => {
try { try {
@ -34,7 +34,7 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length && sdk.users.isGlobalBuilder($auth.user)) { if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {

View File

@ -1,5 +1,6 @@
<script> <script>
import { import {
banner,
Heading, Heading,
Layout, Layout,
Button, Button,
@ -10,6 +11,7 @@
Notification, Notification,
Body, Body,
Search, Search,
BANNER_TYPES,
} from "@budibase/bbui" } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
@ -198,6 +200,20 @@
if (usersLimitLockAction) { if (usersLimitLockAction) {
usersLimitLockAction() usersLimitLockAction()
} }
if (!$admin.isDev) {
await banner.show({
messages: [
{
message:
"We've updated our pricing - see our website to learn more.",
type: BANNER_TYPES.NEUTRAL,
extraButtonText: "Learn More",
extraButtonAction: () =>
window.open("https://budibase.com/pricing"),
},
],
})
}
} catch (error) { } catch (error) {
notifications.error("Error getting init info") notifications.error("Error getting init info")
} }
@ -237,7 +253,7 @@
{#if enrichedApps.length} {#if enrichedApps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.isGlobalBuilder($auth.user)} {#if $auth.user && sdk.users.canCreateApps($auth.user)}
<div class="buttons"> <div class="buttons">
<Button <Button
size="M" size="M"

View File

@ -52,7 +52,7 @@
goToApp() goToApp()
} catch (e) { } catch (e) {
loading = false loading = false
notifications.error("There was a problem creating your app") notifications.error(e.message || "There was a problem creating your app")
} }
} }
</script> </script>

View File

@ -55,6 +55,7 @@
}, },
role: { role: {
width: "1fr", width: "1fr",
displayName: "Access",
}, },
} }
const customGroupTableRenderers = [ const customGroupTableRenderers = [
@ -98,7 +99,7 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = sdk.users.isAdmin(user) ? "admin" : "appUser" $: globalRole = users.getUserRole(user)
const getAvailableApps = (appList, privileged, roles) => { const getAvailableApps = (appList, privileged, roles) => {
let availableApps = appList.slice() let availableApps = appList.slice()
@ -177,12 +178,21 @@
} }
async function updateUserRole({ detail }) { async function updateUserRole({ detail }) {
if (detail === "developer") { if (detail === Constants.BudibaseRoles.Developer) {
toggleFlags({ admin: { global: false }, builder: { global: true } }) toggleFlags({ admin: { global: false }, builder: { global: true } })
} else if (detail === "admin") { } else if (detail === Constants.BudibaseRoles.Admin) {
toggleFlags({ admin: { global: true }, builder: { global: true } }) toggleFlags({ admin: { global: true }, builder: { global: true } })
} else if (detail === "appUser") { } else if (detail === Constants.BudibaseRoles.AppUser) {
toggleFlags({ admin: { global: false }, builder: { global: false } }) toggleFlags({ admin: { global: false }, builder: { global: false } })
} else if (detail === Constants.BudibaseRoles.Creator) {
toggleFlags({
admin: { global: false },
builder: {
global: false,
creator: true,
apps: user?.builder?.apps || [],
},
})
} }
} }
@ -295,6 +305,7 @@
<div class="field"> <div class="field">
<Label size="L">Role</Label> <Label size="L">Role</Label>
<Select <Select
placeholder={null}
disabled={!sdk.users.isAdmin($auth.user)} disabled={!sdk.users.isAdmin($auth.user)}
value={globalRole} value={globalRole}
options={Constants.BudibaseRoleOptions} options={Constants.BudibaseRoleOptions}

View File

@ -29,7 +29,6 @@
}, },
] ]
$: hasError = userData.find(x => x.error != null) $: hasError = userData.find(x => x.error != null)
$: userCount = $licensing.userCount + userData.length $: userCount = $licensing.userCount + userData.length
$: reached = licensing.usersLimitReached(userCount) $: reached = licensing.usersLimitReached(userCount)
$: exceeded = licensing.usersLimitExceeded(userCount) $: exceeded = licensing.usersLimitExceeded(userCount)
@ -98,7 +97,7 @@
align-items: center; align-items: center;
flex-direction: row;" flex-direction: row;"
> >
<div style="width: 90%"> <div style="flex: 1 1 auto;">
<InputDropdown <InputDropdown
inputType="email" inputType="email"
bind:inputValue={input.email} bind:inputValue={input.email}

View File

@ -14,6 +14,10 @@
} }
</script> </script>
<StatusLight square color={RoleUtils.getRoleColour(value)}> {#if value === Constants.Roles.CREATOR}
{getRoleLabel(value)} Can edit
</StatusLight> {:else}
<StatusLight square color={RoleUtils.getRoleColour(value)}>
Can use as {getRoleLabel(value)}
</StatusLight>
{/if}

View File

@ -15,6 +15,7 @@
const BYTES_IN_MB = 1000000 const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5 const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
const MAX_USERS_UPLOAD_LIMIT = 1000 const MAX_USERS_UPLOAD_LIMIT = 1000
export let createUsersFromCsv export let createUsersFromCsv
let files = [] let files = []
@ -22,13 +23,16 @@
let userEmails = [] let userEmails = []
let userGroups = [] let userGroups = []
let usersRole = null let usersRole = null
$: invalidEmails = []
$: invalidEmails = []
$: userCount = $licensing.userCount + userEmails.length $: userCount = $licensing.userCount + userEmails.length
$: exceed = licensing.usersLimitExceeded(userCount) $: exceed = licensing.usersLimitExceeded(userCount)
$: importDisabled = $: importDisabled =
!userEmails.length || !validEmails(userEmails) || !usersRole || exceed !userEmails.length || !validEmails(userEmails) || !usersRole || exceed
$: roleOptions = Constants.BudibaseRoleOptions.map(option => ({
...option,
label: `${option.label} - ${option.subtitle}`,
}))
const validEmails = userEmails => { const validEmails = userEmails => {
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) { if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
@ -100,10 +104,7 @@
users. Upgrade your plan to add more users users. Upgrade your plan to add more users
</div> </div>
{/if} {/if}
<RadioGroup <RadioGroup bind:value={usersRole} options={roleOptions} />
bind:value={usersRole}
options={Constants.BuilderRoleDescriptions}
/>
{#if $licensing.groupsEnabled} {#if $licensing.groupsEnabled}
<Multiselect <Multiselect

View File

@ -4,17 +4,11 @@
export let row export let row
const TooltipMap = { $: role = Constants.BudibaseRoleOptions.find(
appUser: "Only has access to assigned apps",
developer: "Access to the app builder",
admin: "Full access",
}
$: role = Constants.BudibaseRoleOptionsOld.find(
x => x.value === users.getUserRole(row) x => x.value === users.getUserRole(row)
) )
$: value = role?.label || "Not available" $: value = role?.label || "Not available"
$: tooltip = TooltipMap[role?.value] || "" $: tooltip = role?.subtitle || ""
</script> </script>
<div on:click|stopPropagation title={tooltip}> <div on:click|stopPropagation title={tooltip}>

View File

@ -82,6 +82,7 @@
minWidth: "200px", minWidth: "200px",
}, },
role: { role: {
displayName: "Access",
sortable: false, sortable: false,
width: "1fr", width: "1fr",
}, },
@ -171,6 +172,7 @@
const payload = userData?.users?.map(user => ({ const payload = userData?.users?.map(user => ({
email: user.email, email: user.email,
builder: user.role === Constants.BudibaseRoles.Developer, builder: user.role === Constants.BudibaseRoles.Developer,
creator: user.role === Constants.BudibaseRoles.Creator,
admin: user.role === Constants.BudibaseRoles.Admin, admin: user.role === Constants.BudibaseRoles.Admin,
groups: userData.groups, groups: userData.groups,
})) }))
@ -189,18 +191,18 @@
for (const user of userData?.users ?? []) { for (const user of userData?.users ?? []) {
const { email } = user const { email } = user
if ( if (
newUsers.find(x => x.email === email) || newUsers.find(x => x.email === email) ||
currentUserEmails.includes(email) currentUserEmails.includes(email)
) ) {
continue continue
}
newUsers.push(user) newUsers.push(user)
} }
if (!newUsers.length) if (!newUsers.length) {
notifications.info("Duplicated! There is no new users to add.") notifications.info("Duplicated! There is no new users to add.")
}
return { ...userData, users: newUsers } return { ...userData, users: newUsers }
} }
@ -265,7 +267,6 @@
try { try {
await groups.actions.init() await groups.actions.init()
groupsLoaded = true groupsLoaded = true
pendingInvites = await users.getInvites() pendingInvites = await users.getInvites()
invitesLoaded = true invitesLoaded = true
} catch (error) { } catch (error) {

View File

@ -3,6 +3,7 @@ import { API } from "api"
import { update } from "lodash" import { update } from "lodash"
import { licensing } from "." import { licensing } from "."
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
export function createUsersStore() { export function createUsersStore() {
const { subscribe, set } = writable({}) const { subscribe, set } = writable({})
@ -77,6 +78,9 @@ export function createUsersStore() {
case "developer": case "developer":
body.builder = { global: true } body.builder = { global: true }
break break
case "creator":
body.builder = { creator: true, global: false }
break
case "admin": case "admin":
body.admin = { global: true } body.admin = { global: true }
body.builder = { global: true } body.builder = { global: true }
@ -120,12 +124,18 @@ export function createUsersStore() {
return await API.removeAppBuilder({ userId, appId }) return await API.removeAppBuilder({ userId, appId })
} }
const getUserRole = user => const getUserRole = user => {
sdk.users.isAdmin(user) if (sdk.users.isAdmin(user)) {
? "admin" return Constants.BudibaseRoles.Admin
: sdk.users.isBuilder(user) } else if (sdk.users.isBuilder(user)) {
? "developer" return Constants.BudibaseRoles.Developer
: "appUser" } else if (sdk.users.hasCreatorPermissions(user)) {
return Constants.BudibaseRoles.Creator
} else {
return Constants.BudibaseRoles.AppUser
}
}
const refreshUsage = const refreshUsage =
fn => fn =>
async (...args) => { async (...args) => {

View File

@ -2698,6 +2698,22 @@
} }
] ]
}, },
"labelfield": {
"name": "Text Field",
"icon": "Text",
"editable": true,
"size": {
"width": 400,
"height": 32
},
"settings": [
{
"type": "plainText",
"label": "Label",
"key": "label"
}
]
},
"stringfield": { "stringfield": {
"name": "Text Field", "name": "Text Field",
"icon": "Text", "icon": "Text",
@ -6071,18 +6087,6 @@
"options": ["Create", "Update", "View"], "options": ["Create", "Update", "View"],
"defaultValue": "Create" "defaultValue": "Create"
}, },
{
"type": "text",
"label": "Title",
"key": "title",
"nested": true
},
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"section": true, "section": true,
"dependsOn": { "dependsOn": {
@ -6090,7 +6094,7 @@
"value": "Create", "value": "Create",
"invert": true "invert": true
}, },
"name": "Row details", "name": "Row ID",
"info": "<a href='https://docs.budibase.com/docs/form-block' target='_blank'>How to pass a row ID using bindings</a>", "info": "<a href='https://docs.budibase.com/docs/form-block' target='_blank'>How to pass a row ID using bindings</a>",
"settings": [ "settings": [
{ {
@ -6110,8 +6114,20 @@
}, },
{ {
"section": true, "section": true,
"name": "Fields", "name": "Details",
"settings": [ "settings": [
{
"type": "text",
"label": "Title",
"key": "title",
"nested": true
},
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"type": "fieldConfiguration", "type": "fieldConfiguration",
"key": "fields", "key": "fields",
@ -6339,19 +6355,6 @@
"key": "table", "key": "table",
"required": true "required": true
}, },
{
"type": "columns/grid",
"label": "Columns",
"key": "columns",
"dependsOn": [
"table",
{
"setting": "table.type",
"value": "custom",
"invert": true
}
]
},
{ {
"type": "filter", "type": "filter",
"label": "Filtering", "label": "Filtering",
@ -6448,6 +6451,18 @@
"key": "stripeRows", "key": "stripeRows",
"defaultValue": false "defaultValue": false
}, },
{
"section": true,
"name": "Columns",
"settings": [
{
"type": "columns/grid",
"key": "columns",
"nested": true,
"resetOn": "table"
}
]
},
{ {
"section": true, "section": true,
"name": "Buttons", "name": "Buttons",

View File

@ -19,6 +19,22 @@
export let onRowClick = null export let onRowClick = null
export let buttons = null export let buttons = null
// parses columns to fix older formats
const getParsedColumns = columns => {
// If the first element has an active key all elements should be in the new format
if (columns?.length && columns[0]?.active !== undefined) {
return columns
}
return columns?.map(column => ({
label: column.displayName || column.name,
field: column.name,
active: true,
}))
}
$: parsedColumns = getParsedColumns(columns)
const context = getContext("context") const context = getContext("context")
const component = getContext("component") const component = getContext("component")
const { const {
@ -33,16 +49,17 @@
let grid let grid
$: columnWhitelist = columns?.map(col => col.name) $: columnWhitelist = parsedColumns
$: schemaOverrides = getSchemaOverrides(columns) ?.filter(col => col.active)
?.map(col => col.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons) $: enrichedButtons = enrichButtons(buttons)
const getSchemaOverrides = columns => { const getSchemaOverrides = columns => {
let overrides = {} let overrides = {}
columns?.forEach(column => { columns?.forEach(column => {
overrides[column.name] = { overrides[column.field] = {
displayName: column.displayName || column.name, displayName: column.label,
visible: true,
} }
}) })
return overrides return overrides

View File

@ -1,6 +1,6 @@
<script> <script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
@ -108,7 +108,7 @@
} }
} }
$: fetchRows(searchTerm, primaryDisplay, defaultValue) $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
const allRowsFetched = const allRowsFetched =
@ -124,10 +124,22 @@
query: { equal: { _id: defaultVal } }, query: { equal: { _id: defaultVal } },
}) })
} }
// Ensure we match all filters, rather than any
const baseFilter = (filter || []).filter(x => x.operator !== "allOr")
await fetch.update({ await fetch.update({
query: { string: { [primaryDisplay]: searchTerm } }, filter: [
...baseFilter,
{
// Use a big numeric prefix to avoid clashing with an existing filter
field: `999:${primaryDisplay}`,
operator: "string",
value: searchTerm,
},
],
}) })
} }
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
const flatten = values => { const flatten = values => {
if (!values) { if (!values) {

View File

@ -214,15 +214,23 @@ export const buildUserEndpoints = API => ({
inviteUsers: async users => { inviteUsers: async users => {
return await API.post({ return await API.post({
url: "/api/global/users/multi/invite", url: "/api/global/users/multi/invite",
body: users.map(user => ({ body: users.map(user => {
email: user.email, let builder = undefined
userInfo: { if (user.admin || user.builder) {
admin: user.admin ? { global: true } : undefined, builder = { global: true }
builder: user.admin || user.builder ? { global: true } : undefined, } else if (user.creator) {
userGroups: user.groups, builder = { creator: true }
roles: user.apps ? user.apps : undefined, }
}, return {
})), email: user.email,
userInfo: {
admin: user.admin ? { global: true } : undefined,
builder,
userGroups: user.groups,
roles: user.apps ? user.apps : undefined,
},
}
}),
}) })
}, },

View File

@ -55,11 +55,20 @@ export const deriveStores = context => {
// Apply whitelist if specified // Apply whitelist if specified
if ($columnWhitelist?.length) { if ($columnWhitelist?.length) {
Object.keys(enrichedSchema).forEach(key => { const sortedColumns = {}
if (!$columnWhitelist.includes(key)) {
delete enrichedSchema[key] $columnWhitelist.forEach((columnKey, idx) => {
const enrichedColumn = enrichedSchema[columnKey]
if (enrichedColumn) {
sortedColumns[columnKey] = {
...enrichedColumn,
order: idx,
visible: true,
}
} }
}) })
return sortedColumns
} }
return enrichedSchema return enrichedSchema

View File

@ -20,42 +20,31 @@ export const TableNames = {
export const BudibaseRoles = { export const BudibaseRoles = {
AppUser: "appUser", AppUser: "appUser",
Developer: "developer", Developer: "developer",
Creator: "creator",
Admin: "admin", Admin: "admin",
} }
export const BudibaseRoleOptionsOld = [ export const BudibaseRoleOptionsOld = [
{ label: "Developer", value: BudibaseRoles.Developer }, {
{ label: "Member", value: BudibaseRoles.AppUser }, label: "Developer",
{ label: "Admin", value: BudibaseRoles.Admin }, value: BudibaseRoles.Developer,
},
] ]
export const BudibaseRoleOptions = [ export const BudibaseRoleOptions = [
{ label: "Member", value: BudibaseRoles.AppUser },
{ label: "Admin", value: BudibaseRoles.Admin },
]
export const BudibaseRoleOptionsNew = [
{ {
label: "Admin", label: "Account admin",
value: "admin", value: BudibaseRoles.Admin,
subtitle: "Has full access to all apps and settings in your account", subtitle: "Has full access to all apps and settings in your account",
}, },
{ {
label: "Member", label: "Creator",
value: "appUser", value: BudibaseRoles.Creator,
subtitle: "Can only view apps they have access to", subtitle: "Can create and edit apps they have access to",
}, },
]
export const BuilderRoleDescriptions = [
{ {
label: "App user",
value: BudibaseRoles.AppUser, value: BudibaseRoles.AppUser,
icon: "User", subtitle: "Can only use published apps they have access to",
label: "App user - Only has access to published apps",
},
{
value: BudibaseRoles.Admin,
icon: "Draw",
label: "Admin - Full access",
}, },
] ]

@ -1 +1 @@
Subproject commit 5e3d59fc4060fd44b14b2599269c207753d4e5be Subproject commit 1037b032d49244678204704d1bca779a29e395eb

View File

@ -0,0 +1 @@
* @Budibase/backend

View File

@ -18,12 +18,12 @@ ENV TOP_LEVEL_PATH=/
# handle node-gyp # handle node-gyp
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 && apt-get install -y --no-install-recommends g++ make python3 jq
RUN yarn global add pm2 RUN yarn global add pm2
# Install client for oracle datasource # Install client for oracle datasource
RUN apt-get install unzip libaio1 RUN apt-get install unzip libaio1
COPY scripts/integrations/oracle/ scripts/integrations/oracle/ COPY packages/server/scripts/integrations/oracle/ scripts/integrations/oracle/
RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh
# Install postgres client for pg_dump utils # Install postgres client for pg_dump utils
@ -35,18 +35,42 @@ RUN apt update && apt upgrade -y \
&& apt install postgresql-client-15 -y \ && apt install postgresql-client-15 -y \
&& apt remove software-properties-common apt-transport-https curl gpg -y && apt remove software-properties-common apt-transport-https curl gpg -y
WORKDIR /
COPY package.json . COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
COPY dist/yarn.lock . RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN yarn install --production=true --network-timeout 1000000 \
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
WORKDIR /app
COPY packages/server/package.json .
COPY packages/server/dist/yarn.lock .
RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-templates
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \ && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp
COPY dist/ dist/ COPY packages/server/dist/ dist/
COPY docker_run.sh . COPY packages/server/docker_run.sh .
COPY builder/ builder/ COPY packages/server/builder/ builder/
COPY client/ client/ COPY packages/server/client/ client/
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
EXPOSE 4001 EXPOSE 4001

View File

@ -1,84 +0,0 @@
FROM node:18-slim
LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="scripts/watchtower-hooks/pre-check.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="scripts/watchtower-hooks/pre-update.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.post-update="scripts/watchtower-hooks/post-update.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.post-check="scripts/watchtower-hooks/post-check.sh"
WORKDIR /app
ENV PORT=4001
ENV COUCH_DB_URL=https://couchdb.budi.live:5984
ENV BUDIBASE_ENVIRONMENT=PRODUCTION
ENV SERVICE=app-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ENV TOP_LEVEL_PATH=/
# handle node-gyp
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 jq
RUN yarn global add pm2
# Install client for oracle datasource
RUN apt-get install unzip libaio1
COPY packages/server/scripts/integrations/oracle/ scripts/integrations/oracle/
RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh
# Install postgres client for pg_dump utils
RUN apt update && apt upgrade -y \
&& apt install software-properties-common apt-transport-https curl gpg -y \
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
&& apt update -y \
&& apt install postgresql-client-15 -y \
&& apt remove software-properties-common apt-transport-https curl gpg -y
WORKDIR /
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
WORKDIR /app
COPY packages/server/package.json .
COPY packages/server/dist/yarn.lock .
RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-templates
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \
# Remove unneeded data from file system to reduce image size
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp
COPY packages/server/dist/ dist/
COPY packages/server/docker_run.sh .
COPY packages/server/builder/ builder/
COPY packages/server/client/ client/
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
EXPOSE 4001
# have to add node environment production after install
# due to this causing yarn to stop installing dev dependencies
# which are actually needed to get this environment up and running
ENV NODE_ENV=production
ENV CLUSTER_MODE=${CLUSTER_MODE}
ENV TOP_LEVEL_PATH=/app
CMD ["./docker_run.sh"]

View File

@ -51,6 +51,7 @@ import {
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
import { sdk as sharedCoreSDK } from "@budibase/shared-core"
// utility function, need to do away with this // utility function, need to do away with this
async function getLayouts() { async function getLayouts() {
@ -394,6 +395,12 @@ async function appPostCreate(ctx: UserCtx, app: App) {
} }
} }
} }
// If the user is a creator, we need to give them access to the new app
if (sharedCoreSDK.users.hasCreatorPermissions(ctx.user)) {
const user = await users.UserDB.getUser(ctx.user._id!)
await users.addAppBuilder(user, app.appId)
}
} }
export async function create(ctx: UserCtx) { export async function create(ctx: UserCtx) {

View File

@ -16,7 +16,7 @@ router
) )
.post( .post(
"/api/applications", "/api/applications",
authorized(permissions.GLOBAL_BUILDER), authorized(permissions.CREATOR),
applicationValidator(), applicationValidator(),
controller.create controller.create
) )

View File

@ -5,7 +5,7 @@ import {
roles, roles,
users, users,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { PermissionLevel, PermissionType, Role, UserCtx } from "@budibase/types" import { PermissionLevel, PermissionType, UserCtx } from "@budibase/types"
import builderMiddleware from "./builder" import builderMiddleware from "./builder"
import { isWebhookEndpoint } from "./utils" import { isWebhookEndpoint } from "./utils"
import { paramResource } from "./resourceId" import { paramResource } from "./resourceId"
@ -31,13 +31,20 @@ const checkAuthorized = async (
) => { ) => {
const appId = context.getAppId() const appId = context.getAppId()
const isGlobalBuilderApi = permType === PermissionType.GLOBAL_BUILDER const isGlobalBuilderApi = permType === PermissionType.GLOBAL_BUILDER
const isCreatorApi = permType === PermissionType.CREATOR
const isBuilderApi = permType === PermissionType.BUILDER const isBuilderApi = permType === PermissionType.BUILDER
const globalBuilder = users.isGlobalBuilder(ctx.user) const isGlobalBuilder = users.isGlobalBuilder(ctx.user)
let isBuilder = appId const isCreator = users.isCreator(ctx.user)
const isBuilder = appId
? users.isBuilder(ctx.user, appId) ? users.isBuilder(ctx.user, appId)
: users.hasBuilderPermissions(ctx.user) : users.hasBuilderPermissions(ctx.user)
// check if this is a builder api and the user is not a builder
if ((isGlobalBuilderApi && !globalBuilder) || (isBuilderApi && !isBuilder)) { // check api permission type against user
if (
(isGlobalBuilderApi && !isGlobalBuilder) ||
(isCreatorApi && !isCreator) ||
(isBuilderApi && !isBuilder)
) {
return ctx.throw(403, "Not Authorized") return ctx.throw(403, "Not Authorized")
} }
@ -148,6 +155,7 @@ const authorized =
// to find API endpoints which are builder focused // to find API endpoints which are builder focused
if ( if (
permType === PermissionType.BUILDER || permType === PermissionType.BUILDER ||
permType === PermissionType.CREATOR ||
permType === PermissionType.GLOBAL_BUILDER permType === PermissionType.GLOBAL_BUILDER
) { ) {
await builderMiddleware(ctx) await builderMiddleware(ctx)

View File

@ -133,9 +133,14 @@ export async function exportRows(
let result = await search({ tableId, query: requestQuery, sort, sortOrder }) let result = await search({ tableId, query: requestQuery, sort, sortOrder })
let rows: Row[] = [] let rows: Row[] = []
let headers
if (!tableName) {
throw new HTTPError("Could not find table name.", 400)
}
const schema = datasource.entities[tableName].schema
// Filter data to only specified columns if required // Filter data to only specified columns if required
if (columns && columns.length) { if (columns && columns.length) {
for (let i = 0; i < result.rows.length; i++) { for (let i = 0; i < result.rows.length; i++) {
rows[i] = {} rows[i] = {}
@ -143,22 +148,17 @@ export async function exportRows(
rows[i][column] = result.rows[i][column] rows[i][column] = result.rows[i][column]
} }
} }
headers = columns
} else { } else {
rows = result.rows rows = result.rows
} }
if (!tableName) {
throw new HTTPError("Could not find table name.", 400)
}
const schema = datasource.entities[tableName].schema
let exportRows = cleanExportRows(rows, schema, format, columns) let exportRows = cleanExportRows(rows, schema, format, columns)
let headers = Object.keys(schema)
let content: string let content: string
switch (format) { switch (format) {
case exporters.Format.CSV: case exporters.Format.CSV:
content = exporters.csv(headers, exportRows) content = exporters.csv(headers ?? Object.keys(schema), exportRows)
break break
case exporters.Format.JSON: case exporters.Format.JSON:
content = exporters.json(exportRows) content = exporters.json(exportRows)

View File

@ -110,7 +110,7 @@ export async function exportRows(
let rows: Row[] = [] let rows: Row[] = []
let schema = table.schema let schema = table.schema
let headers
// Filter data to only specified columns if required // Filter data to only specified columns if required
if (columns && columns.length) { if (columns && columns.length) {
for (let i = 0; i < result.length; i++) { for (let i = 0; i < result.length; i++) {
@ -119,6 +119,7 @@ export async function exportRows(
rows[i][column] = result[i][column] rows[i][column] = result[i][column]
} }
} }
headers = columns
} else { } else {
rows = result rows = result
} }
@ -127,7 +128,7 @@ export async function exportRows(
if (format === Format.CSV) { if (format === Format.CSV) {
return { return {
fileName: "export.csv", fileName: "export.csv",
content: csv(Object.keys(rows[0]), exportRows), content: csv(headers ?? Object.keys(rows[0]), exportRows),
} }
} else if (format === Format.JSON) { } else if (format === Format.JSON) {
return { return {

View File

@ -18,7 +18,6 @@ jest.mock("../../../utilities/rowProcessor", () => ({
jest.mock("../../../api/controllers/view/exporters", () => ({ jest.mock("../../../api/controllers/view/exporters", () => ({
...jest.requireActual("../../../api/controllers/view/exporters"), ...jest.requireActual("../../../api/controllers/view/exporters"),
csv: jest.fn(),
Format: { Format: {
CSV: "csv", CSV: "csv",
}, },
@ -102,5 +101,32 @@ describe("external row sdk", () => {
new HTTPError("Could not find table name.", 400) new HTTPError("Could not find table name.", 400)
) )
}) })
it("should only export specified columns", async () => {
mockDatasourcesGet.mockImplementation(async () => ({
entities: {
tablename: {
schema: {
name: {},
age: {},
dob: {},
},
},
},
}))
const headers = ["name", "dob"]
const result = await exportRows({
tableId: "datasource__tablename",
format: Format.CSV,
query: {},
columns: headers,
})
expect(result).toEqual({
fileName: "export.csv",
content: `"name","dob"`,
})
})
}) })
}) })

View File

@ -17,7 +17,8 @@
"@budibase/backend-core": ["../backend-core/src"], "@budibase/backend-core": ["../backend-core/src"],
"@budibase/backend-core/*": ["../backend-core/*"], "@budibase/backend-core/*": ["../backend-core/*"],
"@budibase/shared-core": ["../shared-core/src"], "@budibase/shared-core": ["../shared-core/src"],
"@budibase/pro": ["../pro/src"] "@budibase/pro": ["../pro/src"],
"@budibase/string-templates": ["../string-templates/src"]
} }
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@ -315,7 +315,7 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
new Date(docValue).getTime() > new Date(testValue.high).getTime() new Date(docValue).getTime() > new Date(testValue.high).getTime()
) )
} }
throw "Cannot perform range filter - invalid type." return false
} }
) )

View File

@ -25,6 +25,10 @@ export function isGlobalBuilder(user: User | ContextUser): boolean {
return (isBuilder(user) && !hasAppBuilderPermissions(user)) || isAdmin(user) return (isBuilder(user) && !hasAppBuilderPermissions(user)) || isAdmin(user)
} }
export function canCreateApps(user: User | ContextUser): boolean {
return isGlobalBuilder(user) || hasCreatorPermissions(user)
}
// alias for hasAdminPermission, currently do the same thing // alias for hasAdminPermission, currently do the same thing
// in future whether someone has admin permissions and whether they are // in future whether someone has admin permissions and whether they are
// an admin for a specific resource could be separated // an admin for a specific resource could be separated
@ -66,7 +70,7 @@ export function hasAppCreatorPermissions(user?: User | ContextUser): boolean {
return _.flow( return _.flow(
_.get("roles"), _.get("roles"),
_.values, _.values,
_.find(x => ["CREATOR", "ADMIN"].includes(x)), _.find(x => x === "CREATOR"),
x => !!x x => !!x
)(user) )(user)
} }
@ -76,7 +80,11 @@ export function hasBuilderPermissions(user?: User | ContextUser): boolean {
if (!user) { if (!user) {
return false return false
} }
return user.builder?.global || hasAppBuilderPermissions(user) return (
user.builder?.global ||
hasAppBuilderPermissions(user) ||
hasCreatorPermissions(user)
)
} }
// checks if a user is capable of being an admin // checks if a user is capable of being an admin
@ -87,13 +95,21 @@ export function hasAdminPermissions(user?: User | ContextUser): boolean {
return !!user.admin?.global return !!user.admin?.global
} }
export function hasCreatorPermissions(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return !!user.builder?.creator
}
export function isCreator(user?: User | ContextUser): boolean { export function isCreator(user?: User | ContextUser): boolean {
if (!user) { if (!user) {
return false return false
} }
return ( return (
isGlobalBuilder(user) || isGlobalBuilder(user!) ||
hasAdminPermissions(user) || hasAdminPermissions(user) ||
hasCreatorPermissions(user) ||
hasAppBuilderPermissions(user) || hasAppBuilderPermissions(user) ||
hasAppCreatorPermissions(user) hasAppCreatorPermissions(user)
) )

View File

@ -130,32 +130,28 @@ describe("runLuceneQuery", () => {
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2]) expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
}) })
it("should throw an error is an invalid doc value is passed into a range filter", async () => { it("should return return all docs if an invalid doc value is passed into a range filter", async () => {
const docs = [
{
order_id: 4,
customer_id: 1758,
order_status: 5,
order_date: "{{ Binding.INVALID }}",
required_date: "2017-03-05T00:00:00.000Z",
shipped_date: "2017-03-03T00:00:00.000Z",
store_id: 2,
staff_id: 7,
description: undefined,
label: "",
},
]
const query = buildQuery("range", { const query = buildQuery("range", {
order_date: { order_date: {
low: "2016-01-04T00:00:00.000Z", low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z", high: "2016-01-11T00:00:00.000Z",
}, },
}) })
expect(() => expect(runLuceneQuery(docs, query)).toEqual(docs)
runLuceneQuery(
[
{
order_id: 4,
customer_id: 1758,
order_status: 5,
order_date: "INVALID",
required_date: "2017-03-05T00:00:00.000Z",
shipped_date: "2017-03-03T00:00:00.000Z",
store_id: 2,
staff_id: 7,
description: undefined,
label: "",
},
],
query
)
).toThrowError("Cannot perform range filter - invalid type.")
}) })
it("should return rows with matches on empty filter", () => { it("should return rows with matches on empty filter", () => {

View File

@ -44,6 +44,7 @@ export interface User extends Document {
builder?: { builder?: {
global?: boolean global?: boolean
apps?: string[] apps?: string[]
creator?: boolean
} }
admin?: { admin?: {
global: boolean global: boolean

View File

@ -13,6 +13,7 @@ export enum PermissionType {
AUTOMATION = "automation", AUTOMATION = "automation",
WEBHOOK = "webhook", WEBHOOK = "webhook",
BUILDER = "builder", BUILDER = "builder",
CREATOR = "creator",
GLOBAL_BUILDER = "globalBuilder", GLOBAL_BUILDER = "globalBuilder",
QUERY = "query", QUERY = "query",
VIEW = "view", VIEW = "view",

View File

@ -0,0 +1 @@
* @Budibase/backend

View File

@ -5,22 +5,38 @@ LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="scripts/watchtower-ho
LABEL com.centurylinklabs.watchtower.lifecycle.post-update="scripts/watchtower-hooks/post-update.sh" LABEL com.centurylinklabs.watchtower.lifecycle.post-update="scripts/watchtower-hooks/post-update.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.post-check="scripts/watchtower-hooks/post-check.sh" LABEL com.centurylinklabs.watchtower.lifecycle.post-check="scripts/watchtower-hooks/post-check.sh"
WORKDIR /app
# handle node-gyp # handle node-gyp
RUN apk add --no-cache --virtual .gyp python3 make g++ RUN apk add --no-cache --virtual .gyp python3 make g++ jq
RUN yarn global add pm2 RUN yarn global add pm2
WORKDIR /
COPY package.json . COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
COPY dist/yarn.lock . RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN yarn install --production=true --network-timeout 1000000
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
WORKDIR /app
COPY packages/worker/package.json .
COPY packages/worker/dist/yarn.lock .
RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-templates
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
RUN apk del .gyp \ RUN apk del .gyp \
&& yarn cache clean && yarn cache clean
COPY dist/ dist/ COPY packages/worker/dist/ dist/
COPY docker_run.sh . COPY packages/worker/docker_run.sh .
EXPOSE 4001 EXPOSE 4001
@ -34,4 +50,9 @@ ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
CMD ["./docker_run.sh"] CMD ["./docker_run.sh"]

View File

@ -1,58 +0,0 @@
FROM node:18-alpine
LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="scripts/watchtower-hooks/pre-check.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="scripts/watchtower-hooks/pre-update.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.post-update="scripts/watchtower-hooks/post-update.sh"
LABEL com.centurylinklabs.watchtower.lifecycle.post-check="scripts/watchtower-hooks/post-check.sh"
# handle node-gyp
RUN apk add --no-cache --virtual .gyp python3 make g++ jq
RUN yarn global add pm2
WORKDIR /
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
WORKDIR /app
COPY packages/worker/package.json .
COPY packages/worker/dist/yarn.lock .
RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-templates
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
# Remove unneeded data from file system to reduce image size
RUN apk del .gyp \
&& yarn cache clean
COPY packages/worker/dist/ dist/
COPY packages/worker/docker_run.sh .
EXPOSE 4001
# have to add node environment production after install
# due to this causing yarn to stop installing dev dependencies
# which are actually needed to get this environment up and running
ENV NODE_ENV=production
ENV CLUSTER_MODE=${CLUSTER_MODE}
ENV SERVICE=worker-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
CMD ["./docker_run.sh"]

View File

@ -51,10 +51,22 @@ export async function removeAppRole(ctx: Ctx) {
const users = await sdk.users.db.allUsers() const users = await sdk.users.db.allUsers()
const bulk = [] const bulk = []
const cacheInvalidations = [] const cacheInvalidations = []
const prodAppId = dbCore.getProdAppID(appId)
for (let user of users) { for (let user of users) {
if (user.roles[appId]) { let updated = false
cacheInvalidations.push(cache.user.invalidateUser(user._id)) if (user.roles[prodAppId]) {
delete user.roles[appId] cacheInvalidations.push(cache.user.invalidateUser(user._id!))
delete user.roles[prodAppId]
updated = true
}
if (user.builder && Array.isArray(user.builder?.apps)) {
const idx = user.builder.apps.indexOf(prodAppId)
if (idx !== -1) {
user.builder.apps.splice(idx, 1)
updated = true
}
}
if (updated) {
bulk.push(user) bulk.push(user)
} }
} }

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
yarn build --scope @budibase/server --scope @budibase/worker yarn build --scope @budibase/server --scope @budibase/worker
version=$(./scripts/getCurrentVersion.sh) version=$(./scripts/getCurrentVersion.sh)
docker build -f hosting/single/Dockerfile.v2 -t budibase:latest --build-arg BUDIBASE_VERSION=$version . docker build -f hosting/single/Dockerfile -t budibase:latest --build-arg BUDIBASE_VERSION=$version .