Merge branch 'master' into BUDI-7580/account_portal_submodule

This commit is contained in:
Adria Navarro 2023-10-27 10:57:07 +02:00
commit 6abb1b5f70
224 changed files with 4290 additions and 2142 deletions

View File

@ -18,8 +18,7 @@ env:
BASE_BRANCH: ${{ github.event.pull_request.base.ref}}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
NX_BASE_BRANCH: origin/${{ github.base_ref }}
USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' && github.base_ref != 'master'}}
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
@ -231,7 +230,7 @@ jobs:
cache: "yarn"
- run: yarn --frozen-lockfile
- name: Build packages
run: yarn build --scope @budibase/server --scope @budibase/worker --scope @budibase/client --scope @budibase/backend-core
run: yarn build --scope @budibase/server --scope @budibase/worker
- name: Run tests
run: |
cd qa-core

View File

@ -4,6 +4,8 @@ on:
types: [created]
pull_request_target:
types: [opened,closed,synchronize]
branches:
- master
jobs:
CLAssistant:
@ -33,4 +35,4 @@ jobs:
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA
#use-dco-flag: true - If you are using DCO instead of CLA

20
.github/workflows/deploy-qa.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Deploy QA
on:
push:
branches:
- master
workflow_dispatch:
jobs:
trigger-deploy-to-qa-env:
runs-on: ubuntu-latest
steps:
- uses: peter-evans/repository-dispatch@v2
env:
PAYLOAD_VERSION: ${{ github.sha }}
REF_NAME: ${{ github.ref_name}}
with:
repository: budibase/budibase-deploys
event-type: budicloud-qa-deploy
token: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -123,6 +123,7 @@ jobs:
- uses: passeidireto/trigger-external-workflow-action@main
env:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
REF_NAME: ${{ github.ref_name}}
with:
repository: budibase/budibase-deploys
event: budicloud-qa-deploy

View File

@ -1,69 +0,0 @@
name: Test
on:
workflow_dispatch:
env:
CI: true
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
REGISTRY_URL: registry.hub.docker.com
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
jobs:
build:
name: "build"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: "Checkout"
uses: actions/checkout@v4
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Run Yarn
run: yarn
- name: Run Yarn Build
run: yarn build --scope @budibase/server --scope @budibase/worker
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Get the latest release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo $release_version
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Budibase service docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64
tags: budibase/budibase-test:test
file: ./hosting/single/Dockerfile.v2
cache-from: type=registry,ref=budibase/budibase-test:test
cache-to: type=inline
- name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64
build-args: TARGETBUILD=aas
tags: budibase/budibase-test:aas
file: ./hosting/single/Dockerfile.v2

View File

@ -5,7 +5,7 @@ ENV COUCHDB_PASSWORD admin
EXPOSE 5984
RUN apt-get update && apt-get install -y --no-install-recommends software-properties-common wget unzip curl && \
wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | sudo apt-key add - && \
wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | apt-key add - && \
apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \
apt-add-repository 'deb http://archive.debian.org/debian stretch-backports main' && \
apt-add-repository 'deb https://packages.adoptium.net/artifactory/deb bullseye main' && \

View File

@ -3,3 +3,6 @@
[couchdb]
database_dir = DATA_DIR/couch/dbs
view_index_dir = DATA_DIR/couch/views
[chttpd_auth]
timeout = 7200 ; 2 hours in seconds

View File

@ -4,7 +4,11 @@ version: "3"
services:
app-service:
build: ../packages/server
build:
context: ..
dockerfile: packages/server/Dockerfile.v2
args:
- BUDIBASE_VERSION=0.0.0+dev-docker
container_name: build-bbapps
environment:
SELF_HOSTED: 1
@ -28,11 +32,13 @@ services:
depends_on:
- worker-service
- redis-service
# volumes:
# - /some/path/to/plugins:/plugins
worker-service:
build: ../packages/worker
build:
context: ..
dockerfile: packages/worker/Dockerfile.v2
args:
- BUDIBASE_VERSION=0.0.0+dev-docker
container_name: build-bbworker
environment:
SELF_HOSTED: 1

View File

@ -51,7 +51,7 @@ http {
proxy_buffering off;
set $csp_default "default-src 'self'";
set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io";
set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net";
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";

View File

@ -19,13 +19,15 @@ COPY packages/string-templates/package.json packages/string-templates/package.js
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./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 --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production
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
@ -116,6 +118,10 @@ EXPOSE 443
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"

View File

@ -1,5 +1,5 @@
{
"version": "2.11.36",
"version": "2.11.45",
"npmClient": "yarn",
"packages": ["packages/*", "packages/account-portal/packages/*"],
"useNx": true,

View File

@ -3,14 +3,16 @@
"default": {
"runner": "nx-cloud",
"options": {
"cacheableOperations": ["build", "test", "check:types"],
"accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ="
"cacheableOperations": ["build", "test", "check:types"]
}
}
},
"targetDefaults": {
"build": {
"inputs": ["{workspaceRoot}/scripts/build.js"]
"inputs": [
"{workspaceRoot}/scripts/build.js",
"{workspaceRoot}/lerna.json"
]
}
}
}

View File

@ -32,7 +32,6 @@
"build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
"release:develop": "yarn release --dist-tag develop",
"restore": "yarn run clean && yarn && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore",
@ -47,7 +46,7 @@
"dev:server": "yarn run kill-server && lerna run --stream dev:builder --scope @budibase/worker --scope @budibase/server",
"dev:accountportal": "yarn kill-accountportal && lerna run dev --stream --scope @budibase/account-portal-ui --scope @budibase/account-portal-server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
"dev:docker": "yarn build && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"dev:docker": "yarn build --scope @budibase/server --scope @budibase/worker && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream",
"lint:eslint": "eslint packages qa-core --max-warnings=0",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",

@ -1 +1 @@
Subproject commit afe8d1766e1616a7024c4d402c725d52a1561677
Subproject commit a96ef701ea2e0ada8c5c7372b667d1cd26ece4df

View File

@ -63,7 +63,7 @@
"@types/chance": "1.1.3",
"@types/cookies": "0.7.8",
"@types/jest": "29.5.5",
"@types/lodash": "4.14.199",
"@types/lodash": "4.14.200",
"@types/node": "18.17.0",
"@types/node-fetch": "2.6.4",
"@types/pouchdb": "6.4.0",

View File

@ -33,8 +33,8 @@ function isInvalid(metadata?: { state: string }) {
* Get the requested app metadata by id.
* Use redis cache to first read the app metadata.
* If not present fallback to loading the app metadata directly and re-caching.
* @param {string} appId the id of the app to get metadata from.
* @returns {object} the app metadata.
* @param appId the id of the app to get metadata from.
* @returns the app metadata.
*/
export async function getAppMetadata(appId: string): Promise<App | DeletedApp> {
const client = await getAppClient()
@ -72,9 +72,9 @@ export async function getAppMetadata(appId: string): Promise<App | DeletedApp> {
/**
* Invalidate/reset the cached metadata when a change occurs in the db.
* @param appId {string} the cache key to bust/update.
* @param newMetadata {object|undefined} optional - can simply provide the new metadata to update with.
* @return {Promise<void>} will respond with success when cache is updated.
* @param appId the cache key to bust/update.
* @param newMetadata optional - can simply provide the new metadata to update with.
* @return will respond with success when cache is updated.
*/
export async function invalidateAppMetadata(appId: string, newMetadata?: any) {
if (!appId) {

View File

@ -61,9 +61,9 @@ async function populateUsersFromDB(
* Get the requested user by id.
* Use redis cache to first read the user.
* If not present fallback to loading the user directly and re-caching.
* @param {*} userId the id of the user to get
* @param {*} tenantId the tenant of the user to get
* @param {*} populateUser function to provide the user for re-caching. default to couch db
* @param userId the id of the user to get
* @param tenantId the tenant of the user to get
* @param populateUser function to provide the user for re-caching. default to couch db
* @returns
*/
export async function getUser(
@ -111,8 +111,8 @@ export async function getUser(
* Get the requested users by id.
* Use redis cache to first read the users.
* If not present fallback to loading the users directly and re-caching.
* @param {*} userIds the ids of the user to get
* @param {*} tenantId the tenant of the users to get
* @param userIds the ids of the user to get
* @param tenantId the tenant of the users to get
* @returns
*/
export async function getUsers(

View File

@ -23,7 +23,7 @@ import environment from "../environment"
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
* @returns The new configuration ID which the config doc can be stored under.
*/
export function generateConfigID(type: ConfigType) {
return `${DocumentType.CONFIG}${SEPARATOR}${type}`

View File

@ -62,7 +62,7 @@ export function isTenancyEnabled() {
/**
* Given an app ID this will attempt to retrieve the tenant ID from it.
* @return {null|string} The tenant ID found within the app ID.
* @return The tenant ID found within the app ID.
*/
export function getTenantIDFromAppID(appId: string) {
if (!appId) {

View File

@ -8,8 +8,8 @@ class Replication {
/**
*
* @param {String} source - the DB you want to replicate or rollback to
* @param {String} target - the DB you want to replicate to, or rollback from
* @param source - the DB you want to replicate or rollback to
* @param target - the DB you want to replicate to, or rollback from
*/
constructor({ source, target }: any) {
this.source = getPouchDB(source)
@ -38,7 +38,7 @@ class Replication {
/**
* Two way replication operation, intended to be promise based.
* @param {Object} opts - PouchDB replication options
* @param opts - PouchDB replication options
*/
sync(opts = {}) {
this.replication = this.promisify(this.source.sync, opts)
@ -47,7 +47,7 @@ class Replication {
/**
* One way replication operation, intended to be promise based.
* @param {Object} opts - PouchDB replication options
* @param opts - PouchDB replication options
*/
replicate(opts = {}) {
this.replication = this.promisify(this.source.replicate.to, opts)

View File

@ -599,10 +599,10 @@ async function runQuery<T>(
* Gets round the fixed limit of 200 results from a query by fetching as many
* pages as required and concatenating the results. This recursively operates
* until enough results have been found.
* @param dbName {string} Which database to run a lucene query on
* @param index {string} Which search index to utilise
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* @param dbName Which database to run a lucene query on
* @param index Which search index to utilise
* @param query The JSON query structure
* @param params The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
@ -655,10 +655,10 @@ async function recursiveSearch<T>(
* Performs a paginated search. A bookmark will be returned to allow the next
* page to be fetched. There is a max limit off 200 results per page in a
* paginated search.
* @param dbName {string} Which database to run a lucene query on
* @param index {string} Which search index to utilise
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* @param dbName Which database to run a lucene query on
* @param index Which search index to utilise
* @param query The JSON query structure
* @param params The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
@ -722,10 +722,10 @@ export async function paginatedSearch<T>(
* desired amount of results. There is a limit of 1000 results to avoid
* heavy performance hits, and to avoid client components breaking from
* handling too much data.
* @param dbName {string} Which database to run a lucene query on
* @param index {string} Which search index to utilise
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* @param dbName Which database to run a lucene query on
* @param index Which search index to utilise
* @param query The JSON query structure
* @param params The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")

View File

@ -45,7 +45,7 @@ export async function getAllDbs(opts = { efficient: false }) {
* Lots of different points in the system need to find the full list of apps, this will
* enumerate the entire CouchDB cluster and get the list of databases (every app).
*
* @return {Promise<object[]>} returns the app information document stored in each app database.
* @return returns the app information document stored in each app database.
*/
export async function getAllApps({
dev,

View File

@ -25,7 +25,7 @@ export function isDevApp(app: App) {
/**
* Generates a development app ID from a real app ID.
* @returns {string} the dev app ID which can be used for dev database.
* @returns the dev app ID which can be used for dev database.
*/
export function getDevelopmentAppID(appId: string) {
if (!appId || appId.startsWith(APP_DEV_PREFIX)) {

View File

@ -8,7 +8,7 @@ import { newid } from "./newid"
/**
* Generates a new app ID.
* @returns {string} The new app ID which the app doc can be stored under.
* @returns The new app ID which the app doc can be stored under.
*/
export const generateAppID = (tenantId?: string | null) => {
let id = APP_PREFIX
@ -20,9 +20,9 @@ export const generateAppID = (tenantId?: string | null) => {
/**
* Gets a new row ID for the specified table.
* @param {string} tableId The table which the row is being created for.
* @param {string|null} id If an ID is to be used then the UUID can be substituted for this.
* @returns {string} The new ID which a row doc can be stored under.
* @param tableId The table which the row is being created for.
* @param id If an ID is to be used then the UUID can be substituted for this.
* @returns The new ID which a row doc can be stored under.
*/
export function generateRowID(tableId: string, id?: string) {
id = id || newid()
@ -31,7 +31,7 @@ export function generateRowID(tableId: string, id?: string) {
/**
* Generates a new workspace ID.
* @returns {string} The new workspace ID which the workspace doc can be stored under.
* @returns The new workspace ID which the workspace doc can be stored under.
*/
export function generateWorkspaceID() {
return `${DocumentType.WORKSPACE}${SEPARATOR}${newid()}`
@ -39,7 +39,7 @@ export function generateWorkspaceID() {
/**
* Generates a new global user ID.
* @returns {string} The new user ID which the user doc can be stored under.
* @returns The new user ID which the user doc can be stored under.
*/
export function generateGlobalUserID(id?: any) {
return `${DocumentType.USER}${SEPARATOR}${id || newid()}`
@ -52,8 +52,8 @@ export function isGlobalUserID(id: string) {
/**
* Generates a new user ID based on the passed in global ID.
* @param {string} globalId The ID of the global user.
* @returns {string} The new user ID which the user doc can be stored under.
* @param globalId The ID of the global user.
* @returns The new user ID which the user doc can be stored under.
*/
export function generateUserMetadataID(globalId: string) {
return generateRowID(InternalTable.USER_METADATA, globalId)
@ -84,7 +84,7 @@ export function generateAppUserID(prodAppId: string, userId: string) {
/**
* Generates a new role ID.
* @returns {string} The new role ID which the role doc can be stored under.
* @returns The new role ID which the role doc can be stored under.
*/
export function generateRoleID(name: string) {
const prefix = `${DocumentType.ROLE}${SEPARATOR}`
@ -103,7 +103,7 @@ export function prefixRoleID(name: string) {
/**
* Generates a new dev info document ID - this is scoped to a user.
* @returns {string} The new dev info ID which info for dev (like api key) can be stored under.
* @returns The new dev info ID which info for dev (like api key) can be stored under.
*/
export const generateDevInfoID = (userId: any) => {
return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}`
@ -111,7 +111,7 @@ export const generateDevInfoID = (userId: any) => {
/**
* Generates a new plugin ID - to be used in the global DB.
* @returns {string} The new plugin ID which a plugin metadata document can be stored under.
* @returns The new plugin ID which a plugin metadata document can be stored under.
*/
export const generatePluginID = (name: string) => {
return `${DocumentType.PLUGIN}${SEPARATOR}${name}`

View File

@ -12,12 +12,12 @@ import { getProdAppID } from "./conversions"
* is usually the case as most of our docs are top level e.g. tables, automations, users and so on.
* More complex cases such as link docs and rows which have multiple levels of IDs that their
* ID consists of need their own functions to build the allDocs parameters.
* @param {string} docType The type of document which input params are being built for, e.g. user,
* @param docType The type of document which input params are being built for, e.g. user,
* link, app, table and so on.
* @param {string|null} docId The ID of the document minus its type - this is only needed if looking
* @param docId The ID of the document minus its type - this is only needed if looking
* for a singular document.
* @param {object} otherProps Add any other properties onto the request, e.g. include_docs.
* @returns {object} Parameters which can then be used with an allDocs request.
* @param otherProps Add any other properties onto the request, e.g. include_docs.
* @returns Parameters which can then be used with an allDocs request.
*/
export function getDocParams(
docType: string,
@ -36,11 +36,11 @@ export function getDocParams(
/**
* Gets the DB allDocs/query params for retrieving a row.
* @param {string|null} tableId The table in which the rows have been stored.
* @param {string|null} rowId The ID of the row which is being specifically queried for. This can be
* @param tableId The table in which the rows have been stored.
* @param rowId The ID of the row which is being specifically queried for. This can be
* left null to get all the rows in the table.
* @param {object} otherProps Any other properties to add to the request.
* @returns {object} Parameters which can then be used with an allDocs request.
* @param otherProps Any other properties to add to the request.
* @returns Parameters which can then be used with an allDocs request.
*/
export function getRowParams(
tableId?: string | null,

View File

@ -75,12 +75,12 @@ function getPackageJsonFields(): {
const content = readFileSync(packageJsonFile!, "utf-8")
const parsedContent = JSON.parse(content)
return {
VERSION: parsedContent.version,
VERSION: process.env.BUDIBASE_VERSION || parsedContent.version,
SERVICE_NAME: parsedContent.name,
}
} catch {
// throwing an error here is confusing/causes backend-core to be hard to import
return { VERSION: "", SERVICE_NAME: "" }
return { VERSION: process.env.BUDIBASE_VERSION || "", SERVICE_NAME: "" }
}
}

View File

@ -1,8 +1,8 @@
/**
* Makes sure that a URL has the correct number of slashes, while maintaining the
* http(s):// double slashes.
* @param {string} url The URL to test and remove any extra double slashes.
* @return {string} The updated url.
* @param url The URL to test and remove any extra double slashes.
* @return The updated url.
*/
export function checkSlashesInUrl(url: string) {
return url.replace(/(https?:\/\/)|(\/)+/g, "$1$2")

View File

@ -13,10 +13,10 @@ export const options = {
/**
* Passport Local Authentication Middleware.
* @param {*} ctx the request structure
* @param {*} email username to login with
* @param {*} password plain text password to log in with
* @param {*} done callback from passport to return user information and errors
* @param ctx the request structure
* @param email username to login with
* @param password plain text password to log in with
* @param done callback from passport to return user information and errors
* @returns The authenticated user, or errors if they occur
*/
export async function authenticate(

View File

@ -17,15 +17,15 @@ const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
/**
* @param {*} issuer The identity provider base URL
* @param {*} sub The user ID
* @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} jwtClaims The parsed id_token claims
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param {*} idToken The id_token - always a JWT
* @param {*} params The response body from requesting an access_token
* @param {*} done The passport callback: err, user, info
* @param issuer The identity provider base URL
* @param sub The user ID
* @param profile The user profile information. Created by passport from the /userinfo response
* @param jwtClaims The parsed id_token claims
* @param accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param idToken The id_token - always a JWT
* @param params The response body from requesting an access_token
* @param done The passport callback: err, user, info
*/
return async (
issuer: string,
@ -61,8 +61,8 @@ export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
}
/**
* @param {*} profile The structured profile created by passport using the user info endpoint
* @param {*} jwtClaims The claims returned in the id token
* @param profile The structured profile created by passport using the user info endpoint
* @param jwtClaims The claims returned in the id token
*/
function getEmail(profile: SSOProfile, jwtClaims: JwtClaims) {
// profile not guaranteed to contain email e.g. github connected azure ad account

View File

@ -5,9 +5,9 @@ import { ConfigType, GoogleInnerConfig } from "@budibase/types"
/**
* Utility to handle authentication errors.
*
* @param {*} done The passport callback.
* @param {*} message Message that will be returned in the response body
* @param {*} err (Optional) error that will be logged
* @param done The passport callback.
* @param message Message that will be returned in the response body
* @param err (Optional) error that will be logged
*/
export function authError(done: Function, message: string, err?: any) {

View File

@ -1,37 +1,50 @@
import env from "../../environment"
import * as objectStore from "../objectStore"
import * as cloudfront from "../cloudfront"
import qs from "querystring"
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
export function clientLibraryPath(appId: string) {
return `${objectStore.sanitizeKey(appId)}/budibase-client.js`
}
/**
* In production the client library is stored in the object store, however in development
* we use the symlinked version produced by lerna, located in node modules. We link to this
* via a specific endpoint (under /api/assets/client).
* @param {string} appId In production we need the appId to look up the correct bucket, as the
* version of the client lib may differ between apps.
* @param {string} version The version to retrieve.
* @return {string} The URL to be inserted into appPackage response or server rendered
* app index file.
* Previously we used to serve the client library directly from Cloudfront, however
* due to issues with the domain we were unable to continue doing this - keeping
* incase we are able to switch back to CDN path again in future.
*/
export const clientLibraryUrl = (appId: string, version: string) => {
if (env.isProd()) {
let file = `${objectStore.sanitizeKey(appId)}/budibase-client.js`
if (env.CLOUDFRONT_CDN) {
// append app version to bust the cache
if (version) {
file += `?v=${version}`
}
// don't need to use presigned for client with cloudfront
// file is public
return cloudfront.getUrl(file)
} else {
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
export function clientLibraryCDNUrl(appId: string, version: string) {
let file = clientLibraryPath(appId)
if (env.CLOUDFRONT_CDN) {
// append app version to bust the cache
if (version) {
file += `?v=${version}`
}
// don't need to use presigned for client with cloudfront
// file is public
return cloudfront.getUrl(file)
} else {
return `/api/assets/client`
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
}
}
export const getAppFileUrl = (s3Key: string) => {
export function clientLibraryUrl(appId: string, version: string) {
let tenantId, qsParams: { appId: string; version: string; tenantId?: string }
try {
tenantId = getTenantId()
} finally {
qsParams = {
appId,
version,
}
}
if (tenantId && tenantId !== DEFAULT_TENANT_ID) {
qsParams.tenantId = tenantId
}
return `/api/assets/client?${qs.encode(qsParams)}`
}
export function getAppFileUrl(s3Key: string) {
if (env.CLOUDFRONT_CDN) {
return cloudfront.getPresignedUrl(s3Key)
} else {

View File

@ -6,7 +6,7 @@ import { Plugin } from "@budibase/types"
// URLS
export const enrichPluginURLs = (plugins: Plugin[]) => {
export function enrichPluginURLs(plugins: Plugin[]) {
if (!plugins || !plugins.length) {
return []
}
@ -17,12 +17,12 @@ export const enrichPluginURLs = (plugins: Plugin[]) => {
})
}
const getPluginJSUrl = (plugin: Plugin) => {
function getPluginJSUrl(plugin: Plugin) {
const s3Key = getPluginJSKey(plugin)
return getPluginUrl(s3Key)
}
const getPluginIconUrl = (plugin: Plugin): string | undefined => {
function getPluginIconUrl(plugin: Plugin): string | undefined {
const s3Key = getPluginIconKey(plugin)
if (!s3Key) {
return
@ -30,7 +30,7 @@ const getPluginIconUrl = (plugin: Plugin): string | undefined => {
return getPluginUrl(s3Key)
}
const getPluginUrl = (s3Key: string) => {
function getPluginUrl(s3Key: string) {
if (env.CLOUDFRONT_CDN) {
return cloudfront.getPresignedUrl(s3Key)
} else {
@ -40,11 +40,11 @@ const getPluginUrl = (s3Key: string) => {
// S3 KEYS
export const getPluginJSKey = (plugin: Plugin) => {
export function getPluginJSKey(plugin: Plugin) {
return getPluginS3Key(plugin, "plugin.min.js")
}
export const getPluginIconKey = (plugin: Plugin) => {
export function getPluginIconKey(plugin: Plugin) {
// stored iconUrl is deprecated - hardcode to icon.svg in this case
const iconFileName = plugin.iconUrl ? "icon.svg" : plugin.iconFileName
if (!iconFileName) {
@ -53,12 +53,12 @@ export const getPluginIconKey = (plugin: Plugin) => {
return getPluginS3Key(plugin, iconFileName)
}
const getPluginS3Key = (plugin: Plugin, fileName: string) => {
function getPluginS3Key(plugin: Plugin, fileName: string) {
const s3Key = getPluginS3Dir(plugin.name)
return `${s3Key}/${fileName}`
}
export const getPluginS3Dir = (pluginName: string) => {
export function getPluginS3Dir(pluginName: string) {
let s3Key = `${pluginName}`
if (env.MULTI_TENANCY) {
const tenantId = context.getTenantId()

View File

@ -1,5 +1,4 @@
import * as app from "../app"
import { getAppFileUrl } from "../app"
import { testEnv } from "../../../../tests/extra"
describe("app", () => {
@ -7,6 +6,15 @@ describe("app", () => {
testEnv.nodeJest()
})
function baseCheck(url: string, tenantId?: string) {
expect(url).toContain("/api/assets/client")
if (tenantId) {
expect(url).toContain(`tenantId=${tenantId}`)
}
expect(url).toContain("appId=app_123")
expect(url).toContain("version=2.0.0")
}
describe("clientLibraryUrl", () => {
function getClientUrl() {
return app.clientLibraryUrl("app_123/budibase-client.js", "2.0.0")
@ -20,31 +28,19 @@ describe("app", () => {
it("gets url in dev", () => {
testEnv.nodeDev()
const url = getClientUrl()
expect(url).toBe("/api/assets/client")
})
it("gets url with embedded minio", () => {
testEnv.withMinio()
const url = getClientUrl()
expect(url).toBe(
"/files/signed/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
baseCheck(url)
})
it("gets url with custom S3", () => {
testEnv.withS3()
const url = getClientUrl()
expect(url).toBe(
"http://s3.example.com/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
baseCheck(url)
})
it("gets url with cloudfront + s3", () => {
testEnv.withCloudfront()
const url = getClientUrl()
expect(url).toBe(
"http://cf.example.com/app_123/budibase-client.js/budibase-client.js?v=2.0.0"
)
baseCheck(url)
})
})
@ -57,7 +53,7 @@ describe("app", () => {
testEnv.nodeDev()
await testEnv.withTenant(tenantId => {
const url = getClientUrl()
expect(url).toBe("/api/assets/client")
baseCheck(url, tenantId)
})
})
@ -65,9 +61,7 @@ describe("app", () => {
await testEnv.withTenant(tenantId => {
testEnv.withMinio()
const url = getClientUrl()
expect(url).toBe(
"/files/signed/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
baseCheck(url, tenantId)
})
})
@ -75,9 +69,7 @@ describe("app", () => {
await testEnv.withTenant(tenantId => {
testEnv.withS3()
const url = getClientUrl()
expect(url).toBe(
"http://s3.example.com/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
baseCheck(url, tenantId)
})
})
@ -85,9 +77,7 @@ describe("app", () => {
await testEnv.withTenant(tenantId => {
testEnv.withCloudfront()
const url = getClientUrl()
expect(url).toBe(
"http://cf.example.com/app_123/budibase-client.js/budibase-client.js?v=2.0.0"
)
baseCheck(url, tenantId)
})
})
})

View File

@ -1,6 +1,6 @@
const sanitize = require("sanitize-s3-objectkey")
import AWS from "aws-sdk"
import stream from "stream"
import stream, { Readable } from "stream"
import fetch from "node-fetch"
import tar from "tar-fs"
import zlib from "zlib"
@ -61,15 +61,15 @@ export function sanitizeBucket(input: string) {
/**
* Gets a connection to the object store using the S3 SDK.
* @param {string} bucket the name of the bucket which blobs will be uploaded/retrieved from.
* @param {object} opts configuration for the object store.
* @return {Object} an S3 object store object, check S3 Nodejs SDK for usage.
* @param bucket the name of the bucket which blobs will be uploaded/retrieved from.
* @param opts configuration for the object store.
* @return an S3 object store object, check S3 Nodejs SDK for usage.
* @constructor
*/
export const ObjectStore = (
export function ObjectStore(
bucket: string,
opts: { presigning: boolean } = { presigning: false }
) => {
) {
const config: any = {
s3ForcePathStyle: true,
signatureVersion: "v4",
@ -104,7 +104,7 @@ export const ObjectStore = (
* Given an object store and a bucket name this will make sure the bucket exists,
* if it does not exist then it will create it.
*/
export const makeSureBucketExists = async (client: any, bucketName: string) => {
export async function makeSureBucketExists(client: any, bucketName: string) {
bucketName = sanitizeBucket(bucketName)
try {
await client
@ -139,13 +139,13 @@ export const makeSureBucketExists = async (client: any, bucketName: string) => {
* Uploads the contents of a file given the required parameters, useful when
* temp files in use (for example file uploaded as an attachment).
*/
export const upload = async ({
export async function upload({
bucket: bucketName,
filename,
path,
type,
metadata,
}: UploadParams) => {
}: UploadParams) {
const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path)
@ -180,12 +180,12 @@ export const upload = async ({
* Similar to the upload function but can be used to send a file stream
* through to the object store.
*/
export const streamUpload = async (
export async function streamUpload(
bucketName: string,
filename: string,
stream: any,
extra = {}
) => {
) {
const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
@ -215,7 +215,7 @@ export const streamUpload = async (
* retrieves the contents of a file from the object store, if it is a known content type it
* will be converted, otherwise it will be returned as a buffer stream.
*/
export const retrieve = async (bucketName: string, filepath: string) => {
export async function retrieve(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName)
const params = {
Bucket: sanitizeBucket(bucketName),
@ -230,7 +230,7 @@ export const retrieve = async (bucketName: string, filepath: string) => {
}
}
export const listAllObjects = async (bucketName: string, path: string) => {
export async function listAllObjects(bucketName: string, path: string) {
const objectStore = ObjectStore(bucketName)
const list = (params: ListParams = {}) => {
return objectStore
@ -261,11 +261,11 @@ export const listAllObjects = async (bucketName: string, path: string) => {
/**
* Generate a presigned url with a default TTL of 1 hour
*/
export const getPresignedUrl = (
export function getPresignedUrl(
bucketName: string,
key: string,
durationSeconds: number = 3600
) => {
) {
const objectStore = ObjectStore(bucketName, { presigning: true })
const params = {
Bucket: sanitizeBucket(bucketName),
@ -291,7 +291,7 @@ export const getPresignedUrl = (
/**
* Same as retrieval function but puts to a temporary file.
*/
export const retrieveToTmp = async (bucketName: string, filepath: string) => {
export async function retrieveToTmp(bucketName: string, filepath: string) {
bucketName = sanitizeBucket(bucketName)
filepath = sanitizeKey(filepath)
const data = await retrieve(bucketName, filepath)
@ -300,7 +300,7 @@ export const retrieveToTmp = async (bucketName: string, filepath: string) => {
return outputPath
}
export const retrieveDirectory = async (bucketName: string, path: string) => {
export async function retrieveDirectory(bucketName: string, path: string) {
let writePath = join(budibaseTempDir(), v4())
fs.mkdirSync(writePath)
const objects = await listAllObjects(bucketName, path)
@ -324,7 +324,7 @@ export const retrieveDirectory = async (bucketName: string, path: string) => {
/**
* Delete a single file.
*/
export const deleteFile = async (bucketName: string, filepath: string) => {
export async function deleteFile(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
const params = {
@ -334,7 +334,7 @@ export const deleteFile = async (bucketName: string, filepath: string) => {
return objectStore.deleteObject(params).promise()
}
export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
export async function deleteFiles(bucketName: string, filepaths: string[]) {
const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
const params = {
@ -349,10 +349,10 @@ export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
/**
* Delete a path, including everything within.
*/
export const deleteFolder = async (
export async function deleteFolder(
bucketName: string,
folder: string
): Promise<any> => {
): Promise<any> {
bucketName = sanitizeBucket(bucketName)
folder = sanitizeKey(folder)
const client = ObjectStore(bucketName)
@ -383,11 +383,11 @@ export const deleteFolder = async (
}
}
export const uploadDirectory = async (
export async function uploadDirectory(
bucketName: string,
localPath: string,
bucketPath: string
) => {
) {
bucketName = sanitizeBucket(bucketName)
let uploads = []
const files = fs.readdirSync(localPath, { withFileTypes: true })
@ -404,11 +404,11 @@ export const uploadDirectory = async (
return files
}
export const downloadTarballDirect = async (
export async function downloadTarballDirect(
url: string,
path: string,
headers = {}
) => {
) {
path = sanitizeKey(path)
const response = await fetch(url, { headers })
if (!response.ok) {
@ -418,11 +418,11 @@ export const downloadTarballDirect = async (
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path))
}
export const downloadTarball = async (
export async function downloadTarball(
url: string,
bucketName: string,
path: string
) => {
) {
bucketName = sanitizeBucket(bucketName)
path = sanitizeKey(path)
const response = await fetch(url)
@ -438,3 +438,17 @@ export const downloadTarball = async (
// return the temporary path incase there is a use for it
return tmpPath
}
export async function getReadStream(
bucketName: string,
path: string
): Promise<Readable> {
bucketName = sanitizeBucket(bucketName)
path = sanitizeKey(path)
const client = ObjectStore(bucketName)
const params = {
Bucket: bucketName,
Key: path,
}
return client.getObject(params).createReadStream()
}

View File

@ -5,9 +5,9 @@ import { timeout } from "../utils"
* Bull works with a Job wrapper around all messages that contains a lot more information about
* the state of the message, this object constructor implements the same schema of Bull jobs
* for the sake of maintaining API consistency.
* @param {string} queue The name of the queue which the message will be carried on.
* @param {object} message The JSON message which will be passed back to the consumer.
* @returns {Object} A new job which can now be put onto the queue, this is mostly an
* @param queue The name of the queue which the message will be carried on.
* @param message The JSON message which will be passed back to the consumer.
* @returns A new job which can now be put onto the queue, this is mostly an
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
*/
function newJob(queue: string, message: any) {
@ -32,8 +32,8 @@ class InMemoryQueue {
_addCount: number
/**
* The constructor the queue, exactly the same as that of Bulls.
* @param {string} name The name of the queue which is being configured.
* @param {object|null} opts This is not used by the in memory queue as there is no real use
* @param name The name of the queue which is being configured.
* @param opts This is not used by the in memory queue as there is no real use
* case when in memory, but is the same API as Bull
*/
constructor(name: string, opts = null) {
@ -49,7 +49,7 @@ class InMemoryQueue {
* Same callback API as Bull, each callback passed to this will consume messages as they are
* available. Please note this is a queue service, not a notification service, so each
* consumer will receive different messages.
* @param {function<object>} func The callback function which will return a "Job", the same
* @param func The callback function which will return a "Job", the same
* as the Bull API, within this job the property "data" contains the JSON message. Please
* note this is incredibly limited compared to Bull as in reality the Job would contain
* a lot more information about the queue and current status of Bull cluster.
@ -73,9 +73,9 @@ class InMemoryQueue {
* Simple function to replicate the add message functionality of Bull, putting
* a new message on the queue. This then emits an event which will be used to
* return the message to a consumer (if one is attached).
* @param {object} msg A message to be transported over the queue, this should be
* @param msg A message to be transported over the queue, this should be
* a JSON message as this is required by Bull.
* @param {boolean} repeat serves no purpose for the import queue.
* @param repeat serves no purpose for the import queue.
*/
// eslint-disable-next-line no-unused-vars
add(msg: any, repeat: boolean) {
@ -96,7 +96,7 @@ class InMemoryQueue {
/**
* This removes a cron which has been implemented, this is part of Bull API.
* @param {string} cronJobId The cron which is to be removed.
* @param cronJobId The cron which is to be removed.
*/
removeRepeatableByKey(cronJobId: string) {
// TODO: implement for testing

View File

@ -142,7 +142,7 @@ function waitForConnection(selectDb: number = DEFAULT_SELECT_DB) {
* this can only be done with redis streams because they will have an end.
* @param stream A redis stream, specifically as this type of stream will have an end.
* @param client The client to use for further lookups.
* @return {Promise<object>} The final output of the stream
* @return The final output of the stream
*/
function promisifyStream(stream: any, client: RedisWrapper) {
return new Promise((resolve, reject) => {

View File

@ -36,8 +36,8 @@ export function levelToNumber(perm: PermissionLevel) {
/**
* Given the specified permission level for the user return the levels they are allowed to carry out.
* @param {string} userPermLevel The permission level of the user.
* @return {string[]} All the permission levels this user is allowed to carry out.
* @param userPermLevel The permission level of the user.
* @return All the permission levels this user is allowed to carry out.
*/
export function getAllowedLevels(userPermLevel: PermissionLevel): string[] {
switch (userPermLevel) {

View File

@ -149,9 +149,9 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
/**
* Gets the role object, this is mainly useful for two purposes, to check if the level exists and
* to check if the role inherits any others.
* @param {string|null} roleId The level ID to lookup.
* @param {object|null} opts options for the function, like whether to halt errors, instead return public.
* @returns {Promise<Role|object|null>} The role object, which may contain an "inherits" property.
* @param roleId The level ID to lookup.
* @param opts options for the function, like whether to halt errors, instead return public.
* @returns The role object, which may contain an "inherits" property.
*/
export async function getRole(
roleId?: string,
@ -225,8 +225,8 @@ export async function getUserRoleIdHierarchy(
/**
* Returns an ordered array of the user's inherited role IDs, this can be used
* to determine if a user can access something that requires a specific role.
* @param {string} userRoleId The user's role ID, this can be found in their access token.
* @returns {Promise<object[]>} returns an ordered array of the roles, with the first being their
* @param userRoleId The user's role ID, this can be found in their access token.
* @returns returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level.
*/
export async function getUserRoleHierarchy(userRoleId?: string) {
@ -258,7 +258,7 @@ export async function getAllRoleIds(appId?: string) {
/**
* Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found.
* @return An array of the role objects that were found.
*/
export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
if (appId) {

View File

@ -21,7 +21,6 @@ import {
User,
UserStatus,
UserGroup,
ContextUser,
} from "@budibase/types"
import {
getAccountHolderFromUserIds,
@ -135,7 +134,7 @@ export class UserDB {
if (!fullUser.roles) {
fullUser.roles = {}
}
// add the active status to a user if its not provided
// add the active status to a user if it's not provided
if (fullUser.status == null) {
fullUser.status = UserStatus.ACTIVE
}
@ -160,14 +159,14 @@ export class UserDB {
}
}
static async getUsersByAppAccess(appId?: string) {
const opts: any = {
static async getUsersByAppAccess(opts: { appId?: string; limit?: number }) {
const params: any = {
include_docs: true,
limit: 50,
limit: opts.limit || 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
opts.appId,
params
)
return response
}

View File

@ -19,9 +19,11 @@ import {
SearchQueryOperators,
SearchUsersRequest,
User,
DatabaseQueryOpts,
} from "@budibase/types"
import * as context from "../context"
import { getGlobalDB } from "../context"
import * as context from "../context"
import { isCreator } from "./utils"
type GetOpts = { cleanup?: boolean }
@ -240,12 +242,14 @@ export const paginatedUsers = async ({
bookmark,
query,
appId,
limit,
}: SearchUsersRequest = {}) => {
const db = getGlobalDB()
const pageLimit = limit ? limit + 1 : PAGE_LIMIT + 1
// get one extra document, to have the next page
const opts: any = {
const opts: DatabaseQueryOpts = {
include_docs: true,
limit: PAGE_LIMIT + 1,
limit: pageLimit,
}
// add a startkey if the page was specified (anchor)
if (bookmark) {
@ -268,7 +272,7 @@ export const paginatedUsers = async ({
const response = await db.allDocs(getGlobalUserParams(null, opts))
userList = response.rows.map((row: any) => row.doc)
}
return pagination(userList, PAGE_LIMIT, {
return pagination(userList, pageLimit, {
paginate: true,
property,
getKey,
@ -283,6 +287,19 @@ export async function getUserCount() {
return response.total_rows
}
export async function getCreatorCount() {
let creators = 0
async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage })
creators += page.data.filter(isCreator).length
if (page.hasNextPage) {
await iterate(page.nextPage)
}
}
await iterate()
return creators
}
// used to remove the builder/admin permissions, for processing the
// user as an app user (they may have some specific role/group
export function removePortalUserPermissions(user: User | ContextUser) {

View File

@ -10,6 +10,7 @@ import { getAccountByTenantId } from "../accounts"
// extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin
export const isCreator = sdk.users.isCreator
export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions

View File

@ -79,8 +79,8 @@ export function isPublicApiRequest(ctx: Ctx): boolean {
/**
* Given a request tries to find the appId, which can be located in various places
* @param {object} ctx The main request body to look through.
* @returns {string|undefined} If an appId was found it will be returned.
* @param ctx The main request body to look through.
* @returns If an appId was found it will be returned.
*/
export async function getAppIdFromCtx(ctx: Ctx) {
// look in headers
@ -135,7 +135,7 @@ function parseAppIdFromUrl(url?: string) {
/**
* opens the contents of the specified encrypted JWT.
* @return {object} the contents of the token.
* @return the contents of the token.
*/
export function openJwt(token: string) {
if (!token) {
@ -169,8 +169,8 @@ export function isValidInternalAPIKey(apiKey: string) {
/**
* Get a cookie from context, and decrypt if necessary.
* @param {object} ctx The request which is to be manipulated.
* @param {string} name The name of the cookie to get.
* @param ctx The request which is to be manipulated.
* @param name The name of the cookie to get.
*/
export function getCookie(ctx: Ctx, name: string) {
const cookie = ctx.cookies.get(name)
@ -184,10 +184,10 @@ export function getCookie(ctx: Ctx, name: string) {
/**
* Store a cookie for the request - it will not expire.
* @param {object} ctx The request which is to be manipulated.
* @param {string} name The name of the cookie to set.
* @param {string|object} value The value of cookie which will be set.
* @param {object} opts options like whether to sign.
* @param ctx The request which is to be manipulated.
* @param name The name of the cookie to set.
* @param value The value of cookie which will be set.
* @param opts options like whether to sign.
*/
export function setCookie(
ctx: Ctx,
@ -223,8 +223,8 @@ export function clearCookie(ctx: Ctx, name: string) {
/**
* Checks if the API call being made (based on the provided ctx object) is from the client. If
* the call is not from a client app then it is from the builder.
* @param {object} ctx The koa context object to be tested.
* @return {boolean} returns true if the call is from the client lib (a built app rather than the builder).
* @param ctx The koa context object to be tested.
* @return returns true if the call is from the client lib (a built app rather than the builder).
*/
export function isClient(ctx: Ctx) {
return ctx.headers[Header.TYPE] === "client"

View File

@ -72,6 +72,11 @@ export function quotas(): Quotas {
value: 1,
triggers: [],
},
creators: {
name: "Creators",
value: 1,
triggers: [],
},
userGroups: {
name: "User Groups",
value: 1,

View File

@ -1,6 +1,6 @@
import { MonthlyQuotaName, QuotaUsage } from "@budibase/types"
export const usage = (): QuotaUsage => {
export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
return {
_id: "usage_quota",
quotaReset: new Date().toISOString(),
@ -58,7 +58,8 @@ export const usage = (): QuotaUsage => {
usageQuota: {
apps: 0,
plugins: 0,
users: 0,
users,
creators,
userGroups: 0,
rows: 0,
triggers: {},

View File

@ -106,6 +106,13 @@
name: fieldName,
}
}
// Delete numeric only widths as these are grid widths and should be
// ignored
const width = fixedSchema[fieldName].width
if (width != null && `${width}`.trim().match(/^[0-9]+$/)) {
delete fixedSchema[fieldName].width
}
})
return fixedSchema
}

View File

@ -23,7 +23,7 @@ class AnalyticsHub {
posthog.identify(id)
}
captureException(err) {}
captureException(_err) {}
captureEvent(eventName, props = {}) {
posthog.captureEvent(eventName, props)

View File

@ -5,6 +5,7 @@ import {
encodeJSBinding,
findHBSBlocks,
} from "@budibase/string-templates"
import { capitalise } from "helpers"
/**
* Recursively searches for a specific component ID
@ -235,3 +236,13 @@ export const makeComponentUnique = component => {
// Recurse on all children
return JSON.parse(definition)
}
export const getComponentText = component => {
if (component?._instanceName) {
return component._instanceName
}
const type =
component._component.replace("@budibase/standard-components/", "") ||
"component"
return capitalise(type)
}

View File

@ -2,14 +2,14 @@ import sanitizeUrl from "./utils/sanitizeUrl"
import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
export default function (datasources) {
export default function (datasources, mode = "table") {
if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return {
name: `${datasource.label} - List`,
create: () => createScreen(datasource),
create: () => createScreen(datasource, mode),
id: ROW_LIST_TEMPLATE,
resourceId: datasource.resourceId,
}
@ -40,10 +40,24 @@ const generateTableBlock = datasource => {
return tableBlock
}
const createScreen = datasource => {
const generateGridBlock = datasource => {
const gridBlock = new Component("@budibase/standard-components/gridblock")
gridBlock
.customProps({
table: datasource,
})
.instanceName(`${datasource.label} - Grid block`)
return gridBlock
}
const createScreen = (datasource, mode) => {
return new Screen()
.route(rowListUrl(datasource))
.instanceName(`${datasource.label} - List`)
.addChild(generateTableBlock(datasource))
.addChild(
mode === "table"
? generateTableBlock(datasource)
: generateGridBlock(datasource)
)
.json()
}

View File

@ -3,13 +3,10 @@
import { goto, params } from "@roxi/routify"
import { Table, Heading, Layout } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import {
TableNames,
UNEDITABLE_USER_FIELDS,
UNSORTABLE_TYPES,
} from "constants"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
import RoleCell from "./cells/RoleCell.svelte"
import { createEventDispatcher } from "svelte"
import { canBeSortColumn } from "@budibase/shared-core"
export let schema = {}
export let data = []
@ -27,19 +24,23 @@
let selectedRows = []
let customRenderers = []
let parsedSchema = {}
$: if (schema) {
parsedSchema = Object.keys(schema).reduce((acc, key) => {
acc[key] =
typeof schema[key] === "string" ? { type: schema[key] } : schema[key]
if (!canBeSortColumn(acc[key].type)) {
acc[key].sortable = false
}
return acc
}, {})
}
$: selectedRows, dispatch("selectionUpdated", selectedRows)
$: isUsersTable = tableId === TableNames.USERS
$: data && resetSelectedRows()
$: {
UNSORTABLE_TYPES.forEach(type => {
Object.values(schema || {}).forEach(col => {
if (col.type === type) {
col.sortable = false
}
})
})
}
$: {
if (isUsersTable) {
customRenderers = [
@ -49,24 +50,24 @@
},
]
UNEDITABLE_USER_FIELDS.forEach(field => {
if (schema[field]) {
schema[field].editable = false
if (parsedSchema[field]) {
parsedSchema[field].editable = false
}
})
if (schema.email) {
schema.email.displayName = "Email"
if (parsedSchema.email) {
parsedSchema.email.displayName = "Email"
}
if (schema.roleId) {
schema.roleId.displayName = "Role"
if (parsedSchema.roleId) {
parsedSchema.roleId.displayName = "Role"
}
if (schema.firstName) {
schema.firstName.displayName = "First Name"
if (parsedSchema.firstName) {
parsedSchema.firstName.displayName = "First Name"
}
if (schema.lastName) {
schema.lastName.displayName = "Last Name"
if (parsedSchema.lastName) {
parsedSchema.lastName.displayName = "Last Name"
}
if (schema.status) {
schema.status.displayName = "Status"
if (parsedSchema.status) {
parsedSchema.status.displayName = "Status"
}
}
}
@ -102,7 +103,7 @@
<div class="table-wrapper">
<Table
{data}
{schema}
schema={parsedSchema}
{loading}
{customRenderers}
{rowCount}

View File

@ -20,6 +20,7 @@
let type = "internal"
$: name = view.name
$: schema = view.schema
$: calculation = view.calculation
$: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
@ -61,7 +62,7 @@
<Table
title={decodeURI(name)}
schema={view.schema}
{schema}
tableId={view.tableId}
{data}
{loading}

View File

@ -777,7 +777,8 @@
disabled={deleteColName !== originalName}
>
<p>
Are you sure you wish to delete the column <b>{originalName}?</b>
Are you sure you wish to delete the column
<b on:click={() => (deleteColName = originalName)}>{originalName}?</b>
Your data will be deleted and this action cannot be undone - enter the column
name to confirm.
</p>
@ -810,4 +811,11 @@
gap: 8px;
display: flex;
}
b {
transition: color 130ms ease-out;
}
b:hover {
cursor: pointer;
color: var(--spectrum-global-color-gray-900);
}
</style>

View File

@ -16,6 +16,7 @@
export let closeButtonIcon = "Close"
$: customHeaderContent = $$slots["panel-header-content"]
$: customTitleContent = $$slots["panel-title-content"]
</script>
<div
@ -33,7 +34,11 @@
<Icon name={icon} />
{/if}
<div class="title">
<Body size="S">{title}</Body>
{#if customTitleContent}
<slot name="panel-title-content" />
{:else}
<Body size="S">{title || ""}</Body>
{/if}
</div>
{#if showAddButton}
<div class="add-button" on:click={onClickAddButton}>
@ -134,4 +139,7 @@
.custom-content-wrap {
border-bottom: var(--border-light);
}
.title {
display: flex;
}
</style>

View File

@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
const componentMap = {
@ -48,6 +49,7 @@ const componentMap = {
"filter/relationship": RelationshipFilterEditor,
url: URLSelect,
fieldConfiguration: FieldConfiguration,
buttonConfiguration: ButtonConfiguration,
columns: ColumnEditor,
"columns/basic": BasicColumnEditor,
"columns/grid": GridColumnEditor,

View File

@ -0,0 +1,134 @@
<script>
import DraggableList from "../DraggableList/DraggableList.svelte"
import ButtonSetting from "./ButtonSetting.svelte"
import { createEventDispatcher } from "svelte"
import { store } from "builderStore"
import { Helpers } from "@budibase/bbui"
export let componentBindings
export let bindings
export let value
const dispatch = createEventDispatcher()
let focusItem
$: buttonList = sanitizeValue(value) || []
$: buttonCount = buttonList.length
$: itemProps = {
componentBindings: componentBindings || [],
bindings,
removeButton,
canRemove: buttonCount > 1,
}
const sanitizeValue = val => {
return val?.map(button => {
return button._component ? button : buildPseudoInstance(button)
})
}
const processItemUpdate = e => {
const updatedField = e.detail
const newButtonList = [...buttonList]
const fieldIdx = newButtonList.findIndex(pSetting => {
return pSetting._id === updatedField?._id
})
if (fieldIdx === -1) {
newButtonList.push(updatedField)
} else {
newButtonList[fieldIdx] = updatedField
}
dispatch("change", newButtonList)
}
const listUpdated = e => {
dispatch("change", [...e.detail])
}
const buildPseudoInstance = cfg => {
return store.actions.components.createInstance(
`@budibase/standard-components/button`,
{
_instanceName: Helpers.uuid(),
text: cfg.text,
type: cfg.type || "primary",
},
{}
)
}
const addButton = () => {
const newButton = buildPseudoInstance({
text: `Button ${buttonCount + 1}`,
})
dispatch("change", [...buttonList, newButton])
focusItem = newButton._id
}
const removeButton = id => {
dispatch(
"change",
buttonList.filter(button => button._id !== id)
)
}
</script>
<div class="button-configuration">
{#if buttonCount}
<DraggableList
on:change={listUpdated}
on:itemChange={processItemUpdate}
items={buttonList}
listItemKey={"_id"}
listType={ButtonSetting}
listTypeProps={itemProps}
focus={focusItem}
draggable={buttonCount > 1}
/>
<div class="list-footer" on:click={addButton}>
<div class="add-button">Add button</div>
</div>
{/if}
</div>
<style>
.button-configuration :global(.spectrum-ActionButton) {
width: 100%;
}
.button-configuration :global(.list-wrap > li:last-child),
.button-configuration :global(.list-wrap) {
border-bottom-left-radius: unset;
border-bottom-right-radius: unset;
border-bottom: 0px;
}
.list-footer {
width: 100%;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
justify-content: center;
border: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
cursor: pointer;
}
.add-button {
margin: var(--spacing-s);
}
.list-footer:hover {
background-color: var(
--spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover)
);
}
</style>

View File

@ -0,0 +1,64 @@
<script>
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui"
import { runtimeToReadableBinding } from "builderStore/dataBinding"
import { isJSBinding } from "@budibase/string-templates"
export let item
export let componentBindings
export let bindings
export let anchor
export let removeButton
export let canRemove
$: readableText = isJSBinding(item.text)
? "(JavaScript function)"
: runtimeToReadableBinding([...bindings, componentBindings], item.text)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditComponentPopover
{anchor}
componentInstance={item}
{componentBindings}
{bindings}
on:change
/>
<div class="field-label">{readableText || "Button"}</div>
</div>
<div class="list-item-right">
<Icon
disabled={!canRemove}
size="S"
name="Close"
hoverable
on:click={() => removeButton(item._id)}
/>
</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-body {
margin-top: 8px;
margin-bottom: 8px;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
</style>

View File

@ -16,7 +16,11 @@
<DrawerContent>
<div class="container">
<Layout noPadding gap="S">
<Input bind:value={column.width} label="Width" placeholder="Auto" />
<Input
bind:value={column.width}
label="Width (must include a unit like px or %)"
placeholder="Auto"
/>
<Select
label="Alignment"
bind:value={column.align}

View File

@ -1,5 +1,9 @@
<script>
import { getContextProviderComponents } from "builderStore/dataBinding"
import {
getContextProviderComponents,
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import {
Button,
Popover,
@ -9,6 +13,11 @@
Heading,
Drawer,
DrawerContent,
Icon,
Modal,
ModalContent,
CoreDropzone,
notifications,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { store, currentAsset } from "builderStore"
@ -22,6 +31,8 @@
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { API } from "api"
export let value = {}
export let otherSources
@ -31,9 +42,13 @@
const dispatch = createEventDispatcher()
const arrayTypes = ["attachment", "array"]
let anchorRight, dropdownRight
let drawer
let tmpQueryParams
let tmpCustomData
let customDataValid = true
let modal
$: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list.map(m => ({
@ -125,6 +140,10 @@
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: custom = {
type: "custom",
label: "JSON / CSV",
}
const handleSelected = selected => {
dispatch("change", selected)
@ -151,6 +170,11 @@
drawer.show()
}
const openCustomDrawer = () => {
tmpCustomData = runtimeToReadableBinding(bindings, value.data || "")
drawer.show()
}
const getQueryValue = queries => {
return queries.find(q => q._id === value._id) || value
}
@ -162,6 +186,35 @@
})
drawer.hide()
}
const saveCustomData = () => {
handleSelected({
...value,
data: readableToRuntimeBinding(bindings, tmpCustomData),
})
drawer.hide()
}
const promptForCSV = () => {
drawer.hide()
modal.show()
}
const handleCSV = async e => {
try {
const csv = await e.detail[0]?.text()
if (csv?.length) {
const js = await API.csvToJson(csv)
tmpCustomData = JSON.stringify(js)
}
modal.hide()
saveCustomData()
} catch (error) {
notifications.error("Failed to parse CSV")
modal.hide()
drawer.show()
}
}
</script>
<div class="container" bind:this={anchorRight}>
@ -172,7 +225,9 @@
on:click={dropdownRight.show}
/>
{#if value?.type === "query"}
<i class="ri-settings-5-line" on:click={openQueryParamsDrawer} />
<div class="icon">
<Icon hoverable name="Settings" on:click={openQueryParamsDrawer} />
</div>
<Drawer title={"Query Bindings"} bind:this={drawer}>
<Button slot="buttons" cta on:click={saveQueryParams}>Save</Button>
<DrawerContent slot="body">
@ -198,6 +253,29 @@
</DrawerContent>
</Drawer>
{/if}
{#if value?.type === "custom"}
<div class="icon">
<Icon hoverable name="Settings" on:click={openCustomDrawer} />
</div>
<Drawer title="Custom data" bind:this={drawer}>
<div slot="buttons" style="display:contents">
<Button primary on:click={promptForCSV}>Load CSV</Button>
<Button cta on:click={saveCustomData} disabled={!customDataValid}>
Save
</Button>
</div>
<div slot="description">Provide a JSON array to use as data</div>
<ClientBindingPanel
slot="body"
bind:valid={customDataValid}
value={tmpCustomData}
on:change={event => (tmpCustomData = event.detail)}
{bindings}
allowJS
allowHelpers
/>
</Drawer>
{/if}
</div>
<Popover bind:this={dropdownRight} anchor={anchorRight}>
<div class="dropdown">
@ -285,20 +363,27 @@
{/each}
</ul>
{/if}
{#if otherSources?.length}
<Divider />
<div class="title">
<Heading size="XS">Other</Heading>
</div>
<ul>
<Divider />
<div class="title">
<Heading size="XS">Other</Heading>
</div>
<ul>
<li on:click={() => handleSelected(custom)}>{custom.label}</li>
{#if otherSources?.length}
{#each otherSources as source}
<li on:click={() => handleSelected(source)}>{source.label}</li>
{/each}
</ul>
{/if}
{/if}
</ul>
</div>
</Popover>
<Modal bind:this={modal}>
<ModalContent title="Load CSV" showConfirmButton={false}>
<CoreDropzone compact extensions=".csv" on:change={handleCSV} />
</ModalContent>
</Modal>
<style>
.container {
display: flex;
@ -340,16 +425,7 @@
background-color: var(--spectrum-global-color-gray-200);
}
i {
margin-left: 5px;
display: flex;
align-items: center;
transition: all 0.2s;
}
i:hover {
transform: scale(1.1);
font-weight: 600;
cursor: pointer;
.icon {
margin-left: 8px;
}
</style>

View File

@ -1,10 +1,10 @@
<script>
import { Icon } from "@budibase/bbui"
import { dndzone } from "svelte-dnd-action"
import { createEventDispatcher } from "svelte"
import { generate } from "shortid"
import { setContext } from "svelte"
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import DragHandle from "./drag-handle.svelte"
export let items = []
export let showHandle = true
@ -12,6 +12,7 @@
export let listTypeProps = {}
export let listItemKey
export let draggable = true
export let focus
let store = writable({
selected: null,
@ -27,6 +28,10 @@
setContext("draggable", store)
$: if (focus && store) {
get(store).actions.select(focus)
}
const dispatch = createEventDispatcher()
const flipDurationMs = 150
@ -82,13 +87,16 @@
>
{#each draggableItems as draggable (draggable.id)}
<li
on:mousedown={() => {
get(store).actions.select()
}}
bind:this={anchors[draggable.id]}
class:highlighted={draggable.id === $store.selected}
>
<div class="left-content">
{#if showHandle}
<div class="handle" aria-label="drag-handle">
<Icon name="DragHandle" size="XL" />
<div class="handle">
<DragHandle />
</div>
{/if}
</div>
@ -142,8 +150,9 @@
border-top-right-radius: 4px;
}
.list-wrap > li:last-child {
border-top-left-radius: var(--spectrum-table-regular-border-radius);
border-top-right-radius: var(--spectrum-table-regular-border-radius);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: 0px;
}
.right-content {
flex: 1;
@ -153,4 +162,15 @@
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
.handle {
display: flex;
height: var(--spectrum-global-dimension-size-150);
}
.handle :global(svg) {
fill: var(--spectrum-global-color-gray-500);
margin-right: var(--spacing-m);
margin-left: 2px;
width: var(--spectrum-global-dimension-size-65);
height: 100%;
}
</style>

View File

@ -0,0 +1,31 @@
<svg
class="drag-handle spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1,11c0.55228,0 1,-0.4477 1,-1c0,-0.5523 -0.44772,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z"
/>
<path
d="m1,8c0.55228,0 1,-0.4477 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z"
/>
<path
d="m1,5c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
/>
<path
d="m1,2c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
/>
<path
d="m4,11c0.5523,0 1,-0.4477 1,-1c0,-0.5523 -0.4477,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z"
/>
<path
d="m4,8c0.5523,0 1,-0.4477 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z"
/>
<path
d="m4,5c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
/>
<path
d="m4,2c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -3,31 +3,35 @@
import { store } from "builderStore"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import ComponentSettingsSection from "../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import { getContext } from "svelte"
export let anchor
export let field
export let componentInstance
export let componentBindings
export let bindings
export let parseSettings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
let popover
let drawers = []
let pseudoComponentInstance
let open = false
$: if (open && $draggable.selected && $draggable.selected != field._id) {
// Auto hide the component when another item is selected
$: if (open && $draggable.selected != componentInstance._id) {
popover.hide()
}
$: if (field) {
pseudoComponentInstance = field
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === componentInstance._id && popover) {
popover.show()
open = true
}
$: componentDef = store.actions.components.getDefinition(
pseudoComponentInstance._component
componentInstance._component
)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
@ -36,17 +40,16 @@
return {}
}
const clone = cloneDeep(componentDef)
const updatedSettings = clone.settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
clone.settings = updatedSettings
if (typeof parseSettings === "function") {
clone.settings = parseSettings(clone.settings)
}
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(pseudoComponentInstance)
const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = store.actions.components.updateComponentSetting(
setting.key,
@ -54,12 +57,26 @@
)
patchFn(nestedComponentInstance)
const update = {
...nestedComponentInstance,
active: pseudoComponentInstance.active,
dispatch("change", nestedComponentInstance)
}
const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
dispatch("change", update)
return { ...cfg, left, top }
}
</script>
@ -79,11 +96,11 @@
bind:this={popover}
on:open={() => {
drawers = []
$draggable.actions.select(field._id)
$draggable.actions.select(componentInstance._id)
}}
on:close={() => {
open = false
if ($draggable.selected == field._id) {
if ($draggable.selected == componentInstance._id) {
$draggable.actions.select()
}
}}
@ -92,33 +109,13 @@
showPopover={drawers.length == 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}}
handlePostionUpdate={customPositionHandler}
>
<span class="popover-wrap">
<Layout noPadding noGap>
<div class="type-icon">
<Icon name={parsedComponentDef.icon} />
<span>{field.field}</span>
</div>
<slot name="header" />
<ComponentSettingsSection
componentInstance={pseudoComponentInstance}
{componentInstance}
componentDefinition={parsedComponentDef}
isScreen={false}
onUpdateSetting={updateSetting}
@ -141,20 +138,4 @@
.popover-wrap {
background-color: var(--spectrum-alias-background-color-primary);
}
.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

@ -7,7 +7,7 @@
getComponentBindableProperties,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import DraggableList from "../DraggableList.svelte"
import DraggableList from "../DraggableList/DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import { store, selectedScreen } from "builderStore"
import FieldSetting from "./FieldSetting.svelte"
@ -50,7 +50,7 @@
updateSanitsedFields(sanitisedValue)
unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
fieldList = [...sanitisedFields, ...unconfigured]
.map(buildSudoInstance)
.map(buildPseudoInstance)
.filter(x => x != null)
}
@ -104,7 +104,7 @@
})
}
const buildSudoInstance = instance => {
const buildPseudoInstance = instance => {
if (instance._component) {
return instance
}

View File

@ -1,8 +1,11 @@
<script>
import EditFieldPopover from "./EditFieldPopover.svelte"
import { Toggle } from "@budibase/bbui"
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { store } from "builderStore"
import { runtimeToReadableBinding } from "builderStore/dataBinding"
import { isJSBinding } from "@budibase/string-templates"
export let item
export let componentBindings
@ -16,18 +19,43 @@
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const getReadableText = () => {
if (item.label) {
return isJSBinding(item.label)
? "(JavaScript function)"
: runtimeToReadableBinding([...bindings, componentBindings], item.label)
}
return item.field
}
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
$: readableText = getReadableText(item)
$: componentDef = store.actions.components.getDefinition(item._component)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditFieldPopover
<EditComponentPopover
{anchor}
field={item}
componentInstance={item}
{componentBindings}
{bindings}
{parseSettings}
on:change
/>
<div class="field-label">{item.label || item.field}</div>
>
<div slot="header" class="type-icon">
<Icon name={componentDef.icon} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{readableText}</div>
</div>
<div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
@ -53,4 +81,20 @@
.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

@ -6,7 +6,7 @@
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { createEventDispatcher } from "svelte"
import { UNSORTABLE_TYPES } from "constants"
import { canBeSortColumn } from "@budibase/shared-core"
export let componentInstance = {}
export let value = ""
@ -20,7 +20,7 @@
const getSortableFields = schema => {
return Object.entries(schema || {})
.filter(entry => !UNSORTABLE_TYPES.includes(entry[1].type))
.filter(entry => canBeSortColumn(entry[1].type))
.map(entry => entry[0])
}

View File

@ -196,8 +196,36 @@
}
}
const validateQuery = async () => {
const forbiddenBindings = /{{\s?user(\.(\w|\$)*\s?|\s?)}}/g
const bindingError = new Error(
"'user' is a protected binding and cannot be used"
)
if (forbiddenBindings.test(url)) {
throw bindingError
}
if (forbiddenBindings.test(query.fields.requestBody ?? "")) {
throw bindingError
}
Object.values(requestBindings).forEach(bindingValue => {
if (forbiddenBindings.test(bindingValue)) {
throw bindingError
}
})
Object.values(query.fields.headers).forEach(headerValue => {
if (forbiddenBindings.test(headerValue)) {
throw bindingError
}
})
}
async function runQuery() {
try {
await validateQuery()
response = await queries.preview(buildQuery())
if (response.rows.length === 0) {
notifications.info("Request did not return any data")

View File

@ -34,8 +34,6 @@ export const UNEDITABLE_USER_FIELDS = [
"lastName",
]
export const UNSORTABLE_TYPES = ["formula", "attachment", "array", "link"]
export const LAYOUT_NAMES = {
MASTER: {
PRIVATE: "layout_private_master",

View File

@ -114,8 +114,9 @@
query: {
appId: query || !filterByAppAccess ? null : prodAppId,
email: query,
paginated: query || !filterByAppAccess ? null : false,
},
limit: 50,
paginate: query || !filterByAppAccess ? null : false,
})
await usersFetch.refresh()

View File

@ -23,5 +23,7 @@
</script>
{#key $params.datasourceId}
<slot />
{#if $datasources.selected}
<slot />
{/if}
{/key}

View File

@ -16,8 +16,7 @@
let selectedPanel = null
let panelOptions = []
// datasources.selected can return null temporarily on datasource deletion
$: datasource = $datasources.selected || {}
$: datasource = $datasources.selected
$: getOptions(datasource)

View File

@ -53,7 +53,8 @@
}
.alert-wrap {
display: flex;
width: 100%;
flex: 0 0 auto;
margin: -28px -40px 14px -40px;
}
.alert-wrap :global(> *) {
flex: 1;

View File

@ -1,10 +1,12 @@
<script>
import Panel from "components/design/Panel.svelte"
import { store, selectedComponent, selectedScreen } from "builderStore"
import { getComponentText } from "builderStore/componentUtils"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte"
import { notifications } from "@budibase/bbui"
import {
getBindableProperties,
@ -13,6 +15,14 @@
import { ActionButton } from "@budibase/bbui"
import { capitalise } from "helpers"
const onUpdateName = async value => {
try {
await store.actions.components.updateSetting("_instanceName", value)
} catch (error) {
notifications.error("Error updating component name")
}
}
$: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition(
$selectedComponent?._component
@ -39,6 +49,22 @@
{#if $selectedComponent}
{#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft wide>
<span class="panel-title-content" slot="panel-title-content">
<input
class="input"
value={title}
{title}
placeholder={getComponentText(componentInstance)}
on:keypress={e => {
if (e.key.toLowerCase() === "enter") {
e.target.blur()
}
}}
on:change={e => {
onUpdateName(e.target.value)
}}
/>
</span>
<span slot="panel-header-content">
<div class="settings-tabs">
{#each tabs as tab}
@ -65,7 +91,12 @@
/>
{/if}
{#if section == "styles"}
<DesignSection {componentInstance} {componentDefinition} {bindings} />
<DesignSection
{componentInstance}
{componentBindings}
{componentDefinition}
{bindings}
/>
<CustomStylesSection
{componentInstance}
{componentDefinition}
@ -90,4 +121,24 @@
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.input {
color: inherit;
font-family: inherit;
font-size: inherit;
background-color: transparent;
border: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-title-content {
display: contents;
}
.input:focus {
outline: none;
}
input::placeholder {
color: var(--spectrum-global-color-gray-600);
}
</style>

View File

@ -1,6 +1,6 @@
<script>
import { isEmpty } from "lodash/fp"
import { Input, DetailSummary, notifications } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core"
import { DetailSummary, notifications } from "@budibase/bbui"
import { store } from "builderStore"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
@ -16,19 +16,32 @@
export let isScreen = false
export let onUpdateSetting
export let showSectionTitle = true
export let showInstanceName = true
export let tag
$: sections = getSections(componentInstance, componentDefinition, isScreen)
$: sections = getSections(
componentInstance,
componentDefinition,
isScreen,
tag
)
const getSections = (instance, definition, isScreen) => {
const getSections = (instance, definition, isScreen, tag) => {
const settings = definition?.settings ?? []
const generalSettings = settings.filter(setting => !setting.section)
const customSections = settings.filter(setting => setting.section)
const generalSettings = settings.filter(
setting => !setting.section && setting.tag === tag
)
const customSections = settings.filter(
setting => setting.section && setting.tag === tag
)
let sections = [
{
name: "General",
settings: generalSettings,
},
...(generalSettings?.length
? [
{
name: "General",
settings: generalSettings,
},
]
: []),
...(customSections || []),
]
@ -70,41 +83,43 @@
}
const shouldDisplay = (instance, setting) => {
// Parse dependant settings
if (setting.dependsOn) {
let dependantSetting = setting.dependsOn
let dependantValue = null
let invert = !!setting.dependsOn.invert
if (typeof setting.dependsOn === "object") {
dependantSetting = setting.dependsOn.setting
dependantValue = setting.dependsOn.value
let dependsOn = setting.dependsOn
if (dependsOn && !Array.isArray(dependsOn)) {
dependsOn = [dependsOn]
}
if (!dependsOn?.length) {
return true
}
// Ensure all conditions are met
return dependsOn.every(condition => {
let dependantSetting = condition
let dependantValues = null
let invert = !!condition.invert
if (typeof condition === "object") {
dependantSetting = condition.setting
dependantValues = condition.value
}
if (!dependantSetting) {
return false
}
// If no specific value is depended upon, check if a value exists at all
// for the dependent setting
if (dependantValue == null) {
const currentValue = instance[dependantSetting]
if (currentValue === false) {
return false
}
if (currentValue === true) {
return true
}
return !isEmpty(currentValue)
// Ensure values is an array
if (!Array.isArray(dependantValues)) {
dependantValues = [dependantValues]
}
// Otherwise check the value matches
if (invert) {
return instance[dependantSetting] !== dependantValue
} else {
return instance[dependantSetting] === dependantValue
}
}
return typeof setting.visible == "boolean" ? setting.visible : true
// If inverting, we want to ensure that we don't have any matches.
// If not inverting, we want to ensure that we do have any matches.
const currentVal = helpers.deepGet(instance, dependantSetting)
const anyMatches = dependantValues.some(dependantVal => {
if (dependantVal == null) {
return currentVal != null && currentVal !== false && currentVal !== ""
}
return dependantVal === currentVal
})
return anyMatches !== invert
})
}
const canRenderControl = (instance, setting, isScreen) => {
@ -125,28 +140,19 @@
{#if section.visible}
<DetailSummary
name={showSectionTitle ? section.name : ""}
collapsible={false}
show={section.collapsed !== true}
>
{#if section.info}
<div class="section-info">
<InfoDisplay body={section.info} />
</div>
{:else if idx === 0 && section.name === "General" && componentDefinition.info}
{:else if idx === 0 && section.name === "General" && componentDefinition?.info && !tag}
<InfoDisplay
title={componentDefinition.name}
body={componentDefinition.info}
/>
{/if}
<div class="settings">
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen && showInstanceName}
<PropertyControl
control={Input}
label="Name"
key="_instanceName"
value={componentInstance._instanceName}
onChange={val => updateSetting({ key: "_instanceName" }, val)}
/>
{/if}
{#each section.settings as setting (setting.key)}
{#if setting.visible}
<PropertyControl
@ -189,7 +195,7 @@
</DetailSummary>
{/if}
{/each}
{#if componentDefinition?.block}
{#if componentDefinition?.block && !tag}
<DetailSummary name="Eject" collapsible={false}>
<EjectBlockButton />
</DetailSummary>

View File

@ -1,10 +1,12 @@
<script>
import StyleSection from "./StyleSection.svelte"
import * as ComponentStyles from "./componentStyles"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
export let componentDefinition
export let componentInstance
export let bindings
export let componentBindings
const getStyles = def => {
if (!def?.styles?.length) {
@ -22,6 +24,19 @@
$: styles = getStyles(componentDefinition)
</script>
<!--
Load any general settings or sections tagged as "style"
-->
<ComponentSettingsSection
{componentInstance}
{componentDefinition}
isScreen={false}
showInstanceName={false}
{bindings}
{componentBindings}
tag="style"
/>
{#if styles?.length > 0}
{#each styles as style}
<StyleSection

View File

@ -36,6 +36,7 @@
"heading",
"text",
"button",
"buttongroup",
"tag",
"spectrumcard",
"cardstat",

View File

@ -2,14 +2,16 @@
import { store, userSelectedResourceMap } from "builderStore"
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte"
import { capitalise } from "helpers"
import { notifications } from "@budibase/bbui"
import {
selectedComponentPath,
selectedComponent,
selectedScreen,
} from "builderStore"
import { findComponentPath } from "builderStore/componentUtils"
import {
findComponentPath,
getComponentText,
} from "builderStore/componentUtils"
import { get } from "svelte/store"
import { dndStore } from "./dndStore"
@ -35,16 +37,6 @@
return false
}
const getComponentText = component => {
if (component._instanceName) {
return component._instanceName
}
const type =
component._component.replace("@budibase/standard-components/", "") ||
"component"
return capitalise(type)
}
const getComponentIcon = component => {
const def = store.actions.components.getDefinition(component?._component)
return def?.icon

View File

@ -12,6 +12,7 @@
import { capitalise } from "helpers"
import { goto } from "@roxi/routify"
let mode
let pendingScreen
// Modal refs
@ -100,14 +101,15 @@
}
// Handler for NewScreenModal
export const show = mode => {
export const show = newMode => {
mode = newMode
selectedTemplates = null
blankScreenUrl = null
screenMode = mode
pendingScreen = null
screenAccessRole = Roles.BASIC
if (mode === "table") {
if (mode === "table" || mode === "grid") {
datasourceModal.show()
} else if (mode === "blank") {
let templates = getTemplates($tables.list)
@ -123,6 +125,7 @@
// Handler for DatasourceModal confirmation, move to screen access select
const confirmScreenDatasources = async ({ templates }) => {
console.log(templates)
selectedTemplates = templates
screenAccessRoleModal.show()
}
@ -177,6 +180,7 @@
<Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal
{mode}
onConfirm={confirmScreenDatasources}
initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
/>

View File

@ -7,6 +7,7 @@
import rowListScreen from "builderStore/store/screenTemplates/rowListScreen"
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
export let mode
export let onCancel
export let onConfirm
export let initialScreens = []
@ -24,7 +25,10 @@
screen => screen.resourceId !== resourceId
)
} else {
selectedScreens = [...selectedScreens, rowListScreen([datasource])[0]]
selectedScreens = [
...selectedScreens,
rowListScreen([datasource], mode)[0],
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -3,6 +3,7 @@
import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./blank.png"
import tableImage from "./table.png"
import gridImage from "./grid.png"
import CreateScreenModal from "./CreateScreenModal.svelte"
import { store } from "builderStore"
@ -43,6 +44,16 @@
<Body size="XS">View, edit and delete rows on a table</Body>
</div>
</div>
<div class="card" on:click={() => createScreenModal.show("grid")}>
<div class="image">
<img alt="" src={gridImage} />
</div>
<div class="text">
<Body size="S">Grid</Body>
<Body size="XS">View and manipulate rows on a grid</Body>
</div>
</div>
</div>
</CreationPage>
</div>

View File

@ -43,7 +43,7 @@
})
</script>
<TestimonialPage>
<TestimonialPage enabled={$organisation.testimonialsEnabled}>
<Layout gap="S" noPadding>
<img alt="logo" src={$organisation.logoUrl || Logo} />
<span class="heading-wrap">

View File

@ -53,7 +53,7 @@
})
</script>
<TestimonialPage>
<TestimonialPage enabled={$organisation.testimonialsEnabled}>
<Layout gap="S" noPadding>
{#if loaded}
<img alt="logo" src={$organisation.logoUrl || Logo} />

View File

@ -81,9 +81,9 @@ export function createDatasourcesStore() {
}))
}
const updateDatasource = response => {
const updateDatasource = (response, { ignoreErrors } = {}) => {
const { datasource, errors } = response
if (errors && Object.keys(errors).length > 0) {
if (!ignoreErrors && errors && Object.keys(errors).length > 0) {
throw new TableImportError(errors)
}
replaceDatasource(datasource._id, datasource)
@ -137,7 +137,7 @@ export function createDatasourcesStore() {
fetchSchema: integration.plus,
})
return updateDatasource(response)
return updateDatasource(response, { ignoreErrors: true })
}
const update = async ({ integration, datasource }) => {

View File

@ -258,6 +258,186 @@
"description": "Contains your app screens",
"static": true
},
"buttongroup": {
"name": "Button group",
"icon": "Button",
"hasChildren": false,
"settings": [
{
"section": true,
"name": "Buttons",
"settings": [
{
"type": "buttonConfiguration",
"key": "buttons",
"nested": true,
"defaultValue": [
{
"type": "cta",
"text": "Button 1"
},
{
"type": "primary",
"text": "Button 2"
}
]
}
]
},
{
"section": true,
"name": "Layout",
"settings": [
{
"type": "select",
"label": "Direction",
"key": "direction",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Column",
"value": "column",
"barIcon": "ViewColumn",
"barTitle": "Column layout"
},
{
"label": "Row",
"value": "row",
"barIcon": "ViewRow",
"barTitle": "Row layout"
}
],
"defaultValue": "row"
},
{
"type": "select",
"label": "Horiz. align",
"key": "hAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Left",
"value": "left",
"barIcon": "AlignLeft",
"barTitle": "Align left"
},
{
"label": "Center",
"value": "center",
"barIcon": "AlignCenter",
"barTitle": "Align center"
},
{
"label": "Right",
"value": "right",
"barIcon": "AlignRight",
"barTitle": "Align right"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveLeftRight",
"barTitle": "Align stretched horizontally"
}
],
"defaultValue": "left"
},
{
"type": "select",
"label": "Vert. align",
"key": "vAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Top",
"value": "top",
"barIcon": "AlignTop",
"barTitle": "Align top"
},
{
"label": "Middle",
"value": "middle",
"barIcon": "AlignMiddle",
"barTitle": "Align middle"
},
{
"label": "Bottom",
"value": "bottom",
"barIcon": "AlignBottom",
"barTitle": "Align bottom"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveUpDown",
"barTitle": "Align stretched vertically"
}
],
"defaultValue": "top"
},
{
"type": "select",
"label": "Size",
"key": "size",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Shrink",
"value": "shrink",
"barIcon": "Minimize",
"barTitle": "Shrink container"
},
{
"label": "Grow",
"value": "grow",
"barIcon": "Maximize",
"barTitle": "Grow container"
}
],
"defaultValue": "shrink"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
},
{
"type": "boolean",
"label": "Wrap",
"key": "wrap",
"showInBar": true,
"barIcon": "ModernGridView",
"barTitle": "Wrap"
}
]
}
]
},
"button": {
"name": "Button",
"description": "A basic html button that is ready for styling",
@ -2409,7 +2589,6 @@
"key": "disabled",
"defaultValue": false
},
{
"type": "text",
"label": "Initial form step",
@ -5305,6 +5484,12 @@
"key": "title",
"nested": true
},
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{
"section": true,
"dependsOn": {
@ -5385,38 +5570,6 @@
"section": true,
"name": "Fields",
"settings": [
{
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
},
{
"type": "fieldConfiguration",
"key": "fields",
@ -5436,6 +5589,40 @@
}
}
]
},
{
"tag": "style",
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"tag": "style",
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
}
],
"context": [
@ -5556,10 +5743,9 @@
"width": 600,
"height": 400
},
"info": "Grid Blocks are only compatible with internal or SQL tables",
"settings": [
{
"type": "table",
"type": "dataSource",
"label": "Data",
"key": "table",
"required": true
@ -5568,18 +5754,35 @@
"type": "columns/grid",
"label": "Columns",
"key": "columns",
"dependsOn": "table"
"dependsOn": [
"table",
{
"setting": "table.type",
"value": "custom",
"invert": true
}
]
},
{
"type": "filter",
"label": "Filtering",
"key": "initialFilter"
"key": "initialFilter",
"dependsOn": {
"setting": "table.type",
"value": "custom",
"invert": true
}
},
{
"type": "field/sortable",
"label": "Sort column",
"key": "initialSortColumn",
"placeholder": "Default"
"placeholder": "Default",
"dependsOn": {
"setting": "table.type",
"value": "custom",
"invert": true
}
},
{
"type": "select",
@ -5618,29 +5821,37 @@
"label": "Clicked row",
"key": "row"
}
],
"dependsOn": {
"setting": "allowEditRows",
"value": false
}
]
},
{
"type": "boolean",
"label": "Add rows",
"key": "allowAddRows",
"defaultValue": true
"defaultValue": true,
"dependsOn": {
"setting": "table.type",
"value": ["table", "viewV2"]
}
},
{
"type": "boolean",
"label": "Edit rows",
"key": "allowEditRows",
"defaultValue": true
"defaultValue": true,
"dependsOn": {
"setting": "table.type",
"value": ["table", "viewV2"]
}
},
{
"type": "boolean",
"label": "Delete rows",
"key": "allowDeleteRows",
"defaultValue": true
"defaultValue": true,
"dependsOn": {
"setting": "table.type",
"value": ["table", "viewV2"]
}
},
{
"type": "boolean",
@ -5713,4 +5924,4 @@
}
]
}
}
}

View File

@ -0,0 +1,37 @@
<script>
import BlockComponent from "../BlockComponent.svelte"
import Block from "../Block.svelte"
export let buttons = []
export let direction
export let hAlign
export let vAlign
export let gap = "S"
</script>
<Block>
<BlockComponent
type="container"
props={{
direction,
hAlign,
vAlign,
gap,
wrap: true,
}}
>
{#each buttons as { text, type, quiet, disabled, onClick, size }}
<BlockComponent
type="button"
props={{
text: text || "Button",
onClick,
type,
quiet,
disabled,
size,
}}
/>
{/each}
</BlockComponent>
</Block>

View File

@ -81,6 +81,7 @@
sortOrder: $fetch.sortOrder,
},
limit,
primaryDisplay: $fetch.definition?.primaryDisplay,
}
const createFetch = datasource => {

View File

@ -4,6 +4,7 @@
import { getContext } from "svelte"
import { Grid } from "@budibase/frontend-core"
// table is actually any datasource, but called table for legacy compatibility
export let table
export let allowAddRows = true
export let allowEditRows = true
@ -21,7 +22,6 @@
$: columnWhitelist = columns?.map(col => col.name)
$: schemaOverrides = getSchemaOverrides(columns)
$: handleRowClick = allowEditRows ? undefined : onRowClick
const getSchemaOverrides = columns => {
let overrides = {}
@ -58,7 +58,7 @@
showControls={false}
notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error}
on:rowclick={e => handleRowClick?.({ row: e.detail })}
on:rowclick={e => onRowClick?.({ row: e.detail })}
/>
</div>

View File

@ -12,6 +12,7 @@
export let fields
export let labelPosition
export let title
export let description
export let showDeleteButton
export let showSaveButton
export let saveButtonLabel
@ -98,6 +99,7 @@
fields: fieldsOrDefault,
labelPosition,
title,
description,
saveButtonLabel: saveLabel,
deleteButtonLabel: deleteLabel,
schema,

View File

@ -11,6 +11,7 @@
export let fields
export let labelPosition
export let title
export let description
export let saveButtonLabel
export let deleteButtonLabel
export let schema
@ -160,55 +161,71 @@
<BlockComponent
type="container"
props={{
direction: "row",
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
direction: "column",
gap: "S",
}}
order={0}
>
<BlockComponent
type="heading"
props={{ text: title || "" }}
type="container"
props={{
direction: "row",
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
}}
order={0}
/>
{#if renderButtons}
>
<BlockComponent
type="container"
props={{
direction: "row",
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
}}
type="heading"
props={{ text: title || "" }}
order={0}
/>
{#if renderButtons}
<BlockComponent
type="container"
props={{
direction: "row",
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
}}
order={1}
>
{#if renderDeleteButton}
<BlockComponent
type="button"
props={{
text: deleteButtonLabel,
onClick: onDelete,
quiet: true,
type: "secondary",
}}
order={0}
/>
{/if}
{#if renderSaveButton}
<BlockComponent
type="button"
props={{
text: saveButtonLabel,
onClick: onSave,
type: "cta",
}}
order={1}
/>
{/if}
</BlockComponent>
{/if}
</BlockComponent>
{#if description}
<BlockComponent
type="text"
props={{ text: description }}
order={1}
>
{#if renderDeleteButton}
<BlockComponent
type="button"
props={{
text: deleteButtonLabel,
onClick: onDelete,
quiet: true,
type: "secondary",
}}
order={0}
/>
{/if}
{#if renderSaveButton}
<BlockComponent
type="button"
props={{
text: saveButtonLabel,
onClick: onSave,
type: "cta",
}}
order={1}
/>
{/if}
</BlockComponent>
/>
{/if}
</BlockComponent>
{/if}

View File

@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte"
export { default as divider } from "./Divider.svelte"
export { default as screenslot } from "./ScreenSlot.svelte"
export { default as button } from "./Button.svelte"
export { default as buttongroup } from "./ButtonGroup.svelte"
export { default as repeater } from "./Repeater.svelte"
export { default as text } from "./Text.svelte"
export { default as layout } from "./Layout.svelte"

View File

@ -2,8 +2,8 @@
import { getContext } from "svelte"
import { Table } from "@budibase/bbui"
import SlotRenderer from "./SlotRenderer.svelte"
import { UnsortableTypes } from "../../../constants"
import { onDestroy } from "svelte"
import { canBeSortColumn } from "@budibase/shared-core"
export let dataProvider
export let columns
@ -32,7 +32,8 @@
$: loading = dataProvider?.loading ?? false
$: data = dataProvider?.rows || []
$: fullSchema = dataProvider?.schema ?? {}
$: fields = getFields(fullSchema, columns, false)
$: primaryDisplay = dataProvider?.primaryDisplay
$: fields = getFields(fullSchema, columns, false, primaryDisplay)
$: schema = getFilteredSchema(fullSchema, fields, hasChildren)
$: setSorting = getAction(
dataProvider?.id,
@ -55,18 +56,13 @@
}
}
const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection
let invalid = false
customColumns?.forEach(column => {
const columnName = typeof column === "string" ? column : column.name
if (schema[columnName] == null) {
invalid = true
}
})
// Use column selection if it exists
if (!invalid && customColumns?.length) {
const getFields = (
schema,
customColumns,
showAutoColumns,
primaryDisplay
) => {
if (customColumns?.length) {
return customColumns
}
@ -74,13 +70,38 @@
let columns = []
let autoColumns = []
Object.entries(schema).forEach(([field, fieldSchema]) => {
if (fieldSchema.visible === false) {
return
}
if (!fieldSchema?.autocolumn) {
columns.push(field)
} else if (showAutoColumns) {
autoColumns.push(field)
}
})
return columns.concat(autoColumns)
// Sort columns to respect grid metadata
const allCols = columns.concat(autoColumns)
return allCols.sort((a, b) => {
if (a === primaryDisplay) {
return -1
}
if (b === primaryDisplay) {
return 1
}
const aOrder = schema[a].order
const bOrder = schema[b].order
if (aOrder === bOrder) {
return 0
}
if (aOrder == null) {
return 1
}
if (bOrder == null) {
return -1
}
return aOrder < bOrder ? -1 : 1
})
}
const getFilteredSchema = (schema, fields, hasChildren) => {
@ -102,7 +123,7 @@
return
}
newSchema[columnName] = schema[columnName]
if (UnsortableTypes.includes(schema[columnName].type)) {
if (!canBeSortColumn(schema[columnName].type)) {
newSchema[columnName].sortable = false
}

View File

@ -1,13 +1,5 @@
import { FieldType as FieldTypes } from "@budibase/types"
export { FieldType as FieldTypes } from "@budibase/types"
export const UnsortableTypes = [
FieldTypes.FORMULA,
FieldTypes.ATTACHMENT,
FieldTypes.ARRAY,
FieldTypes.LINK,
]
export const ActionTypes = {
ValidateForm: "ValidateForm",
UpdateFieldValue: "UpdateFieldValue",

View File

@ -9,7 +9,9 @@ export const buildRelationshipEndpoints = API => ({
if (!tableId || !rowId) {
return []
}
const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` })
const response = await API.get({
url: `/api/${tableId}/${rowId}/enrich?field=${fieldName}`,
})
if (!fieldName) {
return response || []
} else {

View File

@ -21,6 +21,7 @@
export let invertX = false
export let invertY = false
export let contentLines = 1
export let hidden = false
const emptyError = writable(null)
@ -34,7 +35,7 @@
column.schema.autocolumn ||
column.schema.disabled ||
column.schema.type === "formula" ||
(!$config.canEditRows && row._id)
(!$config.canEditRows && !row._isNewRow)
// Register this cell API if the row is focused
$: {
@ -78,6 +79,7 @@
{focused}
{selectedUser}
{readonly}
{hidden}
error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}

View File

@ -10,6 +10,7 @@
export let defaultHeight = false
export let center = false
export let readonly = false
export let hidden = false
$: style = getStyle(width, selectedUser)
@ -30,6 +31,7 @@
class:error
class:center
class:readonly
class:hidden
class:default-height={defaultHeight}
class:selected-other={selectedUser != null}
class:alt={rowIdx % 2 === 1}
@ -81,6 +83,9 @@
.cell.center {
align-items: center;
}
.cell.hidden {
content-visibility: hidden;
}
/* Cell border */
.cell.focused:after,

View File

@ -1,9 +1,11 @@
<script>
import { getContext, onMount, tick } from "svelte"
import { canBeDisplayColumn } from "@budibase/shared-core"
import { canBeDisplayColumn, canBeSortColumn } from "@budibase/shared-core"
import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui"
import GridCell from "./GridCell.svelte"
import { getColumnIcon } from "../lib/utils"
import { debounce } from "../../../utils/utils"
import { FieldType, FormulaTypes } from "@budibase/types"
export let column
export let idx
@ -15,7 +17,7 @@
isResizing,
rand,
sort,
renderedColumns,
visibleColumns,
dispatch,
subscribe,
config,
@ -23,23 +25,70 @@
columns,
definition,
datasource,
schema,
focusedCellId,
filter,
inlineFilters,
} = getContext("grid")
const searchableTypes = [
FieldType.STRING,
FieldType.OPTIONS,
FieldType.NUMBER,
FieldType.BIGINT,
FieldType.ARRAY,
FieldType.LONGFORM,
]
let anchor
let open = false
let editIsOpen = false
let timeout
let popover
let searchValue
let input
$: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $renderedColumns.length - 1
$: ascendingLabel = ["number", "bigint"].includes(column.schema?.type)
? "low-high"
: "A-Z"
$: descendingLabel = ["number", "bigint"].includes(column.schema?.type)
? "high-low"
: "Z-A"
$: canMoveRight = orderable && idx < $visibleColumns.length - 1
$: sortingLabels = getSortingLabels(column.schema?.type)
$: searchable = isColumnSearchable(column)
$: resetSearchValue(column.name)
$: searching = searchValue != null
$: debouncedUpdateFilter(searchValue)
const getSortingLabels = type => {
switch (type) {
case FieldType.NUMBER:
case FieldType.BIGINT:
return {
ascending: "low-high",
descending: "high-low",
}
case FieldType.DATETIME:
return {
ascending: "old-new",
descending: "new-old",
}
default:
return {
ascending: "A-Z",
descending: "Z-A",
}
}
}
const resetSearchValue = name => {
searchValue = $inlineFilters?.find(x => x.id === `inline-${name}`)?.value
}
const isColumnSearchable = col => {
const { type, formulaType } = col.schema
return (
searchableTypes.includes(type) ||
(type === FieldType.FORMULA && formulaType === FormulaTypes.STATIC)
)
}
const editColumn = async () => {
editIsOpen = true
@ -119,16 +168,16 @@
// Generate new name
let newName = `${column.name} copy`
let attempts = 2
while ($definition.schema[newName]) {
while ($schema[newName]) {
newName = `${column.name} copy ${attempts++}`
}
// Save schema with new column
const existingColumnDefinition = $definition.schema[column.name]
const existingColumnDefinition = $schema[column.name]
await datasource.actions.saveDefinition({
...$definition,
schema: {
...$definition.schema,
...$schema,
[newName]: {
...existingColumnDefinition,
name: newName,
@ -140,12 +189,46 @@
})
}
const startSearching = async () => {
$focusedCellId = null
searchValue = ""
await tick()
input?.focus()
}
const onInputKeyDown = e => {
if (e.key === "Enter") {
updateFilter()
} else if (e.key === "Escape") {
input?.blur()
}
}
const stopSearching = () => {
searchValue = null
updateFilter()
}
const onBlurInput = () => {
if (searchValue === "") {
searchValue = null
}
updateFilter()
}
const updateFilter = () => {
filter.actions.addInlineFilter(column, searchValue)
}
const debouncedUpdateFilter = debounce(updateFilter, 250)
onMount(() => subscribe("close-edit-column", cancelEdit))
</script>
<div
class="header-cell"
class:open
class:searchable
class:searching
style="flex: 0 0 {column.width}px;"
bind:this={anchor}
class:disabled={$isReordering || $isResizing}
@ -160,30 +243,49 @@
defaultHeight
center
>
<Icon
size="S"
name={getColumnIcon(column)}
color={`var(--spectrum-global-color-gray-600)`}
/>
{#if searching}
<input
bind:this={input}
type="text"
bind:value={searchValue}
on:blur={onBlurInput}
on:click={() => focusedCellId.set(null)}
on:keydown={onInputKeyDown}
data-grid-ignore
/>
{/if}
<div class="column-icon">
<Icon size="S" name={getColumnIcon(column)} />
</div>
<div class="search-icon" on:click={startSearching}>
<Icon hoverable size="S" name="Search" />
</div>
<div class="name">
{column.label}
</div>
{#if sortedBy}
<div class="sort-indicator">
<Icon
size="S"
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"}
color="var(--spectrum-global-color-gray-600)"
/>
{#if searching}
<div class="clear-icon" on:click={stopSearching}>
<Icon hoverable size="S" name="Close" />
</div>
{:else}
{#if sortedBy}
<div class="sort-indicator">
<Icon
hoverable
size="S"
name={$sort.order === "descending"
? "SortOrderDown"
: "SortOrderUp"}
/>
</div>
{/if}
<div class="more-icon" on:click={() => (open = true)}>
<Icon hoverable size="S" name="MoreVertical" />
</div>
{/if}
<div class="more" on:click={() => (open = true)}>
<Icon
size="S"
name="MoreVertical"
color="var(--spectrum-global-color-gray-600)"
/>
</div>
</GridCell>
</div>
@ -231,16 +333,18 @@
<MenuItem
icon="SortOrderUp"
on:click={sortAscending}
disabled={column.name === $sort.column && $sort.order === "ascending"}
disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "ascending")}
>
Sort {ascendingLabel}
Sort {sortingLabels.ascending}
</MenuItem>
<MenuItem
icon="SortOrderDown"
on:click={sortDescending}
disabled={column.name === $sort.column && $sort.order === "descending"}
disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "descending")}
>
Sort {descendingLabel}
Sort {sortingLabels.descending}
</MenuItem>
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
Move left
@ -280,6 +384,29 @@
background: var(--grid-background-alt);
}
/* Icon colors */
.header-cell :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-600);
}
.header-cell :global(.spectrum-Icon.hoverable:hover) {
color: var(--spectrum-global-color-gray-800) !important;
cursor: pointer;
}
/* Search icon */
.search-icon {
display: none;
}
.header-cell.searchable:not(.open):hover .search-icon,
.header-cell.searchable.searching .search-icon {
display: block;
}
.header-cell.searchable:not(.open):hover .column-icon,
.header-cell.searchable.searching .column-icon {
display: none;
}
/* Main center content */
.name {
flex: 1 1 auto;
width: 0;
@ -287,23 +414,45 @@
text-overflow: ellipsis;
overflow: hidden;
}
.header-cell.searching .name {
opacity: 0;
pointer-events: none;
}
input {
display: none;
font-family: var(--font-sans);
outline: none;
border: 1px solid transparent;
background: transparent;
color: var(--spectrum-global-color-gray-800);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0 30px;
border-radius: 2px;
}
input:focus {
border: 1px solid var(--accent-color);
}
input:not(:focus) {
background: var(--spectrum-global-color-gray-200);
}
.header-cell.searching input {
display: block;
}
.more {
/* Right icons */
.more-icon {
display: none;
padding: 4px;
margin: 0 -4px;
}
.header-cell.open .more,
.header-cell:hover .more {
.header-cell.open .more-icon,
.header-cell:hover .more-icon {
display: block;
}
.more:hover {
cursor: pointer;
}
.more:hover :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-800) !important;
}
.header-cell.open .sort-indicator,
.header-cell:hover .sort-indicator {
display: none;

View File

@ -260,29 +260,31 @@
class:wrap={editable || contentLines > 1}
on:wheel={e => (focused ? e.stopPropagation() : null)}
>
{#each value || [] as relationship}
{#if relationship[primaryDisplay] || relationship.primaryDisplay}
<div class="badge">
<span
on:click={editable
? () => showRelationship(relationship._id)
: null}
>
{readable(
relationship[primaryDisplay] || relationship.primaryDisplay
)}
</span>
{#if editable}
<Icon
name="Close"
size="XS"
hoverable
on:click={() => toggleRow(relationship)}
/>
{/if}
</div>
{/if}
{/each}
{#if Array.isArray(value) && value.length}
{#each value as relationship}
{#if relationship[primaryDisplay] || relationship.primaryDisplay}
<div class="badge">
<span
on:click={editable
? () => showRelationship(relationship._id)
: null}
>
{readable(
relationship[primaryDisplay] || relationship.primaryDisplay
)}
</span>
{#if editable}
<Icon
name="Close"
size="XS"
hoverable
on:click={() => toggleRow(relationship)}
/>
{/if}
</div>
{/if}
{/each}
{/if}
{#if editable}
<div class="add" on:click={open}>
<Icon name="Add" size="S" />
@ -318,7 +320,7 @@
<div class="searching">
<ProgressCircle size="S" />
</div>
{:else if searchResults?.length}
{:else if Array.isArray(searchResults) && searchResults.length}
<div class="results">
{#each searchResults as row, idx}
<div

View File

@ -1,6 +1,7 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/shared-core"
const { sort, columns, stickyColumn } = getContext("grid")
@ -19,7 +20,7 @@
type: stickyColumn.schema?.type,
})
}
return [
options = [
...options,
...columns.map(col => ({
label: col.label || col.name,
@ -27,6 +28,7 @@
type: col.schema?.type,
})),
]
return options.filter(col => canBeSortColumn(col.type))
}
const getOrderOptions = (column, columnOptions) => {

View File

@ -141,7 +141,14 @@
</div>
</div>
{/if}
{#if $loaded}
{#if $error}
<div class="grid-error">
<div class="grid-error-title">There was a problem loading your grid</div>
<div class="grid-error-subtitle">
{$error}
</div>
</div>
{:else if $loaded}
<div class="grid-data-outer" use:clickOutside={ui.actions.blur}>
<div class="grid-data-inner">
<StickyColumn>
@ -171,13 +178,6 @@
</div>
</div>
</div>
{:else if $error}
<div class="grid-error">
<div class="grid-error-title">There was a problem loading your grid</div>
<div class="grid-error-subtitle">
{$error}
</div>
</div>
{/if}
{#if $loading && !$error}
<div in:fade|local={{ duration: 130 }} class="grid-loading">

View File

@ -7,7 +7,7 @@
const {
bounds,
renderedRows,
renderedColumns,
visibleColumns,
rowVerticalInversionIndex,
hoveredRowId,
dispatch,
@ -17,7 +17,7 @@
let body
$: renderColumnsWidth = $renderedColumns.reduce(
$: columnsWidth = $visibleColumns.reduce(
(total, col) => (total += col.width),
0
)
@ -47,7 +47,7 @@
<div
class="blank"
class:highlighted={$hoveredRowId === BlankRowID}
style="width:{renderColumnsWidth}px"
style="width:{columnsWidth}px"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")}

View File

@ -10,7 +10,7 @@
focusedCellId,
reorder,
selectedRows,
renderedColumns,
visibleColumns,
hoveredRowId,
selectedCellMap,
focusedRow,
@ -18,6 +18,8 @@
contentLines,
isDragging,
dispatch,
rows,
columnRenderMap,
} = getContext("grid")
$: rowSelected = !!$selectedRows[row._id]
@ -31,9 +33,9 @@
on:focus
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", row)}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
>
{#each $renderedColumns as column, columnIdx (column.name)}
{#each $visibleColumns as column, columnIdx}
{@const cellId = `${row._id}-${column.name}`}
<DataCell
{cellId}
@ -50,6 +52,7 @@
selectedUser={$selectedCellMap[cellId]}
width={column.width}
contentLines={$contentLines}
hidden={!$columnRenderMap[column.name]}
/>
{/each}
</div>

View File

@ -11,7 +11,6 @@
maxScrollLeft,
bounds,
hoveredRowId,
hiddenColumnsWidth,
menu,
} = getContext("grid")
@ -23,10 +22,10 @@
let initialTouchX
let initialTouchY
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth)
$: style = generateStyle($scroll, $rowHeight)
const generateStyle = (scroll, rowHeight, hiddenWidths) => {
const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0
const generateStyle = (scroll, rowHeight) => {
const offsetX = scrollHorizontally ? -1 * scroll.left : 0
const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
}

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