1
0
Fork 0
mirror of synced 2024-07-07 15:25:52 +12:00

Merge branch 'master' into master

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

View file

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

View file

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

View file

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

View file

@ -33,7 +33,6 @@
"build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
"release:develop": "yarn release --dist-tag develop",
"restore": "yarn run clean && yarn && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

View file

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

View file

@ -1,8 +1,11 @@
<script>
import EditFieldPopover from "./EditFieldPopover.svelte"
import { Toggle } from "@budibase/bbui"
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { store } from "builderStore"
import { runtimeToReadableBinding } from "builderStore/dataBinding"
import { isJSBinding } from "@budibase/string-templates"
export let item
export let componentBindings
@ -16,18 +19,43 @@
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const getReadableText = () => {
if (item.label) {
return isJSBinding(item.label)
? "(JavaScript function)"
: runtimeToReadableBinding([...bindings, componentBindings], item.label)
}
return item.field
}
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
$: readableText = getReadableText(item)
$: componentDef = store.actions.components.getDefinition(item._component)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditFieldPopover
<EditComponentPopover
{anchor}
field={item}
componentInstance={item}
{componentBindings}
{bindings}
{parseSettings}
on:change
/>
<div class="field-label">{item.label || item.field}</div>
>
<div slot="header" class="type-icon">
<Icon name={componentDef.icon} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{readableText}</div>
</div>
<div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
@ -53,4 +81,20 @@
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

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

View file

