1
0
Fork 0
mirror of synced 2024-08-13 17:11:49 +12:00

Merge branch 'master' into master

This commit is contained in:
Duarte Velez Grilo 2023-10-27 10:08:24 +01:00 committed by GitHub
commit fbbc5d0f64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 1772 additions and 602 deletions

View file

@ -1,72 +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
build-args: BUDIBASE_VERSION=0.0.0+test
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
BUDIBASE_VERSION=0.0.0+test
tags: budibase/budibase-test:aas
file: ./hosting/single/Dockerfile.v2

View file

@ -51,7 +51,7 @@ http {
proxy_buffering off; proxy_buffering off;
set $csp_default "default-src 'self'"; 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_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_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'"; set $csp_base_uri "base-uri 'self'";

View file

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

View file

@ -33,7 +33,6 @@
"build:sdk": "lerna run --stream build:sdk", "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", "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": "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", "restore": "yarn run clean && yarn && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",

View file

@ -1,20 +1,20 @@
import env from "../../environment" import env from "../../environment"
import * as objectStore from "../objectStore" import * as objectStore from "../objectStore"
import * as cloudfront from "../cloudfront" 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 * Previously we used to serve the client library directly from Cloudfront, however
* we use the symlinked version produced by lerna, located in node modules. We link to this * due to issues with the domain we were unable to continue doing this - keeping
* via a specific endpoint (under /api/assets/client). * incase we are able to switch back to CDN path again in future.
* @param 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 version The version to retrieve.
* @return The URL to be inserted into appPackage response or server rendered
* app index file.
*/ */
export const clientLibraryUrl = (appId: string, version: string) => { export function clientLibraryCDNUrl(appId: string, version: string) {
if (env.isProd()) { let file = clientLibraryPath(appId)
let file = `${objectStore.sanitizeKey(appId)}/budibase-client.js`
if (env.CLOUDFRONT_CDN) { if (env.CLOUDFRONT_CDN) {
// append app version to bust the cache // append app version to bust the cache
if (version) { if (version) {
@ -26,12 +26,25 @@ export const clientLibraryUrl = (appId: string, version: string) => {
} else { } else {
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file) return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
} }
} else {
return `/api/assets/client`
}
} }
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) { if (env.CLOUDFRONT_CDN) {
return cloudfront.getPresignedUrl(s3Key) return cloudfront.getPresignedUrl(s3Key)
} else { } else {

View file

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

View file

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

View file

@ -1,6 +1,6 @@
const sanitize = require("sanitize-s3-objectkey") const sanitize = require("sanitize-s3-objectkey")
import AWS from "aws-sdk" import AWS from "aws-sdk"
import stream from "stream" import stream, { Readable } from "stream"
import fetch from "node-fetch" import fetch from "node-fetch"
import tar from "tar-fs" import tar from "tar-fs"
import zlib from "zlib" import zlib from "zlib"
@ -66,10 +66,10 @@ export function sanitizeBucket(input: string) {
* @return an S3 object store object, check S3 Nodejs SDK for usage. * @return an S3 object store object, check S3 Nodejs SDK for usage.
* @constructor * @constructor
*/ */
export const ObjectStore = ( export function ObjectStore(
bucket: string, bucket: string,
opts: { presigning: boolean } = { presigning: false } opts: { presigning: boolean } = { presigning: false }
) => { ) {
const config: any = { const config: any = {
s3ForcePathStyle: true, s3ForcePathStyle: true,
signatureVersion: "v4", signatureVersion: "v4",
@ -104,7 +104,7 @@ export const ObjectStore = (
* Given an object store and a bucket name this will make sure the bucket exists, * 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. * 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) bucketName = sanitizeBucket(bucketName)
try { try {
await client 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 * Uploads the contents of a file given the required parameters, useful when
* temp files in use (for example file uploaded as an attachment). * temp files in use (for example file uploaded as an attachment).
*/ */
export const upload = async ({ export async function upload({
bucket: bucketName, bucket: bucketName,
filename, filename,
path, path,
type, type,
metadata, metadata,
}: UploadParams) => { }: UploadParams) {
const extension = filename.split(".").pop() const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path) 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 * Similar to the upload function but can be used to send a file stream
* through to the object store. * through to the object store.
*/ */
export const streamUpload = async ( export async function streamUpload(
bucketName: string, bucketName: string,
filename: string, filename: string,
stream: any, stream: any,
extra = {} extra = {}
) => { ) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(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 * 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. * 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 objectStore = ObjectStore(bucketName)
const params = { const params = {
Bucket: sanitizeBucket(bucketName), 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 objectStore = ObjectStore(bucketName)
const list = (params: ListParams = {}) => { const list = (params: ListParams = {}) => {
return objectStore 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 * Generate a presigned url with a default TTL of 1 hour
*/ */
export const getPresignedUrl = ( export function getPresignedUrl(
bucketName: string, bucketName: string,
key: string, key: string,
durationSeconds: number = 3600 durationSeconds: number = 3600
) => { ) {
const objectStore = ObjectStore(bucketName, { presigning: true }) const objectStore = ObjectStore(bucketName, { presigning: true })
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: sanitizeBucket(bucketName),
@ -291,7 +291,7 @@ export const getPresignedUrl = (
/** /**
* Same as retrieval function but puts to a temporary file. * 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) bucketName = sanitizeBucket(bucketName)
filepath = sanitizeKey(filepath) filepath = sanitizeKey(filepath)
const data = await retrieve(bucketName, filepath) const data = await retrieve(bucketName, filepath)
@ -300,7 +300,7 @@ export const retrieveToTmp = async (bucketName: string, filepath: string) => {
return outputPath return outputPath
} }
export const retrieveDirectory = async (bucketName: string, path: string) => { export async function retrieveDirectory(bucketName: string, path: string) {
let writePath = join(budibaseTempDir(), v4()) let writePath = join(budibaseTempDir(), v4())
fs.mkdirSync(writePath) fs.mkdirSync(writePath)
const objects = await listAllObjects(bucketName, path) const objects = await listAllObjects(bucketName, path)
@ -324,7 +324,7 @@ export const retrieveDirectory = async (bucketName: string, path: string) => {
/** /**
* Delete a single file. * Delete a single file.
*/ */
export const deleteFile = async (bucketName: string, filepath: string) => { export async function deleteFile(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
const params = { const params = {
@ -334,7 +334,7 @@ export const deleteFile = async (bucketName: string, filepath: string) => {
return objectStore.deleteObject(params).promise() 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) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
const params = { const params = {
@ -349,10 +349,10 @@ export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
/** /**
* Delete a path, including everything within. * Delete a path, including everything within.
*/ */
export const deleteFolder = async ( export async function deleteFolder(
bucketName: string, bucketName: string,
folder: string folder: string
): Promise<any> => { ): Promise<any> {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
folder = sanitizeKey(folder) folder = sanitizeKey(folder)
const client = ObjectStore(bucketName) const client = ObjectStore(bucketName)
@ -383,11 +383,11 @@ export const deleteFolder = async (
} }
} }
export const uploadDirectory = async ( export async function uploadDirectory(
bucketName: string, bucketName: string,
localPath: string, localPath: string,
bucketPath: string bucketPath: string
) => { ) {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
let uploads = [] let uploads = []
const files = fs.readdirSync(localPath, { withFileTypes: true }) const files = fs.readdirSync(localPath, { withFileTypes: true })
@ -404,11 +404,11 @@ export const uploadDirectory = async (
return files return files
} }
export const downloadTarballDirect = async ( export async function downloadTarballDirect(
url: string, url: string,
path: string, path: string,
headers = {} headers = {}
) => { ) {
path = sanitizeKey(path) path = sanitizeKey(path)
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
@ -418,11 +418,11 @@ export const downloadTarballDirect = async (
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path)) await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path))
} }
export const downloadTarball = async ( export async function downloadTarball(
url: string, url: string,
bucketName: string, bucketName: string,
path: string path: string
) => { ) {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
path = sanitizeKey(path) path = sanitizeKey(path)
const response = await fetch(url) 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 the temporary path incase there is a use for it
return tmpPath 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,6 +5,7 @@ import {
encodeJSBinding, encodeJSBinding,
findHBSBlocks, findHBSBlocks,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { capitalise } from "helpers"
/** /**
* Recursively searches for a specific component ID * Recursively searches for a specific component ID
@ -235,3 +236,13 @@ export const makeComponentUnique = component => {
// Recurse on all children // Recurse on all children
return JSON.parse(definition) 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 { Screen } from "./utils/Screen"
import { Component } from "./utils/Component" import { Component } from "./utils/Component"
export default function (datasources) { export default function (datasources, mode = "table") {
if (!Array.isArray(datasources)) { if (!Array.isArray(datasources)) {
return [] return []
} }
return datasources.map(datasource => { return datasources.map(datasource => {
return { return {
name: `${datasource.label} - List`, name: `${datasource.label} - List`,
create: () => createScreen(datasource), create: () => createScreen(datasource, mode),
id: ROW_LIST_TEMPLATE, id: ROW_LIST_TEMPLATE,
resourceId: datasource.resourceId, resourceId: datasource.resourceId,
} }
@ -40,10 +40,24 @@ const generateTableBlock = datasource => {
return tableBlock 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() return new Screen()
.route(rowListUrl(datasource)) .route(rowListUrl(datasource))
.instanceName(`${datasource.label} - List`) .instanceName(`${datasource.label} - List`)
.addChild(generateTableBlock(datasource)) .addChild(
mode === "table"
? generateTableBlock(datasource)
: generateGridBlock(datasource)
)
.json() .json()
} }

View file

@ -24,17 +24,23 @@
let selectedRows = [] let selectedRows = []
let customRenderers = [] 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) $: selectedRows, dispatch("selectionUpdated", selectedRows)
$: isUsersTable = tableId === TableNames.USERS $: isUsersTable = tableId === TableNames.USERS
$: data && resetSelectedRows() $: data && resetSelectedRows()
$: {
Object.values(schema || {}).forEach(col => {
if (!canBeSortColumn(col.type)) {
col.sortable = false
}
})
}
$: { $: {
if (isUsersTable) { if (isUsersTable) {
customRenderers = [ customRenderers = [
@ -44,24 +50,24 @@
}, },
] ]
UNEDITABLE_USER_FIELDS.forEach(field => { UNEDITABLE_USER_FIELDS.forEach(field => {
if (schema[field]) { if (parsedSchema[field]) {
schema[field].editable = false parsedSchema[field].editable = false
} }
}) })
if (schema.email) { if (parsedSchema.email) {
schema.email.displayName = "Email" parsedSchema.email.displayName = "Email"
} }
if (schema.roleId) { if (parsedSchema.roleId) {
schema.roleId.displayName = "Role" parsedSchema.roleId.displayName = "Role"
} }
if (schema.firstName) { if (parsedSchema.firstName) {
schema.firstName.displayName = "First Name" parsedSchema.firstName.displayName = "First Name"
} }
if (schema.lastName) { if (parsedSchema.lastName) {
schema.lastName.displayName = "Last Name" parsedSchema.lastName.displayName = "Last Name"
} }
if (schema.status) { if (parsedSchema.status) {
schema.status.displayName = "Status" parsedSchema.status.displayName = "Status"
} }
} }
} }
@ -97,7 +103,7 @@
<div class="table-wrapper"> <div class="table-wrapper">
<Table <Table
{data} {data}
{schema} schema={parsedSchema}
{loading} {loading}
{customRenderers} {customRenderers}
{rowCount} {rowCount}

View file

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

View file

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

View file

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

View file

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

@ -1,10 +1,10 @@
<script> <script>
import { Icon } from "@budibase/bbui"
import { dndzone } from "svelte-dnd-action" import { dndzone } from "svelte-dnd-action"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { setContext } from "svelte" 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 items = []
export let showHandle = true export let showHandle = true
@ -12,6 +12,7 @@
export let listTypeProps = {} export let listTypeProps = {}
export let listItemKey export let listItemKey
export let draggable = true export let draggable = true
export let focus
let store = writable({ let store = writable({
selected: null, selected: null,
@ -27,6 +28,10 @@
setContext("draggable", store) setContext("draggable", store)
$: if (focus && store) {
get(store).actions.select(focus)
}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flipDurationMs = 150 const flipDurationMs = 150
@ -82,13 +87,16 @@
> >
{#each draggableItems as draggable (draggable.id)} {#each draggableItems as draggable (draggable.id)}
<li <li
on:mousedown={() => {
get(store).actions.select()
}}
bind:this={anchors[draggable.id]} bind:this={anchors[draggable.id]}
class:highlighted={draggable.id === $store.selected} class:highlighted={draggable.id === $store.selected}
> >
<div class="left-content"> <div class="left-content">
{#if showHandle} {#if showHandle}
<div class="handle" aria-label="drag-handle"> <div class="handle">
<Icon name="DragHandle" size="XL" /> <DragHandle />
</div> </div>
{/if} {/if}
</div> </div>
@ -142,8 +150,9 @@
border-top-right-radius: 4px; border-top-right-radius: 4px;
} }
.list-wrap > li:last-child { .list-wrap > li:last-child {
border-top-left-radius: var(--spectrum-table-regular-border-radius); border-bottom-left-radius: 4px;
border-top-right-radius: var(--spectrum-table-regular-border-radius); border-bottom-right-radius: 4px;
border-bottom: 0px;
} }
.right-content { .right-content {
flex: 1; flex: 1;
@ -153,4 +162,15 @@
padding-left: var(--spacing-s); padding-left: var(--spacing-s);
padding-right: 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> </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 { store } from "builderStore"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte" 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" import { getContext } from "svelte"
export let anchor export let anchor
export let field export let componentInstance
export let componentBindings export let componentBindings
export let bindings export let bindings
export let parseSettings
const draggable = getContext("draggable") const draggable = getContext("draggable")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let popover let popover
let drawers = [] let drawers = []
let pseudoComponentInstance
let open = false 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() popover.hide()
} }
$: if (field) { // Open automatically if the component is marked as selected
pseudoComponentInstance = field $: if (!open && $draggable.selected === componentInstance._id && popover) {
popover.show()
open = true
} }
$: componentDef = store.actions.components.getDefinition( $: componentDef = store.actions.components.getDefinition(
pseudoComponentInstance._component componentInstance._component
) )
$: parsedComponentDef = processComponentDefinitionSettings(componentDef) $: parsedComponentDef = processComponentDefinitionSettings(componentDef)
@ -36,17 +40,16 @@
return {} return {}
} }
const clone = cloneDeep(componentDef) const clone = cloneDeep(componentDef)
const updatedSettings = clone.settings
.filter(setting => setting.key !== "field") if (typeof parseSettings === "function") {
.map(setting => { clone.settings = parseSettings(clone.settings)
return { ...setting, nested: true } }
})
clone.settings = updatedSettings
return clone return clone
} }
const updateSetting = async (setting, value) => { const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(pseudoComponentInstance) const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = store.actions.components.updateComponentSetting( const patchFn = store.actions.components.updateComponentSetting(
setting.key, setting.key,
@ -54,12 +57,26 @@
) )
patchFn(nestedComponentInstance) patchFn(nestedComponentInstance)
const update = { dispatch("change", nestedComponentInstance)
...nestedComponentInstance,
active: pseudoComponentInstance.active,
} }
dispatch("change", update) 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
}
return { ...cfg, left, top }
} }
</script> </script>
@ -79,11 +96,11 @@
bind:this={popover} bind:this={popover}
on:open={() => { on:open={() => {
drawers = [] drawers = []
$draggable.actions.select(field._id) $draggable.actions.select(componentInstance._id)
}} }}
on:close={() => { on:close={() => {
open = false open = false
if ($draggable.selected == field._id) { if ($draggable.selected == componentInstance._id) {
$draggable.actions.select() $draggable.actions.select()
} }
}} }}
@ -92,33 +109,13 @@
showPopover={drawers.length == 0} showPopover={drawers.length == 0}
clickOutsideOverride={drawers.length > 0} clickOutsideOverride={drawers.length > 0}
maxHeight={600} maxHeight={600}
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => { handlePostionUpdate={customPositionHandler}
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 }
}}
> >
<span class="popover-wrap"> <span class="popover-wrap">
<Layout noPadding noGap> <Layout noPadding noGap>
<div class="type-icon"> <slot name="header" />
<Icon name={parsedComponentDef.icon} />
<span>{field.field}</span>
</div>
<ComponentSettingsSection <ComponentSettingsSection
componentInstance={pseudoComponentInstance} {componentInstance}
componentDefinition={parsedComponentDef} componentDefinition={parsedComponentDef}
isScreen={false} isScreen={false}
onUpdateSetting={updateSetting} onUpdateSetting={updateSetting}
@ -141,20 +138,4 @@
.popover-wrap { .popover-wrap {
background-color: var(--spectrum-alias-background-color-primary); 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> </style>

View file

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

View file

@ -1,8 +1,11 @@
<script> <script>
import EditFieldPopover from "./EditFieldPopover.svelte" import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle } from "@budibase/bbui" import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" 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 item
export let componentBindings export let componentBindings
@ -16,18 +19,43 @@
dispatch("change", { ...cloneDeep(item), active: e.detail }) 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> </script>
<div class="list-item-body"> <div class="list-item-body">
<div class="list-item-left"> <div class="list-item-left">
<EditFieldPopover <EditComponentPopover
{anchor} {anchor}
field={item} componentInstance={item}
{componentBindings} {componentBindings}
{bindings} {bindings}
{parseSettings}
on:change 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>
<div class="list-item-right"> <div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin /> <Toggle on:change={onToggle(item)} text="" value={item.active} thin />
@ -53,4 +81,20 @@
.list-item-body { .list-item-body {
justify-content: space-between; 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> </style>

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() { async function runQuery() {
try { try {
await validateQuery()
response = await queries.preview(buildQuery()) response = await queries.preview(buildQuery())
if (response.rows.length === 0) { if (response.rows.length === 0) {
notifications.info("Request did not return any data") notifications.info("Request did not return any data")

View file

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

View file

@ -1,10 +1,12 @@
<script> <script>
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { store, selectedComponent, selectedScreen } from "builderStore" import { store, selectedComponent, selectedScreen } from "builderStore"
import { getComponentText } from "builderStore/componentUtils"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte" import ConditionalUISection from "./ConditionalUISection.svelte"
import { notifications } from "@budibase/bbui"
import { import {
getBindableProperties, getBindableProperties,
@ -13,6 +15,14 @@
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { capitalise } from "helpers" 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 $: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition( $: componentDefinition = store.actions.components.getDefinition(
$selectedComponent?._component $selectedComponent?._component
@ -39,6 +49,22 @@
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id} {#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft wide> <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"> <span slot="panel-header-content">
<div class="settings-tabs"> <div class="settings-tabs">
{#each tabs as tab} {#each tabs as tab}
@ -65,7 +91,12 @@
/> />
{/if} {/if}
{#if section == "styles"} {#if section == "styles"}
<DesignSection {componentInstance} {componentDefinition} {bindings} /> <DesignSection
{componentInstance}
{componentBindings}
{componentDefinition}
{bindings}
/>
<CustomStylesSection <CustomStylesSection
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}
@ -90,4 +121,24 @@
padding: 0 var(--spacing-l); padding: 0 var(--spacing-l);
padding-bottom: 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> </style>

View file

@ -1,6 +1,6 @@
<script> <script>
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { Input, DetailSummary, notifications } from "@budibase/bbui" import { DetailSummary, notifications } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte" import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
@ -16,19 +16,32 @@
export let isScreen = false export let isScreen = false
export let onUpdateSetting export let onUpdateSetting
export let showSectionTitle = true 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 settings = definition?.settings ?? []
const generalSettings = settings.filter(setting => !setting.section) const generalSettings = settings.filter(
const customSections = settings.filter(setting => setting.section) setting => !setting.section && setting.tag === tag
)
const customSections = settings.filter(
setting => setting.section && setting.tag === tag
)
let sections = [ let sections = [
...(generalSettings?.length
? [
{ {
name: "General", name: "General",
settings: generalSettings, settings: generalSettings,
}, },
]
: []),
...(customSections || []), ...(customSections || []),
] ]
@ -127,28 +140,19 @@
{#if section.visible} {#if section.visible}
<DetailSummary <DetailSummary
name={showSectionTitle ? section.name : ""} name={showSectionTitle ? section.name : ""}
collapsible={false} show={section.collapsed !== true}
> >
{#if section.info} {#if section.info}
<div class="section-info"> <div class="section-info">
<InfoDisplay body={section.info} /> <InfoDisplay body={section.info} />
</div> </div>
{:else if idx === 0 && section.name === "General" && componentDefinition.info} {:else if idx === 0 && section.name === "General" && componentDefinition?.info && !tag}
<InfoDisplay <InfoDisplay
title={componentDefinition.name} title={componentDefinition.name}
body={componentDefinition.info} body={componentDefinition.info}
/> />
{/if} {/if}
<div class="settings"> <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)} {#each section.settings as setting (setting.key)}
{#if setting.visible} {#if setting.visible}
<PropertyControl <PropertyControl
@ -191,7 +195,7 @@
</DetailSummary> </DetailSummary>
{/if} {/if}
{/each} {/each}
{#if componentDefinition?.block} {#if componentDefinition?.block && !tag}
<DetailSummary name="Eject" collapsible={false}> <DetailSummary name="Eject" collapsible={false}>
<EjectBlockButton /> <EjectBlockButton />
</DetailSummary> </DetailSummary>

View file

@ -1,10 +1,12 @@
<script> <script>
import StyleSection from "./StyleSection.svelte" import StyleSection from "./StyleSection.svelte"
import * as ComponentStyles from "./componentStyles" import * as ComponentStyles from "./componentStyles"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
export let bindings export let bindings
export let componentBindings
const getStyles = def => { const getStyles = def => {
if (!def?.styles?.length) { if (!def?.styles?.length) {
@ -22,6 +24,19 @@
$: styles = getStyles(componentDefinition) $: styles = getStyles(componentDefinition)
</script> </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} {#if styles?.length > 0}
{#each styles as style} {#each styles as style}
<StyleSection <StyleSection

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@
import rowListScreen from "builderStore/store/screenTemplates/rowListScreen" import rowListScreen from "builderStore/store/screenTemplates/rowListScreen"
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte" import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
export let mode
export let onCancel export let onCancel
export let onConfirm export let onConfirm
export let initialScreens = [] export let initialScreens = []
@ -24,7 +25,10 @@
screen => screen.resourceId !== resourceId screen => screen.resourceId !== resourceId
) )
} else { } 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 CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./blank.png" import blankImage from "./blank.png"
import tableImage from "./table.png" import tableImage from "./table.png"
import gridImage from "./grid.png"
import CreateScreenModal from "./CreateScreenModal.svelte" import CreateScreenModal from "./CreateScreenModal.svelte"
import { store } from "builderStore" import { store } from "builderStore"
@ -43,6 +44,16 @@
<Body size="XS">View, edit and delete rows on a table</Body> <Body size="XS">View, edit and delete rows on a table</Body>
</div> </div>
</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> </div>
</CreationPage> </CreationPage>
</div> </div>

View file

@ -258,6 +258,186 @@
"description": "Contains your app screens", "description": "Contains your app screens",
"static": true "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": { "button": {
"name": "Button", "name": "Button",
"description": "A basic html button that is ready for styling", "description": "A basic html button that is ready for styling",
@ -2409,7 +2589,6 @@
"key": "disabled", "key": "disabled",
"defaultValue": false "defaultValue": false
}, },
{ {
"type": "text", "type": "text",
"label": "Initial form step", "label": "Initial form step",
@ -5321,6 +5500,12 @@
"key": "title", "key": "title",
"nested": true "nested": true
}, },
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"section": true, "section": true,
"dependsOn": { "dependsOn": {
@ -5401,38 +5586,6 @@
"section": true, "section": true,
"name": "Fields", "name": "Fields",
"settings": [ "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", "type": "fieldConfiguration",
"key": "fields", "key": "fields",
@ -5452,6 +5605,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": [ "context": [

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

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

View file

@ -11,6 +11,7 @@
export let fields export let fields
export let labelPosition export let labelPosition
export let title export let title
export let description
export let saveButtonLabel export let saveButtonLabel
export let deleteButtonLabel export let deleteButtonLabel
export let schema export let schema
@ -157,6 +158,14 @@
}} }}
> >
{#if renderHeader} {#if renderHeader}
<BlockComponent
type="container"
props={{
direction: "column",
gap: "S",
}}
order={0}
>
<BlockComponent <BlockComponent
type="container" type="container"
props={{ props={{
@ -211,6 +220,14 @@
</BlockComponent> </BlockComponent>
{/if} {/if}
</BlockComponent> </BlockComponent>
{#if description}
<BlockComponent
type="text"
props={{ text: description }}
order={1}
/>
{/if}
</BlockComponent>
{/if} {/if}
{#key fields} {#key fields}
<BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}> <BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}>

View file

@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte"
export { default as divider } from "./Divider.svelte" export { default as divider } from "./Divider.svelte"
export { default as screenslot } from "./ScreenSlot.svelte" export { default as screenslot } from "./ScreenSlot.svelte"
export { default as button } from "./Button.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 repeater } from "./Repeater.svelte"
export { default as text } from "./Text.svelte" export { default as text } from "./Text.svelte"
export { default as layout } from "./Layout.svelte" export { default as layout } from "./Layout.svelte"

View file

@ -21,6 +21,7 @@
export let invertX = false export let invertX = false
export let invertY = false export let invertY = false
export let contentLines = 1 export let contentLines = 1
export let hidden = false
const emptyError = writable(null) const emptyError = writable(null)
@ -78,6 +79,7 @@
{focused} {focused}
{selectedUser} {selectedUser}
{readonly} {readonly}
{hidden}
error={$error} error={$error}
on:click={() => focusedCellId.set(cellId)} on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)} on:contextmenu={e => menu.actions.open(cellId, e)}

View file

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

View file

@ -4,6 +4,8 @@
import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui" import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui"
import GridCell from "./GridCell.svelte" import GridCell from "./GridCell.svelte"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
import { debounce } from "../../../utils/utils"
import { FieldType, FormulaTypes } from "@budibase/types"
export let column export let column
export let idx export let idx
@ -15,7 +17,7 @@
isResizing, isResizing,
rand, rand,
sort, sort,
renderedColumns, visibleColumns,
dispatch, dispatch,
subscribe, subscribe,
config, config,
@ -24,23 +26,69 @@
definition, definition,
datasource, datasource,
schema, schema,
focusedCellId,
filter,
inlineFilters,
} = getContext("grid") } = getContext("grid")
const searchableTypes = [
FieldType.STRING,
FieldType.OPTIONS,
FieldType.NUMBER,
FieldType.BIGINT,
FieldType.ARRAY,
FieldType.LONGFORM,
]
let anchor let anchor
let open = false let open = false
let editIsOpen = false let editIsOpen = false
let timeout let timeout
let popover let popover
let searchValue
let input
$: sortedBy = column.name === $sort.column $: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0 $: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $renderedColumns.length - 1 $: canMoveRight = orderable && idx < $visibleColumns.length - 1
$: ascendingLabel = ["number", "bigint"].includes(column.schema?.type) $: sortingLabels = getSortingLabels(column.schema?.type)
? "low-high" $: searchable = isColumnSearchable(column)
: "A-Z" $: resetSearchValue(column.name)
$: descendingLabel = ["number", "bigint"].includes(column.schema?.type) $: searching = searchValue != null
? "high-low" $: debouncedUpdateFilter(searchValue)
: "Z-A"
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 () => { const editColumn = async () => {
editIsOpen = true editIsOpen = true
@ -141,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)) onMount(() => subscribe("close-edit-column", cancelEdit))
</script> </script>
<div <div
class="header-cell" class="header-cell"
class:open class:open
class:searchable
class:searching
style="flex: 0 0 {column.width}px;" style="flex: 0 0 {column.width}px;"
bind:this={anchor} bind:this={anchor}
class:disabled={$isReordering || $isResizing} class:disabled={$isReordering || $isResizing}
@ -161,30 +243,49 @@
defaultHeight defaultHeight
center center
> >
<Icon {#if searching}
size="S" <input
name={getColumnIcon(column)} bind:this={input}
color={`var(--spectrum-global-color-gray-600)`} 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"> <div class="name">
{column.label} {column.label}
</div> </div>
{#if searching}
<div class="clear-icon" on:click={stopSearching}>
<Icon hoverable size="S" name="Close" />
</div>
{:else}
{#if sortedBy} {#if sortedBy}
<div class="sort-indicator"> <div class="sort-indicator">
<Icon <Icon
hoverable
size="S" size="S"
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"} name={$sort.order === "descending"
color="var(--spectrum-global-color-gray-600)" ? "SortOrderDown"
: "SortOrderUp"}
/> />
</div> </div>
{/if} {/if}
<div class="more" on:click={() => (open = true)}> <div class="more-icon" on:click={() => (open = true)}>
<Icon <Icon hoverable size="S" name="MoreVertical" />
size="S"
name="MoreVertical"
color="var(--spectrum-global-color-gray-600)"
/>
</div> </div>
{/if}
</GridCell> </GridCell>
</div> </div>
@ -235,7 +336,7 @@
disabled={!canBeSortColumn(column.schema.type) || disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "ascending")} (column.name === $sort.column && $sort.order === "ascending")}
> >
Sort {ascendingLabel} Sort {sortingLabels.ascending}
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="SortOrderDown" icon="SortOrderDown"
@ -243,7 +344,7 @@
disabled={!canBeSortColumn(column.schema.type) || disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "descending")} (column.name === $sort.column && $sort.order === "descending")}
> >
Sort {descendingLabel} Sort {sortingLabels.descending}
</MenuItem> </MenuItem>
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}> <MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
Move left Move left
@ -283,6 +384,29 @@
background: var(--grid-background-alt); 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 { .name {
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
@ -290,23 +414,45 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; 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; display: none;
padding: 4px; padding: 4px;
margin: 0 -4px; margin: 0 -4px;
} }
.header-cell.open .more, .header-cell.open .more-icon,
.header-cell:hover .more { .header-cell:hover .more-icon {
display: block; 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.open .sort-indicator,
.header-cell:hover .sort-indicator { .header-cell:hover .sort-indicator {
display: none; display: none;

View file

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

View file

@ -10,7 +10,7 @@
focusedCellId, focusedCellId,
reorder, reorder,
selectedRows, selectedRows,
renderedColumns, visibleColumns,
hoveredRowId, hoveredRowId,
selectedCellMap, selectedCellMap,
focusedRow, focusedRow,
@ -19,6 +19,7 @@
isDragging, isDragging,
dispatch, dispatch,
rows, rows,
columnRenderMap,
} = getContext("grid") } = getContext("grid")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
@ -34,7 +35,7 @@
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(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}`} {@const cellId = `${row._id}-${column.name}`}
<DataCell <DataCell
{cellId} {cellId}
@ -51,6 +52,7 @@
selectedUser={$selectedCellMap[cellId]} selectedUser={$selectedCellMap[cellId]}
width={column.width} width={column.width}
contentLines={$contentLines} contentLines={$contentLines}
hidden={!$columnRenderMap[column.name]}
/> />
{/each} {/each}
</div> </div>

View file

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

View file

@ -5,14 +5,14 @@
import HeaderCell from "../cells/HeaderCell.svelte" import HeaderCell from "../cells/HeaderCell.svelte"
import { TempTooltip, TooltipType } from "@budibase/bbui" import { TempTooltip, TooltipType } from "@budibase/bbui"
const { renderedColumns, config, hasNonAutoColumn, datasource, loading } = const { visibleColumns, config, hasNonAutoColumn, datasource, loading } =
getContext("grid") getContext("grid")
</script> </script>
<div class="header"> <div class="header">
<GridScrollWrapper scrollHorizontally> <GridScrollWrapper scrollHorizontally>
<div class="row"> <div class="row">
{#each $renderedColumns as column, idx} {#each $visibleColumns as column, idx}
<HeaderCell {column} {idx}> <HeaderCell {column} {idx}>
<slot name="edit-column" /> <slot name="edit-column" />
</HeaderCell> </HeaderCell>

View file

@ -2,17 +2,16 @@
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { Icon, Popover, clickOutside } from "@budibase/bbui" import { Icon, Popover, clickOutside } from "@budibase/bbui"
const { renderedColumns, scroll, hiddenColumnsWidth, width, subscribe } = const { visibleColumns, scroll, width, subscribe } = getContext("grid")
getContext("grid")
let anchor let anchor
let open = false let open = false
$: columnsWidth = $renderedColumns.reduce( $: columnsWidth = $visibleColumns.reduce(
(total, col) => (total += col.width), (total, col) => (total += col.width),
0 0
) )
$: end = $hiddenColumnsWidth + columnsWidth - 1 - $scroll.left $: end = columnsWidth - 1 - $scroll.left
$: left = Math.min($width - 40, end) $: left = Math.min($width - 40, end)
const close = () => { const close = () => {
@ -34,7 +33,7 @@
<Popover <Popover
bind:open bind:open
{anchor} {anchor}
align={$renderedColumns.length ? "right" : "left"} align={$visibleColumns.length ? "right" : "left"}
offset={0} offset={0}
popoverTarget={document.getElementById(`add-column-button`)} popoverTarget={document.getElementById(`add-column-button`)}
customZindex={100} customZindex={100}

View file

@ -20,15 +20,18 @@
datasource, datasource,
subscribe, subscribe,
renderedRows, renderedRows,
renderedColumns, visibleColumns,
rowHeight, rowHeight,
hasNextPage, hasNextPage,
maxScrollTop, maxScrollTop,
rowVerticalInversionIndex, rowVerticalInversionIndex,
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
selectedRows, selectedRows,
loading, loaded,
refreshing,
config, config,
filter,
columnRenderMap,
} = getContext("grid") } = getContext("grid")
let visible = false let visible = false
@ -36,7 +39,7 @@
let newRow let newRow
let offset = 0 let offset = 0
$: firstColumn = $stickyColumn || $renderedColumns[0] $: firstColumn = $stickyColumn || $visibleColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0) $: width = GutterWidth + ($stickyColumn?.width || 0)
$: $datasource, (visible = false) $: $datasource, (visible = false)
$: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows) $: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
@ -153,7 +156,7 @@
<!-- New row FAB --> <!-- New row FAB -->
<TempTooltip <TempTooltip
text="Click here to create your first row" text="Click here to create your first row"
condition={hasNoRows && !$loading} condition={hasNoRows && $loaded && !$filter?.length && !$refreshing}
type={TooltipType.Info} type={TooltipType.Info}
> >
{#if !visible && !selectedRowCount && $config.canAddRows} {#if !visible && !selectedRowCount && $config.canAddRows}
@ -209,9 +212,8 @@
<div class="normal-columns" transition:fade|local={{ duration: 130 }}> <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally attachHandlers> <GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row"> <div class="row">
{#each $renderedColumns as column, columnIdx} {#each $visibleColumns as column, columnIdx}
{@const cellId = `new-${column.name}`} {@const cellId = `new-${column.name}`}
{#key cellId}
<DataCell <DataCell
{cellId} {cellId}
{column} {column}
@ -223,6 +225,7 @@
topRow={offset === 0} topRow={offset === 0}
invertX={columnIdx >= $columnHorizontalInversionIndex} invertX={columnIdx >= $columnHorizontalInversionIndex}
{invertY} {invertY}
hidden={!$columnRenderMap[column.name]}
> >
{#if column?.schema?.autocolumn} {#if column?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div> <div class="readonly-overlay">Can't edit auto column</div>
@ -231,7 +234,6 @@
<div in:fade={{ duration: 130 }} class="loading-overlay" /> <div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if} {/if}
</DataCell> </DataCell>
{/key}
{/each} {/each}
</div> </div>
</GridScrollWrapper> </GridScrollWrapper>

View file

@ -21,6 +21,7 @@
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
".spectrum-Modal", ".spectrum-Modal",
"#builder-side-panel-container", "#builder-side-panel-container",
"[data-grid-ignore]",
] ]
// Global key listener which intercepts all key events // Global key listener which intercepts all key events

View file

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { GutterWidth } from "../lib/constants" import { GutterWidth } from "../lib/constants"
const { resize, renderedColumns, stickyColumn, isReordering, scrollLeft } = const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } =
getContext("grid") getContext("grid")
$: offset = GutterWidth + ($stickyColumn?.width || 0) $: offset = GutterWidth + ($stickyColumn?.width || 0)
@ -26,7 +26,7 @@
<div class="resize-indicator" /> <div class="resize-indicator" />
</div> </div>
{/if} {/if}
{#each $renderedColumns as column} {#each $visibleColumns as column}
<div <div
class="resize-slider" class="resize-slider"
class:visible={activeColumn === column.name} class:visible={activeColumn === column.name}

View file

@ -1,8 +1,9 @@
import { derived, get, writable } from "svelte/store" import { derived, get } from "svelte/store"
import { getDatasourceDefinition } from "../../../fetch" import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { memo } from "../../../utils"
export const createStores = () => { export const createStores = () => {
const definition = writable(null) const definition = memo(null)
return { return {
definition, definition,
@ -10,10 +11,15 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { definition, schemaOverrides, columnWhitelist, datasource } = context const { API, definition, schemaOverrides, columnWhitelist, datasource } =
context
const schema = derived(definition, $definition => { const schema = derived(definition, $definition => {
let schema = $definition?.schema let schema = getDatasourceSchema({
API,
datasource: get(datasource),
definition: $definition,
})
if (!schema) { if (!schema) {
return null return null
} }

View file

@ -66,6 +66,8 @@ export const initialise = context => {
datasource, datasource,
sort, sort,
filter, filter,
inlineFilters,
allFilters,
nonPlus, nonPlus,
initialFilter, initialFilter,
initialSortColumn, initialSortColumn,
@ -87,6 +89,7 @@ export const initialise = context => {
// Wipe state // Wipe state
filter.set(get(initialFilter)) filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({ sort.set({
column: get(initialSortColumn), column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending", order: get(initialSortOrder) || "ascending",
@ -94,14 +97,14 @@ export const initialise = context => {
// Update fetch when filter changes // Update fetch when filter changes
unsubscribers.push( unsubscribers.push(
filter.subscribe($filter => { allFilters.subscribe($allFilters => {
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch
const $fetch = get(fetch) const $fetch = get(fetch)
if (!isSameDatasource($fetch?.options?.datasource, $datasource)) { if (!isSameDatasource($fetch?.options?.datasource, $datasource)) {
return return
} }
$fetch.update({ $fetch.update({
filter: $filter, filter: $allFilters,
}) })
}) })
) )

View file

@ -71,6 +71,8 @@ export const initialise = context => {
datasource, datasource,
fetch, fetch,
filter, filter,
inlineFilters,
allFilters,
sort, sort,
table, table,
initialFilter, initialFilter,
@ -93,6 +95,7 @@ export const initialise = context => {
// Wipe state // Wipe state
filter.set(get(initialFilter)) filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({ sort.set({
column: get(initialSortColumn), column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending", order: get(initialSortOrder) || "ascending",
@ -100,14 +103,14 @@ export const initialise = context => {
// Update fetch when filter changes // Update fetch when filter changes
unsubscribers.push( unsubscribers.push(
filter.subscribe($filter => { allFilters.subscribe($allFilters => {
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch
const $fetch = get(fetch) const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
} }
$fetch.update({ $fetch.update({
filter: $filter, filter: $allFilters,
}) })
}) })
) )

View file

@ -73,6 +73,8 @@ export const initialise = context => {
sort, sort,
rows, rows,
filter, filter,
inlineFilters,
allFilters,
subscribe, subscribe,
viewV2, viewV2,
initialFilter, initialFilter,
@ -97,6 +99,7 @@ export const initialise = context => {
// Reset state for new view // Reset state for new view
filter.set(get(initialFilter)) filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({ sort.set({
column: get(initialSortColumn), column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending", order: get(initialSortOrder) || "ascending",
@ -143,21 +146,19 @@ export const initialise = context => {
order: $sort.order || "ascending", order: $sort.order || "ascending",
}, },
}) })
await rows.actions.refreshData()
} }
} }
// Otherwise just update the fetch
else { // Also update the fetch to ensure the new sort is respected.
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch.
const $fetch = get(fetch) const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
} }
$fetch.update({ $fetch.update({
sortOrder: $sort.order || "ascending", sortOrder: $sort.order,
sortColumn: $sort.column, sortColumn: $sort.column,
}) })
}
}) })
) )
@ -176,20 +177,25 @@ export const initialise = context => {
...$view, ...$view,
query: $filter, query: $filter,
}) })
await rows.actions.refreshData()
} }
} }
// Otherwise just update the fetch })
else { )
// Keep fetch up to date with filters.
// If we're able to save filters against the view then we only need to apply
// inline filters to the fetch, as saved filters are applied server side.
// If we can't save filters, then all filters must be applied to the fetch.
unsubscribers.push(
allFilters.subscribe($allFilters => {
// Ensure we're updating the correct fetch // Ensure we're updating the correct fetch
const $fetch = get(fetch) const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return return
} }
$fetch.update({ $fetch.update({
filter: $filter, filter: $allFilters,
}) })
}
}) })
) )

View file

@ -1,13 +1,79 @@
import { writable, get } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { FieldType } from "@budibase/types"
export const createStores = context => { export const createStores = context => {
const { props } = context const { props } = context
// Initialise to default props // Initialise to default props
const filter = writable(get(props).initialFilter) const filter = writable(get(props).initialFilter)
const inlineFilters = writable([])
return { return {
filter, filter,
inlineFilters,
}
}
export const deriveStores = context => {
const { filter, inlineFilters } = context
const allFilters = derived(
[filter, inlineFilters],
([$filter, $inlineFilters]) => {
return [...($filter || []), ...$inlineFilters]
}
)
return {
allFilters,
}
}
export const createActions = context => {
const { filter, inlineFilters } = context
const addInlineFilter = (column, value) => {
const filterId = `inline-${column.name}`
const type = column.schema.type
let inlineFilter = {
field: column.name,
id: filterId,
operator: "string",
valueType: "value",
type,
value,
}
// Add overrides specific so the certain column type
if (type === FieldType.NUMBER) {
inlineFilter.value = parseFloat(value)
inlineFilter.operator = "equal"
} else if (type === FieldType.BIGINT) {
inlineFilter.operator = "equal"
} else if (type === FieldType.ARRAY) {
inlineFilter.operator = "contains"
}
// Add this filter
inlineFilters.update($inlineFilters => {
// Remove any existing inline filter for this column
$inlineFilters = $inlineFilters?.filter(x => x.id !== filterId)
// Add new one if a value exists
if (value) {
$inlineFilters.push(inlineFilter)
}
return $inlineFilters
})
}
return {
filter: {
...filter,
actions: {
addInlineFilter,
},
},
} }
} }

View file

@ -8,6 +8,7 @@ export const createStores = () => {
const rows = writable([]) const rows = writable([])
const loading = writable(false) const loading = writable(false)
const loaded = writable(false) const loaded = writable(false)
const refreshing = writable(false)
const rowChangeCache = writable({}) const rowChangeCache = writable({})
const inProgressChanges = writable({}) const inProgressChanges = writable({})
const hasNextPage = writable(false) const hasNextPage = writable(false)
@ -53,6 +54,7 @@ export const createStores = () => {
fetch, fetch,
rowLookupMap, rowLookupMap,
loaded, loaded,
refreshing,
loading, loading,
rowChangeCache, rowChangeCache,
inProgressChanges, inProgressChanges,
@ -66,7 +68,7 @@ export const createActions = context => {
rows, rows,
rowLookupMap, rowLookupMap,
definition, definition,
filter, allFilters,
loading, loading,
sort, sort,
datasource, datasource,
@ -82,6 +84,7 @@ export const createActions = context => {
notifications, notifications,
fetch, fetch,
isDatasourcePlus, isDatasourcePlus,
refreshing,
} = context } = context
const instanceLoaded = writable(false) const instanceLoaded = writable(false)
@ -108,7 +111,7 @@ export const createActions = context => {
// Tick to allow other reactive logic to update stores when datasource changes // Tick to allow other reactive logic to update stores when datasource changes
// before proceeding. This allows us to wipe filters etc if needed. // before proceeding. This allows us to wipe filters etc if needed.
await tick() await tick()
const $filter = get(filter) const $allFilters = get(allFilters)
const $sort = get(sort) const $sort = get(sort)
// Determine how many rows to fetch per page // Determine how many rows to fetch per page
@ -120,7 +123,7 @@ export const createActions = context => {
API, API,
datasource: $datasource, datasource: $datasource,
options: { options: {
filter: $filter, filter: $allFilters,
sortColumn: $sort.column, sortColumn: $sort.column,
sortOrder: $sort.order, sortOrder: $sort.order,
limit, limit,
@ -176,6 +179,9 @@ export const createActions = context => {
// Notify that we're loaded // Notify that we're loaded
loading.set(false) loading.set(false)
} }
// Update refreshing state
refreshing.set($fetch.loading)
}) })
fetch.set(newFetch) fetch.set(newFetch)

View file

@ -1,4 +1,4 @@
import { derived, get } from "svelte/store" import { derived } from "svelte/store"
import { import {
MaxCellRenderHeight, MaxCellRenderHeight,
MaxCellRenderWidthOverflow, MaxCellRenderWidthOverflow,
@ -50,12 +50,11 @@ export const deriveStores = context => {
const interval = MinColumnWidth const interval = MinColumnWidth
return Math.round($scrollLeft / interval) * interval return Math.round($scrollLeft / interval) * interval
}) })
const renderedColumns = derived( const columnRenderMap = derived(
[visibleColumns, scrollLeftRounded, width], [visibleColumns, scrollLeftRounded, width],
([$visibleColumns, $scrollLeft, $width], set) => { ([$visibleColumns, $scrollLeft, $width]) => {
if (!$visibleColumns.length) { if (!$visibleColumns.length) {
set([]) return {}
return
} }
let startColIdx = 0 let startColIdx = 0
let rightEdge = $visibleColumns[0].width let rightEdge = $visibleColumns[0].width
@ -75,34 +74,16 @@ export const deriveStores = context => {
leftEdge += $visibleColumns[endColIdx].width leftEdge += $visibleColumns[endColIdx].width
endColIdx++ endColIdx++
} }
// Render an additional column on either side to account for
// debounce column updates based on scroll position
const next = $visibleColumns.slice(
Math.max(0, startColIdx - 1),
endColIdx + 1
)
const current = get(renderedColumns)
if (JSON.stringify(next) !== JSON.stringify(current)) {
set(next)
}
}
)
const hiddenColumnsWidth = derived( // Only update the store if different
[renderedColumns, visibleColumns], let next = {}
([$renderedColumns, $visibleColumns]) => { $visibleColumns
const idx = $visibleColumns.findIndex( .slice(Math.max(0, startColIdx), endColIdx)
col => col.name === $renderedColumns[0]?.name .forEach(col => {
) next[col.name] = true
let width = 0 })
if (idx > 0) { return next
for (let i = 0; i < idx; i++) {
width += $visibleColumns[i].width
} }
}
return width
},
0
) )
// Determine the row index at which we should start vertically inverting cell // Determine the row index at which we should start vertically inverting cell
@ -130,12 +111,12 @@ export const deriveStores = context => {
// Determine the column index at which we should start horizontally inverting // Determine the column index at which we should start horizontally inverting
// cell dropdowns // cell dropdowns
const columnHorizontalInversionIndex = derived( const columnHorizontalInversionIndex = derived(
[renderedColumns, scrollLeft, width], [visibleColumns, scrollLeft, width],
([$renderedColumns, $scrollLeft, $width]) => { ([$visibleColumns, $scrollLeft, $width]) => {
const cutoff = $width + $scrollLeft - ScrollBarSize * 3 const cutoff = $width + $scrollLeft - ScrollBarSize * 3
let inversionIdx = $renderedColumns.length let inversionIdx = $visibleColumns.length
for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) { for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) { if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) {
break break
} }
@ -148,8 +129,7 @@ export const deriveStores = context => {
scrolledRowCount, scrolledRowCount,
visualRowCapacity, visualRowCapacity,
renderedRows, renderedRows,
renderedColumns, columnRenderMap,
hiddenColumnsWidth,
rowVerticalInversionIndex, rowVerticalInversionIndex,
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
} }

View file

@ -35,9 +35,28 @@ export default class ViewV2Fetch extends DataFetch {
} }
async getData() { async getData() {
const { datasource, limit, sortColumn, sortOrder, sortType, paginate } = const {
this.options datasource,
const { cursor, query } = get(this.store) limit,
sortColumn,
sortOrder,
sortType,
paginate,
filter,
} = this.options
const { cursor, query, definition } = get(this.store)
// If sort/filter params are not defined, update options to store the
// params built in to this view. This ensures that we can accurately
// compare old and new params and skip a redundant API call.
if (!sortColumn && definition.sort?.field) {
this.options.sortColumn = definition.sort.field
this.options.sortOrder = definition.sort.order
}
if (!filter?.length && definition.query?.length) {
this.options.filter = definition.query
}
try { try {
const res = await this.API.viewV2.fetch({ const res = await this.API.viewV2.fetch({
viewId: datasource.id, viewId: datasource.id,

View file

@ -32,12 +32,24 @@ export const fetchData = ({ API, datasource, options }) => {
return new Fetch({ API, datasource, ...options }) return new Fetch({ API, datasource, ...options })
} }
// Fetches the definition of any type of datasource // Creates an empty fetch instance with no datasource configured, so no data
export const getDatasourceDefinition = async ({ API, datasource }) => { // will initially be loaded
const createEmptyFetchInstance = ({ API, datasource }) => {
const handler = DataFetchMap[datasource?.type] const handler = DataFetchMap[datasource?.type]
if (!handler) { if (!handler) {
return null return null
} }
const instance = new handler({ API }) return new handler({ API })
return await instance.getDefinition(datasource) }
// Fetches the definition of any type of datasource
export const getDatasourceDefinition = async ({ API, datasource }) => {
const instance = createEmptyFetchInstance({ API, datasource })
return await instance?.getDefinition(datasource)
}
// Fetches the schema of any type of datasource
export const getDatasourceSchema = ({ API, datasource, definition }) => {
const instance = createEmptyFetchInstance({ API, datasource })
return instance?.getSchema(datasource, definition)
} }

View file

@ -44,7 +44,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates WORKDIR /string-templates
COPY packages/string-templates/package.json package.json COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates . COPY packages/string-templates .
@ -57,7 +57,7 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true \ RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \ && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp

View file

@ -1,4 +1,10 @@
import { context, db as dbCore, events, roles } from "@budibase/backend-core" import {
context,
db as dbCore,
events,
roles,
Header,
} from "@budibase/backend-core"
import { getUserMetadataParams, InternalTables } from "../../db/utils" import { getUserMetadataParams, InternalTables } from "../../db/utils"
import { Database, Role, UserCtx, UserRoles } from "@budibase/types" import { Database, Role, UserCtx, UserRoles } from "@budibase/types"
import { sdk as sharedSdk } from "@budibase/shared-core" import { sdk as sharedSdk } from "@budibase/shared-core"
@ -143,4 +149,20 @@ export async function accessible(ctx: UserCtx) {
} else { } else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!) ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
} }
// If a custom role is provided in the header, filter out higher level roles
const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
const inherits = (await roles.getRole(roleHeader))?.inherits
const orderedRoles = ctx.body.reverse()
let filteredRoles = [roleHeader]
for (let role of orderedRoles) {
filteredRoles = [role, ...filteredRoles]
if (role === inherits) {
break
}
}
filteredRoles.pop()
ctx.body = [roleHeader, ...filteredRoles]
}
} }

View file

@ -16,7 +16,7 @@ import AWS from "aws-sdk"
import fs from "fs" import fs from "fs"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
import { App } from "@budibase/types" import { App, Ctx } from "@budibase/types"
const send = require("koa-send") const send = require("koa-send")
@ -39,7 +39,7 @@ async function prepareUpload({ s3Key, bucket, metadata, file }: any) {
} }
} }
export const toggleBetaUiFeature = async function (ctx: any) { export const toggleBetaUiFeature = async function (ctx: Ctx) {
const cookieName = `beta:${ctx.params.feature}` const cookieName = `beta:${ctx.params.feature}`
if (ctx.cookies.get(cookieName)) { if (ctx.cookies.get(cookieName)) {
@ -67,16 +67,14 @@ export const toggleBetaUiFeature = async function (ctx: any) {
} }
} }
export const serveBuilder = async function (ctx: any) { export const serveBuilder = async function (ctx: Ctx) {
const builderPath = join(TOP_LEVEL_PATH, "builder") const builderPath = join(TOP_LEVEL_PATH, "builder")
await send(ctx, ctx.file, { root: builderPath }) await send(ctx, ctx.file, { root: builderPath })
} }
export const uploadFile = async function (ctx: any) { export const uploadFile = async function (ctx: Ctx) {
let files = const file = ctx.request?.files?.file
ctx.request.files.file.length > 1 let files = file && Array.isArray(file) ? Array.from(file) : [file]
? Array.from(ctx.request.files.file)
: [ctx.request.files.file]
const uploads = files.map(async (file: any) => { const uploads = files.map(async (file: any) => {
const fileExtension = [...file.name.split(".")].pop() const fileExtension = [...file.name.split(".")].pop()
@ -93,14 +91,14 @@ export const uploadFile = async function (ctx: any) {
ctx.body = await Promise.all(uploads) ctx.body = await Promise.all(uploads)
} }
export const deleteObjects = async function (ctx: any) { export const deleteObjects = async function (ctx: Ctx) {
ctx.body = await objectStore.deleteFiles( ctx.body = await objectStore.deleteFiles(
ObjectStoreBuckets.APPS, ObjectStoreBuckets.APPS,
ctx.request.body.keys ctx.request.body.keys
) )
} }
export const serveApp = async function (ctx: any) { export const serveApp = async function (ctx: Ctx) {
const bbHeaderEmbed = const bbHeaderEmbed =
ctx.request.get("x-budibase-embed")?.toLowerCase() === "true" ctx.request.get("x-budibase-embed")?.toLowerCase() === "true"
@ -181,7 +179,7 @@ export const serveApp = async function (ctx: any) {
} }
} }
export const serveBuilderPreview = async function (ctx: any) { export const serveBuilderPreview = async function (ctx: Ctx) {
const db = context.getAppDB({ skip_setup: true }) const db = context.getAppDB({ skip_setup: true })
const appInfo = await db.get<App>(DocumentType.APP_METADATA) const appInfo = await db.get<App>(DocumentType.APP_METADATA)
@ -197,18 +195,30 @@ export const serveBuilderPreview = async function (ctx: any) {
} }
} }
export const serveClientLibrary = async function (ctx: any) { export const serveClientLibrary = async function (ctx: Ctx) {
const appId = context.getAppId() || (ctx.request.query.appId as string)
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist") let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
// incase running from TS directly if (!appId) {
if (env.isDev() && !fs.existsSync(rootPath)) { ctx.throw(400, "No app ID provided - cannot fetch client library.")
rootPath = join(require.resolve("@budibase/client"), "..")
} }
if (env.isProd()) {
ctx.body = await objectStore.getReadStream(
ObjectStoreBuckets.APPS,
objectStore.clientLibraryPath(appId!)
)
ctx.set("Content-Type", "application/javascript")
} else if (env.isDev()) {
// incase running from TS directly
const tsPath = join(require.resolve("@budibase/client"), "..")
return send(ctx, "budibase-client.js", { return send(ctx, "budibase-client.js", {
root: rootPath, root: !fs.existsSync(rootPath) ? tsPath : rootPath,
}) })
} else {
ctx.throw(500, "Unable to retrieve client library.")
}
} }
export const getSignedUploadURL = async function (ctx: any) { export const getSignedUploadURL = async function (ctx: Ctx) {
// Ensure datasource is valid // Ensure datasource is valid
let datasource let datasource
try { try {
@ -247,7 +257,7 @@ export const getSignedUploadURL = async function (ctx: any) {
const params = { Bucket: bucket, Key: key } const params = { Bucket: bucket, Key: key }
signedUrl = s3.getSignedUrl("putObject", params) signedUrl = s3.getSignedUrl("putObject", params)
publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}` publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`
} catch (error) { } catch (error: any) {
ctx.throw(400, error) ctx.throw(400, error)
} }
} }

View file

@ -27,15 +27,9 @@ router.param("file", async (file: any, ctx: any, next: any) => {
return next() return next()
}) })
// only used in development for retrieving the client library,
// in production the client lib is always stored in the object store.
if (env.isDev()) {
router.get("/api/assets/client", controller.serveClientLibrary)
}
router router
// TODO: for now this builder endpoint is not authorized/secured, will need to be
.get("/builder/:file*", controller.serveBuilder) .get("/builder/:file*", controller.serveBuilder)
.get("/api/assets/client", controller.serveClientLibrary)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post( .post(
"/api/attachments/delete", "/api/attachments/delete",

View file

@ -158,5 +158,25 @@ describe("/roles", () => {
expect(res.body.length).toBe(1) expect(res.body.length).toBe(1)
expect(res.body[0]).toBe("PUBLIC") expect(res.body[0]).toBe("PUBLIC")
}) })
it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
await createRole({
name: `CUSTOM_ROLE`,
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
version: "name",
})
const res = await request
.get("/api/roles/accessible")
.set({
...config.defaultHeaders(),
"x-budibase-role": "CUSTOM_ROLE"
})
.expect(200)
expect(res.body.length).toBe(3)
expect(res.body[0]).toBe("CUSTOM_ROLE")
expect(res.body[1]).toBe("BASIC")
expect(res.body[2]).toBe("PUBLIC")
})
}) })
}) })

View file

@ -1,5 +1,5 @@
const setup = require("./utilities") const setup = require("./utilities")
const { basicScreen } = setup.structures const { basicScreen, powerScreen } = setup.structures
const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions") const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions")
const { roles } = require("@budibase/backend-core") const { roles } = require("@budibase/backend-core")
const { BUILTIN_ROLE_IDS } = roles const { BUILTIN_ROLE_IDS } = roles
@ -12,19 +12,14 @@ const route = "/test"
describe("/routing", () => { describe("/routing", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let screen, screen2 let basic, power
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
screen = basicScreen() basic = await config.createScreen(basicScreen(route))
screen.routing.route = route power = await config.createScreen(powerScreen(route))
screen = await config.createScreen(screen)
screen2 = basicScreen()
screen2.routing.roleId = BUILTIN_ROLE_IDS.POWER
screen2.routing.route = route
screen2 = await config.createScreen(screen2)
await config.publish() await config.publish()
}) })
@ -61,8 +56,8 @@ describe("/routing", () => {
expect(res.body.routes[route]).toEqual({ expect(res.body.routes[route]).toEqual({
subpaths: { subpaths: {
[route]: { [route]: {
screenId: screen._id, screenId: basic._id,
roleId: screen.routing.roleId roleId: basic.routing.roleId
} }
} }
}) })
@ -80,8 +75,8 @@ describe("/routing", () => {
expect(res.body.routes[route]).toEqual({ expect(res.body.routes[route]).toEqual({
subpaths: { subpaths: {
[route]: { [route]: {
screenId: screen2._id, screenId: power._id,
roleId: screen2.routing.roleId roleId: power.routing.roleId
} }
} }
}) })
@ -101,8 +96,8 @@ describe("/routing", () => {
expect(res.body.routes).toBeDefined() expect(res.body.routes).toBeDefined()
expect(res.body.routes[route].subpaths[route]).toBeDefined() expect(res.body.routes[route].subpaths[route]).toBeDefined()
const subpath = res.body.routes[route].subpaths[route] const subpath = res.body.routes[route].subpaths[route]
expect(subpath.screens[screen2.routing.roleId]).toEqual(screen2._id) expect(subpath.screens[power.routing.roleId]).toEqual(power._id)
expect(subpath.screens[screen.routing.roleId]).toEqual(screen._id) expect(subpath.screens[basic.routing.roleId]).toEqual(basic._id)
}) })
it("make sure it is a builder only endpoint", async () => { it("make sure it is a builder only endpoint", async () => {

View file

@ -1,7 +1,15 @@
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
import { BASE_LAYOUT_PROP_IDS } from "./layouts" import { BASE_LAYOUT_PROP_IDS } from "./layouts"
export function createHomeScreen() { export function createHomeScreen(
config: {
roleId: string
route: string
} = {
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
route: "/",
}
) {
return { return {
description: "", description: "",
url: "", url: "",
@ -40,8 +48,8 @@ export function createHomeScreen() {
gap: "M", gap: "M",
}, },
routing: { routing: {
route: "/", route: config.route,
roleId: roles.BUILTIN_ROLE_IDS.BASIC, roleId: config.roleId,
}, },
name: "home-screen", name: "home-screen",
} }

View file

@ -20,6 +20,7 @@ import {
SourceName, SourceName,
Table, Table,
} from "@budibase/types" } from "@budibase/types"
const { BUILTIN_ROLE_IDS } = roles
export function basicTable(): Table { export function basicTable(): Table {
return { return {
@ -322,8 +323,22 @@ export function basicUser(role: string) {
} }
} }
export function basicScreen() { export function basicScreen(route: string = "/") {
return createHomeScreen() return createHomeScreen({
roleId: BUILTIN_ROLE_IDS.BASIC,
route,
})
}
export function powerScreen(route: string = "/") {
return createHomeScreen({
roleId: BUILTIN_ROLE_IDS.POWER,
route,
})
}
export function customScreen(config: { roleId: string; route: string }) {
return createHomeScreen(config)
} }
export function basicLayout() { export function basicLayout() {

View file

@ -46,7 +46,7 @@ export enum MigrationName {
GLOBAL_INFO_SYNC_USERS = "global_info_sync_users", GLOBAL_INFO_SYNC_USERS = "global_info_sync_users",
TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions", TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions",
// increment this number to re-activate this migration // increment this number to re-activate this migration
SYNC_QUOTAS = "sync_quotas_1", SYNC_QUOTAS = "sync_quotas_2",
} }
export interface MigrationDefinition { export interface MigrationDefinition {

View file

@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates WORKDIR /string-templates
COPY packages/string-templates/package.json package.json COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates . COPY packages/string-templates .
@ -30,7 +30,7 @@ RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-te
RUN ../scripts/removeWorkspaceDependencies.sh package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
RUN apk del .gyp \ RUN apk del .gyp \
&& yarn cache clean && yarn cache clean

View file

@ -17,7 +17,7 @@
"test:notify": "node scripts/testResultsWebhook", "test:notify": "node scripts/testResultsWebhook",
"test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.", "test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.",
"test:cloud:qa": "yarn run test", "test:cloud:qa": "yarn run test",
"test:self:ci": "yarn run test --testPathIgnorePatterns=\\.integration\\. \\.cloud\\.", "test:self:ci": "yarn run test --testPathIgnorePatterns=\\.integration\\. \\.cloud\\. \\.license\\.",
"serve:test:self:ci": "start-server-and-test dev:built http://localhost:4001/health test:self:ci", "serve:test:self:ci": "start-server-and-test dev:built http://localhost:4001/health test:self:ci",
"serve": "start-server-and-test dev:built http://localhost:4001/health", "serve": "start-server-and-test dev:built http://localhost:4001/health",
"dev:built": "cd ../ && yarn dev:built" "dev:built": "cd ../ && yarn dev:built"

View file

@ -1,5 +1,5 @@
import AccountInternalAPIClient from "./AccountInternalAPIClient" import AccountInternalAPIClient from "./AccountInternalAPIClient"
import { AccountAPI, LicenseAPI, AuthAPI } from "./apis" import { AccountAPI, LicenseAPI, AuthAPI, StripeAPI } from "./apis"
import { State } from "../../types" import { State } from "../../types"
export default class AccountInternalAPI { export default class AccountInternalAPI {
@ -8,11 +8,13 @@ export default class AccountInternalAPI {
auth: AuthAPI auth: AuthAPI
accounts: AccountAPI accounts: AccountAPI
licenses: LicenseAPI licenses: LicenseAPI
stripe: StripeAPI
constructor(state: State) { constructor(state: State) {
this.client = new AccountInternalAPIClient(state) this.client = new AccountInternalAPIClient(state)
this.auth = new AuthAPI(this.client) this.auth = new AuthAPI(this.client)
this.accounts = new AccountAPI(this.client) this.accounts = new AccountAPI(this.client)
this.licenses = new LicenseAPI(this.client) this.licenses = new LicenseAPI(this.client)
this.stripe = new StripeAPI(this.client)
} }
} }

View file

@ -2,21 +2,19 @@ import AccountInternalAPIClient from "../AccountInternalAPIClient"
import { import {
Account, Account,
CreateOfflineLicenseRequest, CreateOfflineLicenseRequest,
GetLicenseKeyResponse,
GetOfflineLicenseResponse, GetOfflineLicenseResponse,
UpdateLicenseRequest, UpdateLicenseRequest,
} from "@budibase/types" } from "@budibase/types"
import { Response } from "node-fetch" import { Response } from "node-fetch"
import BaseAPI from "./BaseAPI" import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types" import { APIRequestOpts } from "../../../types"
export default class LicenseAPI extends BaseAPI { export default class LicenseAPI extends BaseAPI {
client: AccountInternalAPIClient client: AccountInternalAPIClient
constructor(client: AccountInternalAPIClient) { constructor(client: AccountInternalAPIClient) {
super() super()
this.client = client this.client = client
} }
async updateLicense( async updateLicense(
accountId: string, accountId: string,
body: UpdateLicenseRequest, body: UpdateLicenseRequest,
@ -29,9 +27,7 @@ export default class LicenseAPI extends BaseAPI {
}) })
}, opts) }, opts)
} }
// TODO: Better approach for setting tenant id header // TODO: Better approach for setting tenant id header
async createOfflineLicense( async createOfflineLicense(
accountId: string, accountId: string,
tenantId: string, tenantId: string,
@ -51,7 +47,6 @@ export default class LicenseAPI extends BaseAPI {
expect(response.status).toBe(opts.status ? opts.status : 201) expect(response.status).toBe(opts.status ? opts.status : 201)
return response return response
} }
async getOfflineLicense( async getOfflineLicense(
accountId: string, accountId: string,
tenantId: string, tenantId: string,
@ -69,4 +64,74 @@ export default class LicenseAPI extends BaseAPI {
expect(response.status).toBe(opts.status ? opts.status : 200) expect(response.status).toBe(opts.status ? opts.status : 200)
return [response, json] return [response, json]
} }
async getLicenseKey(
opts: { status?: number } = {}
): Promise<[Response, GetLicenseKeyResponse]> {
const [response, json] = await this.client.get(`/api/license/key`)
expect(response.status).toBe(opts.status || 200)
return [response, json]
}
async activateLicense(
apiKey: string,
tenantId: string,
licenseKey: string,
opts: APIRequestOpts = { status: 200 }
) {
return this.doRequest(() => {
return this.client.post(`/api/license/activate`, {
body: {
apiKey: apiKey,
tenantId: tenantId,
licenseKey: licenseKey,
},
})
}, opts)
}
async regenerateLicenseKey(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.post(`/api/license/key/regenerate`, {})
}, opts)
}
async getPlans(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/plans`)
}, opts)
}
async updatePlan(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.put(`/api/license/plan`)
}, opts)
}
async refreshAccountLicense(
accountId: string,
opts: { status?: number } = {}
): Promise<Response> {
const [response, json] = await this.client.post(
`/api/accounts/${accountId}/license/refresh`,
{
internal: true,
}
)
expect(response.status).toBe(opts.status ? opts.status : 201)
return response
}
async getLicenseUsage(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/license/usage`)
}, opts)
}
async licenseUsageTriggered(
opts: { status?: number } = {}
): Promise<Response> {
const [response, json] = await this.client.post(
`/api/license/usage/triggered`
)
expect(response.status).toBe(opts.status ? opts.status : 201)
return response
}
} }

View file

@ -0,0 +1,64 @@
import AccountInternalAPIClient from "../AccountInternalAPIClient"
import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types"
export default class StripeAPI extends BaseAPI {
client: AccountInternalAPIClient
constructor(client: AccountInternalAPIClient) {
super()
this.client = client
}
async createCheckoutSession(
priceId: string,
opts: APIRequestOpts = { status: 200 }
) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/checkout-session`, {
body: { priceId },
})
}, opts)
}
async checkoutSuccess(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/checkout-success`)
}, opts)
}
async createPortalSession(
stripeCustomerId: string,
opts: APIRequestOpts = { status: 200 }
) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/portal-session`, {
body: { stripeCustomerId },
})
}, opts)
}
async linkStripeCustomer(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/link`)
}, opts)
}
async getInvoices(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/stripe/invoices`)
}, opts)
}
async getUpcomingInvoice(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/stripe/upcoming-invoice`)
}, opts)
}
async getStripeCustomers(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/stripe/customers`)
}, opts)
}
}

View file

@ -1,3 +1,4 @@
export { default as AuthAPI } from "./AuthAPI" export { default as AuthAPI } from "./AuthAPI"
export { default as AccountAPI } from "./AccountAPI" export { default as AccountAPI } from "./AccountAPI"
export { default as LicenseAPI } from "./LicenseAPI" export { default as LicenseAPI } from "./LicenseAPI"
export { default as StripeAPI } from "./StripeAPI"

View file

@ -0,0 +1,68 @@
import TestConfiguration from "../../config/TestConfiguration"
import * as fixures from "../../fixtures"
import { Feature, Hosting } from "@budibase/types"
describe("license activation", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
it("creates, activates and deletes online license - self host", async () => {
// Remove existing license key
await config.internalApi.license.deleteLicenseKey()
// Verify license key not found
await config.internalApi.license.getLicenseKey({ status: 404 })
// Create self host account
const createAccountRequest = fixures.accounts.generateAccount({
hosting: Hosting.SELF,
})
const [createAccountRes, account] =
await config.accountsApi.accounts.create(createAccountRequest, {
autoVerify: true,
})
let licenseKey: string = " "
await config.doInNewState(async () => {
await config.loginAsAccount(createAccountRequest)
// Retrieve license key
const [res, body] = await config.accountsApi.licenses.getLicenseKey()
licenseKey = body.licenseKey
})
const accountId = account.accountId!
// Update license to have paid feature
const [res, acc] = await config.accountsApi.licenses.updateLicense(
accountId,
{
overrides: {
features: [Feature.APP_BACKUPS],
},
}
)
// Activate license key
await config.internalApi.license.activateLicenseKey({ licenseKey })
// Verify license updated with new feature
await config.doInNewState(async () => {
await config.loginAsAccount(createAccountRequest)
const [selfRes, body] = await config.api.accounts.self()
expect(body.license.features[0]).toBe("appBackups")
})
// Remove license key
await config.internalApi.license.deleteLicenseKey()
// Verify license key not found
await config.internalApi.license.getLicenseKey({ status: 404 })
})
})

View file

@ -1,17 +1,19 @@
import { Response } from "node-fetch" import { Response } from "node-fetch"
import { import {
ActivateLicenseKeyRequest,
ActivateOfflineLicenseTokenRequest, ActivateOfflineLicenseTokenRequest,
GetLicenseKeyResponse,
GetOfflineIdentifierResponse, GetOfflineIdentifierResponse,
GetOfflineLicenseTokenResponse, GetOfflineLicenseTokenResponse,
} from "@budibase/types" } from "@budibase/types"
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient" import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
import BaseAPI from "./BaseAPI" import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types"
export default class LicenseAPI extends BaseAPI { export default class LicenseAPI extends BaseAPI {
constructor(client: BudibaseInternalAPIClient) { constructor(client: BudibaseInternalAPIClient) {
super(client) super(client)
} }
async getOfflineLicenseToken( async getOfflineLicenseToken(
opts: { status?: number } = {} opts: { status?: number } = {}
): Promise<[Response, GetOfflineLicenseTokenResponse]> { ): Promise<[Response, GetOfflineLicenseTokenResponse]> {
@ -21,19 +23,16 @@ export default class LicenseAPI extends BaseAPI {
) )
return [response, body] return [response, body]
} }
async deleteOfflineLicenseToken(): Promise<[Response]> { async deleteOfflineLicenseToken(): Promise<[Response]> {
const [response] = await this.del(`/global/license/offline`, 204) const [response] = await this.del(`/global/license/offline`, 204)
return [response] return [response]
} }
async activateOfflineLicenseToken( async activateOfflineLicenseToken(
body: ActivateOfflineLicenseTokenRequest body: ActivateOfflineLicenseTokenRequest
): Promise<[Response]> { ): Promise<[Response]> {
const [response] = await this.post(`/global/license/offline`, body) const [response] = await this.post(`/global/license/offline`, body)
return [response] return [response]
} }
async getOfflineIdentifier(): Promise< async getOfflineIdentifier(): Promise<
[Response, GetOfflineIdentifierResponse] [Response, GetOfflineIdentifierResponse]
> { > {
@ -42,4 +41,23 @@ export default class LicenseAPI extends BaseAPI {
) )
return [response, body] return [response, body]
} }
async getLicenseKey(
opts: { status?: number } = {}
): Promise<[Response, GetLicenseKeyResponse]> {
const [response, body] = await this.get(`/global/license/key`, opts.status)
return [response, body]
}
async activateLicenseKey(
body: ActivateLicenseKeyRequest
): Promise<[Response]> {
const [response] = await this.post(`/global/license/key`, body)
return [response]
}
async deleteLicenseKey(): Promise<[Response]> {
const [response] = await this.del(`/global/license/key`, 204)
return [response]
}
} }

View file

@ -0,0 +1,8 @@
#!/bin/bash
version=$1
echo "Setting version $version"
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn