1
0
Fork 0
mirror of synced 2024-07-13 02:05:54 +12:00

Merge branch 'develop' of github.com:Budibase/budibase into labday/sqs

This commit is contained in:
mike12345567 2023-07-26 14:40:49 +01:00
commit 43024e1a30
120 changed files with 3765 additions and 709 deletions

View file

@ -1,4 +1,4 @@
name: Deploy Budibase Single Container Image to DockerHub name: release-singleimage
on: on:
workflow_dispatch: workflow_dispatch:
@ -8,8 +8,8 @@ env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
REGISTRY_URL: registry.hub.docker.com REGISTRY_URL: registry.hub.docker.com
jobs: jobs:
build: build-amd64:
name: "build" name: "build-amd64"
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
@ -27,14 +27,12 @@ jobs:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0 fetch-depth: 0
- name: Fail if tag is not in master - name: Fail if tag is not in master
run: | run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch" echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1 exit 1
fi fi
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -70,9 +68,139 @@ jobs:
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }} tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile file: ./hosting/single/Dockerfile
- name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64
build-args: TARGETBUILD=aas
tags: budibase/budibase-aas,budibase/budibase-aas:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
build-arm64:
name: "build-arm64"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build:docker:pre
- 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@v2
with:
context: .
push: true
platforms: linux/arm64
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
build-aas:
name: "build-aas"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build:docker:pre
- 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 Azure App Service docker image - name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:

View file

@ -1,5 +1,5 @@
{ {
"version": "2.8.22-alpha.0", "version": "2.8.27-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

11
nx.json
View file

@ -3,11 +3,8 @@
"default": { "default": {
"runner": "nx-cloud", "runner": "nx-cloud",
"options": { "options": {
"cacheableOperations": [ "cacheableOperations": ["build", "test"],
"build", "accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ="
"test"
],
"accessToken": "YWNiYzc5NTEtMzMzZC00NDhjLTgyNjktZTllMjI1MzM4OGQxfHJlYWQtd3JpdGU="
} }
} }
}, },
@ -15,9 +12,7 @@
"dev:builder": { "dev:builder": {
"dependsOn": [ "dependsOn": [
{ {
"projects": [ "projects": ["@budibase/string-templates"],
"@budibase/string-templates"
],
"target": "build" "target": "build"
} }
] ]

View file

@ -6,8 +6,8 @@
"@nx/js": "16.4.3", "@nx/js": "16.4.3",
"@rollup/plugin-json": "^4.0.2", "@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "5.45.0",
"esbuild": "^0.17.18", "esbuild": "^0.18.17",
"esbuild-node-externals": "^1.7.0", "esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0", "eslint": "^8.44.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"husky": "^8.0.3", "husky": "^8.0.3",
@ -51,9 +51,9 @@
"kill-builder": "kill-port 3000", "kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002", "kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server", "kill-all": "yarn run kill-builder && yarn run kill-server",
"dev": "yarn run kill-all && lerna run --stream dev:builder --stream", "dev": "yarn run kill-all && yarn nx run-many --target=dev:builder",
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && yarn nx run-many --target=dev:builder --exclude=@budibase/backend-core,@budibase/server,@budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream dev:builder --scope @budibase/worker --scope @budibase/server", "dev:server": "yarn run kill-server && yarn nx run-many --target=dev:builder --projects=@budibase/worker,@budibase/server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
"dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0", "dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream", "test": "lerna run --stream test --stream",

View file

@ -2,9 +2,14 @@ import { getAppClient } from "../redis/init"
import { doWithDB, DocumentType } from "../db" import { doWithDB, DocumentType } from "../db"
import { Database, App } from "@budibase/types" import { Database, App } from "@budibase/types"
const AppState = { export enum AppState {
INVALID: "invalid", INVALID = "invalid",
} }
export interface DeletedApp {
state: AppState
}
const EXPIRY_SECONDS = 3600 const EXPIRY_SECONDS = 3600
/** /**
@ -31,7 +36,7 @@ function isInvalid(metadata?: { state: string }) {
* @param {string} appId the id of the app to get metadata from. * @param {string} appId the id of the app to get metadata from.
* @returns {object} the app metadata. * @returns {object} the app metadata.
*/ */
export async function getAppMetadata(appId: string) { export async function getAppMetadata(appId: string): Promise<App | DeletedApp> {
const client = await getAppClient() const client = await getAppClient()
// try cache // try cache
let metadata = await client.get(appId) let metadata = await client.get(appId)
@ -61,11 +66,8 @@ export async function getAppMetadata(appId: string) {
} }
await client.store(appId, metadata, expiry) await client.store(appId, metadata, expiry)
} }
// we've stored in the cache an object to tell us that it is currently invalid
if (isInvalid(metadata)) { return metadata
throw { status: 404, message: "No app metadata found" }
}
return metadata as App
} }
/** /**

View file

@ -0,0 +1,10 @@
export const CONSTANT_INTERNAL_ROW_COLS = [
"_id",
"_rev",
"type",
"createdAt",
"updatedAt",
"tableId",
] as const
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const

View file

@ -2,3 +2,4 @@ export * from "./connections"
export * from "./DatabaseImpl" export * from "./DatabaseImpl"
export * from "./utils" export * from "./utils"
export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB" export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB"
export * from "../constants"

View file

@ -2,7 +2,7 @@ import env from "../environment"
import { DEFAULT_TENANT_ID, SEPARATOR, DocumentType } from "../constants" import { DEFAULT_TENANT_ID, SEPARATOR, DocumentType } from "../constants"
import { getTenantId, getGlobalDBName } from "../context" import { getTenantId, getGlobalDBName } from "../context"
import { doWithDB, directCouchAllDbs } from "./db" import { doWithDB, directCouchAllDbs } from "./db"
import { getAppMetadata } from "../cache/appMetadata" import { AppState, DeletedApp, getAppMetadata } from "../cache/appMetadata"
import { isDevApp, isDevAppID, getProdAppID } from "../docIds/conversions" import { isDevApp, isDevAppID, getProdAppID } from "../docIds/conversions"
import { App, Database } from "@budibase/types" import { App, Database } from "@budibase/types"
import { getStartEndKeyURL } from "../docIds" import { getStartEndKeyURL } from "../docIds"
@ -101,7 +101,9 @@ export async function getAllApps({
const response = await Promise.allSettled(appPromises) const response = await Promise.allSettled(appPromises)
const apps = response const apps = response
.filter( .filter(
(result: any) => result.status === "fulfilled" && result.value != null (result: any) =>
result.status === "fulfilled" &&
result.value?.state !== AppState.INVALID
) )
.map(({ value }: any) => value) .map(({ value }: any) => value)
if (!all) { if (!all) {
@ -126,7 +128,11 @@ export async function getAppsByIDs(appIds: string[]) {
) )
// have to list the apps which exist, some may have been deleted // have to list the apps which exist, some may have been deleted
return settled return settled
.filter(promise => promise.status === "fulfilled") .filter(
promise =>
promise.status === "fulfilled" &&
(promise.value as DeletedApp).state !== AppState.INVALID
)
.map(promise => (promise as PromiseFulfilledResult<App>).value) .map(promise => (promise as PromiseFulfilledResult<App>).value)
} }

View file

@ -164,6 +164,7 @@ const environment = {
: false, : false,
...getPackageJsonFields(), ...getPackageJsonFields(),
DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER,
OFFLINE_MODE: process.env.OFFLINE_MODE,
_set(key: any, value: any) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
// @ts-ignore // @ts-ignore

View file

@ -55,6 +55,18 @@ export class HTTPError extends BudibaseError {
} }
} }
export class NotFoundError extends HTTPError {
constructor(message: string) {
super(message, 404)
}
}
export class BadRequestError extends HTTPError {
constructor(message: string) {
super(message, 400)
}
}
// LICENSING // LICENSING
export class UsageLimitError extends HTTPError { export class UsageLimitError extends HTTPError {

View file

@ -264,7 +264,7 @@ const getEventTenantId = async (tenantId: string): Promise<string> => {
} }
} }
const getUniqueTenantId = async (tenantId: string): Promise<string> => { export const getUniqueTenantId = async (tenantId: string): Promise<string> => {
// make sure this tenantId always matches the tenantId in context // make sure this tenantId always matches the tenantId in context
return context.doInTenant(tenantId, () => { return context.doInTenant(tenantId, () => {
return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => { return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => {

View file

@ -1,3 +1,5 @@
import { db } from "../../../src"
export function expectFunctionWasCalledTimesWith( export function expectFunctionWasCalledTimesWith(
jestFunction: any, jestFunction: any,
times: number, times: number,
@ -7,3 +9,22 @@ export function expectFunctionWasCalledTimesWith(
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
).toBe(times) ).toBe(times)
} }
export const expectAnyInternalColsAttributes: {
[K in (typeof db.CONSTANT_INTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
type: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
}
export const expectAnyExternalColsAttributes: {
[K in (typeof db.CONSTANT_EXTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
}

View file

@ -13,7 +13,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import _ from "lodash"
export const account = (): Account => { export const account = (partial: Partial<Account> = {}): Account => {
return { return {
accountId: uuid(), accountId: uuid(),
tenantId: generator.word(), tenantId: generator.word(),
@ -29,6 +29,7 @@ export const account = (): Account => {
size: "10+", size: "10+",
profession: "Software Engineer", profession: "Software Engineer",
quotaUsage: quotas.usage(), quotaUsage: quotas.usage(),
...partial,
} }
} }

View file

@ -1,4 +1,4 @@
import { structures } from ".." import { generator } from "./generator"
import { newid } from "../../../../src/docIds/newid" import { newid } from "../../../../src/docIds/newid"
export function id() { export function id() {
@ -6,7 +6,7 @@ export function id() {
} }
export function rev() { export function rev() {
return `${structures.generator.character({ return `${generator.character({
numeric: true, numeric: true,
})}-${structures.uuid().replace(/-/, "")}` })}-${generator.guid().replace(/-/, "")}`
} }

View file

@ -0,0 +1 @@
export * from "./platform"

View file

@ -0,0 +1 @@
export * as installation from "./installation"

View file

@ -0,0 +1,12 @@
import { generator } from "../../generator"
import { Installation } from "@budibase/types"
import * as db from "../../db"
export function install(): Installation {
return {
_id: "install",
_rev: db.rev(),
installId: generator.guid(),
version: generator.string(),
}
}

View file

@ -2,6 +2,7 @@ export * from "./common"
export * as accounts from "./accounts" export * as accounts from "./accounts"
export * as apps from "./apps" export * as apps from "./apps"
export * as db from "./db" export * as db from "./db"
export * as docs from "./documents"
export * as koa from "./koa" export * as koa from "./koa"
export * as licenses from "./licenses" export * as licenses from "./licenses"
export * as plugins from "./plugins" export * as plugins from "./plugins"

View file

@ -3,6 +3,8 @@ import {
Customer, Customer,
Feature, Feature,
License, License,
OfflineIdentifier,
OfflineLicense,
PlanModel, PlanModel,
PlanType, PlanType,
PriceDuration, PriceDuration,
@ -11,6 +13,7 @@ import {
Quotas, Quotas,
Subscription, Subscription,
} from "@budibase/types" } from "@budibase/types"
import { generator } from "./generator"
export function price(): PurchasedPrice { export function price(): PurchasedPrice {
return { return {
@ -127,15 +130,15 @@ export function subscription(): Subscription {
} }
} }
export const license = ( interface GenerateLicenseOpts {
opts: { quotas?: Quotas
quotas?: Quotas plan?: PurchasedPlan
plan?: PurchasedPlan planType?: PlanType
planType?: PlanType features?: Feature[]
features?: Feature[] billing?: Billing
billing?: Billing }
} = {}
): License => { export const license = (opts: GenerateLicenseOpts = {}): License => {
return { return {
features: opts.features || [], features: opts.features || [],
quotas: opts.quotas || quotas(), quotas: opts.quotas || quotas(),
@ -143,3 +146,22 @@ export const license = (
billing: opts.billing || billing(), billing: opts.billing || billing(),
} }
} }
export function offlineLicense(opts: GenerateLicenseOpts = {}): OfflineLicense {
const base = license(opts)
return {
...base,
expireAt: new Date().toISOString(),
identifier: offlineIdentifier(),
}
}
export function offlineIdentifier(
installId: string = generator.guid(),
tenantId: string = generator.guid()
): OfflineIdentifier {
return {
installId,
tenantId,
}
}

View file

@ -96,7 +96,8 @@
"dependsOn": [ "dependsOn": [
{ {
"projects": [ "projects": [
"@budibase/string-templates" "@budibase/string-templates",
"@budibase/shared-core"
], ],
"target": "build" "target": "build"
} }

View file

@ -64,7 +64,7 @@ export default function positionDropdown(element, opts) {
// Apply styles // Apply styles
Object.entries(styles).forEach(([style, value]) => { Object.entries(styles).forEach(([style, value]) => {
if (value) { if (value != null) {
element.style[style] = `${value.toFixed(0)}px` element.style[style] = `${value.toFixed(0)}px`
} else { } else {
element.style[style] = null element.style[style] = null

View file

@ -491,6 +491,7 @@ const getSelectedRowsBindings = asset => {
readableBinding: `${table._instanceName}.Selected rows`, readableBinding: `${table._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
icon: "ViewRow", icon: "ViewRow",
display: { name: table._instanceName },
})) }))
) )
@ -506,6 +507,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`, )}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`, readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
display: { name: block._instanceName },
})) }))
) )
} }

View file

@ -1,5 +1,5 @@
<script> <script>
import { datasources, tables } from "stores/backend" import { datasources, tables, integrations } from "stores/backend"
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
@ -27,6 +27,17 @@
$: isUsersTable = id === TableNames.USERS $: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external" $: isInternal = $tables.selected?.type !== "external"
$: datasource = $datasources.list.find(datasource => {
return datasource._id === $tables.selected?.sourceId
})
$: relationshipsEnabled = relationshipSupport(datasource)
const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source]
return !isInternal && integration?.relationships !== false
}
const handleGridTableUpdate = async e => { const handleGridTableUpdate = async e => {
tables.replaceTable(id, e.detail) tables.replaceTable(id, e.detail)
@ -58,7 +69,7 @@
<GridCreateViewButton /> <GridCreateViewButton />
{/if} {/if}
<GridManageAccessButton /> <GridManageAccessButton />
{#if !isInternal} {#if relationshipsEnabled}
<GridRelationshipButton /> <GridRelationshipButton />
{/if} {/if}
{#if isUsersTable} {#if isUsersTable}

View file

@ -44,7 +44,6 @@
let fileInput let fileInput
let error = null let error = null
let fileName = null let fileName = null
let fileType = null
let loading = false let loading = false
let validation = {} let validation = {}
let validateHash = "" let validateHash = ""

View file

@ -341,7 +341,7 @@
</Tab> </Tab>
{/if} {/if}
<div class="drawer-actions"> <div class="drawer-actions">
{#if typeof drawerActions.hide === "function" && drawerActions.headless} {#if typeof drawerActions?.hide === "function" && drawerActions?.headless}
<Button <Button
secondary secondary
quiet quiet
@ -352,7 +352,7 @@
Cancel Cancel
</Button> </Button>
{/if} {/if}
{#if typeof bindingDrawerActions?.save === "function" && drawerActions.headless} {#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
<Button <Button
cta cta
disabled={!valid} disabled={!valid}

View file

@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte" import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte" import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
const componentMap = { const componentMap = {
text: DrawerBindableInput, text: DrawerBindableInput,
@ -44,6 +45,7 @@ const componentMap = {
schema: SchemaSelect, schema: SchemaSelect,
section: SectionSelect, section: SectionSelect,
filter: FilterEditor, filter: FilterEditor,
"filter/relationship": RelationshipFilterEditor,
url: URLSelect, url: URLSelect,
fieldConfiguration: FieldConfiguration, fieldConfiguration: FieldConfiguration,
columns: ColumnEditor, columns: ColumnEditor,

View file

@ -206,6 +206,11 @@
return allBindings return allBindings
} }
const toDisplay = eventKey => {
const type = actionTypes.find(action => action.name == eventKey)
return type?.displayName || type?.name
}
</script> </script>
<DrawerContent> <DrawerContent>
@ -231,7 +236,9 @@
<ul> <ul>
{#each category as actionType} {#each category as actionType}
<li on:click={onAddAction(actionType)}> <li on:click={onAddAction(actionType)}>
<span class="action-name">{actionType.name}</span> <span class="action-name">
{actionType.displayName || actionType.name}
</span>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -262,7 +269,7 @@
> >
<Icon name="DragHandle" size="XL" /> <Icon name="DragHandle" size="XL" />
<div class="action-header"> <div class="action-header">
{index + 1}.&nbsp;{action[EVENT_TYPE_KEY]} {index + 1}.&nbsp;{toDisplay(action[EVENT_TYPE_KEY])}
</div> </div>
<Icon <Icon
name="Close" name="Close"

View file

@ -1,5 +1,5 @@
<script> <script>
import { Select, Label, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -10,47 +10,59 @@
</script> </script>
<div class="root"> <div class="root">
<Label>Table</Label> <Body size="small">Please specify one or more rows to delete.</Body>
<Select <div class="params">
bind:value={parameters.tableId} <Label>Table</Label>
options={tableOptions} <Select
getOptionLabel={table => table.name} bind:value={parameters.tableId}
getOptionValue={table => table._id} options={tableOptions}
/> getOptionLabel={table => table.name}
getOptionValue={table => table._id}
<Label small>Row ID</Label>
<DrawerBindableInput
{bindings}
title="Row ID to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete this row?"
bind:value={parameters.confirmText}
/> />
{/if}
<Label small>Row IDs</Label>
<DrawerBindableInput
{bindings}
title="Rows to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
/>
{/if}
</div>
</div> </div>
<style> <style>
.root { .root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.params {
display: grid; display: grid;
column-gap: var(--spacing-l); column-gap: var(--spacing-l);
row-gap: var(--spacing-s); row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr; grid-template-columns: 60px 1fr;
align-items: center; align-items: center;
max-width: 800px;
margin: 0 auto;
} }
</style> </style>

View file

@ -73,9 +73,12 @@
{#if query?.parameters?.length > 0} {#if query?.parameters?.length > 0}
<div class="params"> <div class="params">
<BindingBuilder <BindingBuilder
bind:customParams={parameters.queryParams} customParams={parameters.queryParams}
queryBindings={query.parameters} queryBindings={query.parameters}
bind:bindings bind:bindings
on:change={v => {
parameters.queryParams = { ...v.detail }
}}
/> />
<IntegrationQueryEditor <IntegrationQueryEditor
height={200} height={200}

View file

@ -24,6 +24,7 @@
}, },
{ {
"name": "Delete Row", "name": "Delete Row",
"displayName": "Delete Rows",
"type": "data", "type": "data",
"component": "DeleteRow" "component": "DeleteRow"
}, },

View file

@ -143,13 +143,12 @@
} }
const openQueryParamsDrawer = () => { const openQueryParamsDrawer = () => {
tmpQueryParams = value.queryParams tmpQueryParams = { ...value.queryParams }
drawer.show() drawer.show()
} }
const getQueryValue = queries => { const getQueryValue = queries => {
value = queries.find(q => q._id === value._id) || value return queries.find(q => q._id === value._id) || value
return value
} }
const saveQueryParams = () => { const saveQueryParams = () => {
@ -176,7 +175,10 @@
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
{#if getQueryParams(value).length > 0} {#if getQueryParams(value).length > 0}
<BindingBuilder <BindingBuilder
bind:customParams={tmpQueryParams} customParams={tmpQueryParams}
on:change={v => {
tmpQueryParams = { ...v.detail }
}}
queryBindings={getQueryParams(value)} queryBindings={getQueryParams(value)}
bind:bindings bind:bindings
/> />

View file

@ -13,13 +13,14 @@
export let value = [] export let value = []
export let componentInstance export let componentInstance
export let bindings = [] export let bindings = []
export let schema = null
let drawer let drawer
$: tempValue = value $: tempValue = value
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) $: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource)?.schema $: dsSchema = getSchemaForDatasource($currentAsset, datasource)?.schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || dsSchema || {})
$: text = getText(value?.filter(filter => filter.field)) $: text = getText(value?.filter(filter => filter.field))
async function saveFilter() { async function saveFilter() {

View file

@ -0,0 +1,35 @@
<script>
import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { tables } from "stores/backend"
import FilterEditor from "./FilterEditor/FilterEditor.svelte"
export let componentInstance
// Extract which relationship column we're using
$: column = componentInstance.field
// Find the closest parent form
$: form = findClosestMatchingComponent(
$currentAsset.props,
componentInstance._id,
component => component._component.endsWith("/form")
)
// Get that form's schema
$: datasource = getDatasourceForProvider($currentAsset, form)
$: formSchema = getSchemaForDatasource($currentAsset, datasource)?.schema
// Get the schema for the relationship field that this picker is using
$: columnSchema = formSchema?.[column]
// Get the schema for the table on the other side of this relationship
$: linkedTable = $tables.list.find(x => x._id === columnSchema?.tableId)
$: schema = linkedTable?.schema
</script>
<FilterEditor on:change {...$$props} {schema} />

View file

@ -8,16 +8,29 @@
export let componentDefinition export let componentDefinition
export let type export let type
const dispatch = createEventDispatcher()
let drawer let drawer
const dispatch = createEventDispatcher() $: text = getText(value)
const save = () => { const save = () => {
dispatch("change", value) dispatch("change", value)
drawer.hide() drawer.hide()
} }
const getText = rules => {
if (!rules?.length) {
return "No rules set"
} else {
return `${rules.length} rule${rules.length === 1 ? "" : "s"} set`
}
}
</script> </script>
<ActionButton on:click={drawer.show}>Configure validation</ActionButton> <div class="validation-editor">
<ActionButton on:click={drawer.show}>{text}</ActionButton>
</div>
<Drawer bind:this={drawer} title="Validation Rules"> <Drawer bind:this={drawer} title="Validation Rules">
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Configure validation rules for this field. Configure validation rules for this field.
@ -31,3 +44,9 @@
{componentDefinition} {componentDefinition}
/> />
</Drawer> </Drawer>
<style>
.validation-editor :global(.spectrum-ActionButton) {
width: 100%;
}
</style>

View file

@ -5,6 +5,9 @@
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let bindable = true export let bindable = true
export let queryBindings = [] export let queryBindings = []
@ -20,7 +23,10 @@
// The readable binding in the UI gets converted to a UUID value that the client understands // The readable binding in the UI gets converted to a UUID value that the client understands
// for parsing, then converted back so we can display it the readable form in the UI // for parsing, then converted back so we can display it the readable form in the UI
function onBindingChange(param, valueToParse) { function onBindingChange(param, valueToParse) {
customParams[param] = readableToRuntimeBinding(bindings, valueToParse) dispatch("change", {
...customParams,
[param]: readableToRuntimeBinding(bindings, valueToParse),
})
} }
</script> </script>

View file

@ -14,8 +14,9 @@
Tab, Tab,
Modal, Modal,
ModalContent, ModalContent,
notifications,
Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte" import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte" import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
@ -28,6 +29,7 @@
import KeyValueBuilder from "./KeyValueBuilder.svelte" import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils" import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte" import AccessLevelSelect from "./AccessLevelSelect.svelte"
import { ValidQueryNameRegex } from "@budibase/shared-core"
export let query export let query
@ -47,6 +49,7 @@
let saveModal let saveModal
let override = false let override = false
let navigateTo = null let navigateTo = null
let nameError = null
// seed the transformer // seed the transformer
if (query && !query.transformer) { if (query && !query.transformer) {
@ -77,7 +80,7 @@
$: queryConfig = integrationInfo?.query $: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = queryConfig && query.queryVerb $: shouldShowQueryConfig = queryConfig && query.queryVerb
$: readQuery = query.queryVerb === "read" || query.readable $: readQuery = query.queryVerb === "read" || query.readable
$: queryInvalid = !query.name || (readQuery && data.length === 0) $: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
//Cast field in query preview response to number if specified by schema //Cast field in query preview response to number if specified by schema
$: { $: {
@ -139,9 +142,10 @@
queryStr = JSON.stringify(query) queryStr = JSON.stringify(query)
} }
notifications.success("Query saved successfully")
return response return response
} catch (error) { } catch (error) {
notifications.error("Error saving query") notifications.error(error.message || "Error saving query")
} }
} }
</script> </script>
@ -183,8 +187,14 @@
value={query.name} value={query.name}
on:input={e => { on:input={e => {
let newValue = e.target.value || "" let newValue = e.target.value || ""
query.name = newValue.trim() if (newValue.match(ValidQueryNameRegex)) {
query.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}} }}
error={nameError}
/> />
</div> </div>
{#if queryConfig} {#if queryConfig}
@ -250,9 +260,9 @@
size="L" size="L"
/> />
</div> </div>
<Body size="S" <Body size="S">
>Add a JavaScript function to transform the query result.</Body Add a JavaScript function to transform the query result.
> </Body>
<CodeMirrorEditor <CodeMirrorEditor
height={200} height={200}
label="Transformer" label="Transformer"
@ -264,13 +274,12 @@
</div> </div>
<div class="viewer-controls"> <div class="viewer-controls">
<Heading size="S">Results</Heading> <Heading size="S">Results</Heading>
<ButtonGroup gap="XS"> <ButtonGroup gap="S">
<Button <Button
cta cta
disabled={queryInvalid} disabled={queryInvalid}
on:click={async () => { on:click={async () => {
await saveQuery() await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query // Go to the correct URL if we just created a new query
if (!query._rev) { if (!query._rev) {
$goto(`../../${query._id}`) $goto(`../../${query._id}`)

View file

@ -10,6 +10,8 @@
Label, Label,
ButtonGroup, ButtonGroup,
notifications, notifications,
CopyInput,
File,
} from "@budibase/bbui" } from "@budibase/bbui"
import { auth, admin } from "stores/portal" import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
@ -21,15 +23,20 @@
$: license = $auth.user.license $: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` $: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
// LICENSE KEY
$: activateDisabled = !licenseKey || licenseKeyDisabled $: activateDisabled = !licenseKey || licenseKeyDisabled
let licenseInfo
let licenseKeyDisabled = false let licenseKeyDisabled = false
let licenseKeyType = "text" let licenseKeyType = "text"
let licenseKey = "" let licenseKey = ""
let deleteLicenseKeyModal let deleteLicenseKeyModal
// OFFLINE
let offlineLicenseIdentifier = ""
let offlineLicense = undefined
const offlineLicenseExtensions = [".txt"]
// Make sure page can't be visited directly in cloud // Make sure page can't be visited directly in cloud
$: { $: {
if ($admin.cloud) { if ($admin.cloud) {
@ -37,28 +44,115 @@
} }
} }
const activate = async () => { // LICENSE KEY
const getLicenseKey = async () => {
try { try {
await API.activateLicenseKey({ licenseKey }) licenseKey = await API.getLicenseKey()
await auth.getSelf() if (licenseKey) {
await setLicenseInfo() licenseKey = "**********************************************"
notifications.success("Successfully activated") licenseKeyType = "password"
licenseKeyDisabled = true
activateDisabled = true
}
} catch (e) { } catch (e) {
notifications.error(e.message) console.error(e)
notifications.error("Error retrieving license key")
} }
} }
const destroy = async () => { const activateLicenseKey = async () => {
try {
await API.activateLicenseKey({ licenseKey })
await auth.getSelf()
await getLicenseKey()
notifications.success("Successfully activated")
} catch (e) {
console.error(e)
notifications.error("Error activating license key")
}
}
const deleteLicenseKey = async () => {
try { try {
await API.deleteLicenseKey({ licenseKey }) await API.deleteLicenseKey({ licenseKey })
await auth.getSelf() await auth.getSelf()
await setLicenseInfo() await getLicenseKey()
// reset the form // reset the form
licenseKey = "" licenseKey = ""
licenseKeyDisabled = false licenseKeyDisabled = false
notifications.success("Successfully deleted") notifications.success("Offline license removed")
} catch (e) { } catch (e) {
notifications.error(e.message) console.error(e)
notifications.error("Error deleting license key")
}
}
// OFFLINE LICENSE
const getOfflineLicense = async () => {
try {
const license = await API.getOfflineLicense()
if (license) {
offlineLicense = {
name: "license",
}
} else {
offlineLicense = undefined
}
} catch (e) {
console.error(e)
notifications.error("Error loading offline license")
}
}
const getOfflineLicenseIdentifier = async () => {
try {
const res = await API.getOfflineLicenseIdentifier()
offlineLicenseIdentifier = res.identifierBase64
} catch (e) {
console.error(e)
notifications.error("Error loading installation identifier")
}
}
async function activateOfflineLicense(offlineLicenseToken) {
try {
await API.activateOfflineLicense({ offlineLicenseToken })
await auth.getSelf()
await getOfflineLicense()
notifications.success("Successfully activated")
} catch (e) {
console.error(e)
notifications.error("Error activating offline license")
}
}
async function deleteOfflineLicense() {
try {
await API.deleteOfflineLicense()
await auth.getSelf()
await getOfflineLicense()
notifications.success("Successfully removed ofline license")
} catch (e) {
console.error(e)
notifications.error("Error upload offline license")
}
}
async function onOfflineLicenseChange(event) {
if (event.detail) {
// prevent file preview jitter by assigning constant
// as soon as possible
offlineLicense = {
name: "license",
}
const reader = new FileReader()
reader.readAsText(event.detail)
reader.onload = () => activateOfflineLicense(reader.result)
} else {
offlineLicense = undefined
await deleteOfflineLicense()
} }
} }
@ -73,29 +167,19 @@
} }
} }
// deactivate the license key field if there is a license key set
$: {
if (licenseInfo?.licenseKey) {
licenseKey = "**********************************************"
licenseKeyType = "password"
licenseKeyDisabled = true
activateDisabled = true
}
}
const setLicenseInfo = async () => {
licenseInfo = await API.getLicenseInfo()
}
onMount(async () => { onMount(async () => {
await setLicenseInfo() if ($admin.offlineMode) {
await Promise.all([getOfflineLicense(), getOfflineLicenseIdentifier()])
} else {
await getLicenseKey()
}
}) })
</script> </script>
{#if $auth.isAdmin} {#if $auth.isAdmin}
<DeleteLicenseKeyModal <DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal} bind:this={deleteLicenseKeyModal}
onConfirm={destroy} onConfirm={deleteLicenseKey}
/> />
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
@ -108,42 +192,82 @@
{:else} {:else}
To manage your plan visit your To manage your plan visit your
<Link size="L" href={upgradeUrl}>account</Link> <Link size="L" href={upgradeUrl}>account</Link>
<div>&nbsp</div>
{/if} {/if}
</Body> </Body>
</Layout> </Layout>
<Divider /> <Divider />
<Layout gap="XS" noPadding> {#if $admin.offlineMode}
<Heading size="S">Activate</Heading> <Layout gap="XS" noPadding>
<Body size="S">Enter your license key below to activate your plan</Body> <Heading size="XS">Installation identifier</Heading>
</Layout> <Body size="S"
<Layout noPadding> >Share this with support@budibase.com to obtain your offline license</Body
<div class="fields"> >
<div class="field"> </Layout>
<Label size="L">License key</Label> <Layout noPadding>
<Input <div class="identifier-input">
thin <CopyInput value={offlineLicenseIdentifier} />
bind:value={licenseKey} </div>
type={licenseKeyType} </Layout>
disabled={licenseKeyDisabled} <Divider />
<Layout gap="XS" noPadding>
<Heading size="XS">License</Heading>
<Body size="S">Upload your license to activate your plan</Body>
</Layout>
<Layout noPadding>
<div>
<File
title="Upload license"
extensions={offlineLicenseExtensions}
value={offlineLicense}
on:change={onOfflineLicenseChange}
allowClear={true}
disabled={!!offlineLicense}
/> />
</div> </div>
</div> </Layout>
<ButtonGroup gap="M"> {:else}
<Button cta on:click={activate} disabled={activateDisabled}> <Layout gap="XS" noPadding>
Activate <Heading size="XS">Activate</Heading>
</Button> <Body size="S">Enter your license key below to activate your plan</Body>
{#if licenseInfo?.licenseKey} </Layout>
<Button warning on:click={() => deleteLicenseKeyModal.show()}> <Layout noPadding>
Delete <div class="fields">
<div class="field">
<Label size="L">License key</Label>
<Input
thin
bind:value={licenseKey}
type={licenseKeyType}
disabled={licenseKeyDisabled}
/>
</div>
</div>
<ButtonGroup gap="M">
<Button cta on:click={activateLicenseKey} disabled={activateDisabled}>
Activate
</Button> </Button>
{/if} {#if licenseKey}
</ButtonGroup> <Button warning on:click={() => deleteLicenseKeyModal.show()}>
</Layout> Delete
</Button>
{/if}
</ButtonGroup>
</Layout>
{/if}
<Divider /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Plan</Heading> <Heading size="XS">Plan</Heading>
<Layout noPadding gap="XXS"> <Layout noPadding gap="S">
<Body size="S">You are currently on the {license.plan.type} plan</Body> <Body size="S">You are currently on the {license.plan.type} plan</Body>
<div>
<Body size="S"
>If you purchase or update your plan on the account</Body
>
<Body size="S"
>portal, click the refresh button to sync those changes</Body
>
</div>
<Body size="XS"> <Body size="XS">
{processStringSync("Updated {{ duration time 'millisecond' }} ago", { {processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: time:
@ -169,4 +293,7 @@
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
align-items: center; align-items: center;
} }
.identifier-input {
width: 300px;
}
</style> </style>

View file

@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
adminUser: { checked: false }, adminUser: { checked: false },
sso: { checked: false }, sso: { checked: false },
}, },
offlineMode: false,
} }
export function createAdminStore() { export function createAdminStore() {

View file

@ -3485,6 +3485,16 @@
} }
] ]
}, },
{
"type": "validation/link",
"label": "Validation",
"key": "validation"
},
{
"type": "filter/relationship",
"label": "Filtering",
"key": "filter"
},
{ {
"type": "boolean", "type": "boolean",
"label": "Autocomplete", "label": "Autocomplete",
@ -3496,11 +3506,6 @@
"label": "Disabled", "label": "Disabled",
"key": "disabled", "key": "disabled",
"defaultValue": false "defaultValue": false
},
{
"type": "validation/link",
"label": "Validation",
"key": "validation"
} }
] ]
}, },

View file

@ -1,5 +1,6 @@
<script> <script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
@ -14,43 +15,34 @@
export let autocomplete = false export let autocomplete = false
export let defaultValue export let defaultValue
export let onChange export let onChange
export let filter
let fieldState let fieldState
let fieldApi let fieldApi
let fieldSchema let fieldSchema
let options = []
let tableDefinition let tableDefinition
$: multiselect = fieldSchema?.relationshipType !== "one-to-many" $: multiselect = fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId
$: fetchRows(linkedTableId) $: fetch = fetchData({
$: fetchTable(linkedTableId) API,
datasource: {
type: "table",
tableId: linkedTableId,
},
options: {
filter,
limit: 100,
},
})
$: fetch.update({ filter })
$: options = $fetch.rows
$: tableDefinition = $fetch.definition
$: singleValue = flatten(fieldState?.value)?.[0] $: singleValue = flatten(fieldState?.value)?.[0]
$: multiValue = flatten(fieldState?.value) ?? [] $: multiValue = flatten(fieldState?.value) ?? []
$: component = multiselect ? CoreMultiselect : CoreSelect $: component = multiselect ? CoreMultiselect : CoreSelect
$: expandedDefaultValue = expand(defaultValue) $: expandedDefaultValue = expand(defaultValue)
const fetchTable = async id => {
if (id) {
try {
tableDefinition = await API.fetchTableDefinition(id)
} catch (error) {
tableDefinition = null
}
}
}
const fetchRows = async id => {
if (id) {
try {
options = await API.fetchTableData(id)
} catch (error) {
options = []
}
}
}
const flatten = values => { const flatten = values => {
if (!values) { if (!values) {
return [] return []

View file

@ -47,6 +47,14 @@
) )
} }
// If the data changes, double check that the selected elements are still present.
$: if (data) {
let rowIds = data.map(row => row._id)
if (rowIds.length) {
selectedRows = selectedRows.filter(row => rowIds.includes(row._id))
}
}
const getFields = (schema, customColumns, showAutoColumns) => { const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection // Check for an invalid column selection
let invalid = false let invalid = false

View file

@ -102,12 +102,46 @@ const fetchRowHandler = async action => {
} }
const deleteRowHandler = async action => { const deleteRowHandler = async action => {
const { tableId, revId, rowId, notificationOverride } = action.parameters const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
if (tableId && rowId) {
if (tableId && rowConfig) {
try { try {
await API.deleteRow({ tableId, rowId, revId }) let requestConfig
let parsedRowConfig = []
if (typeof rowConfig === "string") {
try {
parsedRowConfig = JSON.parse(rowConfig)
} catch (e) {
parsedRowConfig = rowConfig
.split(",")
.map(id => id.trim())
.filter(id => id)
}
} else {
parsedRowConfig = rowConfig
}
if (
typeof parsedRowConfig === "object" &&
parsedRowConfig.constructor === Object
) {
requestConfig = [parsedRowConfig]
} else if (Array.isArray(parsedRowConfig)) {
requestConfig = parsedRowConfig
}
if (!requestConfig.length) {
notificationStore.actions.warning("No valid rows were supplied")
return false
}
const resp = await API.deleteRows({ tableId, rows: requestConfig })
if (!notificationOverride) { if (!notificationOverride) {
notificationStore.actions.success("Row deleted") notificationStore.actions.success(
resp?.length == 1 ? "Row deleted" : `${resp.length} Rows deleted`
)
} }
// Refresh related datasources // Refresh related datasources
@ -115,8 +149,10 @@ const deleteRowHandler = async action => {
invalidateRelationships: true, invalidateRelationships: true,
}) })
} catch (error) { } catch (error) {
// Abort next actions console.error(error)
return false notificationStore.actions.error(
"An error occurred while executing the query"
)
} }
} }
} }

View file

@ -1,30 +1,58 @@
export const buildLicensingEndpoints = API => ({ export const buildLicensingEndpoints = API => ({
/** // LICENSE KEY
* Activates a self hosted license key
*/
activateLicenseKey: async data => { activateLicenseKey: async data => {
return API.post({ return API.post({
url: `/api/global/license/activate`, url: `/api/global/license/key`,
body: data, body: data,
}) })
}, },
/**
* Delete a self hosted license key
*/
deleteLicenseKey: async () => { deleteLicenseKey: async () => {
return API.delete({ return API.delete({
url: `/api/global/license/info`, url: `/api/global/license/key`,
}) })
}, },
getLicenseKey: async () => {
try {
return await API.get({
url: "/api/global/license/key",
})
} catch (e) {
if (e.status !== 404) {
throw e
}
}
},
/** // OFFLINE LICENSE
* Get the license info - metadata about the license including the
* obfuscated license key. activateOfflineLicense: async ({ offlineLicenseToken }) => {
*/ return API.post({
getLicenseInfo: async () => { url: "/api/global/license/offline",
return API.get({ body: {
url: "/api/global/license/info", offlineLicenseToken,
},
})
},
deleteOfflineLicense: async () => {
return API.delete({
url: "/api/global/license/offline",
})
},
getOfflineLicense: async () => {
try {
return await API.get({
url: "/api/global/license/offline",
})
} catch (e) {
if (e.status !== 404) {
throw e
}
}
},
getOfflineLicenseIdentifier: async () => {
return await API.get({
url: "/api/global/license/offline/identifier",
}) })
}, },
@ -36,7 +64,6 @@ export const buildLicensingEndpoints = API => ({
url: "/api/global/license/refresh", url: "/api/global/license/refresh",
}) })
}, },
/** /**
* Retrieve the usage information for the tenant * Retrieve the usage information for the tenant
*/ */

@ -1 +1 @@
Subproject commit 4d9840700e7684581c39965b7cb6a2b2428c477c Subproject commit a60183319f410d05aaa1c2f2718b772978b54d64

View file

@ -184,6 +184,16 @@
}, },
"nx": { "nx": {
"targets": { "targets": {
"dev:builder": {
"dependsOn": [
{
"projects": [
"@budibase/backend-core"
],
"target": "build"
}
]
},
"test": { "test": {
"dependsOn": [ "dependsOn": [
{ {

View file

@ -841,7 +841,8 @@
"auto", "auto",
"json", "json",
"internal", "internal",
"barcodeqr" "barcodeqr",
"bigint"
], ],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship." "description": "Defines the type of the column, most explain themselves, a link column is a relationship."
}, },
@ -1045,7 +1046,8 @@
"auto", "auto",
"json", "json",
"internal", "internal",
"barcodeqr" "barcodeqr",
"bigint"
], ],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship." "description": "Defines the type of the column, most explain themselves, a link column is a relationship."
}, },
@ -1260,7 +1262,8 @@
"auto", "auto",
"json", "json",
"internal", "internal",
"barcodeqr" "barcodeqr",
"bigint"
], ],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship." "description": "Defines the type of the column, most explain themselves, a link column is a relationship."
}, },

View file

@ -768,6 +768,7 @@ components:
- json - json
- internal - internal
- barcodeqr - barcodeqr
- bigint
description: Defines the type of the column, most explain themselves, a link description: Defines the type of the column, most explain themselves, a link
column is a relationship. column is a relationship.
constraints: constraints:
@ -931,6 +932,7 @@ components:
- json - json
- internal - internal
- barcodeqr - barcodeqr
- bigint
description: Defines the type of the column, most explain themselves, a link description: Defines the type of the column, most explain themselves, a link
column is a relationship. column is a relationship.
constraints: constraints:
@ -1101,6 +1103,7 @@ components:
- json - json
- internal - internal
- barcodeqr - barcodeqr
- bigint
description: Defines the type of the column, most explain themselves, a link description: Defines the type of the column, most explain themselves, a link
column is a relationship. column is a relationship.
constraints: constraints:

View file

@ -5,7 +5,7 @@ import { convertBookmark } from "../../../utilities"
// makes sure that the user doesn't need to pass in the type, tableId or _id params for // makes sure that the user doesn't need to pass in the type, tableId or _id params for
// the call to be correct // the call to be correct
function fixRow(row: Row, params: any) { export function fixRow(row: Row, params: any) {
if (!params || !row) { if (!params || !row) {
return row return row
} }

View file

@ -10,6 +10,7 @@ import { events, context, utils, constants } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { QueryEvent } from "../../../threads/definitions" import { QueryEvent } from "../../../threads/definitions"
import { Query } from "@budibase/types" import { Query } from "@budibase/types"
import { ValidQueryNameRegex } from "@budibase/shared-core"
const Runner = new Thread(ThreadType.QUERY, { const Runner = new Thread(ThreadType.QUERY, {
timeoutMs: env.QUERY_THREAD_TIMEOUT || 10000, timeoutMs: env.QUERY_THREAD_TIMEOUT || 10000,
@ -76,6 +77,11 @@ export async function save(ctx: any) {
const db = context.getAppDB() const db = context.getAppDB()
const query = ctx.request.body const query = ctx.request.body
// Validate query name
if (!query?.name.match(ValidQueryNameRegex)) {
ctx.throw(400, "Invalid query name")
}
const datasource = await sdk.datasources.get(query.datasourceId) const datasource = await sdk.datasources.get(query.datasourceId)
let eventFn let eventFn

View file

@ -73,9 +73,11 @@ export async function patch(ctx: UserCtx) {
row: inputs, row: inputs,
}) })
const row = await getRow(tableId, id, { relationships: true }) const row = await getRow(tableId, id, { relationships: true })
const table = await sdk.tables.getTable(tableId)
return { return {
...response, ...response,
row, row,
table,
} }
} }

View file

@ -2,9 +2,22 @@ import { quotas } from "@budibase/pro"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import { isExternalTable } from "../../../integrations/utils" import { isExternalTable } from "../../../integrations/utils"
import { Ctx } from "@budibase/types" import {
Ctx,
UserCtx,
DeleteRowRequest,
DeleteRow,
DeleteRows,
Row,
SearchResponse,
SortOrder,
SortType,
ViewV2,
} from "@budibase/types"
import * as utils from "./utils" import * as utils from "./utils"
import { gridSocket } from "../../../websockets" import { gridSocket } from "../../../websockets"
import { addRev } from "../public/utils"
import { fixRow } from "../public/rows"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as exporters from "../view/exporters" import * as exporters from "../view/exporters"
import { apiFileReturn } from "../../../utilities/fileSystem" import { apiFileReturn } from "../../../utilities/fileSystem"
@ -98,35 +111,83 @@ export async function find(ctx: any) {
}) })
} }
export async function destroy(ctx: any) { function isDeleteRows(input: any): input is DeleteRows {
const appId = ctx.appId return input.rows !== undefined && Array.isArray(input.rows)
const inputs = ctx.request.body }
function isDeleteRow(input: any): input is DeleteRow {
return input._id !== undefined
}
async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
let request = ctx.request.body as DeleteRows
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
let response, row
if (inputs.rows) { const processedRows = request.rows.map(row => {
let { rows } = await quotas.addQuery<any>( let processedRow: Row = typeof row == "string" ? { _id: row } : row
() => pickApi(tableId).bulkDestroy(ctx), return !processedRow._rev
{ ? addRev(fixRow(processedRow, ctx.params), tableId)
datasourceId: tableId, : fixRow(processedRow, ctx.params)
} })
)
await quotas.removeRows(rows.length) return await Promise.all(processedRows)
response = rows }
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
gridSocket?.emitRowDeletion(ctx, row._id) const tableId = utils.getTableId(ctx)
} const appId = ctx.appId
} else {
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), { let deleteRequest = ctx.request.body as DeleteRows
const rowDeletes: Row[] = await processDeleteRowsRequest(ctx)
deleteRequest.rows = rowDeletes
let { rows } = await quotas.addQuery<any>(
() => pickApi(tableId).bulkDestroy(ctx),
{
datasourceId: tableId, datasourceId: tableId,
}) }
await quotas.removeRow() )
response = resp.response await quotas.removeRows(rows.length)
row = resp.row
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
gridSocket?.emitRowDeletion(ctx, row._id) gridSocket?.emitRowDeletion(ctx, row._id!)
} }
return rows
}
async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
const appId = ctx.appId
const tableId = utils.getTableId(ctx)
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), {
datasourceId: tableId,
})
await quotas.removeRow()
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
gridSocket?.emitRowDeletion(ctx, resp.row._id)
return resp
}
export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
let response, row
ctx.status = 200 ctx.status = 200
if (isDeleteRows(ctx.request.body)) {
response = await deleteRows(ctx)
} else if (isDeleteRow(ctx.request.body)) {
const deleteResp = await deleteRow(ctx)
response = deleteResp.response
row = deleteResp.row
} else {
ctx.status = 400
response = { message: "Invalid delete rows request" }
}
// for automations include the row that was deleted // for automations include the row that was deleted
ctx.row = row || {} ctx.row = row || {}
ctx.body = response ctx.body = response
@ -146,6 +207,80 @@ export async function search(ctx: any) {
}) })
} }
function getSortOptions(
ctx: Ctx,
view: ViewV2
):
| {
sort: string
sortOrder?: SortOrder
sortType?: SortType
}
| undefined {
const { sort_column, sort_order, sort_type } = ctx.query
if (Array.isArray(sort_column)) {
ctx.throw(400, "sort_column cannot be an array")
}
if (Array.isArray(sort_order)) {
ctx.throw(400, "sort_order cannot be an array")
}
if (Array.isArray(sort_type)) {
ctx.throw(400, "sort_type cannot be an array")
}
if (sort_column) {
return {
sort: sort_column,
sortOrder: sort_order as SortOrder,
sortType: sort_type as SortType,
}
}
if (view.sort) {
return {
sort: view.sort.field,
sortOrder: view.sort.order,
sortType: view.sort.type,
}
}
return
}
export async function searchView(ctx: Ctx<void, SearchResponse>) {
const { viewId } = ctx.params
const view = await sdk.views.get(viewId)
if (!view) {
ctx.throw(404, `View ${viewId} not found`)
}
if (view.version !== 2) {
ctx.throw(400, `This method only supports viewsV2`)
}
const table = await sdk.tables.getTable(view?.tableId)
const viewFields =
(view.columns &&
Object.entries(view.columns).length &&
Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) ||
undefined
ctx.status = 200
ctx.body = await quotas.addQuery(
() =>
sdk.rows.search({
tableId: view.tableId,
query: view.query || {},
fields: viewFields,
...getSortOptions(ctx, view),
}),
{
datasourceId: view.tableId,
}
)
}
export async function validate(ctx: Ctx) { export async function validate(ctx: Ctx) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
// external tables are hard to validate currently // external tables are hard to validate currently

View file

@ -87,7 +87,7 @@ export async function patch(ctx: UserCtx) {
// the row has been updated, need to put it into the ctx // the row has been updated, need to put it into the ctx
ctx.request.body = row ctx.request.body = row
await userController.updateMetadata(ctx) await userController.updateMetadata(ctx)
return { row: ctx.body, table } return { row: ctx.body as Row, table }
} }
return finaliseRow(table, row, { return finaliseRow(table, row, {

View file

@ -6,9 +6,13 @@ import {
isRows, isRows,
} from "../../../utilities/schema" } from "../../../utilities/schema"
import { isExternalTable, isSQL } from "../../../integrations/utils" import { isExternalTable, isSQL } from "../../../integrations/utils"
import { getDatasourceParams } from "../../../db/utils" import { events } from "@budibase/backend-core"
import { context, events } from "@budibase/backend-core" import {
import { Table, UserCtx } from "@budibase/types" FetchTablesResponse,
Table,
TableResponse,
UserCtx,
} from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { jsonFromCsvString } from "../../../utilities/csv" import { jsonFromCsvString } from "../../../utilities/csv"
import { builderSocket } from "../../../websockets" import { builderSocket } from "../../../websockets"
@ -26,37 +30,34 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
} }
// covers both internal and external // covers both internal and external
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
const db = context.getAppDB()
const internal = await sdk.tables.getAllInternalTables() const internal = await sdk.tables.getAllInternalTables()
const externalTables = await db.allDocs( const externalTables = await sdk.datasources.getExternalDatasources()
getDatasourceParams("plus", {
include_docs: true,
})
)
const external = externalTables.rows.flatMap(tableDoc => { const external = externalTables.flatMap(table => {
let entities = tableDoc.doc.entities let entities = table.entities
if (entities) { if (entities) {
return Object.values(entities).map((entity: any) => ({ return Object.values(entities).map<Table>((entity: Table) => ({
...entity, ...entity,
type: "external", type: "external",
sourceId: tableDoc.doc._id, sourceId: table._id,
sql: isSQL(tableDoc.doc), sql: isSQL(table),
})) }))
} else { } else {
return [] return []
} }
}) })
ctx.body = [...internal, ...external] const response = [...internal, ...external].map(sdk.tables.enrichViewSchemas)
ctx.body = response
} }
export async function find(ctx: UserCtx) { export async function find(ctx: UserCtx<void, TableResponse>) {
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
ctx.body = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
ctx.body = sdk.tables.enrichViewSchemas(table)
} }
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {

View file

@ -1,195 +1,2 @@
import viewTemplate from "./viewBuilder" export * as v1 from "./views"
import { apiFileReturn } from "../../../utilities/fileSystem" export * as v2 from "./viewsV2"
import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters"
import { deleteView, getView, getViews, saveView } from "./utils"
import { fetchView } from "../row"
import { context, events } from "@budibase/backend-core"
import { DocumentType } from "../../../db/utils"
import sdk from "../../../sdk"
import { FieldTypes } from "../../../constants"
import {
Ctx,
Row,
Table,
TableExportFormat,
TableSchema,
View,
} from "@budibase/types"
import { builderSocket } from "../../../websockets"
const { cloneDeep, isEqual } = require("lodash")
export async function fetch(ctx: Ctx) {
ctx.body = await getViews()
}
export async function save(ctx: Ctx) {
const db = context.getAppDB()
const { originalName, ...viewToSave } = ctx.request.body
const existingTable = await sdk.tables.getTable(ctx.request.body.tableId)
existingTable.views ??= {}
const table = cloneDeep(existingTable)
const groupByField: any = Object.values(table.schema).find(
(field: any) => field.name == viewToSave.groupBy
)
const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY)
const viewName = viewToSave.name
if (!viewName) {
ctx.throw(400, "Cannot create view without a name")
}
await saveView(originalName, viewName, view)
// add views to table document
if (!table.views) table.views = {}
if (!view.meta.schema) {
view.meta.schema = table.schema
}
table.views[viewName] = { ...view.meta, name: viewName }
if (originalName) {
delete table.views[originalName]
existingTable.views[viewName] = existingTable.views[originalName]
}
await db.put(table)
await handleViewEvents(existingTable.views[viewName], table.views[viewName])
ctx.body = table.views[viewName]
builderSocket?.emitTableUpdate(ctx, table)
}
export async function calculationEvents(existingView: View, newView: View) {
const existingCalculation = existingView && existingView.calculation
const newCalculation = newView && newView.calculation
if (existingCalculation && !newCalculation) {
await events.view.calculationDeleted(existingView)
}
if (!existingCalculation && newCalculation) {
await events.view.calculationCreated(newView)
}
if (
existingCalculation &&
newCalculation &&
existingCalculation !== newCalculation
) {
await events.view.calculationUpdated(newView)
}
}
export async function filterEvents(existingView: View, newView: View) {
const hasExistingFilters = !!(
existingView &&
existingView.filters &&
existingView.filters.length
)
const hasNewFilters = !!(newView && newView.filters && newView.filters.length)
if (hasExistingFilters && !hasNewFilters) {
await events.view.filterDeleted(newView)
}
if (!hasExistingFilters && hasNewFilters) {
await events.view.filterCreated(newView)
}
if (
hasExistingFilters &&
hasNewFilters &&
!isEqual(existingView.filters, newView.filters)
) {
await events.view.filterUpdated(newView)
}
}
async function handleViewEvents(existingView: View, newView: View) {
if (!existingView) {
await events.view.created(newView)
} else {
await events.view.updated(newView)
}
await calculationEvents(existingView, newView)
await filterEvents(existingView, newView)
}
export async function destroy(ctx: Ctx) {
const db = context.getAppDB()
const viewName = decodeURIComponent(ctx.params.viewName)
const view = await deleteView(viewName)
const table = await sdk.tables.getTable(view.meta.tableId)
delete table.views![viewName]
await db.put(table)
await events.view.deleted(view)
ctx.body = view
builderSocket?.emitTableUpdate(ctx, table)
}
export async function exportView(ctx: Ctx) {
const viewName = decodeURIComponent(ctx.query.view as string)
const view = await getView(viewName)
const format = ctx.query.format as unknown
if (!isFormat(format)) {
ctx.throw(
400,
"Format must be specified, either csv, json or jsonWithSchema"
)
}
if (view) {
ctx.params.viewName = viewName
// Fetch view rows
ctx.query = {
group: view.meta.groupBy,
calculation: view.meta.calculation,
// @ts-ignore
stats: !!view.meta.field,
field: view.meta.field,
}
} else {
// table all_ view
/* istanbul ignore next */
ctx.params.viewName = viewName
}
await fetchView(ctx)
let rows = ctx.body as Row[]
let schema: TableSchema = view && view.meta && view.meta.schema
const tableId =
ctx.params.tableId ||
view?.meta?.tableId ||
(viewName.startsWith(DocumentType.TABLE) && viewName)
const table: Table = await sdk.tables.getTable(tableId)
if (!schema) {
schema = table.schema
}
let exportRows = sdk.rows.utils.cleanExportRows(rows, schema, format, [])
if (format === Format.CSV) {
ctx.attachment(`${viewName}.csv`)
ctx.body = apiFileReturn(csv(Object.keys(schema), exportRows))
} else if (format === Format.JSON) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(json(exportRows))
} else if (format === Format.JSON_WITH_SCHEMA) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(jsonWithSchema(schema, exportRows))
} else {
throw "Format not recognised"
}
if (viewName.startsWith(DocumentType.TABLE)) {
await events.table.exported(table, format as TableExportFormat)
} else {
await events.view.exported(table, format as TableExportFormat)
}
}

View file

@ -0,0 +1,198 @@
import viewTemplate from "./viewBuilder"
import { apiFileReturn } from "../../../utilities/fileSystem"
import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters"
import { deleteView, getView, getViews, saveView } from "./utils"
import { fetchView } from "../row"
import { context, events } from "@budibase/backend-core"
import { DocumentType } from "../../../db/utils"
import sdk from "../../../sdk"
import { FieldTypes } from "../../../constants"
import {
Ctx,
Row,
Table,
TableExportFormat,
TableSchema,
View,
} from "@budibase/types"
import { builderSocket } from "../../../websockets"
const { cloneDeep, isEqual } = require("lodash")
export async function fetch(ctx: Ctx) {
ctx.body = await getViews()
}
export async function save(ctx: Ctx) {
const db = context.getAppDB()
const { originalName, ...viewToSave } = ctx.request.body
const existingTable = await sdk.tables.getTable(ctx.request.body.tableId)
existingTable.views ??= {}
const table = cloneDeep(existingTable)
const groupByField: any = Object.values(table.schema).find(
(field: any) => field.name == viewToSave.groupBy
)
const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY)
const viewName = viewToSave.name
if (!viewName) {
ctx.throw(400, "Cannot create view without a name")
}
await saveView(originalName, viewName, view)
// add views to table document
if (!table.views) table.views = {}
if (!view.meta.schema) {
view.meta.schema = table.schema
}
table.views[viewName] = { ...view.meta, name: viewName }
if (originalName) {
delete table.views[originalName]
existingTable.views[viewName] = existingTable.views[originalName]
}
await db.put(table)
await handleViewEvents(
existingTable.views[viewName] as View,
table.views[viewName]
)
ctx.body = table.views[viewName]
builderSocket?.emitTableUpdate(ctx, table)
}
export async function calculationEvents(existingView: View, newView: View) {
const existingCalculation = existingView && existingView.calculation
const newCalculation = newView && newView.calculation
if (existingCalculation && !newCalculation) {
await events.view.calculationDeleted(existingView)
}
if (!existingCalculation && newCalculation) {
await events.view.calculationCreated(newView)
}
if (
existingCalculation &&
newCalculation &&
existingCalculation !== newCalculation
) {
await events.view.calculationUpdated(newView)
}
}
export async function filterEvents(existingView: View, newView: View) {
const hasExistingFilters = !!(
existingView &&
existingView.filters &&
existingView.filters.length
)
const hasNewFilters = !!(newView && newView.filters && newView.filters.length)
if (hasExistingFilters && !hasNewFilters) {
await events.view.filterDeleted(newView)
}
if (!hasExistingFilters && hasNewFilters) {
await events.view.filterCreated(newView)
}
if (
hasExistingFilters &&
hasNewFilters &&
!isEqual(existingView.filters, newView.filters)
) {
await events.view.filterUpdated(newView)
}
}
async function handleViewEvents(existingView: View, newView: View) {
if (!existingView) {
await events.view.created(newView)
} else {
await events.view.updated(newView)
}
await calculationEvents(existingView, newView)
await filterEvents(existingView, newView)
}
export async function destroy(ctx: Ctx) {
const db = context.getAppDB()
const viewName = decodeURIComponent(ctx.params.viewName)
const view = await deleteView(viewName)
const table = await sdk.tables.getTable(view.meta.tableId)
delete table.views![viewName]
await db.put(table)
await events.view.deleted(view)
ctx.body = view
builderSocket?.emitTableUpdate(ctx, table)
}
export async function exportView(ctx: Ctx) {
const viewName = decodeURIComponent(ctx.query.view as string)
const view = await getView(viewName)
const format = ctx.query.format as unknown
if (!isFormat(format)) {
ctx.throw(
400,
"Format must be specified, either csv, json or jsonWithSchema"
)
}
if (view) {
ctx.params.viewName = viewName
// Fetch view rows
ctx.query = {
group: view.meta.groupBy,
calculation: view.meta.calculation,
// @ts-ignore
stats: !!view.meta.field,
field: view.meta.field,
}
} else {
// table all_ view
/* istanbul ignore next */
ctx.params.viewName = viewName
}
await fetchView(ctx)
let rows = ctx.body as Row[]
let schema: TableSchema = view && view.meta && view.meta.schema
const tableId =
ctx.params.tableId ||
view?.meta?.tableId ||
(viewName.startsWith(DocumentType.TABLE) && viewName)
const table: Table = await sdk.tables.getTable(tableId)
if (!schema) {
schema = table.schema
}
let exportRows = sdk.rows.utils.cleanExportRows(rows, schema, format, [])
if (format === Format.CSV) {
ctx.attachment(`${viewName}.csv`)
ctx.body = apiFileReturn(csv(Object.keys(schema), exportRows))
} else if (format === Format.JSON) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(json(exportRows))
} else if (format === Format.JSON_WITH_SCHEMA) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(jsonWithSchema(schema, exportRows))
} else {
throw "Format not recognised"
}
if (viewName.startsWith(DocumentType.TABLE)) {
await events.table.exported(table, format as TableExportFormat)
} else {
await events.view.exported(table, format as TableExportFormat)
}
}

