Merge remote-tracking branch 'origin/master' into feature/monolith-js-refactor
This commit is contained in:
commit
bcbed2400c
|
@ -63,6 +63,9 @@ jobs:
|
||||||
echo "Using tag $version"
|
echo "Using tag $version"
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: Build/release Docker images
|
- name: Build/release Docker images
|
||||||
run: |
|
run: |
|
||||||
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
|
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
name: Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
|
||||||
REGISTRY_URL: registry.hub.docker.com
|
|
||||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: "build"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [18.x]
|
|
||||||
steps:
|
|
||||||
- name: "Checkout"
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: "yarn"
|
|
||||||
- name: Setup QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Setup Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Run Yarn
|
|
||||||
run: yarn
|
|
||||||
- name: Run Yarn Build
|
|
||||||
run: yarn build --scope @budibase/server --scope @budibase/worker
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_API_KEY }}
|
|
||||||
- name: Get the latest release version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
release_version=$(cat lerna.json | jq -r '.version')
|
|
||||||
echo $release_version
|
|
||||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
|
||||||
- name: Tag and release Budibase service docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
pull: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
build-args: BUDIBASE_VERSION=0.0.0+test
|
|
||||||
tags: budibase/budibase-test:test
|
|
||||||
file: ./hosting/single/Dockerfile.v2
|
|
||||||
cache-from: type=registry,ref=budibase/budibase-test:test
|
|
||||||
cache-to: type=inline
|
|
||||||
- name: Tag and release Budibase Azure App Service docker image
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64
|
|
||||||
build-args: |
|
|
||||||
TARGETBUILD=aas
|
|
||||||
BUDIBASE_VERSION=0.0.0+test
|
|
||||||
tags: budibase/budibase-test:aas
|
|
||||||
file: ./hosting/single/Dockerfile.v2
|
|
|
@ -67,7 +67,7 @@ jobs:
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }}
|
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }}
|
||||||
file: ./hosting/single/Dockerfile
|
file: ./hosting/single/Dockerfile.v2
|
||||||
- name: Tag and release Budibase Azure App Service docker image
|
- name: Tag and release Budibase Azure App Service docker image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
|
@ -76,4 +76,4 @@ jobs:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
build-args: TARGETBUILD=aas
|
build-args: TARGETBUILD=aas
|
||||||
tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }}
|
tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }}
|
||||||
file: ./hosting/single/Dockerfile
|
file: ./hosting/single/Dockerfile.v2
|
||||||
|
|
|
@ -126,13 +126,6 @@ You can learn more about the Budibase API at the following places:
|
||||||
|
|
||||||
- [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/)
|
- [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/)
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1647858558/Feb%20release/Start_building_with_Budibase_s_API_3_rhlzhv.png">
|
|
||||||
</p>
|
|
||||||
<br /><br />
|
|
||||||
|
|
||||||
<br /><br /><br />
|
|
||||||
|
|
||||||
## 🏁 Get started
|
## 🏁 Get started
|
||||||
|
|
||||||
Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.
|
Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.
|
||||||
|
|
|
@ -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'";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.11.43",
|
"version": "2.12.0",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -119,8 +119,8 @@ export class Writethrough {
|
||||||
this.writeRateMs = writeRateMs
|
this.writeRateMs = writeRateMs
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(doc: any) {
|
async put(doc: any, writeRateMs: number = this.writeRateMs) {
|
||||||
return put(this.db, doc, this.writeRateMs)
|
return put(this.db, doc, writeRateMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -122,7 +122,9 @@ export async function roleToNumber(id?: string) {
|
||||||
if (isBuiltin(id)) {
|
if (isBuiltin(id)) {
|
||||||
return builtinRoleToNumber(id)
|
return builtinRoleToNumber(id)
|
||||||
}
|
}
|
||||||
const hierarchy = (await getUserRoleHierarchy(id)) as RoleDoc[]
|
const hierarchy = (await getUserRoleHierarchy(id, {
|
||||||
|
defaultPublic: true,
|
||||||
|
})) as RoleDoc[]
|
||||||
for (let role of hierarchy) {
|
for (let role of hierarchy) {
|
||||||
if (isBuiltin(role?.inherits)) {
|
if (isBuiltin(role?.inherits)) {
|
||||||
return builtinRoleToNumber(role.inherits) + 1
|
return builtinRoleToNumber(role.inherits) + 1
|
||||||
|
@ -192,12 +194,15 @@ export async function getRole(
|
||||||
/**
|
/**
|
||||||
* Simple function to get all the roles based on the top level user role ID.
|
* Simple function to get all the roles based on the top level user role ID.
|
||||||
*/
|
*/
|
||||||
async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
|
async function getAllUserRoles(
|
||||||
|
userRoleId?: string,
|
||||||
|
opts?: { defaultPublic?: boolean }
|
||||||
|
): Promise<RoleDoc[]> {
|
||||||
// admins have access to all roles
|
// admins have access to all roles
|
||||||
if (userRoleId === BUILTIN_IDS.ADMIN) {
|
if (userRoleId === BUILTIN_IDS.ADMIN) {
|
||||||
return getAllRoles()
|
return getAllRoles()
|
||||||
}
|
}
|
||||||
let currentRole = await getRole(userRoleId)
|
let currentRole = await getRole(userRoleId, opts)
|
||||||
let roles = currentRole ? [currentRole] : []
|
let roles = currentRole ? [currentRole] : []
|
||||||
let roleIds = [userRoleId]
|
let roleIds = [userRoleId]
|
||||||
// get all the inherited roles
|
// get all the inherited roles
|
||||||
|
@ -226,12 +231,16 @@ export async function getUserRoleIdHierarchy(
|
||||||
* Returns an ordered array of the user's inherited role IDs, this can be used
|
* Returns an ordered array of the user's inherited role IDs, this can be used
|
||||||
* to determine if a user can access something that requires a specific role.
|
* to determine if a user can access something that requires a specific role.
|
||||||
* @param userRoleId The user's role ID, this can be found in their access token.
|
* @param userRoleId The user's role ID, this can be found in their access token.
|
||||||
|
* @param opts optional - if want to default to public use this.
|
||||||
* @returns returns an ordered array of the roles, with the first being their
|
* @returns returns an ordered array of the roles, with the first being their
|
||||||
* highest level of access and the last being the lowest level.
|
* highest level of access and the last being the lowest level.
|
||||||
*/
|
*/
|
||||||
export async function getUserRoleHierarchy(userRoleId?: string) {
|
export async function getUserRoleHierarchy(
|
||||||
|
userRoleId?: string,
|
||||||
|
opts?: { defaultPublic?: boolean }
|
||||||
|
) {
|
||||||
// special case, if they don't have a role then they are a public user
|
// special case, if they don't have a role then they are a public user
|
||||||
return getAllUserRoles(userRoleId)
|
return getAllUserRoles(userRoleId, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// this function checks that the provided permissions are in an array format
|
// this function checks that the provided permissions are in an array format
|
||||||
|
|
|
@ -25,12 +25,17 @@ 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 = (change: number, cb?: () => Promise<any>) => Promise<any>
|
type QuotaUpdateFn = (
|
||||||
|
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[]>
|
||||||
|
@ -245,7 +250,8 @@ 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
|
||||||
return UserDB.quotas.addUsers(change, async () => {
|
const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0
|
||||||
|
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)
|
||||||
|
@ -307,6 +313,7 @@ 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)
|
||||||
|
@ -327,59 +334,66 @@ 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(newUsers.length, async () => {
|
return UserDB.quotas.addUsers(
|
||||||
// create the promises array that will be called by bulkDocs
|
newUsers.length,
|
||||||
newUsers.forEach((user: any) => {
|
newCreators.length,
|
||||||
usersToSave.push(
|
async () => {
|
||||||
UserDB.buildUser(
|
// create the promises array that will be called by bulkDocs
|
||||||
user,
|
newUsers.forEach((user: any) => {
|
||||||
{
|
usersToSave.push(
|
||||||
hashPassword: true,
|
UserDB.buildUser(
|
||||||
requirePassword: user.requirePassword,
|
user,
|
||||||
},
|
{
|
||||||
tenantId,
|
hashPassword: true,
|
||||||
undefined, // no dbUser
|
requirePassword: user.requirePassword,
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
const saved = usersToBulkSave.map(user => {
|
|
||||||
return {
|
return {
|
||||||
_id: user._id,
|
successful: saved,
|
||||||
email: user.email,
|
unsuccessful,
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// 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> {
|
||||||
|
@ -419,11 +433,12 @@ 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
|
||||||
|
@ -472,7 +487,8 @@ export class UserDB {
|
||||||
|
|
||||||
await db.remove(userId, dbUser._rev)
|
await db.remove(userId, dbUser._rev)
|
||||||
|
|
||||||
await UserDB.quotas.removeUsers(1)
|
const creatorsToDelete = isCreator(dbUser) ? 1 : 0
|
||||||
|
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" })
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -123,6 +123,10 @@ 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,
|
||||||
|
@ -131,6 +135,10 @@ 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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,17 +24,23 @@
|
||||||
|
|
||||||
let selectedRows = []
|
let selectedRows = []
|
||||||
let customRenderers = []
|
let customRenderers = []
|
||||||
|
let parsedSchema = {}
|
||||||
|
|
||||||
|
$: if (schema) {
|
||||||
|
parsedSchema = Object.keys(schema).reduce((acc, key) => {
|
||||||
|
acc[key] =
|
||||||
|
typeof schema[key] === "string" ? { type: schema[key] } : schema[key]
|
||||||
|
|
||||||
|
if (!canBeSortColumn(acc[key].type)) {
|
||||||
|
acc[key].sortable = false
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
$: selectedRows, dispatch("selectionUpdated", selectedRows)
|
$: selectedRows, dispatch("selectionUpdated", selectedRows)
|
||||||
$: isUsersTable = tableId === TableNames.USERS
|
$: isUsersTable = tableId === TableNames.USERS
|
||||||
$: data && resetSelectedRows()
|
$: data && resetSelectedRows()
|
||||||
$: {
|
|
||||||
Object.values(schema || {}).forEach(col => {
|
|
||||||
if (!canBeSortColumn(col.type)) {
|
|
||||||
col.sortable = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
$: {
|
$: {
|
||||||
if (isUsersTable) {
|
if (isUsersTable) {
|
||||||
customRenderers = [
|
customRenderers = [
|
||||||
|
@ -44,24 +50,24 @@
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
UNEDITABLE_USER_FIELDS.forEach(field => {
|
UNEDITABLE_USER_FIELDS.forEach(field => {
|
||||||
if (schema[field]) {
|
if (parsedSchema[field]) {
|
||||||
schema[field].editable = false
|
parsedSchema[field].editable = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (schema.email) {
|
if (parsedSchema.email) {
|
||||||
schema.email.displayName = "Email"
|
parsedSchema.email.displayName = "Email"
|
||||||
}
|
}
|
||||||
if (schema.roleId) {
|
if (parsedSchema.roleId) {
|
||||||
schema.roleId.displayName = "Role"
|
parsedSchema.roleId.displayName = "Role"
|
||||||
}
|
}
|
||||||
if (schema.firstName) {
|
if (parsedSchema.firstName) {
|
||||||
schema.firstName.displayName = "First Name"
|
parsedSchema.firstName.displayName = "First Name"
|
||||||
}
|
}
|
||||||
if (schema.lastName) {
|
if (parsedSchema.lastName) {
|
||||||
schema.lastName.displayName = "Last Name"
|
parsedSchema.lastName.displayName = "Last Name"
|
||||||
}
|
}
|
||||||
if (schema.status) {
|
if (parsedSchema.status) {
|
||||||
schema.status.displayName = "Status"
|
parsedSchema.status.displayName = "Status"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -97,7 +103,7 @@
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<Table
|
<Table
|
||||||
{data}
|
{data}
|
||||||
{schema}
|
schema={parsedSchema}
|
||||||
{loading}
|
{loading}
|
||||||
{customRenderers}
|
{customRenderers}
|
||||||
{rowCount}
|
{rowCount}
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
let type = "internal"
|
let type = "internal"
|
||||||
|
|
||||||
$: name = view.name
|
$: name = view.name
|
||||||
|
$: schema = view.schema
|
||||||
$: calculation = view.calculation
|
$: calculation = view.calculation
|
||||||
|
|
||||||
$: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
|
$: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
|
||||||
|
@ -61,7 +62,7 @@
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
title={decodeURI(name)}
|
title={decodeURI(name)}
|
||||||
schema={view.schema}
|
{schema}
|
||||||
tableId={view.tableId}
|
tableId={view.tableId}
|
||||||
{data}
|
{data}
|
||||||
{loading}
|
{loading}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -39,7 +39,15 @@
|
||||||
allowCreator
|
allowCreator
|
||||||
) => {
|
) => {
|
||||||
if (allowedRoles?.length) {
|
if (allowedRoles?.length) {
|
||||||
return roles.filter(role => allowedRoles.includes(role._id))
|
const filteredRoles = roles.filter(role =>
|
||||||
|
allowedRoles.includes(role._id)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
...filteredRoles,
|
||||||
|
...(allowedRoles.includes(Constants.Roles.CREATOR)
|
||||||
|
? [{ _id: Constants.Roles.CREATOR, name: "Creator", enabled: false }]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
let newRoles = [...roles]
|
let newRoles = [...roles]
|
||||||
|
|
||||||
|
@ -129,8 +137,9 @@
|
||||||
getOptionColour={getColor}
|
getOptionColour={getColor}
|
||||||
getOptionIcon={getIcon}
|
getOptionIcon={getIcon}
|
||||||
isOptionEnabled={option =>
|
isOptionEnabled={option =>
|
||||||
option._id !== Constants.Roles.CREATOR ||
|
(option._id !== Constants.Roles.CREATOR ||
|
||||||
$licensing.perAppBuildersEnabled}
|
$licensing.perAppBuildersEnabled) &&
|
||||||
|
option.enabled !== false}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{error}
|
{error}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
|
||||||
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
|
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
|
||||||
import BarButtonList from "./controls/BarButtonList.svelte"
|
import BarButtonList from "./controls/BarButtonList.svelte"
|
||||||
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
|
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
|
||||||
|
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
|
||||||
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
|
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
|
@ -48,6 +49,7 @@ const componentMap = {
|
||||||
"filter/relationship": RelationshipFilterEditor,
|
"filter/relationship": RelationshipFilterEditor,
|
||||||
url: URLSelect,
|
url: URLSelect,
|
||||||
fieldConfiguration: FieldConfiguration,
|
fieldConfiguration: FieldConfiguration,
|
||||||
|
buttonConfiguration: ButtonConfiguration,
|
||||||
columns: ColumnEditor,
|
columns: ColumnEditor,
|
||||||
"columns/basic": BasicColumnEditor,
|
"columns/basic": BasicColumnEditor,
|
||||||
"columns/grid": GridColumnEditor,
|
"columns/grid": GridColumnEditor,
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script>
|
||||||
|
import DraggableList from "../DraggableList/DraggableList.svelte"
|
||||||
|
import ButtonSetting from "./ButtonSetting.svelte"
|
||||||
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import { Helpers } from "@budibase/bbui"
|
||||||
|
|
||||||
|
export let componentBindings
|
||||||
|
export let bindings
|
||||||
|
export let value
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
let focusItem
|
||||||
|
|
||||||
|
$: buttonList = sanitizeValue(value) || []
|
||||||
|
$: buttonCount = buttonList.length
|
||||||
|
$: itemProps = {
|
||||||
|
componentBindings: componentBindings || [],
|
||||||
|
bindings,
|
||||||
|
removeButton,
|
||||||
|
canRemove: buttonCount > 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeValue = val => {
|
||||||
|
return val?.map(button => {
|
||||||
|
return button._component ? button : buildPseudoInstance(button)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const processItemUpdate = e => {
|
||||||
|
const updatedField = e.detail
|
||||||
|
const newButtonList = [...buttonList]
|
||||||
|
const fieldIdx = newButtonList.findIndex(pSetting => {
|
||||||
|
return pSetting._id === updatedField?._id
|
||||||
|
})
|
||||||
|
if (fieldIdx === -1) {
|
||||||
|
newButtonList.push(updatedField)
|
||||||
|
} else {
|
||||||
|
newButtonList[fieldIdx] = updatedField
|
||||||
|
}
|
||||||
|
dispatch("change", newButtonList)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUpdated = e => {
|
||||||
|
dispatch("change", [...e.detail])
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPseudoInstance = cfg => {
|
||||||
|
return store.actions.components.createInstance(
|
||||||
|
`@budibase/standard-components/button`,
|
||||||
|
{
|
||||||
|
_instanceName: Helpers.uuid(),
|
||||||
|
text: cfg.text,
|
||||||
|
type: cfg.type || "primary",
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addButton = () => {
|
||||||
|
const newButton = buildPseudoInstance({
|
||||||
|
text: `Button ${buttonCount + 1}`,
|
||||||
|
})
|
||||||
|
dispatch("change", [...buttonList, newButton])
|
||||||
|
focusItem = newButton._id
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeButton = id => {
|
||||||
|
dispatch(
|
||||||
|
"change",
|
||||||
|
buttonList.filter(button => button._id !== id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="button-configuration">
|
||||||
|
{#if buttonCount}
|
||||||
|
<DraggableList
|
||||||
|
on:change={listUpdated}
|
||||||
|
on:itemChange={processItemUpdate}
|
||||||
|
items={buttonList}
|
||||||
|
listItemKey={"_id"}
|
||||||
|
listType={ButtonSetting}
|
||||||
|
listTypeProps={itemProps}
|
||||||
|
focus={focusItem}
|
||||||
|
draggable={buttonCount > 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="list-footer" on:click={addButton}>
|
||||||
|
<div class="add-button">Add button</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.button-configuration :global(.spectrum-ActionButton) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-configuration :global(.list-wrap > li:last-child),
|
||||||
|
.button-configuration :global(.list-wrap) {
|
||||||
|
border-bottom-left-radius: unset;
|
||||||
|
border-bottom-right-radius: unset;
|
||||||
|
border-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
width: 100%;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
background-color: var(
|
||||||
|
--spectrum-table-background-color,
|
||||||
|
var(--spectrum-global-color-gray-50)
|
||||||
|
);
|
||||||
|
transition: background-color ease-in-out 130ms;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid
|
||||||
|
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
margin: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer:hover {
|
||||||
|
background-color: var(
|
||||||
|
--spectrum-table-row-background-color-hover,
|
||||||
|
var(--spectrum-alias-highlight-hover)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script>
|
||||||
|
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||||
|
import { Icon } from "@budibase/bbui"
|
||||||
|
import { runtimeToReadableBinding } from "builderStore/dataBinding"
|
||||||
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
||||||
|
export let item
|
||||||
|
export let componentBindings
|
||||||
|
export let bindings
|
||||||
|
export let anchor
|
||||||
|
export let removeButton
|
||||||
|
export let canRemove
|
||||||
|
|
||||||
|
$: readableText = isJSBinding(item.text)
|
||||||
|
? "(JavaScript function)"
|
||||||
|
: runtimeToReadableBinding([...bindings, componentBindings], item.text)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="list-item-body">
|
||||||
|
<div class="list-item-left">
|
||||||
|
<EditComponentPopover
|
||||||
|
{anchor}
|
||||||
|
componentInstance={item}
|
||||||
|
{componentBindings}
|
||||||
|
{bindings}
|
||||||
|
on:change
|
||||||
|
/>
|
||||||
|
<div class="field-label">{readableText || "Button"}</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-item-right">
|
||||||
|
<Icon
|
||||||
|
disabled={!canRemove}
|
||||||
|
size="S"
|
||||||
|
name="Close"
|
||||||
|
hoverable
|
||||||
|
on:click={() => removeButton(item._id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.field-label {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.list-item-body,
|
||||||
|
.list-item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.list-item-body {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.list-item-right :global(div.spectrum-Switch) {
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
.list-item-body {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -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}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { Icon } from "@budibase/bbui"
|
|
||||||
import { dndzone } from "svelte-dnd-action"
|
import { dndzone } from "svelte-dnd-action"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import { generate } from "shortid"
|
import { generate } from "shortid"
|
||||||
import { setContext } from "svelte"
|
import { setContext } from "svelte"
|
||||||
import { writable } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
|
import DragHandle from "./drag-handle.svelte"
|
||||||
|
|
||||||
export let items = []
|
export let items = []
|
||||||
export let showHandle = true
|
export let showHandle = true
|
||||||
|
@ -12,6 +12,7 @@
|
||||||
export let listTypeProps = {}
|
export let listTypeProps = {}
|
||||||
export let listItemKey
|
export let listItemKey
|
||||||
export let draggable = true
|
export let draggable = true
|
||||||
|
export let focus
|
||||||
|
|
||||||
let store = writable({
|
let store = writable({
|
||||||
selected: null,
|
selected: null,
|
||||||
|
@ -27,6 +28,10 @@
|
||||||
|
|
||||||
setContext("draggable", store)
|
setContext("draggable", store)
|
||||||
|
|
||||||
|
$: if (focus && store) {
|
||||||
|
get(store).actions.select(focus)
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const flipDurationMs = 150
|
const flipDurationMs = 150
|
||||||
|
|
||||||
|
@ -82,13 +87,16 @@
|
||||||
>
|
>
|
||||||
{#each draggableItems as draggable (draggable.id)}
|
{#each draggableItems as draggable (draggable.id)}
|
||||||
<li
|
<li
|
||||||
|
on:mousedown={() => {
|
||||||
|
get(store).actions.select()
|
||||||
|
}}
|
||||||
bind:this={anchors[draggable.id]}
|
bind:this={anchors[draggable.id]}
|
||||||
class:highlighted={draggable.id === $store.selected}
|
class:highlighted={draggable.id === $store.selected}
|
||||||
>
|
>
|
||||||
<div class="left-content">
|
<div class="left-content">
|
||||||
{#if showHandle}
|
{#if showHandle}
|
||||||
<div class="handle" aria-label="drag-handle">
|
<div class="handle">
|
||||||
<Icon name="DragHandle" size="XL" />
|
<DragHandle />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -142,8 +150,9 @@
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 4px;
|
||||||
}
|
}
|
||||||
.list-wrap > li:last-child {
|
.list-wrap > li:last-child {
|
||||||
border-top-left-radius: var(--spectrum-table-regular-border-radius);
|
border-bottom-left-radius: 4px;
|
||||||
border-top-right-radius: var(--spectrum-table-regular-border-radius);
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: 0px;
|
||||||
}
|
}
|
||||||
.right-content {
|
.right-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
@ -153,4 +162,15 @@
|
||||||
padding-left: var(--spacing-s);
|
padding-left: var(--spacing-s);
|
||||||
padding-right: var(--spacing-s);
|
padding-right: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
.handle {
|
||||||
|
display: flex;
|
||||||
|
height: var(--spectrum-global-dimension-size-150);
|
||||||
|
}
|
||||||
|
.handle :global(svg) {
|
||||||
|
fill: var(--spectrum-global-color-gray-500);
|
||||||
|
margin-right: var(--spacing-m);
|
||||||
|
margin-left: 2px;
|
||||||
|
width: var(--spectrum-global-dimension-size-65);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svg
|
||||||
|
class="drag-handle spectrum-Icon spectrum-Icon--sizeS"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m1,11c0.55228,0 1,-0.4477 1,-1c0,-0.5523 -0.44772,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m1,8c0.55228,0 1,-0.4477 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m1,5c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m1,2c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m4,11c0.5523,0 1,-0.4477 1,-1c0,-0.5523 -0.4477,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m4,8c0.5523,0 1,-0.4477 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m4,5c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m4,2c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
|
@ -3,31 +3,35 @@
|
||||||
import { componentStore } from "stores/frontend"
|
import { componentStore } from "stores/frontend"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
|
import ComponentSettingsSection from "../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
|
|
||||||
export let anchor
|
export let anchor
|
||||||
export let field
|
export let componentInstance
|
||||||
export let componentBindings
|
export let componentBindings
|
||||||
export let bindings
|
export let bindings
|
||||||
|
export let parseSettings
|
||||||
|
|
||||||
const draggable = getContext("draggable")
|
const draggable = getContext("draggable")
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let popover
|
let popover
|
||||||
let drawers = []
|
let drawers = []
|
||||||
let pseudoComponentInstance
|
|
||||||
let open = false
|
let open = false
|
||||||
|
|
||||||
$: if (open && $draggable.selected && $draggable.selected != field._id) {
|
// Auto hide the component when another item is selected
|
||||||
|
$: if (open && $draggable.selected != componentInstance._id) {
|
||||||
popover.hide()
|
popover.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (field) {
|
// Open automatically if the component is marked as selected
|
||||||
pseudoComponentInstance = field
|
$: if (!open && $draggable.selected === componentInstance._id && popover) {
|
||||||
|
popover.show()
|
||||||
|
open = true
|
||||||
}
|
}
|
||||||
$: componentDef = componentStore.getDefinition(
|
|
||||||
pseudoComponentInstance._component
|
$: componentDef = store.actions.components.getDefinition(
|
||||||
|
componentInstance._component
|
||||||
)
|
)
|
||||||
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
|
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
|
||||||
|
|
||||||
|
@ -36,27 +40,40 @@
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
const clone = cloneDeep(componentDef)
|
const clone = cloneDeep(componentDef)
|
||||||
const updatedSettings = clone.settings
|
|
||||||
.filter(setting => setting.key !== "field")
|
if (typeof parseSettings === "function") {
|
||||||
.map(setting => {
|
clone.settings = parseSettings(clone.settings)
|
||||||
return { ...setting, nested: true }
|
}
|
||||||
})
|
|
||||||
clone.settings = updatedSettings
|
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSetting = async (setting, value) => {
|
const updateSetting = async (setting, value) => {
|
||||||
const nestedComponentInstance = cloneDeep(pseudoComponentInstance)
|
const nestedComponentInstance = cloneDeep(componentInstance)
|
||||||
|
|
||||||
const patchFn = componentStore.updateComponentSetting(setting.key, value)
|
const patchFn = componentStore.updateComponentSetting(setting.key, value)
|
||||||
patchFn(nestedComponentInstance)
|
patchFn(nestedComponentInstance)
|
||||||
|
|
||||||
const update = {
|
dispatch("change", nestedComponentInstance)
|
||||||
...nestedComponentInstance,
|
}
|
||||||
active: pseudoComponentInstance.active,
|
|
||||||
|
const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
|
||||||
|
let { left, top } = cfg
|
||||||
|
let percentageOffset = 30
|
||||||
|
// left-outside
|
||||||
|
left = anchorBounds.left - eleBounds.width - 18
|
||||||
|
|
||||||
|
// shift up from the anchor, if space allows
|
||||||
|
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
|
||||||
|
let defaultTop = anchorBounds.top - offsetPos
|
||||||
|
|
||||||
|
if (window.innerHeight - defaultTop < eleBounds.height) {
|
||||||
|
top = window.innerHeight - eleBounds.height - 5
|
||||||
|
} else {
|
||||||
|
top = anchorBounds.top - offsetPos
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch("change", update)
|
return { ...cfg, left, top }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -76,11 +93,11 @@
|
||||||
bind:this={popover}
|
bind:this={popover}
|
||||||
on:open={() => {
|
on:open={() => {
|
||||||
drawers = []
|
drawers = []
|
||||||
$draggable.actions.select(field._id)
|
$draggable.actions.select(componentInstance._id)
|
||||||
}}
|
}}
|
||||||
on:close={() => {
|
on:close={() => {
|
||||||
open = false
|
open = false
|
||||||
if ($draggable.selected == field._id) {
|
if ($draggable.selected == componentInstance._id) {
|
||||||
$draggable.actions.select()
|
$draggable.actions.select()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -89,33 +106,13 @@
|
||||||
showPopover={drawers.length == 0}
|
showPopover={drawers.length == 0}
|
||||||
clickOutsideOverride={drawers.length > 0}
|
clickOutsideOverride={drawers.length > 0}
|
||||||
maxHeight={600}
|
maxHeight={600}
|
||||||
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => {
|
handlePostionUpdate={customPositionHandler}
|
||||||
let { left, top } = cfg
|
|
||||||
let percentageOffset = 30
|
|
||||||
// left-outside
|
|
||||||
left = anchorBounds.left - eleBounds.width - 18
|
|
||||||
|
|
||||||
// shift up from the anchor, if space allows
|
|
||||||
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
|
|
||||||
let defaultTop = anchorBounds.top - offsetPos
|
|
||||||
|
|
||||||
if (window.innerHeight - defaultTop < eleBounds.height) {
|
|
||||||
top = window.innerHeight - eleBounds.height - 5
|
|
||||||
} else {
|
|
||||||
top = anchorBounds.top - offsetPos
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...cfg, left, top }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span class="popover-wrap">
|
<span class="popover-wrap">
|
||||||
<Layout noPadding noGap>
|
<Layout noPadding noGap>
|
||||||
<div class="type-icon">
|
<slot name="header" />
|
||||||
<Icon name={parsedComponentDef.icon} />
|
|
||||||
<span>{field.field}</span>
|
|
||||||
</div>
|
|
||||||
<ComponentSettingsSection
|
<ComponentSettingsSection
|
||||||
componentInstance={pseudoComponentInstance}
|
{componentInstance}
|
||||||
componentDefinition={parsedComponentDef}
|
componentDefinition={parsedComponentDef}
|
||||||
isScreen={false}
|
isScreen={false}
|
||||||
onUpdateSetting={updateSetting}
|
onUpdateSetting={updateSetting}
|
||||||
|
@ -138,20 +135,4 @@
|
||||||
.popover-wrap {
|
.popover-wrap {
|
||||||
background-color: var(--spectrum-alias-background-color-primary);
|
background-color: var(--spectrum-alias-background-color-primary);
|
||||||
}
|
}
|
||||||
.type-icon {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
margin: var(--spacing-xl);
|
|
||||||
margin-bottom: 0px;
|
|
||||||
height: var(--spectrum-alias-item-height-m);
|
|
||||||
padding: 0px var(--spectrum-alias-item-padding-m);
|
|
||||||
border-width: var(--spectrum-actionbutton-border-size);
|
|
||||||
border-radius: var(--spectrum-alias-border-radius-regular);
|
|
||||||
border: 1px solid
|
|
||||||
var(
|
|
||||||
--spectrum-actionbutton-m-border-color,
|
|
||||||
var(--spectrum-alias-border-color)
|
|
||||||
);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
|
@ -49,7 +49,7 @@
|
||||||
updateSanitsedFields(sanitisedValue)
|
updateSanitsedFields(sanitisedValue)
|
||||||
unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
|
unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
|
||||||
fieldList = [...sanitisedFields, ...unconfigured]
|
fieldList = [...sanitisedFields, ...unconfigured]
|
||||||
.map(buildSudoInstance)
|
.map(buildPseudoInstance)
|
||||||
.filter(x => x != null)
|
.filter(x => x != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildSudoInstance = instance => {
|
const buildPseudoInstance = instance => {
|
||||||
if (instance._component) {
|
if (instance._component) {
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import EditFieldPopover from "./EditFieldPopover.svelte"
|
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||||
import { Toggle } from "@budibase/bbui"
|
import { Toggle, Icon } from "@budibase/bbui"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import { runtimeToReadableBinding } from "builderStore/dataBinding"
|
||||||
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
||||||
export let item
|
export let item
|
||||||
export let componentBindings
|
export let componentBindings
|
||||||
|
@ -16,18 +19,43 @@
|
||||||
dispatch("change", { ...cloneDeep(item), active: e.detail })
|
dispatch("change", { ...cloneDeep(item), active: e.detail })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const getReadableText = () => {
|
||||||
|
if (item.label) {
|
||||||
|
return isJSBinding(item.label)
|
||||||
|
? "(JavaScript function)"
|
||||||
|
: runtimeToReadableBinding([...bindings, componentBindings], item.label)
|
||||||
|
}
|
||||||
|
return item.field
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSettings = settings => {
|
||||||
|
return settings
|
||||||
|
.filter(setting => setting.key !== "field")
|
||||||
|
.map(setting => {
|
||||||
|
return { ...setting, nested: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$: readableText = getReadableText(item)
|
||||||
|
$: componentDef = store.actions.components.getDefinition(item._component)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="list-item-body">
|
<div class="list-item-body">
|
||||||
<div class="list-item-left">
|
<div class="list-item-left">
|
||||||
<EditFieldPopover
|
<EditComponentPopover
|
||||||
{anchor}
|
{anchor}
|
||||||
field={item}
|
componentInstance={item}
|
||||||
{componentBindings}
|
{componentBindings}
|
||||||
{bindings}
|
{bindings}
|
||||||
|
{parseSettings}
|
||||||
on:change
|
on:change
|
||||||
/>
|
>
|
||||||
<div class="field-label">{item.label || item.field}</div>
|
<div slot="header" class="type-icon">
|
||||||
|
<Icon name={componentDef.icon} />
|
||||||
|
<span>{item.field}</span>
|
||||||
|
</div>
|
||||||
|
</EditComponentPopover>
|
||||||
|
<div class="field-label">{readableText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-item-right">
|
<div class="list-item-right">
|
||||||
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
|
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
|
||||||
|
@ -53,4 +81,20 @@
|
||||||
.list-item-body {
|
.list-item-body {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
.type-icon {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
margin: var(--spacing-xl);
|
||||||
|
margin-bottom: 0px;
|
||||||
|
height: var(--spectrum-alias-item-height-m);
|
||||||
|
padding: 0px var(--spectrum-alias-item-padding-m);
|
||||||
|
border-width: var(--spectrum-actionbutton-border-size);
|
||||||
|
border-radius: var(--spectrum-alias-border-radius-regular);
|
||||||
|
border: 1px solid
|
||||||
|
var(
|
||||||
|
--spectrum-actionbutton-m-border-color,
|
||||||
|
var(--spectrum-alias-border-color)
|
||||||
|
);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -516,6 +516,13 @@
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseRole = user => {
|
||||||
|
if (user.isAdminOrGlobalBuilder) {
|
||||||
|
return Constants.Roles.CREATOR
|
||||||
|
}
|
||||||
|
return user.role
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeyDown} />
|
<svelte:window on:keydown={handleKeyDown} />
|
||||||
|
@ -717,7 +724,7 @@
|
||||||
<RoleSelect
|
<RoleSelect
|
||||||
footer={getRoleFooter(user)}
|
footer={getRoleFooter(user)}
|
||||||
placeholder={false}
|
placeholder={false}
|
||||||
value={user.role}
|
value={parseRole(user)}
|
||||||
allowRemove={user.role && !user.group}
|
allowRemove={user.role && !user.group}
|
||||||
allowPublic={false}
|
allowPublic={false}
|
||||||
allowCreator={true}
|
allowCreator={true}
|
||||||
|
@ -736,7 +743,7 @@
|
||||||
autoWidth
|
autoWidth
|
||||||
align="right"
|
align="right"
|
||||||
allowedRoles={user.isAdminOrGlobalBuilder
|
allowedRoles={user.isAdminOrGlobalBuilder
|
||||||
? [Constants.Roles.ADMIN]
|
? [Constants.Roles.CREATOR]
|
||||||
: null}
|
: null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -54,7 +54,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;
|
||||||
|
|
|
@ -9,7 +9,8 @@
|
||||||
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 { getComponentText } from "stores/frontend/components/utils"
|
||||||
import {
|
import {
|
||||||
getBindableProperties,
|
getBindableProperties,
|
||||||
getComponentBindableProperties,
|
getComponentBindableProperties,
|
||||||
|
@ -17,6 +18,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 = componentStore.getDefinition(
|
$: componentDefinition = componentStore.getDefinition(
|
||||||
$selectedComponent?._component
|
$selectedComponent?._component
|
||||||
|
@ -43,6 +52,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}
|
||||||
|
@ -69,7 +94,12 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if section == "styles"}
|
{#if section == "styles"}
|
||||||
<DesignSection {componentInstance} {componentDefinition} {bindings} />
|
<DesignSection
|
||||||
|
{componentInstance}
|
||||||
|
{componentBindings}
|
||||||
|
{componentDefinition}
|
||||||
|
{bindings}
|
||||||
|
/>
|
||||||
<CustomStylesSection
|
<CustomStylesSection
|
||||||
{componentInstance}
|
{componentInstance}
|
||||||
{componentDefinition}
|
{componentDefinition}
|
||||||
|
@ -94,4 +124,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>
|
||||||
|
|
|
@ -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 { componentStore } from "stores/frontend"
|
import { componentStore } from "stores/frontend"
|
||||||
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
|
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
|
||||||
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
|
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
|
||||||
|
@ -16,19 +16,32 @@
|
||||||
export let isScreen = false
|
export let isScreen = false
|
||||||
export let onUpdateSetting
|
export let onUpdateSetting
|
||||||
export let showSectionTitle = true
|
export let showSectionTitle = true
|
||||||
export let showInstanceName = true
|
export let tag
|
||||||
|
|
||||||
$: sections = getSections(componentInstance, componentDefinition, isScreen)
|
$: sections = getSections(
|
||||||
|
componentInstance,
|
||||||
|
componentDefinition,
|
||||||
|
isScreen,
|
||||||
|
tag
|
||||||
|
)
|
||||||
|
|
||||||
const getSections = (instance, definition, isScreen) => {
|
const getSections = (instance, definition, isScreen, tag) => {
|
||||||
const settings = definition?.settings ?? []
|
const settings = definition?.settings ?? []
|
||||||
const generalSettings = settings.filter(setting => !setting.section)
|
const generalSettings = settings.filter(
|
||||||
const customSections = settings.filter(setting => setting.section)
|
setting => !setting.section && setting.tag === tag
|
||||||
|
)
|
||||||
|
const customSections = settings.filter(
|
||||||
|
setting => setting.section && setting.tag === tag
|
||||||
|
)
|
||||||
let sections = [
|
let sections = [
|
||||||
{
|
...(generalSettings?.length
|
||||||
name: "General",
|
? [
|
||||||
settings: generalSettings,
|
{
|
||||||
},
|
name: "General",
|
||||||
|
settings: generalSettings,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(customSections || []),
|
...(customSections || []),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -127,28 +140,19 @@
|
||||||
{#if section.visible}
|
{#if section.visible}
|
||||||
<DetailSummary
|
<DetailSummary
|
||||||
name={showSectionTitle ? section.name : ""}
|
name={showSectionTitle ? section.name : ""}
|
||||||
collapsible={false}
|
show={section.collapsed !== true}
|
||||||
>
|
>
|
||||||
{#if section.info}
|
{#if section.info}
|
||||||
<div class="section-info">
|
<div class="section-info">
|
||||||
<InfoDisplay body={section.info} />
|
<InfoDisplay body={section.info} />
|
||||||
</div>
|
</div>
|
||||||
{:else if idx === 0 && section.name === "General" && componentDefinition.info}
|
{:else if idx === 0 && section.name === "General" && componentDefinition?.info && !tag}
|
||||||
<InfoDisplay
|
<InfoDisplay
|
||||||
title={componentDefinition.name}
|
title={componentDefinition.name}
|
||||||
body={componentDefinition.info}
|
body={componentDefinition.info}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen && showInstanceName}
|
|
||||||
<PropertyControl
|
|
||||||
control={Input}
|
|
||||||
label="Name"
|
|
||||||
key="_instanceName"
|
|
||||||
value={componentInstance._instanceName}
|
|
||||||
onChange={val => updateSetting({ key: "_instanceName" }, val)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each section.settings as setting (setting.key)}
|
{#each section.settings as setting (setting.key)}
|
||||||
{#if setting.visible}
|
{#if setting.visible}
|
||||||
<PropertyControl
|
<PropertyControl
|
||||||
|
@ -192,7 +196,7 @@
|
||||||
</DetailSummary>
|
</DetailSummary>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if componentDefinition?.block}
|
{#if componentDefinition?.block && !tag}
|
||||||
<DetailSummary name="Eject" collapsible={false}>
|
<DetailSummary name="Eject" collapsible={false}>
|
||||||
<EjectBlockButton />
|
<EjectBlockButton />
|
||||||
</DetailSummary>
|
</DetailSummary>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
import StyleSection from "./StyleSection.svelte"
|
import StyleSection from "./StyleSection.svelte"
|
||||||
import * as ComponentStyles from "./componentStyles"
|
import * as ComponentStyles from "./componentStyles"
|
||||||
|
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
|
||||||
|
|
||||||
export let componentDefinition
|
export let componentDefinition
|
||||||
export let componentInstance
|
export let componentInstance
|
||||||
export let bindings
|
export let bindings
|
||||||
|
export let componentBindings
|
||||||
|
|
||||||
const getStyles = def => {
|
const getStyles = def => {
|
||||||
if (!def?.styles?.length) {
|
if (!def?.styles?.length) {
|
||||||
|
@ -22,6 +24,19 @@
|
||||||
$: styles = getStyles(componentDefinition)
|
$: styles = getStyles(componentDefinition)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Load any general settings or sections tagged as "style"
|
||||||
|
-->
|
||||||
|
<ComponentSettingsSection
|
||||||
|
{componentInstance}
|
||||||
|
{componentDefinition}
|
||||||
|
isScreen={false}
|
||||||
|
showInstanceName={false}
|
||||||
|
{bindings}
|
||||||
|
{componentBindings}
|
||||||
|
tag="style"
|
||||||
|
/>
|
||||||
|
|
||||||
{#if styles?.length > 0}
|
{#if styles?.length > 0}
|
||||||
{#each styles as style}
|
{#each styles as style}
|
||||||
<StyleSection
|
<StyleSection
|
||||||
|
|
|
@ -36,6 +36,7 @@
|
||||||
"heading",
|
"heading",
|
||||||
"text",
|
"text",
|
||||||
"button",
|
"button",
|
||||||
|
"buttongroup",
|
||||||
"tag",
|
"tag",
|
||||||
"spectrumcard",
|
"spectrumcard",
|
||||||
"cardstat",
|
"cardstat",
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
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 {
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
|
@ -10,7 +9,10 @@
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
selectedComponentPath,
|
selectedComponentPath,
|
||||||
} from "stores/frontend"
|
} from "stores/frontend"
|
||||||
import { findComponentPath } from "stores/frontend/components/utils"
|
import {
|
||||||
|
findComponentPath,
|
||||||
|
getComponentText,
|
||||||
|
} from "stores/frontend/components/utils"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { dndStore } from "./dndStore"
|
import { dndStore } from "./dndStore"
|
||||||
|
|
||||||
|
@ -36,16 +38,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 = componentStore.getDefinition(component?._component)
|
const def = componentStore.getDefinition(component?._component)
|
||||||
return def?.icon
|
return def?.icon
|
||||||
|
|
|
@ -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
|
||||||
|
@ -102,14 +103,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)
|
||||||
|
@ -125,6 +127,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()
|
||||||
}
|
}
|
||||||
|
@ -179,6 +182,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]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import rowListScreen from "builder/store/screenTemplates/rowListScreen"
|
import rowListScreen from "builder/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 |
|
@ -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 { screenStore } from "stores/frontend"
|
import { screenStore } from "stores/frontend"
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -258,6 +258,186 @@
|
||||||
"description": "Contains your app screens",
|
"description": "Contains your app screens",
|
||||||
"static": true
|
"static": true
|
||||||
},
|
},
|
||||||
|
"buttongroup": {
|
||||||
|
"name": "Button group",
|
||||||
|
"icon": "Button",
|
||||||
|
"hasChildren": false,
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"section": true,
|
||||||
|
"name": "Buttons",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "buttonConfiguration",
|
||||||
|
"key": "buttons",
|
||||||
|
"nested": true,
|
||||||
|
"defaultValue": [
|
||||||
|
{
|
||||||
|
"type": "cta",
|
||||||
|
"text": "Button 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "primary",
|
||||||
|
"text": "Button 2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": true,
|
||||||
|
"name": "Layout",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"label": "Direction",
|
||||||
|
"key": "direction",
|
||||||
|
"showInBar": true,
|
||||||
|
"barStyle": "buttons",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Column",
|
||||||
|
"value": "column",
|
||||||
|
"barIcon": "ViewColumn",
|
||||||
|
"barTitle": "Column layout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Row",
|
||||||
|
"value": "row",
|
||||||
|
"barIcon": "ViewRow",
|
||||||
|
"barTitle": "Row layout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValue": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"label": "Horiz. align",
|
||||||
|
"key": "hAlign",
|
||||||
|
"showInBar": true,
|
||||||
|
"barStyle": "buttons",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Left",
|
||||||
|
"value": "left",
|
||||||
|
"barIcon": "AlignLeft",
|
||||||
|
"barTitle": "Align left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Center",
|
||||||
|
"value": "center",
|
||||||
|
"barIcon": "AlignCenter",
|
||||||
|
"barTitle": "Align center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Right",
|
||||||
|
"value": "right",
|
||||||
|
"barIcon": "AlignRight",
|
||||||
|
"barTitle": "Align right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Stretch",
|
||||||
|
"value": "stretch",
|
||||||
|
"barIcon": "MoveLeftRight",
|
||||||
|
"barTitle": "Align stretched horizontally"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValue": "left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"label": "Vert. align",
|
||||||
|
"key": "vAlign",
|
||||||
|
"showInBar": true,
|
||||||
|
"barStyle": "buttons",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Top",
|
||||||
|
"value": "top",
|
||||||
|
"barIcon": "AlignTop",
|
||||||
|
"barTitle": "Align top"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Middle",
|
||||||
|
"value": "middle",
|
||||||
|
"barIcon": "AlignMiddle",
|
||||||
|
"barTitle": "Align middle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Bottom",
|
||||||
|
"value": "bottom",
|
||||||
|
"barIcon": "AlignBottom",
|
||||||
|
"barTitle": "Align bottom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Stretch",
|
||||||
|
"value": "stretch",
|
||||||
|
"barIcon": "MoveUpDown",
|
||||||
|
"barTitle": "Align stretched vertically"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValue": "top"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"label": "Size",
|
||||||
|
"key": "size",
|
||||||
|
"showInBar": true,
|
||||||
|
"barStyle": "buttons",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Shrink",
|
||||||
|
"value": "shrink",
|
||||||
|
"barIcon": "Minimize",
|
||||||
|
"barTitle": "Shrink container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Grow",
|
||||||
|
"value": "grow",
|
||||||
|
"barIcon": "Maximize",
|
||||||
|
"barTitle": "Grow container"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValue": "shrink"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"label": "Gap",
|
||||||
|
"key": "gap",
|
||||||
|
"showInBar": true,
|
||||||
|
"barStyle": "picker",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "None",
|
||||||
|
"value": "N"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Small",
|
||||||
|
"value": "S"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Medium",
|
||||||
|
"value": "M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Large",
|
||||||
|
"value": "L"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValue": "M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Wrap",
|
||||||
|
"key": "wrap",
|
||||||
|
"showInBar": true,
|
||||||
|
"barIcon": "ModernGridView",
|
||||||
|
"barTitle": "Wrap"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"name": "Button",
|
"name": "Button",
|
||||||
"description": "A basic html button that is ready for styling",
|
"description": "A basic html button that is ready for styling",
|
||||||
|
@ -2409,7 +2589,6 @@
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"label": "Initial form step",
|
"label": "Initial form step",
|
||||||
|
@ -5305,6 +5484,12 @@
|
||||||
"key": "title",
|
"key": "title",
|
||||||
"nested": true
|
"nested": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Description",
|
||||||
|
"key": "description",
|
||||||
|
"nested": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"section": true,
|
"section": true,
|
||||||
"dependsOn": {
|
"dependsOn": {
|
||||||
|
@ -5385,38 +5570,6 @@
|
||||||
"section": true,
|
"section": true,
|
||||||
"name": "Fields",
|
"name": "Fields",
|
||||||
"settings": [
|
"settings": [
|
||||||
{
|
|
||||||
"type": "select",
|
|
||||||
"label": "Align labels",
|
|
||||||
"key": "labelPosition",
|
|
||||||
"defaultValue": "left",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"label": "Left",
|
|
||||||
"value": "left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Above",
|
|
||||||
"value": "above"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "select",
|
|
||||||
"label": "Size",
|
|
||||||
"key": "size",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"label": "Medium",
|
|
||||||
"value": "spectrum--medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Large",
|
|
||||||
"value": "spectrum--large"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"defaultValue": "spectrum--medium"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "fieldConfiguration",
|
"type": "fieldConfiguration",
|
||||||
"key": "fields",
|
"key": "fields",
|
||||||
|
@ -5436,6 +5589,40 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "style",
|
||||||
|
"type": "select",
|
||||||
|
"label": "Align labels",
|
||||||
|
"key": "labelPosition",
|
||||||
|
"defaultValue": "left",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Left",
|
||||||
|
"value": "left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Above",
|
||||||
|
"value": "above"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "style",
|
||||||
|
"type": "select",
|
||||||
|
"label": "Size",
|
||||||
|
"key": "size",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Medium",
|
||||||
|
"value": "spectrum--medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Large",
|
||||||
|
"value": "spectrum--large"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValue": "spectrum--medium"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"context": [
|
"context": [
|
||||||
|
@ -5737,4 +5924,4 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script>
|
||||||
|
import BlockComponent from "../BlockComponent.svelte"
|
||||||
|
import Block from "../Block.svelte"
|
||||||
|
|
||||||
|
export let buttons = []
|
||||||
|
export let direction
|
||||||
|
export let hAlign
|
||||||
|
export let vAlign
|
||||||
|
export let gap = "S"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Block>
|
||||||
|
<BlockComponent
|
||||||
|
type="container"
|
||||||
|
props={{
|
||||||
|
direction,
|
||||||
|
hAlign,
|
||||||
|
vAlign,
|
||||||
|
gap,
|
||||||
|
wrap: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each buttons as { text, type, quiet, disabled, onClick, size }}
|
||||||
|
<BlockComponent
|
||||||
|
type="button"
|
||||||
|
props={{
|
||||||
|
text: text || "Button",
|
||||||
|
onClick,
|
||||||
|
type,
|
||||||
|
quiet,
|
||||||
|
disabled,
|
||||||
|
size,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</BlockComponent>
|
||||||
|
</Block>
|
|
@ -81,6 +81,7 @@
|
||||||
sortOrder: $fetch.sortOrder,
|
sortOrder: $fetch.sortOrder,
|
||||||
},
|
},
|
||||||
limit,
|
limit,
|
||||||
|
primaryDisplay: $fetch.definition?.primaryDisplay,
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFetch = datasource => {
|
const createFetch = datasource => {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
export let fields
|
export let fields
|
||||||
export let labelPosition
|
export let labelPosition
|
||||||
export let title
|
export let title
|
||||||
|
export let description
|
||||||
export let saveButtonLabel
|
export let saveButtonLabel
|
||||||
export let deleteButtonLabel
|
export let deleteButtonLabel
|
||||||
export let schema
|
export let schema
|
||||||
|
@ -160,55 +161,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}
|
||||||
|
|
|
@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte"
|
||||||
export { default as divider } from "./Divider.svelte"
|
export { default as divider } from "./Divider.svelte"
|
||||||
export { default as screenslot } from "./ScreenSlot.svelte"
|
export { default as screenslot } from "./ScreenSlot.svelte"
|
||||||
export { default as button } from "./Button.svelte"
|
export { default as button } from "./Button.svelte"
|
||||||
|
export { default as buttongroup } from "./ButtonGroup.svelte"
|
||||||
export { default as repeater } from "./Repeater.svelte"
|
export { default as repeater } from "./Repeater.svelte"
|
||||||
export { default as text } from "./Text.svelte"
|
export { default as text } from "./Text.svelte"
|
||||||
export { default as layout } from "./Layout.svelte"
|
export { default as layout } from "./Layout.svelte"
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 d24c0dc3a30014cbe61860252aa48104cad36376
|
Subproject commit 3820c0c93a3e448e10a60a9feb5396844b537ca8
|
|
@ -38,7 +38,7 @@ RUN apt update && apt upgrade -y \
|
||||||
|
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
COPY dist/yarn.lock .
|
COPY dist/yarn.lock .
|
||||||
RUN yarn install --production=true \
|
RUN yarn install --production=true --network-timeout 1000000 \
|
||||||
# Remove unneeded data from file system to reduce image size
|
# Remove unneeded data from file system to reduce image size
|
||||||
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \
|
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \
|
||||||
&& 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
|
"build:docker": "yarn nx build && docker buildx build ../.. -t app-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION -f Dockerfile.v2 --platform linux/amd64,linux/arm64",
|
||||||
"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",
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,128 +11,24 @@ const { PermissionType, PermissionLevel } = permissions
|
||||||
const router: Router = new Router()
|
const router: Router = new Router()
|
||||||
|
|
||||||
router
|
router
|
||||||
/**
|
|
||||||
* @api {get} /api/:sourceId/:rowId/enrich Get an enriched row
|
|
||||||
* @apiName Get an enriched row
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table read access
|
|
||||||
* @apiDescription This API is only useful when dealing with rows that have relationships.
|
|
||||||
* Normally when a row is a returned from the API relationships will only have the structure
|
|
||||||
* `{ primaryDisplay: "name", _id: ... }` but this call will return the full related rows
|
|
||||||
* for each relationship instead.
|
|
||||||
*
|
|
||||||
* @apiParam {string} rowId The ID of the row which is to be retrieved and enriched.
|
|
||||||
*
|
|
||||||
* @apiSuccess {object} row The response body will be the enriched row.
|
|
||||||
*/
|
|
||||||
.get(
|
.get(
|
||||||
"/api/:sourceId/:rowId/enrich",
|
"/api/:sourceId/:rowId/enrich",
|
||||||
paramSubResource("sourceId", "rowId"),
|
paramSubResource("sourceId", "rowId"),
|
||||||
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
||||||
rowController.fetchEnrichedRow
|
rowController.fetchEnrichedRow
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {get} /api/:sourceId/rows Get all rows in a table
|
|
||||||
* @apiName Get all rows in a table
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table read access
|
|
||||||
* @apiDescription This is a deprecated endpoint that should not be used anymore, instead use the search endpoint.
|
|
||||||
* This endpoint gets all of the rows within the specified table - it is not heavily used
|
|
||||||
* due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then
|
|
||||||
* will simply stop.
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table to retrieve all rows within.
|
|
||||||
*
|
|
||||||
* @apiSuccess {object[]} rows The response body will be an array of all rows found.
|
|
||||||
*/
|
|
||||||
.get(
|
.get(
|
||||||
"/api/:sourceId/rows",
|
"/api/:sourceId/rows",
|
||||||
paramResource("sourceId"),
|
paramResource("sourceId"),
|
||||||
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
||||||
rowController.fetch
|
rowController.fetch
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {get} /api/:sourceId/rows/:rowId Retrieve a single row
|
|
||||||
* @apiName Retrieve a single row
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table read access
|
|
||||||
* @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve
|
|
||||||
* a row by anything other than its _id field, use the search endpoint.
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table to retrieve a row from.
|
|
||||||
* @apiParam {string} rowId The ID of the row to retrieve.
|
|
||||||
*
|
|
||||||
* @apiSuccess {object} body The response body will be the row that was found.
|
|
||||||
*/
|
|
||||||
.get(
|
.get(
|
||||||
"/api/:sourceId/rows/:rowId",
|
"/api/:sourceId/rows/:rowId",
|
||||||
paramSubResource("sourceId", "rowId"),
|
paramSubResource("sourceId", "rowId"),
|
||||||
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
||||||
rowController.find
|
rowController.find
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {post} /api/:sourceId/search Search for rows in a table
|
|
||||||
* @apiName Search for rows in a table
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table read access
|
|
||||||
* @apiDescription This is the primary method of accessing rows in Budibase, the data provider
|
|
||||||
* and data UI in the builder are built atop this. All filtering, sorting and pagination is
|
|
||||||
* handled through this, for internal and external (datasource plus, e.g. SQL) tables.
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table to retrieve rows from.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true,
|
|
||||||
* defaults to false.
|
|
||||||
* @apiParam (Body) {object} [query] This contains a set of filters which should be applied, if none
|
|
||||||
* specified then the request will be unfiltered. An example with all of the possible query
|
|
||||||
* options has been supplied below.
|
|
||||||
* @apiParam (Body) {number} [limit] This sets a limit for the number of rows that will be returned,
|
|
||||||
* this will be implemented at the database level if supported for performance reasons. This
|
|
||||||
* is useful when paginating to set exactly how many rows per page.
|
|
||||||
* @apiParam (Body) {string} [bookmark] If pagination is enabled then a bookmark will be returned
|
|
||||||
* with each successful search request, this should be supplied back to get the next page.
|
|
||||||
* @apiParam (Body) {object} [sort] If sort is desired this should contain the name of the column to
|
|
||||||
* sort on.
|
|
||||||
* @apiParam (Body) {string} [sortOrder] If sort is enabled then this can be either "descending" or
|
|
||||||
* "ascending" as required.
|
|
||||||
* @apiParam (Body) {string} [sortType] If sort is enabled then you must specify the type of search
|
|
||||||
* being used, either "string" or "number". This is only used for internal tables.
|
|
||||||
*
|
|
||||||
* @apiParamExample {json} Example:
|
|
||||||
* {
|
|
||||||
* "tableId": "ta_70260ff0b85c467ca74364aefc46f26d",
|
|
||||||
* "query": {
|
|
||||||
* "string": {},
|
|
||||||
* "fuzzy": {},
|
|
||||||
* "range": {
|
|
||||||
* "columnName": {
|
|
||||||
* "high": 20,
|
|
||||||
* "low": 10,
|
|
||||||
* }
|
|
||||||
* },
|
|
||||||
* "equal": {
|
|
||||||
* "columnName": "someValue"
|
|
||||||
* },
|
|
||||||
* "notEqual": {},
|
|
||||||
* "empty": {},
|
|
||||||
* "notEmpty": {},
|
|
||||||
* "oneOf": {
|
|
||||||
* "columnName": ["value"]
|
|
||||||
* }
|
|
||||||
* },
|
|
||||||
* "limit": 10,
|
|
||||||
* "sort": "name",
|
|
||||||
* "sortOrder": "descending",
|
|
||||||
* "sortType": "string",
|
|
||||||
* "paginate": true
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @apiSuccess {object[]} rows An array of rows that was found based on the supplied parameters.
|
|
||||||
* @apiSuccess {boolean} hasNextPage If pagination was enabled then this specifies whether or
|
|
||||||
* not there is another page after this request.
|
|
||||||
* @apiSuccess {string} bookmark The bookmark to be sent with the next request to get the next
|
|
||||||
* page.
|
|
||||||
*/
|
|
||||||
.post(
|
.post(
|
||||||
"/api/:sourceId/search",
|
"/api/:sourceId/search",
|
||||||
internalSearchValidator(),
|
internalSearchValidator(),
|
||||||
|
@ -148,30 +44,6 @@ router
|
||||||
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
||||||
rowController.search
|
rowController.search
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {post} /api/:sourceId/rows Creates a new row
|
|
||||||
* @apiName Creates a new row
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table write access
|
|
||||||
* @apiDescription This API will create a new row based on the supplied body. If the
|
|
||||||
* body includes an "_id" field then it will update an existing row if the field
|
|
||||||
* links to one. Please note that "_id", "_rev" and "tableId" are fields that are
|
|
||||||
* already used by Budibase tables and cannot be used for columns.
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table to save a row to.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided.
|
|
||||||
* @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision
|
|
||||||
* must also be provided.
|
|
||||||
* @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself.
|
|
||||||
* @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches
|
|
||||||
* a column in the specified table. All other fields will be dropped and not stored.
|
|
||||||
*
|
|
||||||
* @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this
|
|
||||||
* is the rows new ID.
|
|
||||||
* @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned.
|
|
||||||
* @apiSuccess {object} body The contents of the row that was saved will be returned as well.
|
|
||||||
*/
|
|
||||||
.post(
|
.post(
|
||||||
"/api/:sourceId/rows",
|
"/api/:sourceId/rows",
|
||||||
paramResource("sourceId"),
|
paramResource("sourceId"),
|
||||||
|
@ -179,14 +51,6 @@ router
|
||||||
trimViewRowInfo,
|
trimViewRowInfo,
|
||||||
rowController.save
|
rowController.save
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {patch} /api/:sourceId/rows Updates a row
|
|
||||||
* @apiName Update a row
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table write access
|
|
||||||
* @apiDescription This endpoint is identical to the row creation endpoint but instead it will
|
|
||||||
* error if an _id isn't provided, it will only function for existing rows.
|
|
||||||
*/
|
|
||||||
.patch(
|
.patch(
|
||||||
"/api/:sourceId/rows",
|
"/api/:sourceId/rows",
|
||||||
paramResource("sourceId"),
|
paramResource("sourceId"),
|
||||||
|
@ -194,52 +58,12 @@ router
|
||||||
trimViewRowInfo,
|
trimViewRowInfo,
|
||||||
rowController.patch
|
rowController.patch
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {post} /api/:sourceId/rows/validate Validate inputs for a row
|
|
||||||
* @apiName Validate inputs for a row
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table write access
|
|
||||||
* @apiDescription When attempting to save a row you may want to check if the row is valid
|
|
||||||
* given the table schema, this will iterate through all the constraints on the table and
|
|
||||||
* check if the request body is valid.
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table the row is to be validated for.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {any} [any] Any fields provided in the request body will be tested
|
|
||||||
* against the table schema and constraints.
|
|
||||||
*
|
|
||||||
* @apiSuccess {boolean} valid If inputs provided are acceptable within the table schema this
|
|
||||||
* will be true, if it is not then then errors property will be populated.
|
|
||||||
* @apiSuccess {object} [errors] A key value map of information about fields on the input
|
|
||||||
* which do not match the table schema. The key name will be the column names that have breached
|
|
||||||
* the schema.
|
|
||||||
*/
|
|
||||||
.post(
|
.post(
|
||||||
"/api/:sourceId/rows/validate",
|
"/api/:sourceId/rows/validate",
|
||||||
paramResource("sourceId"),
|
paramResource("sourceId"),
|
||||||
authorized(PermissionType.TABLE, PermissionLevel.WRITE),
|
authorized(PermissionType.TABLE, PermissionLevel.WRITE),
|
||||||
rowController.validate
|
rowController.validate
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {delete} /api/:sourceId/rows Delete rows
|
|
||||||
* @apiName Delete rows
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table write access
|
|
||||||
* @apiDescription This endpoint can delete a single row, or delete them in a bulk
|
|
||||||
* fashion.
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table the row is to be deleted from.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this
|
|
||||||
* key of the request body that are to be deleted.
|
|
||||||
* @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field.
|
|
||||||
* @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its
|
|
||||||
* revision here.
|
|
||||||
*
|
|
||||||
* @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array
|
|
||||||
* of the deleted rows, if deleting a single row then the body will contain a "row" property which
|
|
||||||
* is the deleted row.
|
|
||||||
*/
|
|
||||||
.delete(
|
.delete(
|
||||||
"/api/:sourceId/rows",
|
"/api/:sourceId/rows",
|
||||||
paramResource("sourceId"),
|
paramResource("sourceId"),
|
||||||
|
@ -247,20 +71,6 @@ router
|
||||||
trimViewRowInfo,
|
trimViewRowInfo,
|
||||||
rowController.destroy
|
rowController.destroy
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* @api {post} /api/:sourceId/rows/exportRows Export Rows
|
|
||||||
* @apiName Export rows
|
|
||||||
* @apiGroup rows
|
|
||||||
* @apiPermission table write access
|
|
||||||
* @apiDescription This API can export a number of provided rows
|
|
||||||
*
|
|
||||||
* @apiParam {string} sourceId The ID of the table the row is to be deleted from.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {object[]} [rows] The row IDs which are to be exported
|
|
||||||
*
|
|
||||||
* @apiSuccess {object[]|object}
|
|
||||||
*/
|
|
||||||
.post(
|
.post(
|
||||||
"/api/:sourceId/rows/exportRows",
|
"/api/:sourceId/rows/exportRows",
|
||||||
paramResource("sourceId"),
|
paramResource("sourceId"),
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -9,99 +9,13 @@ const { BUILDER, PermissionLevel, PermissionType } = permissions
|
||||||
const router: Router = new Router()
|
const router: Router = new Router()
|
||||||
|
|
||||||
router
|
router
|
||||||
/**
|
|
||||||
* @api {get} /api/tables Fetch all tables
|
|
||||||
* @apiName Fetch all tables
|
|
||||||
* @apiGroup tables
|
|
||||||
* @apiPermission table read access
|
|
||||||
* @apiDescription This endpoint retrieves all of the tables which have been created in
|
|
||||||
* an app. This includes all of the external and internal tables; to tell the difference
|
|
||||||
* between these look for the "type" property on each table, either being "internal" or "external".
|
|
||||||
*
|
|
||||||
* @apiSuccess {object[]} body The response body will be the list of tables that was found - as
|
|
||||||
* this does not take any parameters the only error scenario is no access.
|
|
||||||
*/
|
|
||||||
.get("/api/tables", authorized(BUILDER), tableController.fetch)
|
.get("/api/tables", authorized(BUILDER), tableController.fetch)
|
||||||
/**
|
|
||||||
* @api {get} /api/tables/:id Fetch a single table
|
|
||||||
* @apiName Fetch a single table
|
|
||||||
* @apiGroup tables
|
|
||||||
* @apiPermission table read access
|
|
||||||
* @apiDescription Retrieves a single table this could be be internal or external based on
|
|
||||||
* the provided table ID.
|
|
||||||
*
|
|
||||||
* @apiParam {string} id The ID of the table which is to be retrieved.
|
|
||||||
*
|
|
||||||
* @apiSuccess {object[]} body The response body will be the table that was found.
|
|
||||||
*/
|
|
||||||
.get(
|
.get(
|
||||||
"/api/tables/:tableId",
|
"/api/tables/:tableId",
|
||||||
paramResource("tableId"),
|
paramResource("tableId"),
|
||||||
authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }),
|
authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }),
|
||||||
tableController.find
|
tableController.find
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {post} /api/tables Save a table
|
|
||||||
* @apiName Save a table
|
|
||||||
* @apiGroup tables
|
|
||||||
* @apiPermission builder
|
|
||||||
* @apiDescription Create or update a table with this endpoint, this will function for both internal
|
|
||||||
* external tables.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {string} [_id] If updating an existing table then the ID of the table must be specified.
|
|
||||||
* @apiParam (Body) {string} [_rev] If updating an existing internal table then the revision must also be specified.
|
|
||||||
* @apiParam (Body) {string} type] This should either be "internal" or "external" depending on the table type -
|
|
||||||
* this will default to internal.
|
|
||||||
* @apiParam (Body) {string} [sourceId] If creating an external table then this should be set to the datasource ID. If
|
|
||||||
* building an internal table this does not need to be set, although it will be returned as "bb_internal".
|
|
||||||
* @apiParam (Body) {string} name The name of the table, this will be used in the UI. To rename the table simply
|
|
||||||
* supply the table structure to this endpoint with the name changed.
|
|
||||||
* @apiParam (Body) {object} schema A key value object which has all of the columns in the table as the keys in this
|
|
||||||
* object. For each column a "type" and "constraints" must be specified, with some types requiring further information.
|
|
||||||
* More information about the schema structure can be found in the Typescript definitions.
|
|
||||||
* @apiParam (Body) {string} [primaryDisplay] The name of the column which should be used when displaying rows
|
|
||||||
* from this table as relationships.
|
|
||||||
* @apiParam (Body) {object[]} [indexes] Specifies the search indexes - this is deprecated behaviour with the introduction
|
|
||||||
* of lucene indexes. This functionality is only available for internal tables.
|
|
||||||
* @apiParam (Body) {object} [_rename] If a column is to be renamed then the "old" column name should be set in this
|
|
||||||
* structure, and the "updated", new column name should also be supplied. The schema should also be updated, this field
|
|
||||||
* lets the server know that a field hasn't just been deleted, that the data has moved to a new name, this will fix
|
|
||||||
* the rows in the table. This functionality is only available for internal tables.
|
|
||||||
* @apiParam (Body) {object[]} [rows] When creating a table using a compatible data source, an array of objects to be imported into the new table can be provided.
|
|
||||||
*
|
|
||||||
* @apiParamExample {json} Example:
|
|
||||||
* {
|
|
||||||
* "_id": "ta_05541307fa0f4044abee071ca2a82119",
|
|
||||||
* "_rev": "10-0fbe4e78f69b255d79f1017e2eeef807",
|
|
||||||
* "type": "internal",
|
|
||||||
* "views": {},
|
|
||||||
* "name": "tableName",
|
|
||||||
* "schema": {
|
|
||||||
* "column": {
|
|
||||||
* "type": "string",
|
|
||||||
* "constraints": {
|
|
||||||
* "type": "string",
|
|
||||||
* "length": {
|
|
||||||
* "maximum": null
|
|
||||||
* },
|
|
||||||
* "presence": false
|
|
||||||
* },
|
|
||||||
* "name": "column"
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* "primaryDisplay": "column",
|
|
||||||
* "indexes": [],
|
|
||||||
* "sourceId": "bb_internal",
|
|
||||||
* "_rename": {
|
|
||||||
* "old": "columnName",
|
|
||||||
* "updated": "newColumnName",
|
|
||||||
* },
|
|
||||||
* "rows": []
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @apiSuccess {object} table The response body will contain the table structure after being cleaned up and
|
|
||||||
* saved to the database.
|
|
||||||
*/
|
|
||||||
.post(
|
.post(
|
||||||
"/api/tables",
|
"/api/tables",
|
||||||
// allows control over updating a table
|
// allows control over updating a table
|
||||||
|
@ -125,41 +39,12 @@ router
|
||||||
authorized(BUILDER),
|
authorized(BUILDER),
|
||||||
tableController.validateExistingTableImport
|
tableController.validateExistingTableImport
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {post} /api/tables/:tableId/:revId Delete a table
|
|
||||||
* @apiName Delete a table
|
|
||||||
* @apiGroup tables
|
|
||||||
* @apiPermission builder
|
|
||||||
* @apiDescription This endpoint will delete a table and all of its associated data, for this reason it is
|
|
||||||
* quite dangerous - it will work for internal and external tables.
|
|
||||||
*
|
|
||||||
* @apiParam {string} tableId The ID of the table which is to be deleted.
|
|
||||||
* @apiParam {string} [revId] If deleting an internal table then the revision must also be supplied (_rev), for
|
|
||||||
* external tables this can simply be set to anything, e.g. "external".
|
|
||||||
*
|
|
||||||
* @apiSuccess {string} message A message stating that the table was deleted successfully.
|
|
||||||
*/
|
|
||||||
.delete(
|
.delete(
|
||||||
"/api/tables/:tableId/:revId",
|
"/api/tables/:tableId/:revId",
|
||||||
paramResource("tableId"),
|
paramResource("tableId"),
|
||||||
authorized(BUILDER),
|
authorized(BUILDER),
|
||||||
tableController.destroy
|
tableController.destroy
|
||||||
)
|
)
|
||||||
/**
|
|
||||||
* @api {post} /api/tables/:tableId/:revId Import CSV to existing table
|
|
||||||
* @apiName Import CSV to existing table
|
|
||||||
* @apiGroup tables
|
|
||||||
* @apiPermission builder
|
|
||||||
* @apiDescription This endpoint will import data to existing tables, internal or external. It is used in combination
|
|
||||||
* with the CSV validation endpoint. Take the output of the CSV validation endpoint and pass it to this endpoint to
|
|
||||||
* import the data; please note this will only import fields that already exist on the table/match the type.
|
|
||||||
*
|
|
||||||
* @apiParam {string} tableId The ID of the table which the data should be imported to.
|
|
||||||
*
|
|
||||||
* @apiParam (Body) {object[]} rows An array of objects representing the rows to be imported, key-value pairs not matching the table schema will be ignored.
|
|
||||||
*
|
|
||||||
* @apiSuccess {string} message A message stating that the data was imported successfully.
|
|
||||||
*/
|
|
||||||
.post(
|
.post(
|
||||||
"/api/tables/:tableId/import",
|
"/api/tables/:tableId/import",
|
||||||
paramResource("tableId"),
|
paramResource("tableId"),
|
||||||
|
|
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
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 {
|
||||||
|
|
|
@ -5,10 +5,17 @@ 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
|
||||||
|
|
|
@ -4,7 +4,9 @@ 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",
|
||||||
}
|
}
|
||||||
|
@ -26,10 +28,12 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -14,7 +14,7 @@ RUN yarn global add pm2
|
||||||
|
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
COPY dist/yarn.lock .
|
COPY dist/yarn.lock .
|
||||||
RUN yarn install --production=true
|
RUN 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
|
"build:docker": "yarn nx build && docker buildx build ../.. -t worker-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION -f Dockerfile.v2 --platform linux/amd64,linux/arm64",
|
||||||
"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",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { events } from "@budibase/backend-core"
|
import { events } from "@budibase/backend-core"
|
||||||
import { generator } from "@budibase/backend-core/tests"
|
import { generator } from "@budibase/backend-core/tests"
|
||||||
import { structures, TestConfiguration, mocks } from "../../../../tests"
|
import { structures, TestConfiguration, mocks } from "../../../../tests"
|
||||||
import { UserGroup } from "@budibase/types"
|
import { User, UserGroup } from "@budibase/types"
|
||||||
|
|
||||||
mocks.licenses.useGroups()
|
mocks.licenses.useGroups()
|
||||||
|
|
||||||
|
@ -231,4 +231,39 @@ describe("/api/global/groups", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("with global builder role", () => {
|
||||||
|
let builder: User
|
||||||
|
let group: UserGroup
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
builder = await config.createUser({
|
||||||
|
builder: { global: true },
|
||||||
|
admin: { global: false },
|
||||||
|
})
|
||||||
|
await config.createSession(builder)
|
||||||
|
|
||||||
|
let resp = await config.api.groups.saveGroup(
|
||||||
|
structures.groups.UserGroup()
|
||||||
|
)
|
||||||
|
group = resp.body as UserGroup
|
||||||
|
})
|
||||||
|
|
||||||
|
it("find should return 200", async () => {
|
||||||
|
await config.withUser(builder, async () => {
|
||||||
|
await config.api.groups.searchUsers(group._id!, {
|
||||||
|
emailSearch: `user1`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("update should return 200", async () => {
|
||||||
|
await config.withUser(builder, async () => {
|
||||||
|
await config.api.groups.updateGroupUsers(group._id!, {
|
||||||
|
add: [builder._id!],
|
||||||
|
remove: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -190,6 +190,16 @@ class TestConfiguration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async withUser(user: User, f: () => Promise<void>) {
|
||||||
|
const oldUser = this.user
|
||||||
|
this.user = user
|
||||||
|
try {
|
||||||
|
await f()
|
||||||
|
} finally {
|
||||||
|
this.user = oldUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
authHeaders(user: User) {
|
authHeaders(user: User) {
|
||||||
const authToken: AuthToken = {
|
const authToken: AuthToken = {
|
||||||
userId: user._id!,
|
userId: user._id!,
|
||||||
|
@ -257,9 +267,10 @@ class TestConfiguration {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(user?: User) {
|
async createUser(opts?: Partial<User>) {
|
||||||
if (!user) {
|
let user = structures.users.user()
|
||||||
user = structures.users.user()
|
if (user) {
|
||||||
|
user = { ...user, ...opts }
|
||||||
}
|
}
|
||||||
const response = await this._req(user, null, controllers.users.save)
|
const response = await this._req(user, null, controllers.users.save)
|
||||||
const body = response as SaveUserResponse
|
const body = response as SaveUserResponse
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { generator } from "@budibase/backend-core/tests"
|
import { generator } from "@budibase/backend-core/tests"
|
||||||
import { db } from "@budibase/backend-core"
|
import { db } from "@budibase/backend-core"
|
||||||
import { UserGroupRoles } from "@budibase/types"
|
import { UserGroup as UserGroupType, UserGroupRoles } from "@budibase/types"
|
||||||
|
|
||||||
export const UserGroup = () => {
|
export function UserGroup(): UserGroupType {
|
||||||
const appsCount = generator.integer({ min: 0, max: 3 })
|
const appsCount = generator.integer({ min: 0, max: 3 })
|
||||||
const roles = Array.from({ length: appsCount }).reduce(
|
const roles = Array.from({ length: appsCount }).reduce(
|
||||||
(p: UserGroupRoles, v) => {
|
(p: UserGroupRoles, v) => {
|
||||||
|
@ -14,13 +14,11 @@ export const UserGroup = () => {
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
|
|
||||||
let group = {
|
return {
|
||||||
apps: [],
|
|
||||||
color: generator.color(),
|
color: generator.color(),
|
||||||
icon: generator.word(),
|
icon: generator.word(),
|
||||||
name: generator.word(),
|
name: generator.word(),
|
||||||
roles: roles,
|
roles: roles,
|
||||||
users: [],
|
users: [],
|
||||||
}
|
}
|
||||||
return group
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue