Merge remote-tracking branch 'origin/develop' into feature/delete-multiple-button-action
This commit is contained in:
commit
a5b81558b9
|
@ -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
11
nx.json
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(/-/, "")}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./platform"
|
|
@ -0,0 +1 @@
|
||||||
|
export * as installation from "./installation"
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -96,7 +96,8 @@
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
{
|
{
|
||||||
"projects": [
|
"projects": [
|
||||||
"@budibase/string-templates"
|
"@budibase/string-templates",
|
||||||
|
"@budibase/shared-core"
|
||||||
],
|
],
|
||||||
"target": "build"
|
"target": "build"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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} />
|
|
@ -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>
|
||||||
|
|
|
@ -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> </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>
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 []
|
||||||
|
|
|
@ -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
|
|
@ -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."
|
||||||
},
|
},
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
|
|
@ -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"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
}
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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!
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface SearchResponse {
|
||||||
|
rows: any[]
|
||||||
|
}
|
|
@ -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[]
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { TableSchema, ViewV2 } from "../../../documents"
|
||||||
|
|
||||||
|
export interface ViewResponse {
|
||||||
|
data: ViewV2
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateViewRequest = Omit<ViewV2, "version" | "id">
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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[]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
|
@ -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: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue