Merge branch 'master' of github.com:Budibase/budibase into form-block-field-layout

This commit is contained in:
Andrew Kingston 2023-10-27 08:07:57 +01:00
commit acc1e6f14c
87 changed files with 1229 additions and 631 deletions

View File

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

View File

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

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

@ -118,6 +118,10 @@ EXPOSE 443
EXPOSE 2222 EXPOSE 2222
VOLUME /data VOLUME /data
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh" HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh"

View File

@ -1,5 +1,5 @@
{ {
"version": "2.11.41", "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

@ -119,8 +119,8 @@ export class Writethrough {
this.writeRateMs = writeRateMs this.writeRateMs = writeRateMs
} }
async put(doc: any, writeRateMs: number = this.writeRateMs) { async put(doc: any) {
return put(this.db, doc, writeRateMs) return put(this.db, doc, this.writeRateMs)
} }
async get(id: string) { async get(id: string) {

View File

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

View File

@ -1,37 +1,50 @@
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) { file += `?v=${version}`
file += `?v=${version}`
}
// don't need to use presigned for client with cloudfront
// file is public
return cloudfront.getUrl(file)
} else {
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
} }
// don't need to use presigned for client with cloudfront
// file is public
return cloudfront.getUrl(file)
} else { } else {
return `/api/assets/client` return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
} }
} }
export const getAppFileUrl = (s3Key: string) => { export function clientLibraryUrl(appId: string, version: string) {
let tenantId, qsParams: { appId: string; version: string; tenantId?: string }
try {
tenantId = getTenantId()
} finally {
qsParams = {
appId,
version,
}
}
if (tenantId && tenantId !== DEFAULT_TENANT_ID) {
qsParams.tenantId = tenantId
}
return `/api/assets/client?${qs.encode(qsParams)}`
}
export function getAppFileUrl(s3Key: string) {
if (env.CLOUDFRONT_CDN) { 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

@ -25,17 +25,12 @@ import {
import { import {
getAccountHolderFromUserIds, getAccountHolderFromUserIds,
isAdmin, isAdmin,
isCreator,
validateUniqueUser, validateUniqueUser,
} from "./utils" } from "./utils"
import { searchExistingEmails } from "./lookup" import { searchExistingEmails } from "./lookup"
import { hash } from "../utils" import { hash } from "../utils"
type QuotaUpdateFn = ( type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
change: number,
creatorsChange: number,
cb?: () => Promise<any>
) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any> type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean> type FeatureFn = () => Promise<Boolean>
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]> type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
@ -250,8 +245,7 @@ export class UserDB {
} }
const change = dbUser ? 0 : 1 // no change if there is existing user const change = dbUser ? 0 : 1 // no change if there is existing user
const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0 return UserDB.quotas.addUsers(change, async () => {
return UserDB.quotas.addUsers(change, creatorsChange, async () => {
await validateUniqueUser(email, tenantId) await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser) let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
@ -313,7 +307,6 @@ export class UserDB {
let usersToSave: any[] = [] let usersToSave: any[] = []
let newUsers: any[] = [] let newUsers: any[] = []
let newCreators: any[] = []
const emails = newUsersRequested.map((user: User) => user.email) const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails) const existingEmails = await searchExistingEmails(emails)
@ -334,66 +327,59 @@ export class UserDB {
} }
newUser.userGroups = groups newUser.userGroups = groups
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) {
newCreators.push(newUser)
}
} }
const account = await accountSdk.getAccountByTenantId(tenantId) const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers( return UserDB.quotas.addUsers(newUsers.length, async () => {
newUsers.length, // create the promises array that will be called by bulkDocs
newCreators.length, newUsers.forEach((user: any) => {
async () => { usersToSave.push(
// create the promises array that will be called by bulkDocs UserDB.buildUser(
newUsers.forEach((user: any) => { user,
usersToSave.push( {
UserDB.buildUser( hashPassword: true,
user, requirePassword: user.requirePassword,
{ },
hashPassword: true, tenantId,
requirePassword: user.requirePassword, undefined, // no dbUser
}, account
tenantId,
undefined, // no dbUser
account
)
) )
}) )
})
const usersToBulkSave = await Promise.all(usersToSave) const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. events and cache operations // Post-processing of bulk added users, e.g. events and cache operations
for (const user of usersToBulkSave) { for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db // TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation // instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email) await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined) await eventHelpers.handleSaveEvents(user, undefined)
}
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
} }
)
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
})
} }
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> { static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
@ -433,12 +419,11 @@ export class UserDB {
_deleted: true, _deleted: true,
})) }))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
const creatorsToDelete = usersToDelete.filter(isCreator)
await UserDB.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) { for (let user of usersToDelete) {
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length)
// Build Response // Build Response
// index users by id // index users by id
@ -487,8 +472,7 @@ export class UserDB {
await db.remove(userId, dbUser._rev) await db.remove(userId, dbUser._rev)
const creatorsToDelete = isCreator(dbUser) ? 1 : 0 await UserDB.quotas.removeUsers(1)
await UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" }) await sessions.invalidateSessions(userId, { reason: "deletion" })

View File

@ -14,11 +14,11 @@ import {
} from "../db" } from "../db"
import { import {
BulkDocsResponse, BulkDocsResponse,
ContextUser,
SearchQuery, SearchQuery,
SearchQueryOperators, SearchQueryOperators,
SearchUsersRequest, SearchUsersRequest,
User, User,
ContextUser,
DatabaseQueryOpts, DatabaseQueryOpts,
} from "@budibase/types" } from "@budibase/types"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"

View File

@ -1,54 +0,0 @@
const _ = require('lodash/fp')
const {structures} = require("../../../tests")
jest.mock("../../../src/context")
jest.mock("../../../src/db")
const context = require("../../../src/context")
const db = require("../../../src/db")
const {getCreatorCount} = require('../../../src/users/users')
describe("Users", () => {
let getGlobalDBMock
let getGlobalUserParamsMock
let paginationMock
beforeEach(() => {
jest.resetAllMocks()
getGlobalDBMock = jest.spyOn(context, "getGlobalDB")
getGlobalUserParamsMock = jest.spyOn(db, "getGlobalUserParams")
paginationMock = jest.spyOn(db, "pagination")
})
it("Retrieves the number of creators", async () => {
const getUsers = (offset, limit, creators = false) => {
const range = _.range(offset, limit)
const opts = creators ? {builder: {global: true}} : undefined
return range.map(() => structures.users.user(opts))
}
const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true)
getGlobalDBMock.mockImplementation(() => ({
name : "fake-db",
allDocs: () => ({
rows: [...page1Data, ...page2Data]
})
}))
paginationMock.mockImplementationOnce(() => ({
data: page1Data,
hasNextPage: true,
nextPage: "1"
}))
paginationMock.mockImplementation(() => ({
data: page2Data,
hasNextPage: false,
nextPage: undefined
}))
const creatorsCount = await getCreatorCount()
expect(creatorsCount).toBe(4)
expect(paginationMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -123,10 +123,6 @@ export function customer(): Customer {
export function subscription(): Subscription { export function subscription(): Subscription {
return { return {
amount: 10000, amount: 10000,
amounts: {
user: 10000,
creator: 0,
},
cancelAt: undefined, cancelAt: undefined,
currency: "usd", currency: "usd",
currentPeriodEnd: 0, currentPeriodEnd: 0,
@ -135,10 +131,6 @@ export function subscription(): Subscription {
duration: PriceDuration.MONTHLY, duration: PriceDuration.MONTHLY,
pastDueAt: undefined, pastDueAt: undefined,
quantity: 0, quantity: 0,
quantities: {
user: 0,
creator: 0,
},
status: "active", status: "active",
} }
} }

View File

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

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

@ -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

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

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}
@ -90,4 +116,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,7 +16,6 @@
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
$: sections = getSections(componentInstance, componentDefinition, isScreen) $: sections = getSections(componentInstance, componentDefinition, isScreen)
@ -127,7 +126,7 @@
{#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">
@ -140,15 +139,6 @@
/> />
{/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

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

@ -5669,6 +5669,12 @@
"key": "title", "key": "title",
"nested": true "nested": true
}, },
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"section": true, "section": true,
"dependsOn": { "dependsOn": {

View File

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

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

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

View File

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

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 sortedBy}
<div class="sort-indicator"> {#if searching}
<Icon <div class="clear-icon" on:click={stopSearching}>
size="S" <Icon hoverable size="S" name="Close" />
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"} </div>
color="var(--spectrum-global-color-gray-600)" {:else}
/> {#if sortedBy}
<div class="sort-indicator">
<Icon
hoverable
size="S"
name={$sort.order === "descending"
? "SortOrderDown"
: "SortOrderUp"}
/>
</div>
{/if}
<div class="more-icon" on:click={() => (open = true)}>
<Icon hoverable size="S" name="MoreVertical" />
</div> </div>
{/if} {/if}
<div class="more" on:click={() => (open = true)}>
<Icon
size="S"
name="MoreVertical"
color="var(--spectrum-global-color-gray-600)"
/>
</div>
</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,29 +212,28 @@
<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} {updateValue}
{updateValue} rowFocused
rowFocused row={newRow}
row={newRow} focused={$focusedCellId === cellId}
focused={$focusedCellId === cellId} width={column.width}
width={column.width} 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>
{/if} {/if}
{#if isAdding} {#if isAdding}
<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({
sortOrder: $sort.order || "ascending",
sortColumn: $sort.column,
})
} }
$fetch.update({
sortOrder: $sort.order,
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 { )
// Ensure we're updating the correct fetch
const $fetch = get(fetch) // Keep fetch up to date with filters.
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { // If we're able to save filters against the view then we only need to apply
return // 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.
$fetch.update({ unsubscribers.push(
filter: $filter, allFilters.subscribe($allFilters => {
}) // Ensure we're updating the correct fetch
const $fetch = get(fetch)
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return
} }
$fetch.update({
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)
} }

@ -1 +1 @@
Subproject commit 56d968bfe6998e1077d3fce4eb1c9e483d1d6fc9 Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376

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
@ -67,6 +67,11 @@ COPY packages/server/docker_run.sh .
COPY packages/server/builder/ builder/ COPY packages/server/builder/ builder/
COPY packages/server/client/ client/ COPY packages/server/client/ client/
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
EXPOSE 4001 EXPOSE 4001
# have to add node environment production after install # have to add node environment production after install

View File

@ -18,7 +18,7 @@
"test": "bash scripts/test.sh", "test": "bash scripts/test.sh",
"test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit", "test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"build:docker": "yarn build && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION", "build:docker": "yarn build && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
"run:docker": "node dist/index.js", "run:docker": "node dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.js", "run:docker:cluster": "pm2-runtime start pm2.config.js",
"dev:stack:up": "node scripts/dev/manage.js up", "dev:stack:up": "node scripts/dev/manage.js up",

View File

@ -47,6 +47,7 @@ async function init() {
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR", TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
HTTP_MIGRATIONS: "0", HTTP_MIGRATIONS: "0",
HTTP_LOGGING: "0", HTTP_LOGGING: "0",
VERSION: "0.0.0+local",
} }
let envFile = "" let envFile = ""
Object.keys(envFileJson).forEach(key => { Object.keys(envFileJson).forEach(key => {

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", {
root: !fs.existsSync(rootPath) ? tsPath : rootPath,
})
} else {
ctx.throw(500, "Unable to retrieve client library.")
} }
return send(ctx, "budibase-client.js", {
root: rootPath,
})
} }
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

@ -41,7 +41,7 @@ describe("/component", () => {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body).toEqual({ expect(res.body).toEqual({
budibaseVersion: "0.0.0", budibaseVersion: "0.0.0+jest",
cpuArch: "arm64", cpuArch: "arm64",
cpuCores: 1, cpuCores: 1,
cpuInfo: "test", cpuInfo: "test",

View File

@ -1,6 +1,6 @@
const setup = require("./utilities") const setup = require("./utilities")
const { events } = require("@budibase/backend-core") const { events } = require("@budibase/backend-core")
const version = require("../../../../package.json").version
describe("/dev", () => { describe("/dev", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -32,9 +32,9 @@ describe("/dev", () => {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.version).toBe(version) expect(res.body.version).toBe('0.0.0+jest')
expect(events.installation.versionChecked).toBeCalledTimes(1) expect(events.installation.versionChecked).toBeCalledTimes(1)
expect(events.installation.versionChecked).toBeCalledWith(version) expect(events.installation.versionChecked).toBeCalledWith('0.0.0+jest')
}) })
}) })
}) })

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

@ -9,3 +9,4 @@ process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error"
process.env.MOCK_REDIS = "1" process.env.MOCK_REDIS = "1"
process.env.PLATFORM_URL = "http://localhost:10000" process.env.PLATFORM_URL = "http://localhost:10000"
process.env.REDIS_PASSWORD = "budibase" process.env.REDIS_PASSWORD = "budibase"
process.env.BUDIBASE_VERSION = "0.0.0+jest"

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

@ -1,8 +1,5 @@
export enum FeatureFlag { export enum FeatureFlag {
LICENSING = "LICENSING", LICENSING = "LICENSING",
// Feature IDs in Posthog
PER_CREATOR_PER_USER_PRICE = "18873",
PER_CREATOR_PER_USER_PRICE_ALERT = "18530",
} }
export interface TenantFeatureFlags { export interface TenantFeatureFlags {

View File

@ -5,17 +5,10 @@ export interface Customer {
currency: string | null | undefined currency: string | null | undefined
} }
export interface SubscriptionItems {
user: number | undefined
creator: number | undefined
}
export interface Subscription { export interface Subscription {
amount: number amount: number
amounts: SubscriptionItems | undefined
currency: string currency: string
quantity: number quantity: number
quantities: SubscriptionItems | undefined
duration: PriceDuration duration: PriceDuration
cancelAt: number | null | undefined cancelAt: number | null | undefined
currentPeriodStart: number currentPeriodStart: number

View File

@ -4,9 +4,7 @@ export enum PlanType {
PRO = "pro", PRO = "pro",
/** @deprecated */ /** @deprecated */
TEAM = "team", TEAM = "team",
/** @deprecated */
PREMIUM = "premium", PREMIUM = "premium",
PREMIUM_PLUS = "premium_plus",
BUSINESS = "business", BUSINESS = "business",
ENTERPRISE = "enterprise", ENTERPRISE = "enterprise",
} }
@ -28,12 +26,10 @@ export interface AvailablePrice {
currency: string currency: string
duration: PriceDuration duration: PriceDuration
priceId: string priceId: string
type?: string
} }
export enum PlanModel { export enum PlanModel {
PER_USER = "perUser", PER_USER = "perUser",
PER_CREATOR_PER_USER = "per_creator_per_user",
DAY_PASS = "dayPass", DAY_PASS = "dayPass",
} }

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
@ -50,4 +50,9 @@ ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ARG BUDIBASE_VERSION
# Ensuring the version argument is sent
RUN test -n "$BUDIBASE_VERSION"
ENV BUDIBASE_VERSION=$BUDIBASE_VERSION
CMD ["./docker_run.sh"] CMD ["./docker_run.sh"]

View File

@ -20,7 +20,7 @@
"run:docker": "node dist/index.js", "run:docker": "node dist/index.js",
"debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.js", "run:docker:cluster": "pm2-runtime start pm2.config.js",
"build:docker": "yarn build && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION", "build:docker": "yarn build && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
"dev:stack:init": "node ./scripts/dev/manage.js init", "dev:stack:init": "node ./scripts/dev/manage.js init",
"dev:builder": "npm run dev:stack:init && nodemon", "dev:builder": "npm run dev:stack:init && nodemon",
"dev:built": "yarn run dev:stack:init && yarn run run:docker", "dev:built": "yarn run dev:stack:init && yarn run run:docker",

View File

@ -31,6 +31,7 @@ async function init() {
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR", TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
ENABLE_EMAIL_TEST_MODE: 1, ENABLE_EMAIL_TEST_MODE: 1,
HTTP_LOGGING: 0, HTTP_LOGGING: 0,
VERSION: "0.0.0+local",
} }
let envFile = "" let envFile = ""
Object.keys(envFileJson).forEach(key => { Object.keys(envFileJson).forEach(key => {

View File

@ -10,3 +10,4 @@ process.env.PLATFORM_URL = "http://localhost:10000"
process.env.INTERNAL_API_KEY = "tet" process.env.INTERNAL_API_KEY = "tet"
process.env.DISABLE_ACCOUNT_PORTAL = "0" process.env.DISABLE_ACCOUNT_PORTAL = "0"
process.env.MOCK_REDIS = "1" process.env.MOCK_REDIS = "1"
process.env.BUDIBASE_VERSION = "0.0.0+jest"

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

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

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