@ -258,6 +258,186 @@
"description": "Contains your app screens",
"static": true
},
"buttongroup": {
"name": "Button group",
"icon": "Button",
"hasChildren": false,
"settings": [
{
"section": true,
"name": "Buttons",
"settings": [
{
"type": "buttonConfiguration",
"key": "buttons",
"nested": true,
"defaultValue": [
{
"type": "cta",
"text": "Button 1"
},
{
"type": "primary",
"text": "Button 2"
}
]
}
]
},
{
"section": true,
"name": "Layout",
"settings": [
{
"type": "select",
"label": "Direction",
"key": "direction",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Column",
"value": "column",
"barIcon": "ViewColumn",
"barTitle": "Column layout"
},
{
"label": "Row",
"value": "row",
"barIcon": "ViewRow",
"barTitle": "Row layout"
}
],
"defaultValue": "row"
},
{
"type": "select",
"label": "Horiz. align",
"key": "hAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Left",
"value": "left",
"barIcon": "AlignLeft",
"barTitle": "Align left"
},
{
"label": "Center",
"value": "center",
"barIcon": "AlignCenter",
"barTitle": "Align center"
},
{
"label": "Right",
"value": "right",
"barIcon": "AlignRight",
"barTitle": "Align right"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveLeftRight",
"barTitle": "Align stretched horizontally"
}
],
"defaultValue": "left"
},
{
"type": "select",
"label": "Vert. align",
"key": "vAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Top",
"value": "top",
"barIcon": "AlignTop",
"barTitle": "Align top"
},
{
"label": "Middle",
"value": "middle",
"barIcon": "AlignMiddle",
"barTitle": "Align middle"
},
{
"label": "Bottom",
"value": "bottom",
"barIcon": "AlignBottom",
"barTitle": "Align bottom"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveUpDown",
"barTitle": "Align stretched vertically"
}
],
"defaultValue": "top"
},
{
"type": "select",
"label": "Size",
"key": "size",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Shrink",
"value": "shrink",
"barIcon": "Minimize",
"barTitle": "Shrink container"
},
{
"label": "Grow",
"value": "grow",
"barIcon": "Maximize",
"barTitle": "Grow container"
}
],
"defaultValue": "shrink"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
},
{
"type": "boolean",
"label": "Wrap",
"key": "wrap",
"showInBar": true,
"barIcon": "ModernGridView",
"barTitle": "Wrap"
}
]
}
]
},
"button": {
"name": "Button",
"description": "A basic html button that is ready for styling",
@ -2409,7 +2589,6 @@
"key": "disabled",
"defaultValue": false
},
{
"type": "text",
"label": "Initial form step",
@ -5321,6 +5500,12 @@
"key": "title",
"nested": true
},
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{
"section": true,
"dependsOn": {
@ -5401,38 +5586,6 @@
"section": true,
"name": "Fields",
"settings": [
{
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
},
{
"type": "fieldConfiguration",
"key": "fields",
@ -5452,6 +5605,40 @@
}
}
]
},
{
"tag": "style",
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"tag": "style",
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
}
],
"context": [
@ -5753,4 +5940,4 @@
}
]
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -71,6 +71,8 @@ export const initialise = context => {
datasource,
fetch,
filter,
inlineFilters,
allFilters,
sort,
table,
initialFilter,
@ -93,6 +95,7 @@ export const initialise = context => {
// Wipe state
filter.set(get(initialFilter))
inlineFilters.set([])
sort.set({
column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending",
@ -100,14 +103,14 @@ export const initialise = context => {
// Update fetch when filter changes
unsubscribers.push(
filter.subscribe($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: $filter,
filter: $allFilters,
})
})
)

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { derived, get } from "svelte/store"
import { derived } from "svelte/store"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
@ -50,12 +50,11 @@ export const deriveStores = context => {
const interval = MinColumnWidth
return Math.round($scrollLeft / interval) * interval
})
const renderedColumns = derived(
const columnRenderMap = derived(
[visibleColumns, scrollLeftRounded, width],
([$visibleColumns, $scrollLeft, $width], set) => {
([$visibleColumns, $scrollLeft, $width]) => {
if (!$visibleColumns.length) {
set([])
return
return {}
}
let startColIdx = 0
let rightEdge = $visibleColumns[0].width
@ -75,34 +74,16 @@ export const deriveStores = context => {
leftEdge += $visibleColumns[endColIdx].width
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(
[renderedColumns, visibleColumns],
([$renderedColumns, $visibleColumns]) => {
const idx = $visibleColumns.findIndex(
col => col.name === $renderedColumns[0]?.name
)
let width = 0
if (idx > 0) {
for (let i = 0; i < idx; i++) {
width += $visibleColumns[i].width
}
}
return width
},
0
// Only update the store if different
let next = {}
$visibleColumns
.slice(Math.max(0, startColIdx), endColIdx)
.forEach(col => {
next[col.name] = true
})
return next
}
)
// 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
// cell dropdowns
const columnHorizontalInversionIndex = derived(
[renderedColumns, scrollLeft, width],
([$renderedColumns, $scrollLeft, $width]) => {
[visibleColumns, scrollLeft, width],
([$visibleColumns, $scrollLeft, $width]) => {
const cutoff = $width + $scrollLeft - ScrollBarSize * 3
let inversionIdx = $renderedColumns.length
for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width
let inversionIdx = $visibleColumns.length
for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) {
break
}
@ -148,8 +129,7 @@ export const deriveStores = context => {
scrolledRowCount,
visualRowCapacity,
renderedRows,
renderedColumns,
hiddenColumnsWidth,
columnRenderMap,
rowVerticalInversionIndex,
columnHorizontalInversionIndex,
}

View file

@ -35,9 +35,28 @@ export default class ViewV2Fetch extends DataFetch {
}
async getData() {
const { datasource, limit, sortColumn, sortOrder, sortType, paginate } =
this.options
const { cursor, query } = get(this.store)
const {
datasource,
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 {
const res = await this.API.viewV2.fetch({
viewId: datasource.id,

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ import AWS from "aws-sdk"
import fs from "fs"
import sdk from "../../../sdk"
import * as pro from "@budibase/pro"
import { App } from "@budibase/types"
import { App, Ctx } from "@budibase/types"
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}`
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")
await send(ctx, ctx.file, { root: builderPath })
}
export const uploadFile = async function (ctx: any) {
let files =
ctx.request.files.file.length > 1
? Array.from(ctx.request.files.file)
: [ctx.request.files.file]
export const uploadFile = async function (ctx: Ctx) {
const file = ctx.request?.files?.file
let files = file && Array.isArray(file) ? Array.from(file) : [file]
const uploads = files.map(async (file: any) => {
const fileExtension = [...file.name.split(".")].pop()
@ -93,14 +91,14 @@ export const uploadFile = async function (ctx: any) {
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(
ObjectStoreBuckets.APPS,
ctx.request.body.keys
)
}
export const serveApp = async function (ctx: any) {
export const serveApp = async function (ctx: Ctx) {
const bbHeaderEmbed =
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 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")
// incase running from TS directly
if (env.isDev() && !fs.existsSync(rootPath)) {
rootPath = join(require.resolve("@budibase/client"), "..")
if (!appId) {
ctx.throw(400, "No app ID provided - cannot fetch client library.")
}
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
let datasource
try {
@ -247,7 +257,7 @@ export const getSignedUploadURL = async function (ctx: any) {
const params = { Bucket: bucket, Key: key }
signedUrl = s3.getSignedUrl("putObject", params)
publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`
} catch (error) {
} catch (error: any) {
ctx.throw(400, error)
}
}

View file

@ -27,15 +27,9 @@ router.param("file", async (file: any, ctx: any, next: any) => {
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
// TODO: for now this builder endpoint is not authorized/secured, will need to be
.get("/builder/:file*", controller.serveBuilder)
.get("/api/assets/client", controller.serveClientLibrary)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post(
"/api/attachments/delete",

View file

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

View file

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

View file

@ -1,7 +1,15 @@
import { roles } from "@budibase/backend-core"
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 {
description: "",
url: "",
@ -40,8 +48,8 @@ export function createHomeScreen() {
gap: "M",
},
routing: {
route: "/",
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
route: config.route,
roleId: config.roleId,
},
name: "home-screen",
}

View file

@ -20,6 +20,7 @@ import {
SourceName,
Table,
} from "@budibase/types"
const { BUILTIN_ROLE_IDS } = roles
export function basicTable(): Table {
return {
@ -322,8 +323,22 @@ export function basicUser(role: string) {
}
}
export function basicScreen() {
return createHomeScreen()
export function basicScreen(route: string = "/") {
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() {

View file

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

View file

@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
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 --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
RUN apk del .gyp \
&& yarn cache clean

View file

@ -17,7 +17,7 @@
"test:notify": "node scripts/testResultsWebhook",
"test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.",
"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": "start-server-and-test dev:built http://localhost:4001/health",
"dev:built": "cd ../ && yarn dev:built"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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