Merge remote-tracking branch 'origin/develop' into feature/delete-multiple-button-action

This commit is contained in:
Dean 2023-07-25 15:42:01 +01:00
commit a5b81558b9
85 changed files with 2608 additions and 470 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.8.22-alpha.1", "version": "2.8.22-alpha.4",
"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

@ -163,6 +163,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

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

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

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

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

@ -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 fecebc4adcc7353810c585195026c29f85db6c10 Subproject commit 347ee5326812c01ef07f0e744f691ab4823e185a

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

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

@ -9,6 +9,10 @@ import {
DeleteRow, DeleteRow,
DeleteRows, DeleteRows,
Row, Row,
SearchResponse,
SortOrder,
SortType,
ViewV2,
} from "@budibase/types" } from "@budibase/types"
import * as utils from "./utils" import * as utils from "./utils"
import { gridSocket } from "../../../websockets" import { gridSocket } from "../../../websockets"
@ -148,7 +152,7 @@ async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
for (let row of rows) { 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 return rows
@ -203,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

@ -19,7 +19,6 @@ import { UserCtx, LinkDocumentValue, Row, Table } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
export async function patch(ctx: UserCtx) { export async function patch(ctx: UserCtx) {
const db = context.getAppDB()
const inputs = ctx.request.body const inputs = ctx.request.body
const tableId = inputs.tableId const tableId = inputs.tableId
const isUserTable = tableId === InternalTables.USER_METADATA const isUserTable = tableId === InternalTables.USER_METADATA
@ -77,7 +76,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,20 @@
import sdk from "../../../sdk"
import { CreateViewRequest, Ctx, 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 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

@ -14,8 +14,10 @@ import {
Row, Row,
Table, Table,
FieldType, FieldType,
SortType,
SortOrder,
} from "@budibase/types" } from "@budibase/types"
import { structures } from "@budibase/backend-core/tests" import { generator, structures } from "@budibase/backend-core/tests"
describe("/rows", () => { describe("/rows", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -760,4 +762,244 @@ 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, no other columns 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 => ({ 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,109 @@
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("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,23 @@ 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
)
.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 +34,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

@ -186,6 +186,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.
@ -271,3 +278,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,8 +1,9 @@
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"
import { Format } from "../../../api/controllers/view/exporters" import { Format } from "../../../api/controllers/view/exporters"
import _ from "lodash"
export interface SearchParams { export interface SearchParams {
tableId: string tableId: string
@ -11,10 +12,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,8 +32,17 @@ function pickApi(tableId: any) {
return internal return internal
} }
export async function search(options: SearchParams) { export async function search(options: SearchParams): Promise<{
return pickApi(options.tableId).search(options) rows: any[]
hasNextPage?: boolean
bookmark?: number | null
}> {
const result = await pickApi(options.tableId).search(options)
if (options.fields) {
result.rows = result.rows.map((r: any) => _.pick(r, options.fields!))
}
return result
} }
export interface ExportRowsParams { export interface ExportRowsParams {

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,91 @@
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 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,72 @@
import { SortOrder, SortType, ViewV2 } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
import { generator } from "@budibase/backend-core/tests"
export class ViewV2API extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
create = async (
viewData?: Partial<ViewV2>,
{ 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
}
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

@ -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,3 +1,6 @@
export * from "./backup" export * from "./backup"
export * from "./datasource" export * from "./datasource"
export * from "./row" export * from "./row"
export * from "./view"
export * from "./rows"
export * from "./table"

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,7 @@
import { TableSchema, ViewV2 } from "../../../documents"
export interface ViewResponse {
data: ViewV2
}
export type CreateViewRequest = Omit<ViewV2, "version" | "id">

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
primary?: string[] primary?: string[]
schema: TableSchema schema: TableSchema

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,27 @@
const actual = jest.requireActual("@budibase/pro")
const pro = {
...actual,
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

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

View File

@ -1,10 +1,11 @@
import { Ctx } from "@budibase/types" import { Ctx } from "@budibase/types"
import env from "../../../environment" import env from "../../../environment"
import { env as coreEnv } from "@budibase/backend-core"
export const fetch = async (ctx: Ctx) => { export const fetch = async (ctx: Ctx) => {
ctx.body = { ctx.body = {
multiTenancy: !!env.MULTI_TENANCY, multiTenancy: !!env.MULTI_TENANCY,
offlineMode: !!env.OFFLINE_MODE, offlineMode: !!coreEnv.OFFLINE_MODE,
cloud: !env.SELF_HOSTED, cloud: !env.SELF_HOSTED,
accountPortalUrl: env.ACCOUNT_PORTAL_URL, accountPortalUrl: env.ACCOUNT_PORTAL_URL,
disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL, disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL,

View File

@ -1,13 +1,44 @@
import Router from "@koa/router" import Router from "@koa/router"
import * as controller from "../../controllers/global/license" import * as controller from "../../controllers/global/license"
import { middleware } from "@budibase/backend-core"
import Joi from "joi"
const activateLicenseKeyValidator = middleware.joiValidator.body(
Joi.object({
licenseKey: Joi.string().required(),
}).required()
)
const activateOfflineLicenseValidator = middleware.joiValidator.body(
Joi.object({
offlineLicenseToken: Joi.string().required(),
}).required()
)
const router: Router = new Router() const router: Router = new Router()
router router
.post("/api/global/license/activate", controller.activate)
.post("/api/global/license/refresh", controller.refresh) .post("/api/global/license/refresh", controller.refresh)
.get("/api/global/license/info", controller.getInfo)
.delete("/api/global/license/info", controller.deleteInfo)
.get("/api/global/license/usage", controller.getQuotaUsage) .get("/api/global/license/usage", controller.getQuotaUsage)
// LICENSE KEY
.post(
"/api/global/license/key",
activateLicenseKeyValidator,
controller.activateLicenseKey
)
.get("/api/global/license/key", controller.getLicenseKey)
.delete("/api/global/license/key", controller.deleteLicenseKey)
// OFFLINE LICENSE
.post(
"/api/global/license/offline",
activateOfflineLicenseValidator,
controller.activateOfflineLicenseToken
)
.get("/api/global/license/offline", controller.getOfflineLicenseToken)
.delete("/api/global/license/offline", controller.deleteOfflineLicenseToken)
.get(
"/api/global/license/offline/identifier",
controller.getOfflineLicenseIdentifier
)
export default router export default router

View File

@ -1,4 +1,6 @@
import { TestConfiguration } from "../../../../tests" import { TestConfiguration, mocks, structures } from "../../../../tests"
const licensing = mocks.pro.licensing
const quotas = mocks.pro.quotas
describe("/api/global/license", () => { describe("/api/global/license", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
@ -12,18 +14,105 @@ describe("/api/global/license", () => {
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.resetAllMocks()
}) })
describe("POST /api/global/license/activate", () => { describe("POST /api/global/license/refresh", () => {
it("activates license", () => {}) it("returns 200", async () => {
const res = await config.api.license.refresh()
expect(res.status).toBe(200)
expect(licensing.cache.refresh).toBeCalledTimes(1)
})
}) })
describe("POST /api/global/license/refresh", () => {}) describe("GET /api/global/license/usage", () => {
it("returns 200 + usage", async () => {
const usage = structures.quotas.usage()
quotas.getQuotaUsage.mockResolvedValue(usage)
const res = await config.api.license.getUsage()
expect(res.status).toBe(200)
expect(res.body).toEqual(usage)
})
})
describe("GET /api/global/license/info", () => {}) describe("POST /api/global/license/key", () => {
it("returns 200", async () => {
const res = await config.api.license.activateLicenseKey({
licenseKey: "licenseKey",
})
expect(res.status).toBe(200)
expect(licensing.keys.activateLicenseKey).toBeCalledWith("licenseKey")
})
})
describe("DELETE /api/global/license/info", () => {}) describe("GET /api/global/license/key", () => {
it("returns 404 when not found", async () => {
const res = await config.api.license.getLicenseKey()
expect(res.status).toBe(404)
})
it("returns 200 + license key", async () => {
licensing.keys.getLicenseKey.mockResolvedValue("licenseKey")
const res = await config.api.license.getLicenseKey()
expect(res.status).toBe(200)
expect(res.body).toEqual({
licenseKey: "*",
})
})
})
describe("GET /api/global/license/usage", () => {}) describe("DELETE /api/global/license/key", () => {
it("returns 204", async () => {
const res = await config.api.license.deleteLicenseKey()
expect(licensing.keys.deleteLicenseKey).toBeCalledTimes(1)
expect(res.status).toBe(204)
})
})
describe("POST /api/global/license/offline", () => {
it("activates offline license", async () => {
const res = await config.api.license.activateOfflineLicense({
offlineLicenseToken: "offlineLicenseToken",
})
expect(licensing.offline.activateOfflineLicenseToken).toBeCalledWith(
"offlineLicenseToken"
)
expect(res.status).toBe(200)
})
})
describe("GET /api/global/license/offline", () => {
it("returns 404 when not found", async () => {
const res = await config.api.license.getOfflineLicense()
expect(res.status).toBe(404)
})
it("returns 200 + offline license token", async () => {
licensing.offline.getOfflineLicenseToken.mockResolvedValue(
"offlineLicenseToken"
)
const res = await config.api.license.getOfflineLicense()
expect(res.status).toBe(200)
expect(res.body).toEqual({
offlineLicenseToken: "*",
})
})
})
describe("DELETE /api/global/license/offline", () => {
it("returns 204", async () => {
const res = await config.api.license.deleteOfflineLicense()
expect(res.status).toBe(204)
expect(licensing.offline.deleteOfflineLicenseToken).toBeCalledTimes(1)
})
})
describe("GET /api/global/license/offline/identifier", () => {
it("returns 200 + identifier base64", async () => {
licensing.offline.getIdentifierBase64.mockResolvedValue("base64")
const res = await config.api.license.getOfflineLicenseIdentifier()
expect(res.status).toBe(200)
expect(res.body).toEqual({
identifierBase64: "base64",
})
})
})
}) })

View File

@ -61,7 +61,6 @@ const environment = {
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY, ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
OFFLINE_MODE: process.env.OFFLINE_MODE,
/** /**
* Mock the email service in use - links to ethereal hosted emails are logged instead. * Mock the email service in use - links to ethereal hosted emails are logged instead.
*/ */

View File

@ -1,8 +1,7 @@
import mocks from "./mocks" import mocks from "./mocks"
// init the licensing mock // init the licensing mock
import * as pro from "@budibase/pro" mocks.licenses.init(mocks.pro)
mocks.licenses.init(pro)
// use unlimited license by default // use unlimited license by default
mocks.licenses.useUnlimited() mocks.licenses.useUnlimited()
@ -238,21 +237,21 @@ class TestConfiguration {
const db = context.getGlobalDB() const db = context.getGlobalDB()
const id = dbCore.generateDevInfoID(this.user._id) const id = dbCore.generateDevInfoID(this.user!._id)
// TODO: dry // TODO: dry
this.apiKey = encryption.encrypt( this.apiKey = encryption.encrypt(
`${this.tenantId}${dbCore.SEPARATOR}${utils.newid()}` `${this.tenantId}${dbCore.SEPARATOR}${utils.newid()}`
) )
const devInfo = { const devInfo = {
_id: id, _id: id,
userId: this.user._id, userId: this.user!._id,
apiKey: this.apiKey, apiKey: this.apiKey,
} }
await db.put(devInfo) await db.put(devInfo)
}) })
} }
async getUser(email: string): Promise<User> { async getUser(email: string): Promise<User | undefined> {
return context.doInTenant(this.getTenantId(), () => { return context.doInTenant(this.getTenantId(), () => {
return users.getGlobalUserByEmail(email) return users.getGlobalUserByEmail(email)
}) })
@ -264,7 +263,7 @@ class TestConfiguration {
} }
const response = await this._req(user, null, controllers.users.save) const response = await this._req(user, null, controllers.users.save)
const body = response as SaveUserResponse const body = response as SaveUserResponse
return this.getUser(body.email) return this.getUser(body.email) as Promise<User>
} }
// CONFIGS // CONFIGS

View File

@ -1,17 +1,62 @@
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base" import { TestAPI } from "./base"
import {
ActivateLicenseKeyRequest,
ActivateOfflineLicenseTokenRequest,
} from "@budibase/types"
export class LicenseAPI extends TestAPI { export class LicenseAPI extends TestAPI {
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
super(config) super(config)
} }
activate = async (licenseKey: string) => { refresh = async () => {
return this.request return this.request
.post("/api/global/license/activate") .post("/api/global/license/refresh")
.send({ licenseKey: licenseKey }) .set(this.config.defaultHeaders())
}
getUsage = async () => {
return this.request
.get("/api/global/license/usage")
.set(this.config.defaultHeaders()) .set(this.config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
} }
activateLicenseKey = async (body: ActivateLicenseKeyRequest) => {
return this.request
.post("/api/global/license/key")
.send(body)
.set(this.config.defaultHeaders())
}
getLicenseKey = async () => {
return this.request
.get("/api/global/license/key")
.set(this.config.defaultHeaders())
}
deleteLicenseKey = async () => {
return this.request
.delete("/api/global/license/key")
.set(this.config.defaultHeaders())
}
activateOfflineLicense = async (body: ActivateOfflineLicenseTokenRequest) => {
return this.request
.post("/api/global/license/offline")
.send(body)
.set(this.config.defaultHeaders())
}
getOfflineLicense = async () => {
return this.request
.get("/api/global/license/offline")
.set(this.config.defaultHeaders())
}
deleteOfflineLicense = async () => {
return this.request
.delete("/api/global/license/offline")
.set(this.config.defaultHeaders())
}
getOfflineLicenseIdentifier = async () => {
return this.request
.get("/api/global/license/offline/identifier")
.set(this.config.defaultHeaders())
}
} }

View File

@ -1,7 +1,11 @@
import * as email from "./email" import * as email from "./email"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import * as _pro from "@budibase/pro"
const pro = jest.mocked(_pro, true)
export default { export default {
email, email,
pro,
...mocks, ...mocks,
} }

View File

@ -42,7 +42,7 @@ async function discordResultsNotification(report) {
Accept: "application/json", Accept: "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
content: `**Nightly Tests Status**: ${OUTCOME}`, content: `**Tests Status**: ${OUTCOME}`,
embeds: [ embeds: [
{ {
title: `Budi QA Bot - ${env}`, title: `Budi QA Bot - ${env}`,

View File

@ -49,6 +49,7 @@ export default class AccountInternalAPIClient {
requestOptions.headers = { requestOptions.headers = {
...requestOptions.headers, ...requestOptions.headers,
...{ [Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY }, ...{ [Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY },
cookie: "",
} }
} }

View File

@ -1,5 +1,10 @@
import AccountInternalAPIClient from "../AccountInternalAPIClient" import AccountInternalAPIClient from "../AccountInternalAPIClient"
import { Account, UpdateLicenseRequest } from "@budibase/types" import {
Account,
CreateOfflineLicenseRequest,
GetOfflineLicenseResponse,
UpdateLicenseRequest,
} from "@budibase/types"
import { Response } from "node-fetch" import { Response } from "node-fetch"
import BaseAPI from "./BaseAPI" import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types" import { APIRequestOpts } from "../../../types"
@ -24,4 +29,44 @@ export default class LicenseAPI extends BaseAPI {
}) })
}, opts) }, opts)
} }
// TODO: Better approach for setting tenant id header
async createOfflineLicense(
accountId: string,
tenantId: string,
body: CreateOfflineLicenseRequest,
opts: { status?: number } = {}
): Promise<Response> {
const [response, json] = await this.client.post(
`/api/internal/accounts/${accountId}/license/offline`,
{
body,
internal: true,
headers: {
"x-budibase-tenant-id": tenantId,
},
}
)
expect(response.status).toBe(opts.status ? opts.status : 201)
return response
}
async getOfflineLicense(
accountId: string,
tenantId: string,
opts: { status?: number } = {}
): Promise<[Response, GetOfflineLicenseResponse]> {
const [response, json] = await this.client.get(
`/api/internal/accounts/${accountId}/license/offline`,
{
internal: true,
headers: {
"x-budibase-tenant-id": tenantId,
},
}
)
expect(response.status).toBe(opts.status ? opts.status : 200)
return [response, json]
}
} }

View File

@ -2,7 +2,9 @@ import { generator } from "../../shared"
import { Hosting, CreateAccountRequest } from "@budibase/types" import { Hosting, CreateAccountRequest } from "@budibase/types"
// TODO: Refactor me to central location // TODO: Refactor me to central location
export const generateAccount = (): CreateAccountRequest => { export const generateAccount = (
partial: Partial<CreateAccountRequest>
): CreateAccountRequest => {
const uuid = generator.guid() const uuid = generator.guid()
const email = `${uuid}@budibase.com` const email = `${uuid}@budibase.com`
@ -17,5 +19,6 @@ export const generateAccount = (): CreateAccountRequest => {
size: "10+", size: "10+",
tenantId: tenant, tenantId: tenant,
tenantName: tenant, tenantName: tenant,
...partial,
} }
} }

View File

@ -15,7 +15,7 @@ describe("Account Internal Operations", () => {
it("performs account deletion by ID", async () => { it("performs account deletion by ID", async () => {
// Deleting by unknown id doesn't work // Deleting by unknown id doesn't work
const accountId = generator.string() const accountId = generator.guid()
await config.api.accounts.delete(accountId, { status: 404 }) await config.api.accounts.delete(accountId, { status: 404 })
// Create new account // Create new account

View File

@ -0,0 +1,79 @@
import TestConfiguration from "../../config/TestConfiguration"
import * as fixures from "../../fixtures"
import { Hosting, Feature } from "@budibase/types"
describe("offline", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
// TODO: Currently requires a self host install + account portal
// Ignored until we set this up
xit("creates, activates and deletes offline license", async () => {
// installation: Delete any token
await config.internalApi.license.deleteOfflineLicenseToken()
// installation: Assert token not found
let [getTokenRes] = await config.internalApi.license.getOfflineLicenseToken(
{ status: 404 }
)
// installation: Retrieve Identifier
const [getIdentifierRes, identifier] =
await config.internalApi.license.getOfflineIdentifier()
// account-portal: Create self-host account
const createAccountRequest = fixures.accounts.generateAccount({
hosting: Hosting.SELF,
})
const [createAccountRes, account] =
await config.accountsApi.accounts.create(createAccountRequest)
const accountId = account.accountId!
const tenantId = account.tenantId!
// account-portal: Enable feature on license
await config.accountsApi.licenses.updateLicense(accountId, {
overrides: {
features: [Feature.OFFLINE],
},
})
// account-portal: Create offline token
const expireAt = new Date()
expireAt.setDate(new Date().getDate() + 1)
await config.accountsApi.licenses.createOfflineLicense(
accountId,
tenantId,
{
expireAt: expireAt.toISOString(),
installationIdentifierBase64: identifier.identifierBase64,
}
)
// account-portal: Retrieve offline token
const [getLicenseRes, offlineLicense] =
await config.accountsApi.licenses.getOfflineLicense(accountId, tenantId)
// installation: Activate offline token
await config.internalApi.license.activateOfflineLicenseToken({
offlineLicenseToken: offlineLicense.offlineLicenseToken,
})
// installation: Assert token found
await config.internalApi.license.getOfflineLicenseToken()
// TODO: Assert on license for current user
// installation: Remove the token
await config.internalApi.license.deleteOfflineLicenseToken()
// installation: Assert token not found
await config.internalApi.license.getOfflineLicenseToken({ status: 404 })
})
})

View File

@ -11,6 +11,7 @@ import DatasourcesAPI from "./apis/DatasourcesAPI"
import IntegrationsAPI from "./apis/IntegrationsAPI" import IntegrationsAPI from "./apis/IntegrationsAPI"
import QueriesAPI from "./apis/QueriesAPI" import QueriesAPI from "./apis/QueriesAPI"
import PermissionsAPI from "./apis/PermissionsAPI" import PermissionsAPI from "./apis/PermissionsAPI"
import LicenseAPI from "./apis/LicenseAPI"
import BudibaseInternalAPIClient from "./BudibaseInternalAPIClient" import BudibaseInternalAPIClient from "./BudibaseInternalAPIClient"
import { State } from "../../types" import { State } from "../../types"
@ -30,6 +31,7 @@ export default class BudibaseInternalAPI {
integrations: IntegrationsAPI integrations: IntegrationsAPI
queries: QueriesAPI queries: QueriesAPI
permissions: PermissionsAPI permissions: PermissionsAPI
license: LicenseAPI
constructor(state: State) { constructor(state: State) {
this.client = new BudibaseInternalAPIClient(state) this.client = new BudibaseInternalAPIClient(state)
@ -47,5 +49,6 @@ export default class BudibaseInternalAPI {
this.integrations = new IntegrationsAPI(this.client) this.integrations = new IntegrationsAPI(this.client)
this.queries = new QueriesAPI(this.client) this.queries = new QueriesAPI(this.client)
this.permissions = new PermissionsAPI(this.client) this.permissions = new PermissionsAPI(this.client)
this.license = new LicenseAPI(this.client)
} }
} }

View File

@ -8,9 +8,9 @@ export default class BaseAPI {
this.client = client this.client = client
} }
async get(url: string): Promise<[Response, any]> { async get(url: string, status?: number): Promise<[Response, any]> {
const [response, json] = await this.client.get(url) const [response, json] = await this.client.get(url)
expect(response).toHaveStatusCode(200) expect(response).toHaveStatusCode(status ? status : 200)
return [response, json] return [response, json]
} }

View File

@ -0,0 +1,45 @@
import { Response } from "node-fetch"
import {
ActivateOfflineLicenseTokenRequest,
GetOfflineIdentifierResponse,
GetOfflineLicenseTokenResponse,
} from "@budibase/types"
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
import BaseAPI from "./BaseAPI"
export default class LicenseAPI extends BaseAPI {
constructor(client: BudibaseInternalAPIClient) {
super(client)
}
async getOfflineLicenseToken(
opts: { status?: number } = {}
): Promise<[Response, GetOfflineLicenseTokenResponse]> {
const [response, body] = await this.get(
`/global/license/offline`,
opts.status
)
return [response, body]
}
async deleteOfflineLicenseToken(): Promise<[Response]> {
const [response] = await this.del(`/global/license/offline`, 204)
return [response]
}
async activateOfflineLicenseToken(
body: ActivateOfflineLicenseTokenRequest
): Promise<[Response]> {
const [response] = await this.post(`/global/license/offline`, body)
return [response]
}
async getOfflineIdentifier(): Promise<
[Response, GetOfflineIdentifierResponse]
> {
const [response, body] = await this.get(
`/global/license/offline/identifier`
)
return [response, body]
}
}