View file

@ -0,0 +1,44 @@
import sdk from "../../../sdk"
import {
CreateViewRequest,
Ctx,
UpdateViewRequest,
ViewResponse,
} from "@budibase/types"
export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
const view = ctx.request.body
const { tableId } = view
const result = await sdk.views.create(tableId, view)
ctx.status = 201
ctx.body = {
data: result,
}
}
export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
const view = ctx.request.body
if (view.version !== 2) {
ctx.throw(400, "Only views V2 can be updated")
}
if (ctx.params.viewId !== view.id) {
ctx.throw(400, "View id does not match between the body and the uri path")
}
const { tableId } = view
const result = await sdk.views.update(tableId, view)
ctx.body = {
data: result,
}
}
export async function remove(ctx: Ctx) {
const { viewId } = ctx.params
await sdk.views.remove(viewId)
ctx.status = 204
}

View file

@ -146,6 +146,11 @@ router
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search rowController.search
) )
.get(
"/api/v2/views/:viewId/search",
authorized(PermissionType.VIEW, PermissionLevel.READ),
rowController.searchView
)
/** /**
* @api {post} /api/:tableId/rows Creates a new row * @api {post} /api/:tableId/rows Creates a new row
* @apiName Creates a new row * @apiName Creates a new row

View file

@ -5,7 +5,7 @@ tk.freeze(timestamp)
import { outputProcessing } from "../../../utilities/rowProcessor" import { outputProcessing } from "../../../utilities/rowProcessor"
import * as setup from "./utilities" import * as setup from "./utilities"
const { basicRow } = setup.structures const { basicRow } = setup.structures
import { context, tenancy } from "@budibase/backend-core" import { context, db, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import {
QuotaUsageType, QuotaUsageType,
@ -14,8 +14,14 @@ import {
Row, Row,
Table, Table,
FieldType, FieldType,
SortType,
SortOrder,
} from "@budibase/types" } from "@budibase/types"
import { structures } from "@budibase/backend-core/tests" import {
expectAnyInternalColsAttributes,
generator,
structures,
} from "@budibase/backend-core/tests"
describe("/rows", () => { describe("/rows", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -517,6 +523,81 @@ describe("/rows", () => {
await assertRowUsage(rowUsage - 2) await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1) await assertQueryUsage(queryUsage + 1)
}) })
it("should be able to delete a variety of row set types", async () => {
const row1 = await config.createRow()
const row2 = await config.createRow()
const row3 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({
rows: [row1, row2._id, { _id: row3._id }],
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(3)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 3)
await assertQueryUsage(queryUsage + 1)
})
it("should accept a valid row object and delete the row", async () => {
const row1 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send(row1)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(row1._id)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
})
it("Should ignore malformed/invalid delete requests", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({ not: "valid" })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res.body.message).toEqual("Invalid delete rows request")
const res2 = await request
.delete(`/api/${table._id}/rows`)
.send({ rows: 123 })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res2.body.message).toEqual("Invalid delete rows request")
const res3 = await request
.delete(`/api/${table._id}/rows`)
.send("invalid")
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res3.body.message).toEqual("Invalid delete rows request")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
}) })
describe("fetchView", () => { describe("fetchView", () => {
@ -685,4 +766,249 @@ describe("/rows", () => {
expect(row._id).toEqual(existing._id) expect(row._id).toEqual(existing._id)
}) })
}) })
describe("view search", () => {
function userTable(): Table {
return {
name: "user",
type: "user",
schema: {
name: {
type: FieldType.STRING,
name: "name",
constraints: { type: "string" },
},
age: {
type: FieldType.NUMBER,
name: "age",
constraints: {},
},
},
}
}
it("returns table rows from view", async () => {
const table = await config.createTable(userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(await config.createRow({ tableId: table._id }))
}
const createViewResponse = await config.api.viewV2.create()
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(10)
expect(response.body).toEqual({
rows: expect.arrayContaining(rows.map(expect.objectContaining)),
})
})
it("searching respects the view filters", async () => {
const table = await config.createTable(userTable())
const expectedRows = []
for (let i = 0; i < 10; i++)
await config.createRow({
tableId: table._id,
name: generator.name(),
age: generator.integer({ min: 10, max: 30 }),
})
for (let i = 0; i < 5; i++)
expectedRows.push(
await config.createRow({
tableId: table._id,
name: generator.name(),
age: 40,
})
)
const createViewResponse = await config.api.viewV2.create({
query: { equal: { age: 40 } },
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(5)
expect(response.body).toEqual({
rows: expect.arrayContaining(expectedRows.map(expect.objectContaining)),
})
})
const sortTestOptions: [
{
field: string
order?: SortOrder
type?: SortType
},
string[]
][] = [
[
{
field: "name",
order: SortOrder.ASCENDING,
type: SortType.STRING,
},
["Alice", "Bob", "Charly", "Danny"],
],
[
{
field: "name",
},
["Alice", "Bob", "Charly", "Danny"],
],
[
{
field: "name",
order: SortOrder.DESCENDING,
},
["Danny", "Charly", "Bob", "Alice"],
],
[
{
field: "name",
order: SortOrder.DESCENDING,
type: SortType.STRING,
},
["Danny", "Charly", "Bob", "Alice"],
],
[
{
field: "age",
order: SortOrder.ASCENDING,
type: SortType.number,
},
["Danny", "Alice", "Charly", "Bob"],
],
[
{
field: "age",
order: SortOrder.ASCENDING,
},
["Danny", "Alice", "Charly", "Bob"],
],
[
{
field: "age",
order: SortOrder.DESCENDING,
},
["Bob", "Charly", "Alice", "Danny"],
],
[
{
field: "age",
order: SortOrder.DESCENDING,
type: SortType.number,
},
["Bob", "Charly", "Alice", "Danny"],
],
]
it.each(sortTestOptions)(
"allow sorting (%s)",
async (sortParams, expected) => {
await config.createTable(userTable())
const users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charly", age: 27 },
{ name: "Danny", age: 15 },
]
for (const user of users) {
await config.createRow({
tableId: config.table!._id,
...user,
})
}
const createViewResponse = await config.api.viewV2.create({
sort: sortParams,
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(4)
expect(response.body).toEqual({
rows: expected.map(name => expect.objectContaining({ name })),
})
}
)
it.each(sortTestOptions)(
"allow override the default view sorting (%s)",
async (sortParams, expected) => {
await config.createTable(userTable())
const users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charly", age: 27 },
{ name: "Danny", age: 15 },
]
for (const user of users) {
await config.createRow({
tableId: config.table!._id,
...user,
})
}
const createViewResponse = await config.api.viewV2.create({
sort: {
field: "name",
order: SortOrder.ASCENDING,
type: SortType.STRING,
},
})
const response = await config.api.viewV2.search(createViewResponse.id, {
sort: {
column: sortParams.field,
order: sortParams.order,
type: sortParams.type,
},
})
expect(response.body.rows).toHaveLength(4)
expect(response.body).toEqual({
rows: expected.map(name => expect.objectContaining({ name })),
})
}
)
it("when schema is defined, defined columns and row attributes are returned", async () => {
const table = await config.createTable(userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(
await config.createRow({
tableId: table._id,
name: generator.name(),
age: generator.age(),
})
)
}
const createViewResponse = await config.api.viewV2.create({
columns: { name: { visible: true } },
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(10)
expect(response.body.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...expectAnyInternalColsAttributes,
name: r.name,
}))
)
)
})
it("views without data can be returned", async () => {
const table = await config.createTable(userTable())
const createViewResponse = await config.api.viewV2.create()
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(0)
})
})
}) })

View file

@ -1,12 +1,15 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions") import { generator } from "@budibase/backend-core/tests"
const setup = require("./utilities") import { events, context } from "@budibase/backend-core"
import { FieldType, Table } from "@budibase/types"
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities"
const { basicTable } = setup.structures const { basicTable } = setup.structures
const { events, context } = require("@budibase/backend-core") import sdk from "../../../sdk"
describe("/tables", () => { describe("/tables", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let appId let appId: string
afterAll(setup.afterAll) afterAll(setup.afterAll)
@ -16,12 +19,11 @@ describe("/tables", () => {
}) })
describe("create", () => { describe("create", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
const createTable = (table) => { const createTable = (table?: Table) => {
if (!table) { if (!table) {
table = basicTable() table = basicTable()
} }
@ -29,15 +31,16 @@ describe("/tables", () => {
.post(`/api/tables`) .post(`/api/tables`)
.send(table) .send(table)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
} }
it("returns a success message when the table is successfully created", async () => { it("returns a success message when the table is successfully created", async () => {
const res = await createTable() const res = await createTable()
expect(res.res.statusMessage).toEqual("Table TestTable saved successfully.") expect((res as any).res.statusMessage).toEqual(
"Table TestTable saved successfully."
)
expect(res.body.name).toEqual("TestTable") expect(res.body.name).toEqual("TestTable")
expect(events.table.created).toBeCalledTimes(1) expect(events.table.created).toBeCalledTimes(1)
expect(events.table.created).toBeCalledWith(res.body) expect(events.table.created).toBeCalledWith(res.body)
@ -45,7 +48,7 @@ describe("/tables", () => {
it("creates a table via data import", async () => { it("creates a table via data import", async () => {
const table = basicTable() const table = basicTable()
table.rows = [{ name: 'test-name', description: 'test-desc' }] table.rows = [{ name: "test-name", description: "test-desc" }]
const res = await createTable(table) const res = await createTable(table)
@ -62,7 +65,7 @@ describe("/tables", () => {
config, config,
method: "POST", method: "POST",
url: `/api/tables`, url: `/api/tables`,
body: basicTable() body: basicTable(),
}) })
}) })
}) })
@ -75,7 +78,7 @@ describe("/tables", () => {
.post(`/api/tables`) .post(`/api/tables`)
.send(testTable) .send(testTable)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(events.table.updated).toBeCalledTimes(1) expect(events.table.updated).toBeCalledTimes(1)
@ -94,10 +97,10 @@ describe("/tables", () => {
const testRow = await request const testRow = await request
.post(`/api/${testTable._id}/rows`) .post(`/api/${testTable._id}/rows`)
.send({ .send({
name: "test" name: "test",
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
const updatedTable = await request const updatedTable = await request
@ -109,22 +112,24 @@ describe("/tables", () => {
key: "name", key: "name",
_rename: { _rename: {
old: "name", old: "name",
updated: "updatedName" updated: "updatedName",
}, },
schema: { schema: {
updatedName: { type: "string" } updatedName: { type: "string" },
} },
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(updatedTable.res.statusMessage).toEqual("Table TestTable saved successfully.") expect((updatedTable as any).res.statusMessage).toEqual(
"Table TestTable saved successfully."
)
expect(updatedTable.body.name).toEqual("TestTable") expect(updatedTable.body.name).toEqual("TestTable")
const res = await request const res = await request
.get(`/api/${testTable._id}/rows/${testRow.body._id}`) .get(`/api/${testTable._id}/rows/${testRow.body._id}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.updatedName).toEqual("test") expect(res.body.updatedName).toEqual("test")
@ -140,7 +145,7 @@ describe("/tables", () => {
_id: "ta_users", _id: "ta_users",
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.schema.email).toBeDefined() expect(res.body.schema.email).toBeDefined()
expect(res.body.schema.roleId).toBeDefined() expect(res.body.schema.roleId).toBeDefined()
@ -153,7 +158,7 @@ describe("/tables", () => {
const table = await config.createTable() const table = await config.createTable()
const importRequest = { const importRequest = {
schema: table.schema, schema: table.schema,
rows: [{ name: 'test-name', description: 'test-desc' }] rows: [{ name: "test-name", description: "test-desc" }],
} }
jest.clearAllMocks() jest.clearAllMocks()
@ -162,20 +167,23 @@ describe("/tables", () => {
.post(`/api/tables/${table._id}/import`) .post(`/api/tables/${table._id}/import`)
.send(importRequest) .send(importRequest)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(events.table.created).not.toHaveBeenCalled() expect(events.table.created).not.toHaveBeenCalled()
expect(events.rows.imported).toBeCalledTimes(1) expect(events.rows.imported).toBeCalledTimes(1)
expect(events.rows.imported).toBeCalledWith(expect.objectContaining({ expect(events.rows.imported).toBeCalledWith(
name: "TestTable", expect.objectContaining({
_id: table._id name: "TestTable",
}), 1) _id: table._id,
}),
1
)
}) })
}) })
describe("fetch", () => { describe("fetch", () => {
let testTable let testTable: Table
beforeEach(async () => { beforeEach(async () => {
testTable = await config.createTable(testTable) testTable = await config.createTable(testTable)
@ -189,7 +197,7 @@ describe("/tables", () => {
const res = await request const res = await request
.get(`/api/tables`) .get(`/api/tables`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
const fetchedTable = res.body[0] const fetchedTable = res.body[0]
expect(fetchedTable.name).toEqual(testTable.name) expect(fetchedTable.name).toEqual(testTable.name)
@ -203,6 +211,70 @@ describe("/tables", () => {
url: `/api/tables`, url: `/api/tables`,
}) })
}) })
it("should fetch views", async () => {
const tableId = config.table!._id!
const views = [
await config.api.viewV2.create({ tableId }),
await config.api.viewV2.create({ tableId }),
]
const res = await request
.get(`/api/tables`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: tableId,
views: views.reduce((p, c) => {
p[c.name] = { ...c, schema: expect.anything() }
return p
}, {} as any),
}),
])
)
})
it("should enrich the view schemas for viewsV2", async () => {
const tableId = config.table!._id!
jest.spyOn(sdk.tables, "enrichViewSchemas").mockImplementation(t => ({
...t,
views: {
view1: {
version: 2,
name: "view1",
schema: {},
id: "new_view_id",
tableId,
},
},
}))
await config.api.viewV2.create({ tableId })
await config.createView({ tableId, name: generator.guid() })
const res = await config.api.table.fetch()
expect(res).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: tableId,
views: {
view1: {
version: 2,
name: "view1",
schema: {},
id: "new_view_id",
tableId,
},
},
}),
])
)
})
}) })
describe("indexing", () => { describe("indexing", () => {
@ -216,7 +288,7 @@ describe("/tables", () => {
.post(`/api/tables`) .post(`/api/tables`)
.send(table) .send(table)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
@ -231,7 +303,7 @@ describe("/tables", () => {
_rev: res.body._rev, _rev: res.body._rev,
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
// shouldn't have created a new index // shouldn't have created a new index
expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1) expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1)
@ -240,7 +312,7 @@ describe("/tables", () => {
}) })
describe("destroy", () => { describe("destroy", () => {
let testTable let testTable: Table
beforeEach(async () => { beforeEach(async () => {
testTable = await config.createTable(testTable) testTable = await config.createTable(testTable)
@ -254,40 +326,44 @@ describe("/tables", () => {
const res = await request const res = await request
.delete(`/api/tables/${testTable._id}/${testTable._rev}`) .delete(`/api/tables/${testTable._id}/${testTable._rev}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`) expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
expect(events.table.deleted).toBeCalledTimes(1) expect(events.table.deleted).toBeCalledTimes(1)
expect(events.table.deleted).toBeCalledWith({ ...testTable, tableId: testTable._id }) expect(events.table.deleted).toBeCalledWith({
...testTable,
tableId: testTable._id,
})
}) })
it("deletes linked references to the table after deletion", async () => { it("deletes linked references to the table after deletion", async () => {
const linkedTable = await config.createTable({ const linkedTable = await config.createTable({
name: "LinkedTable", name: "LinkedTable",
type: "table", type: "table",
key: "name",
schema: { schema: {
name: { name: {
type: "string", type: FieldType.STRING,
name: "name",
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
TestTable: { TestTable: {
type: "link", type: FieldType.LINK,
name: "TestTable",
fieldName: "TestTable", fieldName: "TestTable",
tableId: testTable._id, tableId: testTable._id,
constraints: { constraints: {
type: "array" type: "array",
} },
} },
}, },
}) })
const res = await request const res = await request
.delete(`/api/tables/${testTable._id}/${testTable._rev}`) .delete(`/api/tables/${testTable._id}/${testTable._rev}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`) expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
const dependentTable = await config.getTable(linkedTable._id) const dependentTable = await config.getTable(linkedTable._id)

View file

@ -0,0 +1,227 @@
import * as setup from "./utilities"
import {
CreateViewRequest,
FieldType,
SortOrder,
SortType,
Table,
ViewV2,
} from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
function priceTable(): Table {
return {
name: "table",
type: "table",
schema: {
Price: {
type: FieldType.NUMBER,
name: "Price",
constraints: {},
},
Category: {
type: FieldType.STRING,
name: "Category",
constraints: {
type: "string",
},
},
},
}
}
describe("/v2/views", () => {
const config = setup.getConfig()
const viewFilters: Omit<CreateViewRequest, "name" | "tableId"> = {
query: { allOr: false, equal: { field: "value" } },
sort: {
field: "fieldToSort",
order: SortOrder.DESCENDING,
type: SortType.STRING,
},
columns: {
name: {
visible: true,
},
},
}
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
await config.createTable(priceTable())
})
describe("create", () => {
it("persist the view when the view is successfully created", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
}
const res = await config.api.viewV2.create(newView)
expect(res).toEqual({
...newView,
id: expect.stringMatching(new RegExp(`${config.table?._id!}_`)),
version: 2,
})
})
it("can persist views with queries", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
...viewFilters,
}
const res = await config.api.viewV2.create(newView)
expect(res).toEqual({
...newView,
...viewFilters,
id: expect.any(String),
version: 2,
})
})
})
describe("update", () => {
let view: ViewV2
beforeEach(async () => {
await config.createTable(priceTable())
view = await config.api.viewV2.create({ name: "View A" })
})
it("can update an existing view data", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update({
...view,
query: { equal: { newField: "thatValue" } },
})
expect(await config.api.table.get(tableId)).toEqual({
...config.table,
views: {
[view.name]: {
...view,
query: { equal: { newField: "thatValue" } },
schema: expect.anything(),
},
},
_rev: expect.any(String),
updatedAt: expect.any(String),
})
})
it("can update an existing view name", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update({ ...view, name: "View B" })
expect(await config.api.table.get(tableId)).toEqual(
expect.objectContaining({
views: {
"View B": { ...view, name: "View B", schema: expect.anything() },
},
})
)
})
it("cannot update an unexisting views nor edit ids", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update(
{ ...view, id: generator.guid() },
{ expectStatus: 404 }
)
expect(await config.api.table.get(tableId)).toEqual(
expect.objectContaining({
views: {
[view.name]: {
...view,
schema: expect.anything(),
},
},
})
)
})
it("cannot update views with the wrong tableId", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update(
{
...view,
tableId: generator.guid(),
query: { equal: { newField: "thatValue" } },
},
{ expectStatus: 404 }
)
expect(await config.api.table.get(tableId)).toEqual(
expect.objectContaining({
views: {
[view.name]: {
...view,
schema: expect.anything(),
},
},
})
)
})
it("cannot update views v1", async () => {
const viewV1 = await config.createView()
await config.api.viewV2.update(
{
...viewV1,
},
{
expectStatus: 400,
handleResponse: r => {
expect(r.body).toEqual({
message: "Only views V2 can be updated",
status: 400,
})
},
}
)
})
it("cannot update the a view with unmatching ids between url and body", async () => {
const anotherView = await config.api.viewV2.create()
const result = await config
.request!.put(`/api/v2/views/${anotherView.id}`)
.send(view)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(result.body).toEqual({
message: "View id does not match between the body and the uri path",
status: 400,
})
})
})
describe("delete", () => {
let view: ViewV2
beforeAll(async () => {
await config.createTable(priceTable())
view = await config.api.viewV2.create()
})
it("can delete an existing view", async () => {
const tableId = config.table!._id!
const getPersistedView = async () =>
(await config.api.table.get(tableId)).views![view.name]
expect(await getPersistedView()).toBeDefined()
await config.api.viewV2.delete(view.id)
expect(await getPersistedView()).toBeUndefined()
})
})
})

View file

@ -7,11 +7,28 @@ import { permissions } from "@budibase/backend-core"
const router: Router = new Router() const router: Router = new Router()
router
.post(
"/api/v2/views",
authorized(permissions.BUILDER),
viewController.v2.create
)
.put(
`/api/v2/views/:viewId`,
authorized(permissions.BUILDER),
viewController.v2.update
)
.delete(
`/api/v2/views/:viewId`,
authorized(permissions.BUILDER),
viewController.v2.remove
)
router router
.get( .get(
"/api/views/export", "/api/views/export",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),
viewController.exportView viewController.v1.exportView
) )
.get( .get(
"/api/views/:viewName", "/api/views/:viewName",
@ -22,13 +39,13 @@ router
), ),
rowController.fetchView rowController.fetchView
) )
.get("/api/views", authorized(permissions.BUILDER), viewController.fetch) .get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch)
.delete( .delete(
"/api/views/:viewName", "/api/views/:viewName",
paramResource("viewName"), paramResource("viewName"),
authorized(permissions.BUILDER), authorized(permissions.BUILDER),
viewController.destroy viewController.v1.destroy
) )
.post("/api/views", authorized(permissions.BUILDER), viewController.save) .post("/api/views", authorized(permissions.BUILDER), viewController.v1.save)
export default router export default router

View file

@ -195,6 +195,13 @@ export function getDatasourceParams(
return getDocParams(DocumentType.DATASOURCE, datasourceId, otherProps) return getDocParams(DocumentType.DATASOURCE, datasourceId, otherProps)
} }
export function getDatasourcePlusParams(
datasourceId?: Optional,
otherProps?: { include_docs: boolean }
) {
return getDocParams(DocumentType.DATASOURCE_PLUS, datasourceId, otherProps)
}
/** /**
* Generates a new query ID. * Generates a new query ID.
* @returns {string} The new query ID which the query doc can be stored under. * @returns {string} The new query ID which the query doc can be stored under.
@ -286,3 +293,19 @@ export function getMultiIDParams(ids: string[]) {
include_docs: true, include_docs: true,
} }
} }
/**
* Generates a new view ID.
* @returns {string} The new view ID which the view doc can be stored under.
*/
export function generateViewID(tableId: string) {
return `${tableId}${SEPARATOR}${newid()}`
}
export function extractViewInfoFromID(viewId: string) {
const regex = new RegExp(`^(?<tableId>.+)${SEPARATOR}([^${SEPARATOR}]+)$`)
const res = regex.exec(viewId)
return {
tableId: res!.groups!["tableId"],
}
}

View file

@ -81,7 +81,6 @@ const environment = {
SELF_HOSTED: process.env.SELF_HOSTED, SELF_HOSTED: process.env.SELF_HOSTED,
HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT, HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT,
FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main", FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main",
OFFLINE_MODE: process.env.OFFLINE_MODE,
// old // old
CLIENT_ID: process.env.CLIENT_ID, CLIENT_ID: process.env.CLIENT_ID,
_set(key: string, value: any) { _set(key: string, value: any) {

View file

@ -10,6 +10,10 @@ export const backfill = async (appDb: Database, timestamp: string | number) => {
if (table.views) { if (table.views) {
for (const view of Object.values(table.views)) { for (const view of Object.values(table.views)) {
if (sdk.views.isV2(view)) {
continue
}
await events.view.created(view, timestamp) await events.view.created(view, timestamp)
if (view.calculation) { if (view.calculation) {

View file

@ -19,6 +19,7 @@ import _ from "lodash"
import { import {
BudibaseInternalDB, BudibaseInternalDB,
getDatasourceParams, getDatasourceParams,
getDatasourcePlusParams,
getTableParams, getTableParams,
} from "../../../db/utils" } from "../../../db/utils"
import sdk from "../../index" import sdk from "../../index"
@ -243,3 +244,15 @@ export function mergeConfigs(update: Datasource, old: Datasource) {
return update return update
} }
export async function getExternalDatasources(): Promise<Datasource[]> {
const db = context.getAppDB()
const externalDatasources = await db.allDocs<Datasource>(
getDatasourcePlusParams(undefined, {
include_docs: true,
})
)
return externalDatasources.rows.map(r => r.doc)
}

View file

@ -1,4 +1,4 @@
import { SearchFilters } from "@budibase/types" import { SearchFilters, SortOrder, SortType } from "@budibase/types"
import { isExternalTable } from "../../../integrations/utils" import { isExternalTable } from "../../../integrations/utils"
import * as internal from "./search/internal" import * as internal from "./search/internal"
import * as external from "./search/external" import * as external from "./search/external"
@ -11,10 +11,11 @@ export interface SearchParams {
bookmark?: string bookmark?: string
limit?: number limit?: number
sort?: string sort?: string
sortOrder?: string sortOrder?: SortOrder
sortType?: string sortType?: SortType
version?: string version?: string
disableEscaping?: boolean disableEscaping?: boolean
fields?: string[]
} }
export interface ViewParams { export interface ViewParams {
@ -30,7 +31,11 @@ function pickApi(tableId: any) {
return internal return internal
} }
export async function search(options: SearchParams) { export async function search(options: SearchParams): Promise<{
rows: any[]
hasNextPage?: boolean
bookmark?: number | null
}> {
return pickApi(options.tableId).search(options) return pickApi(options.tableId).search(options)
} }

View file

@ -14,7 +14,8 @@ import { breakExternalTableId } from "../../../../integrations/utils"
import { cleanExportRows } from "../utils" import { cleanExportRows } from "../utils"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search" import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search"
import { HTTPError } from "@budibase/backend-core" import { HTTPError, db } from "@budibase/backend-core"
import pick from "lodash/pick"
export async function search(options: SearchParams) { export async function search(options: SearchParams) {
const { tableId } = options const { tableId } = options
@ -48,7 +49,7 @@ export async function search(options: SearchParams) {
} }
} }
try { try {
const rows = (await handleRequest(Operation.READ, tableId, { let rows = (await handleRequest(Operation.READ, tableId, {
filters: query, filters: query,
sort, sort,
paginate: paginateObj as PaginationJson, paginate: paginateObj as PaginationJson,
@ -67,6 +68,12 @@ export async function search(options: SearchParams) {
})) as Row[] })) as Row[]
hasNextPage = nextRows.length > 0 hasNextPage = nextRows.length > 0
} }
if (options.fields) {
const fields = [...options.fields, ...db.CONSTANT_EXTERNAL_ROW_COLS]
rows = rows.map((r: any) => pick(r, fields))
}
// need wrapper object for bookmarks etc when paginating // need wrapper object for bookmarks etc when paginating
return { rows, hasNextPage, bookmark: bookmark && bookmark + 1 } return { rows, hasNextPage, bookmark: bookmark && bookmark + 1 }
} catch (err: any) { } catch (err: any) {

View file

@ -1,5 +1,6 @@
import { import {
context, context,
db,
SearchParams as InternalSearchParams, SearchParams as InternalSearchParams,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import env from "../../../../environment" import env from "../../../../environment"
@ -27,6 +28,7 @@ import {
} from "../../../../api/controllers/view/utils" } from "../../../../api/controllers/view/utils"
import sdk from "../../../../sdk" import sdk from "../../../../sdk"
import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search" import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search"
import pick from "lodash/pick"
export async function search(options: SearchParams) { export async function search(options: SearchParams) {
const { tableId } = options const { tableId } = options
@ -71,6 +73,12 @@ export async function search(options: SearchParams) {
response.rows = await getGlobalUsersFromMetadata(response.rows) response.rows = await getGlobalUsersFromMetadata(response.rows)
} }
table = table || (await sdk.tables.getTable(tableId)) table = table || (await sdk.tables.getTable(tableId))
if (options.fields) {
const fields = [...options.fields, ...db.CONSTANT_INTERNAL_ROW_COLS]
response.rows = response.rows.map((r: any) => pick(r, fields))
}
response.rows = await outputProcessing(table, response.rows) response.rows = await outputProcessing(table, response.rows)
} }

View file

@ -0,0 +1,143 @@
import { GenericContainer } from "testcontainers"
import { Datasource, FieldType, Row, SourceName, Table } from "@budibase/types"
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { SearchParams } from "../../search"
import { search } from "../external"
import {
expectAnyExternalColsAttributes,
generator,
} from "@budibase/backend-core/tests"
jest.unmock("mysql2/promise")
jest.setTimeout(30000)
describe("external", () => {
const config = new TestConfiguration()
let externalDatasource: Datasource
const tableData: Table = {
name: generator.word(),
type: "external",
primary: ["id"],
schema: {
id: {
name: "id",
type: FieldType.AUTO,
autocolumn: true,
},
name: {
name: "name",
type: FieldType.STRING,
},
surname: {
name: "surname",
type: FieldType.STRING,
},
age: {
name: "age",
type: FieldType.NUMBER,
},
address: {
name: "address",
type: FieldType.STRING,
},
},
}
beforeAll(async () => {
const container = await new GenericContainer("mysql")
.withExposedPorts(3306)
.withEnv("MYSQL_ROOT_PASSWORD", "admin")
.withEnv("MYSQL_DATABASE", "db")
.withEnv("MYSQL_USER", "user")
.withEnv("MYSQL_PASSWORD", "password")
.start()
const host = container.getContainerIpAddress()
const port = container.getMappedPort(3306)
await config.init()
externalDatasource = await config.createDatasource({
datasource: {
type: "datasource",
name: "Test",
source: SourceName.MYSQL,
plus: true,
config: {
host,
port,
user: "user",
database: "db",
password: "password",
rejectUnauthorized: true,
},
},
})
})
describe("search", () => {
const rows: Row[] = []
beforeAll(async () => {
const table = await config.createTable({
...tableData,
sourceId: externalDatasource._id,
})
for (let i = 0; i < 10; i++) {
rows.push(
await config.createRow({
tableId: table._id,
name: generator.first(),
surname: generator.last(),
age: generator.age(),
address: generator.address(),
})
)
}
})
it("default search returns all the data", async () => {
await config.doInContext(config.appId, async () => {
const tableId = config.table!._id!
const searchParams: SearchParams = {
tableId,
query: {},
}
const result = await search(searchParams)
expect(result.rows).toHaveLength(10)
expect(result.rows).toEqual(
expect.arrayContaining(rows.map(r => expect.objectContaining(r)))
)
})
})
it("querying by fields will always return data attribute columns", async () => {
await config.doInContext(config.appId, async () => {
const tableId = config.table!._id!
const searchParams: SearchParams = {
tableId,
query: {},
fields: ["name", "age"],
}
const result = await search(searchParams)
expect(result.rows).toHaveLength(10)
expect(result.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...expectAnyExternalColsAttributes,
name: r.name,
age: r.age,
}))
)
)
})
})
})
})

View file

@ -0,0 +1,109 @@
import { FieldType, Row, Table } from "@budibase/types"
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { SearchParams } from "../../search"
import { search } from "../internal"
import {
expectAnyInternalColsAttributes,
generator,
} from "@budibase/backend-core/tests"
describe("internal", () => {
const config = new TestConfiguration()
const tableData: Table = {
name: generator.word(),
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
surname: {
name: "surname",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
age: {
name: "age",
type: FieldType.NUMBER,
constraints: {
type: FieldType.NUMBER,
},
},
address: {
name: "address",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
},
}
beforeAll(async () => {
await config.init()
})
describe("search", () => {
const rows: Row[] = []
beforeAll(async () => {
await config.createTable(tableData)
for (let i = 0; i < 10; i++) {
rows.push(
await config.createRow({
name: generator.first(),
surname: generator.last(),
age: generator.age(),
address: generator.address(),
})
)
}
})
it("default search returns all the data", async () => {
await config.doInContext(config.appId, async () => {
const tableId = config.table!._id!
const searchParams: SearchParams = {
tableId,
query: {},
}
const result = await search(searchParams)
expect(result.rows).toHaveLength(10)
expect(result.rows).toEqual(
expect.arrayContaining(rows.map(r => expect.objectContaining(r)))
)
})
})
it("querying by fields will always return data attribute columns", async () => {
await config.doInContext(config.appId, async () => {
const tableId = config.table!._id!
const searchParams: SearchParams = {
tableId,
query: {},
fields: ["name", "age"],
}
const result = await search(searchParams)
expect(result.rows).toHaveLength(10)
expect(result.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...expectAnyInternalColsAttributes,
name: r.name,
age: r.age,
}))
)
)
})
})
})
})

View file

@ -5,9 +5,16 @@ import {
isExternalTable, isExternalTable,
isSQL, isSQL,
} from "../../../integrations/utils" } from "../../../integrations/utils"
import { Table, Database } from "@budibase/types" import {
Table,
Database,
TableResponse,
TableViewsResponse,
} from "@budibase/types"
import datasources from "../datasources" import datasources from "../datasources"
import { populateExternalTableSchemas, isEditableColumn } from "./validation" import { populateExternalTableSchemas, isEditableColumn } from "./validation"
import sdk from "../../../sdk"
import _ from "lodash"
async function getAllInternalTables(db?: Database): Promise<Table[]> { async function getAllInternalTables(db?: Database): Promise<Table[]> {
if (!db) { if (!db) {
@ -55,6 +62,20 @@ async function getTable(tableId: any): Promise<Table> {
} }
} }
function enrichViewSchemas(table: Table): TableResponse {
const result: TableResponse = {
...table,
views: Object.values(table.views ?? [])
.map(v => sdk.views.enrichSchema(v, table.schema))
.reduce((p, v) => {
p[v.name] = v
return p
}, {} as TableViewsResponse),
}
return result
}
export default { export default {
getAllInternalTables, getAllInternalTables,
getAllExternalTables, getAllExternalTables,
@ -62,4 +83,5 @@ export default {
getTable, getTable,
populateExternalTableSchemas, populateExternalTableSchemas,
isEditableColumn, isEditableColumn,
enrichViewSchemas,
} }

View file

@ -0,0 +1,100 @@
import { FieldType, Table, ViewV2 } from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
import sdk from "../../.."
jest.mock("../../views", () => ({
...jest.requireActual("../../views"),
enrichSchema: jest.fn().mockImplementation(v => ({ ...v, mocked: true })),
}))
describe("table sdk", () => {
describe("enrichViewSchemas", () => {
const basicTable: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
schema: {
name: {
type: FieldType.STRING,
name: "name",
visible: true,
width: 80,
order: 2,
constraints: {
type: "string",
},
},
description: {
type: FieldType.STRING,
name: "description",
visible: true,
width: 200,
constraints: {
type: "string",
},
},
id: {
type: FieldType.NUMBER,
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
hiddenField: {
type: FieldType.STRING,
name: "hiddenField",
visible: false,
constraints: {
type: "string",
},
},
},
}
it("should fetch the default schema if not overriden", async () => {
const tableId = basicTable._id!
function getTable() {
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
}
return view
}
const view1 = getTable()
const view2 = getTable()
const view3 = getTable()
const res = sdk.tables.enrichViewSchemas({
...basicTable,
views: {
[view1.name]: view1,
[view2.name]: view2,
[view3.name]: view3,
},
})
expect(sdk.views.enrichSchema).toBeCalledTimes(3)
expect(res).toEqual({
...basicTable,
views: {
[view1.name]: {
...view1,
mocked: true,
},
[view2.name]: {
...view2,
mocked: true,
},
[view3.name]: {
...view3,
mocked: true,
},
},
})
})
})
})

View file

@ -0,0 +1,109 @@
import { HTTPError, context } from "@budibase/backend-core"
import { TableSchema, UIFieldMetadata, View, ViewV2 } from "@budibase/types"
import sdk from "../../../sdk"
import * as utils from "../../../db/utils"
import _ from "lodash"
export async function get(viewId: string): Promise<ViewV2 | undefined> {
const { tableId } = utils.extractViewInfoFromID(viewId)
const table = await sdk.tables.getTable(tableId)
const views = Object.values(table.views!)
const view = views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined
return view
}
export async function create(
tableId: string,
viewRequest: Omit<ViewV2, "id" | "version">
): Promise<ViewV2> {
const view: ViewV2 = {
...viewRequest,
id: utils.generateViewID(tableId),
version: 2,
}
const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId)
table.views ??= {}
table.views[view.name] = view
await db.put(table)
return view
}
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId)
table.views ??= {}
const existingView = Object.values(table.views).find(
v => isV2(v) && v.id === view.id
)
if (!existingView) {
throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404)
}
delete table.views[existingView.name]
table.views[view.name] = view
await db.put(table)
return view
}
export function isV2(view: View | ViewV2): view is ViewV2 {
return (view as ViewV2).version === 2
}
export async function remove(viewId: string): Promise<void> {
const db = context.getAppDB()
const view = await get(viewId)
const table = await sdk.tables.getTable(view?.tableId)
if (!view) {
throw new HTTPError(`View ${viewId} not found`, 404)
}
delete table.views![view?.name]
await db.put(table)
}
export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) {
if (!sdk.views.isV2(view)) {
return view
}
return {
...view,
schema:
!view?.columns || !Object.entries(view?.columns).length
? tableSchema
: enrichViewV2Schema(tableSchema, view.columns),
}
}
function enrichViewV2Schema(
tableSchema: TableSchema,
viewOverrides: Record<string, UIFieldMetadata>
) {
const result: TableSchema = {}
const viewOverridesEntries = Object.entries(viewOverrides)
const viewSetsOrder = viewOverridesEntries.some(([_, v]) => v.order)
for (const [columnName, columnUIMetadata] of viewOverridesEntries) {
if (!columnUIMetadata.visible) {
continue
}
if (!tableSchema[columnName]) {
continue
}
const tableFieldSchema = tableSchema[columnName]
if (viewSetsOrder) {
delete tableFieldSchema.order
}
result[columnName] = _.merge(tableFieldSchema, columnUIMetadata)
}
return result
}

View file

@ -0,0 +1,265 @@
import { FieldType, Table, ViewV2 } from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
import { enrichSchema } from ".."
describe("table sdk", () => {
describe("enrichViewSchemas", () => {
const basicTable: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
schema: {
name: {
type: FieldType.STRING,
name: "name",
visible: true,
width: 80,
order: 2,
constraints: {
type: "string",
},
},
description: {
type: FieldType.STRING,
name: "description",
visible: true,
width: 200,
constraints: {
type: "string",
},
},
id: {
type: FieldType.NUMBER,
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
hiddenField: {
type: FieldType.STRING,
name: "hiddenField",
visible: false,
constraints: {
type: "string",
},
},
},
}
it("should fetch the default schema if not overriden", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual({
...view,
schema: {
name: {
type: "string",
name: "name",
visible: true,
order: 2,
width: 80,
constraints: {
type: "string",
},
},
description: {
type: "string",
name: "description",
visible: true,
width: 200,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
hiddenField: {
type: "string",
name: "hiddenField",
visible: false,
constraints: {
type: "string",
},
},
},
})
})
it("if view schema only defines visiblility, should only fetch the selected fields", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: {
name: { visible: true },
id: { visible: true },
description: { visible: false },
},
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual({
...view,
schema: {
name: {
type: "string",
name: "name",
visible: true,
order: 2,
width: 80,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
},
})
})
it("schema does not break if the view has corrupted columns", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: { unnexisting: { visible: true }, name: { visible: true } },
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual(
expect.objectContaining({
...view,
schema: {
name: {
type: "string",
name: "name",
order: 2,
visible: true,
width: 80,
constraints: {
type: "string",
},
},
},
})
)
})
it("if view schema only defines visiblility, should only fetch the selected fields", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: {
name: { visible: true },
id: { visible: true },
description: { visible: false },
},
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual(
expect.objectContaining({
...view,
schema: {
name: {
type: "string",
name: "name",
order: 2,
visible: true,
width: 80,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
order: 1,
visible: true,
constraints: {
type: "number",
},
},
},
})
)
})
it("if view defines order, the table schema order should be ignored", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: {
name: { visible: true, order: 1 },
id: { visible: true },
description: { visible: false, order: 2 },
},
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual(
expect.objectContaining({
...view,
schema: {
name: {
type: "string",
name: "name",
order: 1,
visible: true,
width: 80,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
visible: true,
constraints: {
type: "number",
},
},
},
})
)
})
})
})

View file

@ -7,6 +7,7 @@ import { default as queries } from "./app/queries"
import { default as rows } from "./app/rows" import { default as rows } from "./app/rows"
import { default as users } from "./users" import { default as users } from "./users"
import { default as plugins } from "./plugins" import { default as plugins } from "./plugins"
import * as views from "./app/views"
const sdk = { const sdk = {
backups, backups,
@ -18,6 +19,7 @@ const sdk = {
datasources, datasources,
queries, queries,
plugins, plugins,
views,
} }
// default export for TS // default export for TS

View file

@ -53,6 +53,8 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { BUILTIN_ROLE_IDS } from "@budibase/backend-core/src/security/roles" import { BUILTIN_ROLE_IDS } from "@budibase/backend-core/src/security/roles"
import API from "./api"
type DefaultUserValues = { type DefaultUserValues = {
globalUserId: string globalUserId: string
email: string email: string
@ -73,12 +75,13 @@ class TestConfiguration {
user: any user: any
globalUserId: any globalUserId: any
userMetadataId: any userMetadataId: any
table: any table?: Table
linkedTable: any linkedTable: any
automation: any automation: any
datasource: any datasource: any
tenantId?: string tenantId?: string
defaultUserValues: DefaultUserValues defaultUserValues: DefaultUserValues
api: API
constructor(openServer = true) { constructor(openServer = true) {
if (openServer) { if (openServer) {
@ -94,6 +97,8 @@ class TestConfiguration {
this.appId = null this.appId = null
this.allApps = [] this.allApps = []
this.defaultUserValues = this.populateDefaultUserValues() this.defaultUserValues = this.populateDefaultUserValues()
this.api = new API(this)
} }
populateDefaultUserValues(): DefaultUserValues { populateDefaultUserValues(): DefaultUserValues {
@ -242,7 +247,7 @@ class TestConfiguration {
const db = tenancy.getTenantDB(this.getTenantId()) const db = tenancy.getTenantDB(this.getTenantId())
let existing let existing
try { try {
existing = await db.get(id) existing = await db.get<any>(id)
} catch (err) { } catch (err) {
existing = { email } existing = { email }
} }
@ -460,7 +465,7 @@ class TestConfiguration {
async generateApiKey(userId = this.defaultUserValues.globalUserId) { async generateApiKey(userId = this.defaultUserValues.globalUserId) {
const db = tenancy.getTenantDB(this.getTenantId()) const db = tenancy.getTenantDB(this.getTenantId())
const id = dbCore.generateDevInfoID(userId) const id = dbCore.generateDevInfoID(userId)
let devInfo let devInfo: any
try { try {
devInfo = await db.get(id) devInfo = await db.get(id)
} catch (err) { } catch (err) {
@ -522,21 +527,27 @@ class TestConfiguration {
// TABLE // TABLE
async updateTable(config?: any): Promise<Table> { async updateTable(
config?: any,
{ skipReassigning } = { skipReassigning: false }
): Promise<Table> {
config = config || basicTable() config = config || basicTable()
this.table = await this._req(config, null, controllers.table.save) const response = await this._req(config, null, controllers.table.save)
return this.table if (!skipReassigning) {
this.table = response
}
return response
} }
async createTable(config?: Table) { async createTable(config?: Table, options = { skipReassigning: false }) {
if (config != null && config._id) { if (config != null && config._id) {
delete config._id delete config._id
} }
return this.updateTable(config) return this.updateTable(config, options)
} }
async getTable(tableId?: string) { async getTable(tableId?: string) {
tableId = tableId || this.table._id tableId = tableId || this.table?._id
return this._req(null, { tableId }, controllers.table.find) return this._req(null, { tableId }, controllers.table.find)
} }
@ -577,7 +588,7 @@ class TestConfiguration {
throw "Test requires table to be configured." throw "Test requires table to be configured."
} }
const tableId = (config && config.tableId) || this.table._id const tableId = (config && config.tableId) || this.table._id
config = config || basicRow(tableId) config = config || basicRow(tableId!)
return this._req(config, { tableId }, controllers.row.save) return this._req(config, { tableId }, controllers.row.save)
} }
@ -587,14 +598,14 @@ class TestConfiguration {
async getRows(tableId: string) { async getRows(tableId: string) {
if (!tableId && this.table) { if (!tableId && this.table) {
tableId = this.table._id tableId = this.table._id!
} }
return this._req(null, { tableId }, controllers.row.fetch) return this._req(null, { tableId }, controllers.row.fetch)
} }
async searchRows(tableId: string, searchParams: SearchFilters = {}) { async searchRows(tableId: string, searchParams: SearchFilters = {}) {
if (!tableId && this.table) { if (!tableId && this.table) {
tableId = this.table._id tableId = this.table._id!
} }
const body = { const body = {
query: searchParams, query: searchParams,
@ -631,7 +642,7 @@ class TestConfiguration {
tableId: this.table._id, tableId: this.table._id,
name: "ViewTest", name: "ViewTest",
} }
return this._req(view, null, controllers.view.save) return this._req(view, null, controllers.view.v1.save)
} }
// AUTOMATION // AUTOMATION

View file

@ -0,0 +1,17 @@
import TestConfiguration from "../TestConfiguration"
import { SuperTest, Test } from "supertest"
export interface TestAPIOpts {
headers?: any
status?: number
}
export abstract class TestAPI {
config: TestConfiguration
request: SuperTest<Test>
protected constructor(config: TestConfiguration) {
this.config = config
this.request = config.request!
}
}

View file

@ -0,0 +1,13 @@
import TestConfiguration from "../TestConfiguration"
import { TableAPI } from "./table"
import { ViewV2API } from "./viewV2"
export default class API {
table: TableAPI
viewV2: ViewV2API
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
this.viewV2 = new ViewV2API(config)
}
}

View file

@ -0,0 +1,32 @@
import { Table } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
export class TableAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
fetch = async (
{ expectStatus } = { expectStatus: 200 }
): Promise<Table[]> => {
const res = await this.request
.get(`/api/tables`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return res.body
}
get = async (
tableId: string,
{ expectStatus } = { expectStatus: 200 }
): Promise<Table> => {
const res = await this.request
.get(`/api/tables/${tableId}`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return res.body
}
}

View file

@ -0,0 +1,96 @@
import { CreateViewRequest, SortOrder, SortType, ViewV2 } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
import { generator } from "@budibase/backend-core/tests"
import { Response } from "superagent"
export class ViewV2API extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
create = async (
viewData?: Partial<CreateViewRequest>,
{ expectStatus } = { expectStatus: 201 }
): Promise<ViewV2> => {
let tableId = viewData?.tableId
if (!tableId && !this.config.table) {
throw "Test requires table to be configured."
}
tableId = this.config.table!._id!
const view = {
tableId,
name: generator.guid(),
...viewData,
}
const result = await this.request
.post(`/api/v2/views`)
.send(view)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return result.body.data as ViewV2
}
update = async (
view: ViewV2,
{
expectStatus,
handleResponse,
}: {
expectStatus: number
handleResponse?: (response: Response) => void
} = { expectStatus: 200 }
): Promise<ViewV2> => {
const result = await this.request
.put(`/api/v2/views/${view.id}`)
.send(view)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
if (handleResponse) {
handleResponse(result)
}
return result.body.data as ViewV2
}
delete = async (viewId: string, { expectStatus } = { expectStatus: 204 }) => {
return this.request
.delete(`/api/v2/views/${viewId}`)
.set(this.config.defaultHeaders())
.expect(expectStatus)
}
search = async (
viewId: string,
options?: {
sort: {
column: string
order?: SortOrder
type?: SortType
}
},
{ expectStatus } = { expectStatus: 200 }
) => {
const qs: [string, any][] = []
if (options?.sort.column) {
qs.push(["sort_column", options.sort.column])
}
if (options?.sort.order) {
qs.push(["sort_order", options.sort.order])
}
if (options?.sort.type) {
qs.push(["sort_type", options.sort.type])
}
let url = `/api/v2/views/${viewId}/search`
if (qs.length) {
url += "?" + qs.map(q => q.join("=")).join("&")
}
return this.request
.get(url)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
}
}

View file

@ -16,23 +16,26 @@ import {
AutomationTrigger, AutomationTrigger,
AutomationTriggerStepId, AutomationTriggerStepId,
Datasource, Datasource,
FieldType,
SourceName, SourceName,
Table,
} from "@budibase/types" } from "@budibase/types"
export function basicTable() { export function basicTable(): Table {
return { return {
name: "TestTable", name: "TestTable",
type: "table", type: "table",
key: "name",
schema: { schema: {
name: { name: {
type: "string", type: FieldType.STRING,
name: "name",
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
description: { description: {
type: "string", type: FieldType.STRING,
name: "description",
constraints: { constraints: {
type: "string", type: "string",
}, },

View file

@ -94,4 +94,5 @@ export enum BuilderSocketEvent {
} }
export const SocketSessionTTL = 60 export const SocketSessionTTL = 60
export const ValidQueryNameRegex = /^[^()]*$/
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g

View file

@ -1,5 +1,6 @@
import { LicenseOverrides, QuotaUsage } from "../../documents" import { LicenseOverrides, QuotaUsage } from "../../documents"
import { PlanType } from "../../sdk" import { OfflineLicense, PlanType } from "../../sdk"
import { ISO8601 } from "../../shared"
export interface GetLicenseRequest { export interface GetLicenseRequest {
// All fields should be optional to cater for // All fields should be optional to cater for
@ -26,3 +27,13 @@ export interface UpdateLicenseRequest {
planType?: PlanType planType?: PlanType
overrides?: LicenseOverrides overrides?: LicenseOverrides
} }
export interface CreateOfflineLicenseRequest {
installationIdentifierBase64: string
expireAt: ISO8601
}
export interface GetOfflineLicenseResponse {
offlineLicenseToken: string
license: OfflineLicense
}

View file

@ -1,2 +1,6 @@
export * from "./backup" export * from "./backup"
export * from "./datasource" export * from "./datasource"
export * from "./row"
export * from "./view"
export * from "./rows"
export * from "./table"

View file

@ -0,0 +1,11 @@
import { Row } from "../../../documents/app/row"
export interface DeleteRows {
rows: (Row | string)[]
}
export interface DeleteRow {
_id: string
}
export type DeleteRowRequest = DeleteRows | DeleteRow

View file

@ -0,0 +1,3 @@
export interface SearchResponse {
rows: any[]
}

View file

@ -0,0 +1,13 @@
import { Table, TableSchema, View, ViewV2 } from "../../../documents"
interface ViewV2Response extends ViewV2 {
schema: TableSchema
}
export type TableViewsResponse = { [key: string]: View | ViewV2Response }
export interface TableResponse extends Table {
views?: TableViewsResponse
}
export type FetchTablesResponse = TableResponse[]

View file

@ -0,0 +1,9 @@
import { TableSchema, ViewV2 } from "../../../documents"
export interface ViewResponse {
data: ViewV2
}
export type CreateViewRequest = Omit<ViewV2, "version" | "id">
export type UpdateViewRequest = ViewV2

View file

@ -3,3 +3,4 @@ export * from "./auditLogs"
export * from "./events" export * from "./events"
export * from "./configs" export * from "./configs"
export * from "./scim" export * from "./scim"
export * from "./license"

View file

@ -0,0 +1,25 @@
// LICENSE KEY
export interface ActivateLicenseKeyRequest {
licenseKey: string
}
export interface GetLicenseKeyResponse {
licenseKey: string
}
// OFFLINE LICENSE
export interface ActivateOfflineLicenseTokenRequest {
offlineLicenseToken: string
}
export interface GetOfflineLicenseTokenResponse {
offlineLicenseToken: string
}
// IDENTIFIER
export interface GetOfflineIdentifierResponse {
identifierBase64: string
}

View file

@ -51,6 +51,7 @@ export interface Account extends CreateAccount {
licenseRequestedAt?: number licenseRequestedAt?: number
licenseOverrides?: LicenseOverrides licenseOverrides?: LicenseOverrides
quotaUsage?: QuotaUsage quotaUsage?: QuotaUsage
offlineLicenseToken?: string
} }
export interface PasswordAccount extends Account { export interface PasswordAccount extends Account {

View file

@ -1,11 +1,11 @@
import { Document } from "../../document" import { Document } from "../../document"
import { View } from "../view" import { View, ViewV2 } from "../view"
import { RenameColumn } from "../../../sdk" import { RenameColumn } from "../../../sdk"
import { TableSchema } from "./schema" import { TableSchema } from "./schema"
export interface Table extends Document { export interface Table extends Document {
type?: string type?: string
views?: { [key: string]: View } views?: { [key: string]: View | ViewV2 }
name: string name: string
originalName?: string originalName?: string
primary?: string[] primary?: string[]

View file

@ -1,3 +1,7 @@
import { SortOrder, SortType } from "../../api"
import { SearchFilters } from "../../sdk"
import { TableSchema, UIFieldMetadata } from "./table"
export interface View { export interface View {
name: string name: string
tableId: string tableId: string
@ -10,6 +14,20 @@ export interface View {
meta?: Record<string, any> meta?: Record<string, any>
} }
export interface ViewV2 {
version: 2
id: string
name: string
tableId: string
query?: SearchFilters
sort?: {
field: string
order?: SortOrder
type?: SortType
}
columns?: Record<string, UIFieldMetadata>
}
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
export interface ViewCountOrSumSchema { export interface ViewCountOrSumSchema {

View file

@ -9,6 +9,7 @@ export enum Feature {
BRANDING = "branding", BRANDING = "branding",
SCIM = "scim", SCIM = "scim",
SYNC_AUTOMATIONS = "syncAutomations", SYNC_AUTOMATIONS = "syncAutomations",
OFFLINE = "offline",
} }
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }

View file

@ -1,4 +1,15 @@
import { PurchasedPlan, Quotas, Feature, Billing } from "." import { PurchasedPlan, Quotas, Feature, Billing } from "."
import { ISO8601 } from "../../shared"
export interface OfflineIdentifier {
installId: string
tenantId: string
}
export interface OfflineLicense extends License {
identifier: OfflineIdentifier
expireAt: ISO8601
}
export interface License { export interface License {
features: Feature[] features: Feature[]

View file

@ -1,3 +1,5 @@
export type DeepPartial<T> = { export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P] [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
} }
export type ISO8601 = string

View file

@ -0,0 +1,31 @@
const actual = jest.requireActual("@budibase/pro")
const pro = {
...actual,
features: {
...actual.features,
isSSOEnforced: jest.fn(),
},
licensing: {
keys: {
activateLicenseKey: jest.fn(),
getLicenseKey: jest.fn(),
deleteLicenseKey: jest.fn(),
},
offline: {
activateOfflineLicenseToken: jest.fn(),
getOfflineLicenseToken: jest.fn(),
deleteOfflineLicenseToken: jest.fn(),
getIdentifierBase64: jest.fn(),
},
cache: {
...actual.licensing.cache,
refresh: jest.fn(),
},
},
quotas: {
...actual.quotas,
getQuotaUsage: jest.fn(),
},
}
export = pro

View file

@ -0,0 +1,133 @@
openapi: 3.0.0
info:
title: Worker API Specification
version: 1.0.0
servers:
- url: "http://localhost:10000"
description: localhost
- url: "https://budibaseqa.app"
description: QA
- url: "https://preprod.qa.budibase.net"
description: Preprod
- url: "https://budibase.app"
description: Production
tags:
- name: license
description: License operations
paths:
/api/global/license/key:
post:
tags:
- license
summary: Activate license key
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ActivateLicenseKeyRequest'
responses:
'200':
description: Success
get:
tags:
- license
summary: Get license key
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetLicenseKeyResponse'
delete:
tags:
- license
summary: Delete license key
responses:
'204':
description: No content
/api/global/license/offline:
post:
tags:
- license
summary: Activate offline license
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ActivateOfflineLicenseTokenRequest'
responses:
'200':
description: Success
get:
tags:
- license
summary: Get offline license
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetOfflineLicenseTokenResponse'
delete:
tags:
- license
summary: Delete offline license
responses:
'204':
description: No content
/api/global/license/offline/identifier:
get:
tags:
- license
summary: Get offline identifier
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetOfflineIdentifierResponse'
components:
schemas:
ActivateOfflineLicenseTokenRequest:
type: object
properties:
offlineLicenseToken:
type: string
required:
- offlineLicenseToken
GetOfflineLicenseTokenResponse:
type: object
properties:
offlineLicenseToken:
type: string
required:
- offlineLicenseToken
ActivateLicenseKeyRequest:
type: object
properties:
licenseKey:
type: string
required:
- licenseKey
GetLicenseKeyResponse:
type: object
properties:
licenseKey:
type: string
required:
- licenseKey
GetOfflineIdentifierResponse:
type: object
properties:
identifierBase64:
type: string
required:
- identifierBase64

View file

@ -104,5 +104,19 @@
"typescript": "4.7.3", "typescript": "4.7.3",
"update-dotenv": "1.1.1" "update-dotenv": "1.1.1"
}, },
"nx": {
"targets": {
"dev:builder": {
"dependsOn": [
{
"projects": [
"@budibase/backend-core"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc" "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View file

@ -1,34 +1,83 @@
import { licensing, quotas } from "@budibase/pro" import { licensing, quotas } from "@budibase/pro"
import {
ActivateLicenseKeyRequest,
ActivateOfflineLicenseTokenRequest,
GetLicenseKeyResponse,
GetOfflineIdentifierResponse,
GetOfflineLicenseTokenResponse,
UserCtx,
} from "@budibase/types"
export const activate = async (ctx: any) => { // LICENSE KEY
export async function activateLicenseKey(
ctx: UserCtx<ActivateLicenseKeyRequest>
) {
const { licenseKey } = ctx.request.body const { licenseKey } = ctx.request.body
if (!licenseKey) { await licensing.keys.activateLicenseKey(licenseKey)
ctx.throw(400, "licenseKey is required")
}
await licensing.activateLicenseKey(licenseKey)
ctx.status = 200 ctx.status = 200
} }
export async function getLicenseKey(ctx: UserCtx<void, GetLicenseKeyResponse>) {
const licenseKey = await licensing.keys.getLicenseKey()
if (licenseKey) {
ctx.body = { licenseKey: "*" }
ctx.status = 200
} else {
ctx.status = 404
}
}
export async function deleteLicenseKey(ctx: UserCtx<void, void>) {
await licensing.keys.deleteLicenseKey()
ctx.status = 204
}
// OFFLINE LICENSE
export async function activateOfflineLicenseToken(
ctx: UserCtx<ActivateOfflineLicenseTokenRequest>
) {
const { offlineLicenseToken } = ctx.request.body
await licensing.offline.activateOfflineLicenseToken(offlineLicenseToken)
ctx.status = 200
}
export async function getOfflineLicenseToken(
ctx: UserCtx<void, GetOfflineLicenseTokenResponse>
) {
const offlineLicenseToken = await licensing.offline.getOfflineLicenseToken()
if (offlineLicenseToken) {
ctx.body = { offlineLicenseToken: "*" }
ctx.status = 200
} else {
ctx.status = 404
}
}
export async function deleteOfflineLicenseToken(ctx: UserCtx<void, void>) {
await licensing.offline.deleteOfflineLicenseToken()
ctx.status = 204
}
export async function getOfflineLicenseIdentifier(
ctx: UserCtx<void, GetOfflineIdentifierResponse>
) {
const identifierBase64 = await licensing.offline.getIdentifierBase64()
ctx.body = { identifierBase64 }
ctx.status = 200
}
// LICENSES
export const refresh = async (ctx: any) => { export const refresh = async (ctx: any) => {
await licensing.cache.refresh() await licensing.cache.refresh()
ctx.status = 200 ctx.status = 200
} }
export const getInfo = async (ctx: any) => { // USAGE
const licenseInfo = await licensing.getLicenseInfo()
if (licenseInfo) {
licenseInfo.licenseKey = "*"
ctx.body = licenseInfo
}
ctx.status = 200
}
export const deleteInfo = async (ctx: any) => {
await licensing.deleteLicenseInfo()
ctx.status = 200
}
export const getQuotaUsage = async (ctx: any) => { export const getQuotaUsage = async (ctx: any) => {
ctx.body = await quotas.getQuotaUsage() ctx.body = await quotas.getQuotaUsage()
ctx.status = 200
} }

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