Merge branch 'develop' of github.com:Budibase/budibase into labday/sqs
This commit is contained in:
commit
43024e1a30
|
@ -1,4 +1,4 @@
|
|||
name: Deploy Budibase Single Container Image to DockerHub
|
||||
name: release-singleimage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
@ -8,8 +8,8 @@ env:
|
|||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
REGISTRY_URL: registry.hub.docker.com
|
||||
jobs:
|
||||
build:
|
||||
name: "build"
|
||||
build-amd64:
|
||||
name: "build-amd64"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
@ -27,14 +27,12 @@ jobs:
|
|||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fail if tag is not in master
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
|
||||
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
|
@ -70,9 +68,139 @@ jobs:
|
|||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }}
|
||||
platforms: linux/amd64
|
||||
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
|
||||
file: ./hosting/single/Dockerfile
|
||||
|
||||
- name: Tag and release Budibase Azure App Service docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
build-args: TARGETBUILD=aas
|
||||
tags: budibase/budibase-aas,budibase/budibase-aas:v${{ env.RELEASE_VERSION }}
|
||||
file: ./hosting/single/Dockerfile
|
||||
|
||||
build-arm64:
|
||||
name: "build-arm64"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [14.x]
|
||||
steps:
|
||||
- name: Fail if not a tag
|
||||
run: |
|
||||
if [[ $GITHUB_REF != refs/tags/* ]]; then
|
||||
echo "Workflow Dispatch can only be run on tags"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- name: Fail if tag is not in master
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
|
||||
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
|
||||
exit 1
|
||||
fi
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Setup Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Run Yarn
|
||||
run: yarn
|
||||
- name: Update versions
|
||||
run: ./scripts/updateVersions.sh
|
||||
- name: Runt Yarn Lint
|
||||
run: yarn lint
|
||||
- name: Update versions
|
||||
run: ./scripts/updateVersions.sh
|
||||
- name: Run Yarn Build
|
||||
run: yarn build:docker:pre
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_API_KEY }}
|
||||
- name: Get the latest release version
|
||||
id: version
|
||||
run: |
|
||||
release_version=$(cat lerna.json | jq -r '.version')
|
||||
echo $release_version
|
||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
||||
- name: Tag and release Budibase service docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
|
||||
file: ./hosting/single/Dockerfile
|
||||
|
||||
build-aas:
|
||||
name: "build-aas"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [14.x]
|
||||
steps:
|
||||
- name: Fail if not a tag
|
||||
run: |
|
||||
if [[ $GITHUB_REF != refs/tags/* ]]; then
|
||||
echo "Workflow Dispatch can only be run on tags"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- name: Fail if tag is not in master
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
|
||||
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
|
||||
exit 1
|
||||
fi
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Setup Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Run Yarn
|
||||
run: yarn
|
||||
- name: Update versions
|
||||
run: ./scripts/updateVersions.sh
|
||||
- name: Runt Yarn Lint
|
||||
run: yarn lint
|
||||
- name: Update versions
|
||||
run: ./scripts/updateVersions.sh
|
||||
- name: Run Yarn Build
|
||||
run: yarn build:docker:pre
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_API_KEY }}
|
||||
- name: Get the latest release version
|
||||
id: version
|
||||
run: |
|
||||
release_version=$(cat lerna.json | jq -r '.version')
|
||||
echo $release_version
|
||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
||||
- name: Tag and release Budibase Azure App Service docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.8.22-alpha.0",
|
||||
"version": "2.8.27-alpha.0",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
11
nx.json
11
nx.json
|
@ -3,11 +3,8 @@
|
|||
"default": {
|
||||
"runner": "nx-cloud",
|
||||
"options": {
|
||||
"cacheableOperations": [
|
||||
"build",
|
||||
"test"
|
||||
],
|
||||
"accessToken": "YWNiYzc5NTEtMzMzZC00NDhjLTgyNjktZTllMjI1MzM4OGQxfHJlYWQtd3JpdGU="
|
||||
"cacheableOperations": ["build", "test"],
|
||||
"accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -15,9 +12,7 @@
|
|||
"dev:builder": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@budibase/string-templates"
|
||||
],
|
||||
"projects": ["@budibase/string-templates"],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
|
|
10
package.json
10
package.json
|
@ -6,8 +6,8 @@
|
|||
"@nx/js": "16.4.3",
|
||||
"@rollup/plugin-json": "^4.0.2",
|
||||
"@typescript-eslint/parser": "5.45.0",
|
||||
"esbuild": "^0.17.18",
|
||||
"esbuild-node-externals": "^1.7.0",
|
||||
"esbuild": "^0.18.17",
|
||||
"esbuild-node-externals": "^1.8.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-cypress": "^2.11.3",
|
||||
"husky": "^8.0.3",
|
||||
|
@ -51,9 +51,9 @@
|
|||
"kill-builder": "kill-port 3000",
|
||||
"kill-server": "kill-port 4001 4002",
|
||||
"kill-all": "yarn run kill-builder && yarn run kill-server",
|
||||
"dev": "yarn run kill-all && lerna run --stream dev:builder --stream",
|
||||
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
|
||||
"dev:server": "yarn run kill-server && lerna run --stream dev:builder --scope @budibase/worker --scope @budibase/server",
|
||||
"dev": "yarn run kill-all && yarn nx run-many --target=dev:builder",
|
||||
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && yarn nx run-many --target=dev:builder --exclude=@budibase/backend-core,@budibase/server,@budibase/worker",
|
||||
"dev:server": "yarn run kill-server && yarn nx run-many --target=dev:builder --projects=@budibase/worker,@budibase/server",
|
||||
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
|
||||
"dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
|
||||
"test": "lerna run --stream test --stream",
|
||||
|
|
|
@ -2,9 +2,14 @@ import { getAppClient } from "../redis/init"
|
|||
import { doWithDB, DocumentType } from "../db"
|
||||
import { Database, App } from "@budibase/types"
|
||||
|
||||
const AppState = {
|
||||
INVALID: "invalid",
|
||||
export enum AppState {
|
||||
INVALID = "invalid",
|
||||
}
|
||||
|
||||
export interface DeletedApp {
|
||||
state: AppState
|
||||
}
|
||||
|
||||
const EXPIRY_SECONDS = 3600
|
||||
|
||||
/**
|
||||
|
@ -31,7 +36,7 @@ function isInvalid(metadata?: { state: string }) {
|
|||
* @param {string} appId the id of the app to get metadata from.
|
||||
* @returns {object} the app metadata.
|
||||
*/
|
||||
export async function getAppMetadata(appId: string) {
|
||||
export async function getAppMetadata(appId: string): Promise<App | DeletedApp> {
|
||||
const client = await getAppClient()
|
||||
// try cache
|
||||
let metadata = await client.get(appId)
|
||||
|
@ -61,11 +66,8 @@ export async function getAppMetadata(appId: string) {
|
|||
}
|
||||
await client.store(appId, metadata, expiry)
|
||||
}
|
||||
// we've stored in the cache an object to tell us that it is currently invalid
|
||||
if (isInvalid(metadata)) {
|
||||
throw { status: 404, message: "No app metadata found" }
|
||||
}
|
||||
return metadata as App
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export const CONSTANT_INTERNAL_ROW_COLS = [
|
||||
"_id",
|
||||
"_rev",
|
||||
"type",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"tableId",
|
||||
] as const
|
||||
|
||||
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const
|
|
@ -2,3 +2,4 @@ export * from "./connections"
|
|||
export * from "./DatabaseImpl"
|
||||
export * from "./utils"
|
||||
export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB"
|
||||
export * from "../constants"
|
||||
|
|
|
@ -2,7 +2,7 @@ import env from "../environment"
|
|||
import { DEFAULT_TENANT_ID, SEPARATOR, DocumentType } from "../constants"
|
||||
import { getTenantId, getGlobalDBName } from "../context"
|
||||
import { doWithDB, directCouchAllDbs } from "./db"
|
||||
import { getAppMetadata } from "../cache/appMetadata"
|
||||
import { AppState, DeletedApp, getAppMetadata } from "../cache/appMetadata"
|
||||
import { isDevApp, isDevAppID, getProdAppID } from "../docIds/conversions"
|
||||
import { App, Database } from "@budibase/types"
|
||||
import { getStartEndKeyURL } from "../docIds"
|
||||
|
@ -101,7 +101,9 @@ export async function getAllApps({
|
|||
const response = await Promise.allSettled(appPromises)
|
||||
const apps = response
|
||||
.filter(
|
||||
(result: any) => result.status === "fulfilled" && result.value != null
|
||||
(result: any) =>
|
||||
result.status === "fulfilled" &&
|
||||
result.value?.state !== AppState.INVALID
|
||||
)
|
||||
.map(({ value }: any) => value)
|
||||
if (!all) {
|
||||
|
@ -126,7 +128,11 @@ export async function getAppsByIDs(appIds: string[]) {
|
|||
)
|
||||
// have to list the apps which exist, some may have been deleted
|
||||
return settled
|
||||
.filter(promise => promise.status === "fulfilled")
|
||||
.filter(
|
||||
promise =>
|
||||
promise.status === "fulfilled" &&
|
||||
(promise.value as DeletedApp).state !== AppState.INVALID
|
||||
)
|
||||
.map(promise => (promise as PromiseFulfilledResult<App>).value)
|
||||
}
|
||||
|
||||
|
|
|
@ -164,6 +164,7 @@ const environment = {
|
|||
: false,
|
||||
...getPackageJsonFields(),
|
||||
DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER,
|
||||
OFFLINE_MODE: process.env.OFFLINE_MODE,
|
||||
_set(key: any, value: any) {
|
||||
process.env[key] = value
|
||||
// @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
|
||||
|
||||
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
|
||||
return context.doInTenant(tenantId, () => {
|
||||
return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { db } from "../../../src"
|
||||
|
||||
export function expectFunctionWasCalledTimesWith(
|
||||
jestFunction: any,
|
||||
times: number,
|
||||
|
@ -7,3 +9,22 @@ export function expectFunctionWasCalledTimesWith(
|
|||
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
|
||||
).toBe(times)
|
||||
}
|
||||
|
||||
export const expectAnyInternalColsAttributes: {
|
||||
[K in (typeof db.CONSTANT_INTERNAL_ROW_COLS)[number]]: any
|
||||
} = {
|
||||
tableId: expect.anything(),
|
||||
type: expect.anything(),
|
||||
_id: expect.anything(),
|
||||
_rev: expect.anything(),
|
||||
createdAt: expect.anything(),
|
||||
updatedAt: expect.anything(),
|
||||
}
|
||||
|
||||
export const expectAnyExternalColsAttributes: {
|
||||
[K in (typeof db.CONSTANT_EXTERNAL_ROW_COLS)[number]]: any
|
||||
} = {
|
||||
tableId: expect.anything(),
|
||||
_id: expect.anything(),
|
||||
_rev: expect.anything(),
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from "@budibase/types"
|
||||
import _ from "lodash"
|
||||
|
||||
export const account = (): Account => {
|
||||
export const account = (partial: Partial<Account> = {}): Account => {
|
||||
return {
|
||||
accountId: uuid(),
|
||||
tenantId: generator.word(),
|
||||
|
@ -29,6 +29,7 @@ export const account = (): Account => {
|
|||
size: "10+",
|
||||
profession: "Software Engineer",
|
||||
quotaUsage: quotas.usage(),
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { structures } from ".."
|
||||
import { generator } from "./generator"
|
||||
import { newid } from "../../../../src/docIds/newid"
|
||||
|
||||
export function id() {
|
||||
|
@ -6,7 +6,7 @@ export function id() {
|
|||
}
|
||||
|
||||
export function rev() {
|
||||
return `${structures.generator.character({
|
||||
return `${generator.character({
|
||||
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 apps from "./apps"
|
||||
export * as db from "./db"
|
||||
export * as docs from "./documents"
|
||||
export * as koa from "./koa"
|
||||
export * as licenses from "./licenses"
|
||||
export * as plugins from "./plugins"
|
||||
|
|
|
@ -3,6 +3,8 @@ import {
|
|||
Customer,
|
||||
Feature,
|
||||
License,
|
||||
OfflineIdentifier,
|
||||
OfflineLicense,
|
||||
PlanModel,
|
||||
PlanType,
|
||||
PriceDuration,
|
||||
|
@ -11,6 +13,7 @@ import {
|
|||
Quotas,
|
||||
Subscription,
|
||||
} from "@budibase/types"
|
||||
import { generator } from "./generator"
|
||||
|
||||
export function price(): PurchasedPrice {
|
||||
return {
|
||||
|
@ -127,15 +130,15 @@ export function subscription(): Subscription {
|
|||
}
|
||||
}
|
||||
|
||||
export const license = (
|
||||
opts: {
|
||||
quotas?: Quotas
|
||||
plan?: PurchasedPlan
|
||||
planType?: PlanType
|
||||
features?: Feature[]
|
||||
billing?: Billing
|
||||
} = {}
|
||||
): License => {
|
||||
interface GenerateLicenseOpts {
|
||||
quotas?: Quotas
|
||||
plan?: PurchasedPlan
|
||||
planType?: PlanType
|
||||
features?: Feature[]
|
||||
billing?: Billing
|
||||
}
|
||||
|
||||
export const license = (opts: GenerateLicenseOpts = {}): License => {
|
||||
return {
|
||||
features: opts.features || [],
|
||||
quotas: opts.quotas || quotas(),
|
||||
|
@ -143,3 +146,22 @@ export const license = (
|
|||
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": [
|
||||
{
|
||||
"projects": [
|
||||
"@budibase/string-templates"
|
||||
"@budibase/string-templates",
|
||||
"@budibase/shared-core"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@ export default function positionDropdown(element, opts) {
|
|||
|
||||
// Apply styles
|
||||
Object.entries(styles).forEach(([style, value]) => {
|
||||
if (value) {
|
||||
if (value != null) {
|
||||
element.style[style] = `${value.toFixed(0)}px`
|
||||
} else {
|
||||
element.style[style] = null
|
||||
|
|
|
@ -491,6 +491,7 @@ const getSelectedRowsBindings = asset => {
|
|||
readableBinding: `${table._instanceName}.Selected rows`,
|
||||
category: "Selected rows",
|
||||
icon: "ViewRow",
|
||||
display: { name: table._instanceName },
|
||||
}))
|
||||
)
|
||||
|
||||
|
@ -506,6 +507,7 @@ const getSelectedRowsBindings = asset => {
|
|||
)}.${makePropSafe("selectedRows")}`,
|
||||
readableBinding: `${block._instanceName}.Selected rows`,
|
||||
category: "Selected rows",
|
||||
display: { name: block._instanceName },
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { datasources, tables } from "stores/backend"
|
||||
import { datasources, tables, integrations } from "stores/backend"
|
||||
import EditRolesButton from "./buttons/EditRolesButton.svelte"
|
||||
import { TableNames } from "constants"
|
||||
import { Grid } from "@budibase/frontend-core"
|
||||
|
@ -27,6 +27,17 @@
|
|||
$: isUsersTable = id === TableNames.USERS
|
||||
$: isInternal = $tables.selected?.type !== "external"
|
||||
|
||||
$: datasource = $datasources.list.find(datasource => {
|
||||
return datasource._id === $tables.selected?.sourceId
|
||||
})
|
||||
|
||||
$: relationshipsEnabled = relationshipSupport(datasource)
|
||||
|
||||
const relationshipSupport = datasource => {
|
||||
const integration = $integrations[datasource?.source]
|
||||
return !isInternal && integration?.relationships !== false
|
||||
}
|
||||
|
||||
const handleGridTableUpdate = async e => {
|
||||
tables.replaceTable(id, e.detail)
|
||||
|
||||
|
@ -58,7 +69,7 @@
|
|||
<GridCreateViewButton />
|
||||
{/if}
|
||||
<GridManageAccessButton />
|
||||
{#if !isInternal}
|
||||
{#if relationshipsEnabled}
|
||||
<GridRelationshipButton />
|
||||
{/if}
|
||||
{#if isUsersTable}
|
||||
|
|
|
@ -44,7 +44,6 @@
|
|||
let fileInput
|
||||
let error = null
|
||||
let fileName = null
|
||||
let fileType = null
|
||||
let loading = false
|
||||
let validation = {}
|
||||
let validateHash = ""
|
||||
|
|
|
@ -341,7 +341,7 @@
|
|||
</Tab>
|
||||
{/if}
|
||||
<div class="drawer-actions">
|
||||
{#if typeof drawerActions.hide === "function" && drawerActions.headless}
|
||||
{#if typeof drawerActions?.hide === "function" && drawerActions?.headless}
|
||||
<Button
|
||||
secondary
|
||||
quiet
|
||||
|
@ -352,7 +352,7 @@
|
|||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
{#if typeof bindingDrawerActions?.save === "function" && drawerActions.headless}
|
||||
{#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
|
||||
<Button
|
||||
cta
|
||||
disabled={!valid}
|
||||
|
|
|
@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
|
|||
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
|
||||
import BarButtonList from "./controls/BarButtonList.svelte"
|
||||
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
|
||||
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
|
||||
|
||||
const componentMap = {
|
||||
text: DrawerBindableInput,
|
||||
|
@ -44,6 +45,7 @@ const componentMap = {
|
|||
schema: SchemaSelect,
|
||||
section: SectionSelect,
|
||||
filter: FilterEditor,
|
||||
"filter/relationship": RelationshipFilterEditor,
|
||||
url: URLSelect,
|
||||
fieldConfiguration: FieldConfiguration,
|
||||
columns: ColumnEditor,
|
||||
|
|
|
@ -206,6 +206,11 @@
|
|||
|
||||
return allBindings
|
||||
}
|
||||
|
||||
const toDisplay = eventKey => {
|
||||
const type = actionTypes.find(action => action.name == eventKey)
|
||||
return type?.displayName || type?.name
|
||||
}
|
||||
</script>
|
||||
|
||||
<DrawerContent>
|
||||
|
@ -231,7 +236,9 @@
|
|||
<ul>
|
||||
{#each category as actionType}
|
||||
<li on:click={onAddAction(actionType)}>
|
||||
<span class="action-name">{actionType.name}</span>
|
||||
<span class="action-name">
|
||||
{actionType.displayName || actionType.name}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
@ -262,7 +269,7 @@
|
|||
>
|
||||
<Icon name="DragHandle" size="XL" />
|
||||
<div class="action-header">
|
||||
{index + 1}. {action[EVENT_TYPE_KEY]}
|
||||
{index + 1}. {toDisplay(action[EVENT_TYPE_KEY])}
|
||||
</div>
|
||||
<Icon
|
||||
name="Close"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { Select, Label, Checkbox, Input } from "@budibase/bbui"
|
||||
import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
|
||||
import { tables } from "stores/backend"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
|
||||
|
@ -10,47 +10,59 @@
|
|||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Label>Table</Label>
|
||||
<Select
|
||||
bind:value={parameters.tableId}
|
||||
options={tableOptions}
|
||||
getOptionLabel={table => table.name}
|
||||
getOptionValue={table => table._id}
|
||||
/>
|
||||
|
||||
<Label small>Row ID</Label>
|
||||
<DrawerBindableInput
|
||||
{bindings}
|
||||
title="Row ID to delete"
|
||||
value={parameters.rowId}
|
||||
on:change={value => (parameters.rowId = value.detail)}
|
||||
/>
|
||||
|
||||
<Label small />
|
||||
<Checkbox
|
||||
text="Do not display default notification"
|
||||
bind:value={parameters.notificationOverride}
|
||||
/>
|
||||
<br />
|
||||
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
|
||||
|
||||
{#if parameters.confirm}
|
||||
<Label small>Confirm text</Label>
|
||||
<Input
|
||||
placeholder="Are you sure you want to delete this row?"
|
||||
bind:value={parameters.confirmText}
|
||||
<Body size="small">Please specify one or more rows to delete.</Body>
|
||||
<div class="params">
|
||||
<Label>Table</Label>
|
||||
<Select
|
||||
bind:value={parameters.tableId}
|
||||
options={tableOptions}
|
||||
getOptionLabel={table => table.name}
|
||||
getOptionValue={table => table._id}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Label small>Row IDs</Label>
|
||||
<DrawerBindableInput
|
||||
{bindings}
|
||||
title="Rows to delete"
|
||||
value={parameters.rowId}
|
||||
on:change={value => (parameters.rowId = value.detail)}
|
||||
/>
|
||||
|
||||
<Label small />
|
||||
<Checkbox
|
||||
text="Do not display default notification"
|
||||
bind:value={parameters.notificationOverride}
|
||||
/>
|
||||
<br />
|
||||
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
|
||||
|
||||
{#if parameters.confirm}
|
||||
<Label small>Confirm text</Label>
|
||||
<Input
|
||||
placeholder="Are you sure you want to delete?"
|
||||
bind:value={parameters.confirmText}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.params {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 60px 1fr;
|
||||
align-items: center;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -73,9 +73,12 @@
|
|||
{#if query?.parameters?.length > 0}
|
||||
<div class="params">
|
||||
<BindingBuilder
|
||||
bind:customParams={parameters.queryParams}
|
||||
customParams={parameters.queryParams}
|
||||
queryBindings={query.parameters}
|
||||
bind:bindings
|
||||
on:change={v => {
|
||||
parameters.queryParams = { ...v.detail }
|
||||
}}
|
||||
/>
|
||||
<IntegrationQueryEditor
|
||||
height={200}
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
},
|
||||
{
|
||||
"name": "Delete Row",
|
||||
"displayName": "Delete Rows",
|
||||
"type": "data",
|
||||
"component": "DeleteRow"
|
||||
},
|
||||
|
|
|
@ -143,13 +143,12 @@
|
|||
}
|
||||
|
||||
const openQueryParamsDrawer = () => {
|
||||
tmpQueryParams = value.queryParams
|
||||
tmpQueryParams = { ...value.queryParams }
|
||||
drawer.show()
|
||||
}
|
||||
|
||||
const getQueryValue = queries => {
|
||||
value = queries.find(q => q._id === value._id) || value
|
||||
return value
|
||||
return queries.find(q => q._id === value._id) || value
|
||||
}
|
||||
|
||||
const saveQueryParams = () => {
|
||||
|
@ -176,7 +175,10 @@
|
|||
<Layout noPadding gap="XS">
|
||||
{#if getQueryParams(value).length > 0}
|
||||
<BindingBuilder
|
||||
bind:customParams={tmpQueryParams}
|
||||
customParams={tmpQueryParams}
|
||||
on:change={v => {
|
||||
tmpQueryParams = { ...v.detail }
|
||||
}}
|
||||
queryBindings={getQueryParams(value)}
|
||||
bind:bindings
|
||||
/>
|
||||
|
|
|
@ -13,13 +13,14 @@
|
|||
export let value = []
|
||||
export let componentInstance
|
||||
export let bindings = []
|
||||
export let schema = null
|
||||
|
||||
let drawer
|
||||
|
||||
$: tempValue = value
|
||||
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
|
||||
$: schema = getSchemaForDatasource($currentAsset, datasource)?.schema
|
||||
$: schemaFields = Object.values(schema || {})
|
||||
$: dsSchema = getSchemaForDatasource($currentAsset, datasource)?.schema
|
||||
$: schemaFields = Object.values(schema || dsSchema || {})
|
||||
$: text = getText(value?.filter(filter => filter.field))
|
||||
|
||||
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 type
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let drawer
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
$: text = getText(value)
|
||||
|
||||
const save = () => {
|
||||
dispatch("change", value)
|
||||
drawer.hide()
|
||||
}
|
||||
|
||||
const getText = rules => {
|
||||
if (!rules?.length) {
|
||||
return "No rules set"
|
||||
} else {
|
||||
return `${rules.length} rule${rules.length === 1 ? "" : "s"} set`
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<svelte:fragment slot="description">
|
||||
Configure validation rules for this field.
|
||||
|
@ -31,3 +44,9 @@
|
|||
{componentDefinition}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<style>
|
||||
.validation-editor :global(.spectrum-ActionButton) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
runtimeToReadableBinding,
|
||||
} from "builderStore/dataBinding"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let bindable = true
|
||||
export let queryBindings = []
|
||||
|
@ -20,7 +23,10 @@
|
|||
// The readable binding in the UI gets converted to a UUID value that the client understands
|
||||
// for parsing, then converted back so we can display it the readable form in the UI
|
||||
function onBindingChange(param, valueToParse) {
|
||||
customParams[param] = readableToRuntimeBinding(bindings, valueToParse)
|
||||
dispatch("change", {
|
||||
...customParams,
|
||||
[param]: readableToRuntimeBinding(bindings, valueToParse),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -14,8 +14,9 @@
|
|||
Tab,
|
||||
Modal,
|
||||
ModalContent,
|
||||
notifications,
|
||||
Divider,
|
||||
} from "@budibase/bbui"
|
||||
import { notifications, Divider } from "@budibase/bbui"
|
||||
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
|
||||
import IntegrationQueryEditor from "components/integration/index.svelte"
|
||||
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
|
||||
|
@ -28,6 +29,7 @@
|
|||
import KeyValueBuilder from "./KeyValueBuilder.svelte"
|
||||
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
|
||||
import AccessLevelSelect from "./AccessLevelSelect.svelte"
|
||||
import { ValidQueryNameRegex } from "@budibase/shared-core"
|
||||
|
||||
export let query
|
||||
|
||||
|
@ -47,6 +49,7 @@
|
|||
let saveModal
|
||||
let override = false
|
||||
let navigateTo = null
|
||||
let nameError = null
|
||||
|
||||
// seed the transformer
|
||||
if (query && !query.transformer) {
|
||||
|
@ -77,7 +80,7 @@
|
|||
$: queryConfig = integrationInfo?.query
|
||||
$: shouldShowQueryConfig = queryConfig && query.queryVerb
|
||||
$: readQuery = query.queryVerb === "read" || query.readable
|
||||
$: queryInvalid = !query.name || (readQuery && data.length === 0)
|
||||
$: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
|
||||
|
||||
//Cast field in query preview response to number if specified by schema
|
||||
$: {
|
||||
|
@ -139,9 +142,10 @@
|
|||
queryStr = JSON.stringify(query)
|
||||
}
|
||||
|
||||
notifications.success("Query saved successfully")
|
||||
return response
|
||||
} catch (error) {
|
||||
notifications.error("Error saving query")
|
||||
notifications.error(error.message || "Error saving query")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -183,8 +187,14 @@
|
|||
value={query.name}
|
||||
on:input={e => {
|
||||
let newValue = e.target.value || ""
|
||||
query.name = newValue.trim()
|
||||
if (newValue.match(ValidQueryNameRegex)) {
|
||||
query.name = newValue.trim()
|
||||
nameError = null
|
||||
} else {
|
||||
nameError = "Invalid query name"
|
||||
}
|
||||
}}
|
||||
error={nameError}
|
||||
/>
|
||||
</div>
|
||||
{#if queryConfig}
|
||||
|
@ -250,9 +260,9 @@
|
|||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<Body size="S"
|
||||
>Add a JavaScript function to transform the query result.</Body
|
||||
>
|
||||
<Body size="S">
|
||||
Add a JavaScript function to transform the query result.
|
||||
</Body>
|
||||
<CodeMirrorEditor
|
||||
height={200}
|
||||
label="Transformer"
|
||||
|
@ -264,13 +274,12 @@
|
|||
</div>
|
||||
<div class="viewer-controls">
|
||||
<Heading size="S">Results</Heading>
|
||||
<ButtonGroup gap="XS">
|
||||
<ButtonGroup gap="S">
|
||||
<Button
|
||||
cta
|
||||
disabled={queryInvalid}
|
||||
on:click={async () => {
|
||||
await saveQuery()
|
||||
notifications.success(`Query saved successfully`)
|
||||
// Go to the correct URL if we just created a new query
|
||||
if (!query._rev) {
|
||||
$goto(`../../${query._id}`)
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
Label,
|
||||
ButtonGroup,
|
||||
notifications,
|
||||
CopyInput,
|
||||
File,
|
||||
} from "@budibase/bbui"
|
||||
import { auth, admin } from "stores/portal"
|
||||
import { redirect } from "@roxi/routify"
|
||||
|
@ -21,15 +23,20 @@
|
|||
$: license = $auth.user.license
|
||||
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
||||
|
||||
// LICENSE KEY
|
||||
|
||||
$: activateDisabled = !licenseKey || licenseKeyDisabled
|
||||
|
||||
let licenseInfo
|
||||
|
||||
let licenseKeyDisabled = false
|
||||
let licenseKeyType = "text"
|
||||
let licenseKey = ""
|
||||
let deleteLicenseKeyModal
|
||||
|
||||
// OFFLINE
|
||||
|
||||
let offlineLicenseIdentifier = ""
|
||||
let offlineLicense = undefined
|
||||
const offlineLicenseExtensions = [".txt"]
|
||||
|
||||
// Make sure page can't be visited directly in cloud
|
||||
$: {
|
||||
if ($admin.cloud) {
|
||||
|
@ -37,28 +44,115 @@
|
|||
}
|
||||
}
|
||||
|
||||
const activate = async () => {
|
||||
// LICENSE KEY
|
||||
|
||||
const getLicenseKey = async () => {
|
||||
try {
|
||||
await API.activateLicenseKey({ licenseKey })
|
||||
await auth.getSelf()
|
||||
await setLicenseInfo()
|
||||
notifications.success("Successfully activated")
|
||||
licenseKey = await API.getLicenseKey()
|
||||
if (licenseKey) {
|
||||
licenseKey = "**********************************************"
|
||||
licenseKeyType = "password"
|
||||
licenseKeyDisabled = true
|
||||
activateDisabled = true
|
||||
}
|
||||
} 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 {
|
||||
await API.deleteLicenseKey({ licenseKey })
|
||||
await auth.getSelf()
|
||||
await setLicenseInfo()
|
||||
await getLicenseKey()
|
||||
// reset the form
|
||||
licenseKey = ""
|
||||
licenseKeyDisabled = false
|
||||
notifications.success("Successfully deleted")
|
||||
notifications.success("Offline license removed")
|
||||
} 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 () => {
|
||||
await setLicenseInfo()
|
||||
if ($admin.offlineMode) {
|
||||
await Promise.all([getOfflineLicense(), getOfflineLicenseIdentifier()])
|
||||
} else {
|
||||
await getLicenseKey()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $auth.isAdmin}
|
||||
<DeleteLicenseKeyModal
|
||||
bind:this={deleteLicenseKeyModal}
|
||||
onConfirm={destroy}
|
||||
onConfirm={deleteLicenseKey}
|
||||
/>
|
||||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
|
@ -108,42 +192,82 @@
|
|||
{:else}
|
||||
To manage your plan visit your
|
||||
<Link size="L" href={upgradeUrl}>account</Link>
|
||||
<div> </div>
|
||||
{/if}
|
||||
</Body>
|
||||
</Layout>
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">Activate</Heading>
|
||||
<Body size="S">Enter your license key below to activate your plan</Body>
|
||||
</Layout>
|
||||
<Layout noPadding>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">License key</Label>
|
||||
<Input
|
||||
thin
|
||||
bind:value={licenseKey}
|
||||
type={licenseKeyType}
|
||||
disabled={licenseKeyDisabled}
|
||||
{#if $admin.offlineMode}
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="XS">Installation identifier</Heading>
|
||||
<Body size="S"
|
||||
>Share this with support@budibase.com to obtain your offline license</Body
|
||||
>
|
||||
</Layout>
|
||||
<Layout noPadding>
|
||||
<div class="identifier-input">
|
||||
<CopyInput value={offlineLicenseIdentifier} />
|
||||
</div>
|
||||
</Layout>
|
||||
<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>
|
||||
<ButtonGroup gap="M">
|
||||
<Button cta on:click={activate} disabled={activateDisabled}>
|
||||
Activate
|
||||
</Button>
|
||||
{#if licenseInfo?.licenseKey}
|
||||
<Button warning on:click={() => deleteLicenseKeyModal.show()}>
|
||||
Delete
|
||||
</Layout>
|
||||
{:else}
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="XS">Activate</Heading>
|
||||
<Body size="S">Enter your license key below to activate your plan</Body>
|
||||
</Layout>
|
||||
<Layout noPadding>
|
||||
<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>
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
</Layout>
|
||||
{#if licenseKey}
|
||||
<Button warning on:click={() => deleteLicenseKeyModal.show()}>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
</Layout>
|
||||
{/if}
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">Plan</Heading>
|
||||
<Layout noPadding gap="XXS">
|
||||
<Heading size="XS">Plan</Heading>
|
||||
<Layout noPadding gap="S">
|
||||
<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">
|
||||
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
|
||||
time:
|
||||
|
@ -169,4 +293,7 @@
|
|||
grid-gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
.identifier-input {
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
|
|||
adminUser: { checked: false },
|
||||
sso: { checked: false },
|
||||
},
|
||||
offlineMode: false,
|
||||
}
|
||||
|
||||
export function createAdminStore() {
|
||||
|
|
|
@ -3485,6 +3485,16 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "validation/link",
|
||||
"label": "Validation",
|
||||
"key": "validation"
|
||||
},
|
||||
{
|
||||
"type": "filter/relationship",
|
||||
"label": "Filtering",
|
||||
"key": "filter"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"label": "Autocomplete",
|
||||
|
@ -3496,11 +3506,6 @@
|
|||
"label": "Disabled",
|
||||
"key": "disabled",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"type": "validation/link",
|
||||
"label": "Validation",
|
||||
"key": "validation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||
import { fetchData } from "@budibase/frontend-core"
|
||||
import { getContext } from "svelte"
|
||||
import Field from "./Field.svelte"
|
||||
import { FieldTypes } from "../../../constants"
|
||||
|
@ -14,43 +15,34 @@
|
|||
export let autocomplete = false
|
||||
export let defaultValue
|
||||
export let onChange
|
||||
export let filter
|
||||
|
||||
let fieldState
|
||||
let fieldApi
|
||||
let fieldSchema
|
||||
|
||||
let options = []
|
||||
let tableDefinition
|
||||
|
||||
$: multiselect = fieldSchema?.relationshipType !== "one-to-many"
|
||||
$: linkedTableId = fieldSchema?.tableId
|
||||
$: fetchRows(linkedTableId)
|
||||
$: fetchTable(linkedTableId)
|
||||
$: fetch = fetchData({
|
||||
API,
|
||||
datasource: {
|
||||
type: "table",
|
||||
tableId: linkedTableId,
|
||||
},
|
||||
options: {
|
||||
filter,
|
||||
limit: 100,
|
||||
},
|
||||
})
|
||||
$: fetch.update({ filter })
|
||||
$: options = $fetch.rows
|
||||
$: tableDefinition = $fetch.definition
|
||||
$: singleValue = flatten(fieldState?.value)?.[0]
|
||||
$: multiValue = flatten(fieldState?.value) ?? []
|
||||
$: component = multiselect ? CoreMultiselect : CoreSelect
|
||||
$: 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 => {
|
||||
if (!values) {
|
||||
return []
|
||||
|
|
|
@ -47,6 +47,14 @@
|
|||
)
|
||||
}
|
||||
|
||||
// If the data changes, double check that the selected elements are still present.
|
||||
$: if (data) {
|
||||
let rowIds = data.map(row => row._id)
|
||||
if (rowIds.length) {
|
||||
selectedRows = selectedRows.filter(row => rowIds.includes(row._id))
|
||||
}
|
||||
}
|
||||
|
||||
const getFields = (schema, customColumns, showAutoColumns) => {
|
||||
// Check for an invalid column selection
|
||||
let invalid = false
|
||||
|
|
|
@ -102,12 +102,46 @@ const fetchRowHandler = async action => {
|
|||
}
|
||||
|
||||
const deleteRowHandler = async action => {
|
||||
const { tableId, revId, rowId, notificationOverride } = action.parameters
|
||||
if (tableId && rowId) {
|
||||
const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
|
||||
|
||||
if (tableId && rowConfig) {
|
||||
try {
|
||||
await API.deleteRow({ tableId, rowId, revId })
|
||||
let requestConfig
|
||||
|
||||
let parsedRowConfig = []
|
||||
if (typeof rowConfig === "string") {
|
||||
try {
|
||||
parsedRowConfig = JSON.parse(rowConfig)
|
||||
} catch (e) {
|
||||
parsedRowConfig = rowConfig
|
||||
.split(",")
|
||||
.map(id => id.trim())
|
||||
.filter(id => id)
|
||||
}
|
||||
} else {
|
||||
parsedRowConfig = rowConfig
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsedRowConfig === "object" &&
|
||||
parsedRowConfig.constructor === Object
|
||||
) {
|
||||
requestConfig = [parsedRowConfig]
|
||||
} else if (Array.isArray(parsedRowConfig)) {
|
||||
requestConfig = parsedRowConfig
|
||||
}
|
||||
|
||||
if (!requestConfig.length) {
|
||||
notificationStore.actions.warning("No valid rows were supplied")
|
||||
return false
|
||||
}
|
||||
|
||||
const resp = await API.deleteRows({ tableId, rows: requestConfig })
|
||||
|
||||
if (!notificationOverride) {
|
||||
notificationStore.actions.success("Row deleted")
|
||||
notificationStore.actions.success(
|
||||
resp?.length == 1 ? "Row deleted" : `${resp.length} Rows deleted`
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh related datasources
|
||||
|
@ -115,8 +149,10 @@ const deleteRowHandler = async action => {
|
|||
invalidateRelationships: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// Abort next actions
|
||||
return false
|
||||
console.error(error)
|
||||
notificationStore.actions.error(
|
||||
"An error occurred while executing the query"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,58 @@
|
|||
export const buildLicensingEndpoints = API => ({
|
||||
/**
|
||||
* Activates a self hosted license key
|
||||
*/
|
||||
// LICENSE KEY
|
||||
|
||||
activateLicenseKey: async data => {
|
||||
return API.post({
|
||||
url: `/api/global/license/activate`,
|
||||
url: `/api/global/license/key`,
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a self hosted license key
|
||||
*/
|
||||
deleteLicenseKey: async () => {
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the license info - metadata about the license including the
|
||||
* obfuscated license key.
|
||||
*/
|
||||
getLicenseInfo: async () => {
|
||||
return API.get({
|
||||
url: "/api/global/license/info",
|
||||
// OFFLINE LICENSE
|
||||
|
||||
activateOfflineLicense: async ({ offlineLicenseToken }) => {
|
||||
return API.post({
|
||||
url: "/api/global/license/offline",
|
||||
body: {
|
||||
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",
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the usage information for the tenant
|
||||
*/
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 4d9840700e7684581c39965b7cb6a2b2428c477c
|
||||
Subproject commit a60183319f410d05aaa1c2f2718b772978b54d64
|
|
@ -184,6 +184,16 @@
|
|||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"dev:builder": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@budibase/backend-core"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": [
|
||||
{
|
||||
|
|
|
@ -841,7 +841,8 @@
|
|||
"auto",
|
||||
"json",
|
||||
"internal",
|
||||
"barcodeqr"
|
||||
"barcodeqr",
|
||||
"bigint"
|
||||
],
|
||||
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
||||
},
|
||||
|
@ -1045,7 +1046,8 @@
|
|||
"auto",
|
||||
"json",
|
||||
"internal",
|
||||
"barcodeqr"
|
||||
"barcodeqr",
|
||||
"bigint"
|
||||
],
|
||||
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
||||
},
|
||||
|
@ -1260,7 +1262,8 @@
|
|||
"auto",
|
||||
"json",
|
||||
"internal",
|
||||
"barcodeqr"
|
||||
"barcodeqr",
|
||||
"bigint"
|
||||
],
|
||||
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
|
||||
},
|
||||
|
|
|
@ -768,6 +768,7 @@ components:
|
|||
- json
|
||||
- internal
|
||||
- barcodeqr
|
||||
- bigint
|
||||
description: Defines the type of the column, most explain themselves, a link
|
||||
column is a relationship.
|
||||
constraints:
|
||||
|
@ -931,6 +932,7 @@ components:
|
|||
- json
|
||||
- internal
|
||||
- barcodeqr
|
||||
- bigint
|
||||
description: Defines the type of the column, most explain themselves, a link
|
||||
column is a relationship.
|
||||
constraints:
|
||||
|
@ -1101,6 +1103,7 @@ components:
|
|||
- json
|
||||
- internal
|
||||
- barcodeqr
|
||||
- bigint
|
||||
description: Defines the type of the column, most explain themselves, a link
|
||||
column is a relationship.
|
||||
constraints:
|
||||
|
|
|
@ -5,7 +5,7 @@ import { convertBookmark } from "../../../utilities"
|
|||
|
||||
// makes sure that the user doesn't need to pass in the type, tableId or _id params for
|
||||
// the call to be correct
|
||||
function fixRow(row: Row, params: any) {
|
||||
export function fixRow(row: Row, params: any) {
|
||||
if (!params || !row) {
|
||||
return row
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { events, context, utils, constants } from "@budibase/backend-core"
|
|||
import sdk from "../../../sdk"
|
||||
import { QueryEvent } from "../../../threads/definitions"
|
||||
import { Query } from "@budibase/types"
|
||||
import { ValidQueryNameRegex } from "@budibase/shared-core"
|
||||
|
||||
const Runner = new Thread(ThreadType.QUERY, {
|
||||
timeoutMs: env.QUERY_THREAD_TIMEOUT || 10000,
|
||||
|
@ -76,6 +77,11 @@ export async function save(ctx: any) {
|
|||
const db = context.getAppDB()
|
||||
const query = ctx.request.body
|
||||
|
||||
// Validate query name
|
||||
if (!query?.name.match(ValidQueryNameRegex)) {
|
||||
ctx.throw(400, "Invalid query name")
|
||||
}
|
||||
|
||||
const datasource = await sdk.datasources.get(query.datasourceId)
|
||||
|
||||
let eventFn
|
||||
|
|
|
@ -73,9 +73,11 @@ export async function patch(ctx: UserCtx) {
|
|||
row: inputs,
|
||||
})
|
||||
const row = await getRow(tableId, id, { relationships: true })
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
return {
|
||||
...response,
|
||||
row,
|
||||
table,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,9 +2,22 @@ import { quotas } from "@budibase/pro"
|
|||
import * as internal from "./internal"
|
||||
import * as external from "./external"
|
||||
import { isExternalTable } from "../../../integrations/utils"
|
||||
import { Ctx } from "@budibase/types"
|
||||
import {
|
||||
Ctx,
|
||||
UserCtx,
|
||||
DeleteRowRequest,
|
||||
DeleteRow,
|
||||
DeleteRows,
|
||||
Row,
|
||||
SearchResponse,
|
||||
SortOrder,
|
||||
SortType,
|
||||
ViewV2,
|
||||
} from "@budibase/types"
|
||||
import * as utils from "./utils"
|
||||
import { gridSocket } from "../../../websockets"
|
||||
import { addRev } from "../public/utils"
|
||||
import { fixRow } from "../public/rows"
|
||||
import sdk from "../../../sdk"
|
||||
import * as exporters from "../view/exporters"
|
||||
import { apiFileReturn } from "../../../utilities/fileSystem"
|
||||
|
@ -98,35 +111,83 @@ export async function find(ctx: any) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function destroy(ctx: any) {
|
||||
const appId = ctx.appId
|
||||
const inputs = ctx.request.body
|
||||
function isDeleteRows(input: any): input is DeleteRows {
|
||||
return input.rows !== undefined && Array.isArray(input.rows)
|
||||
}
|
||||
|
||||
function isDeleteRow(input: any): input is DeleteRow {
|
||||
return input._id !== undefined
|
||||
}
|
||||
|
||||
async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
|
||||
let request = ctx.request.body as DeleteRows
|
||||
const tableId = utils.getTableId(ctx)
|
||||
let response, row
|
||||
if (inputs.rows) {
|
||||
let { rows } = await quotas.addQuery<any>(
|
||||
() => pickApi(tableId).bulkDestroy(ctx),
|
||||
{
|
||||
datasourceId: tableId,
|
||||
}
|
||||
)
|
||||
await quotas.removeRows(rows.length)
|
||||
response = rows
|
||||
for (let row of rows) {
|
||||
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
|
||||
gridSocket?.emitRowDeletion(ctx, row._id)
|
||||
}
|
||||
} else {
|
||||
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), {
|
||||
|
||||
const processedRows = request.rows.map(row => {
|
||||
let processedRow: Row = typeof row == "string" ? { _id: row } : row
|
||||
return !processedRow._rev
|
||||
? addRev(fixRow(processedRow, ctx.params), tableId)
|
||||
: fixRow(processedRow, ctx.params)
|
||||
})
|
||||
|
||||
return await Promise.all(processedRows)
|
||||
}
|
||||
|
||||
async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
|
||||
const tableId = utils.getTableId(ctx)
|
||||
const appId = ctx.appId
|
||||
|
||||
let deleteRequest = ctx.request.body as DeleteRows
|
||||
|
||||
const rowDeletes: Row[] = await processDeleteRowsRequest(ctx)
|
||||
deleteRequest.rows = rowDeletes
|
||||
|
||||
let { rows } = await quotas.addQuery<any>(
|
||||
() => pickApi(tableId).bulkDestroy(ctx),
|
||||
{
|
||||
datasourceId: tableId,
|
||||
})
|
||||
await quotas.removeRow()
|
||||
response = resp.response
|
||||
row = resp.row
|
||||
}
|
||||
)
|
||||
await quotas.removeRows(rows.length)
|
||||
|
||||
for (let row of rows) {
|
||||
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
|
||||
gridSocket?.emitRowDeletion(ctx, row._id)
|
||||
gridSocket?.emitRowDeletion(ctx, row._id!)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
|
||||
const appId = ctx.appId
|
||||
const tableId = utils.getTableId(ctx)
|
||||
|
||||
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), {
|
||||
datasourceId: tableId,
|
||||
})
|
||||
await quotas.removeRow()
|
||||
|
||||
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
|
||||
gridSocket?.emitRowDeletion(ctx, resp.row._id)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
|
||||
let response, row
|
||||
ctx.status = 200
|
||||
|
||||
if (isDeleteRows(ctx.request.body)) {
|
||||
response = await deleteRows(ctx)
|
||||
} else if (isDeleteRow(ctx.request.body)) {
|
||||
const deleteResp = await deleteRow(ctx)
|
||||
response = deleteResp.response
|
||||
row = deleteResp.row
|
||||
} else {
|
||||
ctx.status = 400
|
||||
response = { message: "Invalid delete rows request" }
|
||||
}
|
||||
|
||||
// for automations include the row that was deleted
|
||||
ctx.row = row || {}
|
||||
ctx.body = response
|
||||
|
@ -146,6 +207,80 @@ export async function search(ctx: any) {
|
|||
})
|
||||
}
|
||||
|
||||
function getSortOptions(
|
||||
ctx: Ctx,
|
||||
view: ViewV2
|
||||
):
|
||||
| {
|
||||
sort: string
|
||||
sortOrder?: SortOrder
|
||||
sortType?: SortType
|
||||
}
|
||||
| undefined {
|
||||
const { sort_column, sort_order, sort_type } = ctx.query
|
||||
if (Array.isArray(sort_column)) {
|
||||
ctx.throw(400, "sort_column cannot be an array")
|
||||
}
|
||||
if (Array.isArray(sort_order)) {
|
||||
ctx.throw(400, "sort_order cannot be an array")
|
||||
}
|
||||
if (Array.isArray(sort_type)) {
|
||||
ctx.throw(400, "sort_type cannot be an array")
|
||||
}
|
||||
|
||||
if (sort_column) {
|
||||
return {
|
||||
sort: sort_column,
|
||||
sortOrder: sort_order as SortOrder,
|
||||
sortType: sort_type as SortType,
|
||||
}
|
||||
}
|
||||
if (view.sort) {
|
||||
return {
|
||||
sort: view.sort.field,
|
||||
sortOrder: view.sort.order,
|
||||
sortType: view.sort.type,
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
export async function searchView(ctx: Ctx<void, SearchResponse>) {
|
||||
const { viewId } = ctx.params
|
||||
|
||||
const view = await sdk.views.get(viewId)
|
||||
if (!view) {
|
||||
ctx.throw(404, `View ${viewId} not found`)
|
||||
}
|
||||
|
||||
if (view.version !== 2) {
|
||||
ctx.throw(400, `This method only supports viewsV2`)
|
||||
}
|
||||
|
||||
const table = await sdk.tables.getTable(view?.tableId)
|
||||
|
||||
const viewFields =
|
||||
(view.columns &&
|
||||
Object.entries(view.columns).length &&
|
||||
Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) ||
|
||||
undefined
|
||||
|
||||
ctx.status = 200
|
||||
ctx.body = await quotas.addQuery(
|
||||
() =>
|
||||
sdk.rows.search({
|
||||
tableId: view.tableId,
|
||||
query: view.query || {},
|
||||
fields: viewFields,
|
||||
...getSortOptions(ctx, view),
|
||||
}),
|
||||
{
|
||||
datasourceId: view.tableId,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function validate(ctx: Ctx) {
|
||||
const tableId = utils.getTableId(ctx)
|
||||
// external tables are hard to validate currently
|
||||
|
|
|
@ -87,7 +87,7 @@ export async function patch(ctx: UserCtx) {
|
|||
// the row has been updated, need to put it into the ctx
|
||||
ctx.request.body = row
|
||||
await userController.updateMetadata(ctx)
|
||||
return { row: ctx.body, table }
|
||||
return { row: ctx.body as Row, table }
|
||||
}
|
||||
|
||||
return finaliseRow(table, row, {
|
||||
|
|
|
@ -6,9 +6,13 @@ import {
|
|||
isRows,
|
||||
} from "../../../utilities/schema"
|
||||
import { isExternalTable, isSQL } from "../../../integrations/utils"
|
||||
import { getDatasourceParams } from "../../../db/utils"
|
||||
import { context, events } from "@budibase/backend-core"
|
||||
import { Table, UserCtx } from "@budibase/types"
|
||||
import { events } from "@budibase/backend-core"
|
||||
import {
|
||||
FetchTablesResponse,
|
||||
Table,
|
||||
TableResponse,
|
||||
UserCtx,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../../../sdk"
|
||||
import { jsonFromCsvString } from "../../../utilities/csv"
|
||||
import { builderSocket } from "../../../websockets"
|
||||
|
@ -26,37 +30,34 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
|||
}
|
||||
|
||||
// covers both internal and external
|
||||
export async function fetch(ctx: UserCtx) {
|
||||
const db = context.getAppDB()
|
||||
|
||||
export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
|
||||
const internal = await sdk.tables.getAllInternalTables()
|
||||
|
||||
const externalTables = await db.allDocs(
|
||||
getDatasourceParams("plus", {
|
||||
include_docs: true,
|
||||
})
|
||||
)
|
||||
const externalTables = await sdk.datasources.getExternalDatasources()
|
||||
|
||||
const external = externalTables.rows.flatMap(tableDoc => {
|
||||
let entities = tableDoc.doc.entities
|
||||
const external = externalTables.flatMap(table => {
|
||||
let entities = table.entities
|
||||
if (entities) {
|
||||
return Object.values(entities).map((entity: any) => ({
|
||||
return Object.values(entities).map<Table>((entity: Table) => ({
|
||||
...entity,
|
||||
type: "external",
|
||||
sourceId: tableDoc.doc._id,
|
||||
sql: isSQL(tableDoc.doc),
|
||||
sourceId: table._id,
|
||||
sql: isSQL(table),
|
||||
}))
|
||||
} else {
|
||||
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
|
||||
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) {
|
||||
|
|
|
@ -1,195 +1,2 @@
|
|||
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], 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)
|
||||
}
|
||||
}
|
||||
export * as v1 from "./views"
|
||||
export * as v2 from "./viewsV2"
|
||||
|
|
|
@ -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,44 @@
|
|||
import sdk from "../../../sdk"
|
||||
import {
|
||||
CreateViewRequest,
|
||||
Ctx,
|
||||
UpdateViewRequest,
|
||||
ViewResponse,
|
||||
} from "@budibase/types"
|
||||
|
||||
export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
|
||||
const view = ctx.request.body
|
||||
const { tableId } = view
|
||||
|
||||
const result = await sdk.views.create(tableId, view)
|
||||
ctx.status = 201
|
||||
ctx.body = {
|
||||
data: result,
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
|
||||
const view = ctx.request.body
|
||||
|
||||
if (view.version !== 2) {
|
||||
ctx.throw(400, "Only views V2 can be updated")
|
||||
}
|
||||
|
||||
if (ctx.params.viewId !== view.id) {
|
||||
ctx.throw(400, "View id does not match between the body and the uri path")
|
||||
}
|
||||
|
||||
const { tableId } = view
|
||||
|
||||
const result = await sdk.views.update(tableId, view)
|
||||
ctx.body = {
|
||||
data: result,
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(ctx: Ctx) {
|
||||
const { viewId } = ctx.params
|
||||
|
||||
await sdk.views.remove(viewId)
|
||||
ctx.status = 204
|
||||
}
|
|
@ -146,6 +146,11 @@ router
|
|||
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
||||
rowController.search
|
||||
)
|
||||
.get(
|
||||
"/api/v2/views/:viewId/search",
|
||||
authorized(PermissionType.VIEW, PermissionLevel.READ),
|
||||
rowController.searchView
|
||||
)
|
||||
/**
|
||||
* @api {post} /api/:tableId/rows Creates a new row
|
||||
* @apiName Creates a new row
|
||||
|
|
|
@ -5,7 +5,7 @@ tk.freeze(timestamp)
|
|||
import { outputProcessing } from "../../../utilities/rowProcessor"
|
||||
import * as setup from "./utilities"
|
||||
const { basicRow } = setup.structures
|
||||
import { context, tenancy } from "@budibase/backend-core"
|
||||
import { context, db, tenancy } from "@budibase/backend-core"
|
||||
import { quotas } from "@budibase/pro"
|
||||
import {
|
||||
QuotaUsageType,
|
||||
|
@ -14,8 +14,14 @@ import {
|
|||
Row,
|
||||
Table,
|
||||
FieldType,
|
||||
SortType,
|
||||
SortOrder,
|
||||
} from "@budibase/types"
|
||||
import { structures } from "@budibase/backend-core/tests"
|
||||
import {
|
||||
expectAnyInternalColsAttributes,
|
||||
generator,
|
||||
structures,
|
||||
} from "@budibase/backend-core/tests"
|
||||
|
||||
describe("/rows", () => {
|
||||
let request = setup.getRequest()
|
||||
|
@ -517,6 +523,81 @@ describe("/rows", () => {
|
|||
await assertRowUsage(rowUsage - 2)
|
||||
await assertQueryUsage(queryUsage + 1)
|
||||
})
|
||||
|
||||
it("should be able to delete a variety of row set types", async () => {
|
||||
const row1 = await config.createRow()
|
||||
const row2 = await config.createRow()
|
||||
const row3 = await config.createRow()
|
||||
const rowUsage = await getRowUsage()
|
||||
const queryUsage = await getQueryUsage()
|
||||
|
||||
const res = await request
|
||||
.delete(`/api/${table._id}/rows`)
|
||||
.send({
|
||||
rows: [row1, row2._id, { _id: row3._id }],
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.length).toEqual(3)
|
||||
await loadRow(row1._id!, table._id!, 404)
|
||||
await assertRowUsage(rowUsage - 3)
|
||||
await assertQueryUsage(queryUsage + 1)
|
||||
})
|
||||
|
||||
it("should accept a valid row object and delete the row", async () => {
|
||||
const row1 = await config.createRow()
|
||||
const rowUsage = await getRowUsage()
|
||||
const queryUsage = await getQueryUsage()
|
||||
|
||||
const res = await request
|
||||
.delete(`/api/${table._id}/rows`)
|
||||
.send(row1)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.id).toEqual(row1._id)
|
||||
await loadRow(row1._id!, table._id!, 404)
|
||||
await assertRowUsage(rowUsage - 1)
|
||||
await assertQueryUsage(queryUsage + 1)
|
||||
})
|
||||
|
||||
it("Should ignore malformed/invalid delete requests", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
const queryUsage = await getQueryUsage()
|
||||
|
||||
const res = await request
|
||||
.delete(`/api/${table._id}/rows`)
|
||||
.send({ not: "valid" })
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.message).toEqual("Invalid delete rows request")
|
||||
|
||||
const res2 = await request
|
||||
.delete(`/api/${table._id}/rows`)
|
||||
.send({ rows: 123 })
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
|
||||
expect(res2.body.message).toEqual("Invalid delete rows request")
|
||||
|
||||
const res3 = await request
|
||||
.delete(`/api/${table._id}/rows`)
|
||||
.send("invalid")
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
|
||||
expect(res3.body.message).toEqual("Invalid delete rows request")
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
await assertQueryUsage(queryUsage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchView", () => {
|
||||
|
@ -685,4 +766,249 @@ describe("/rows", () => {
|
|||
expect(row._id).toEqual(existing._id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("view search", () => {
|
||||
function userTable(): Table {
|
||||
return {
|
||||
name: "user",
|
||||
type: "user",
|
||||
schema: {
|
||||
name: {
|
||||
type: FieldType.STRING,
|
||||
name: "name",
|
||||
constraints: { type: "string" },
|
||||
},
|
||||
age: {
|
||||
type: FieldType.NUMBER,
|
||||
name: "age",
|
||||
constraints: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it("returns table rows from view", async () => {
|
||||
const table = await config.createTable(userTable())
|
||||
const rows = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rows.push(await config.createRow({ tableId: table._id }))
|
||||
}
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create()
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
|
||||
expect(response.body.rows).toHaveLength(10)
|
||||
expect(response.body).toEqual({
|
||||
rows: expect.arrayContaining(rows.map(expect.objectContaining)),
|
||||
})
|
||||
})
|
||||
|
||||
it("searching respects the view filters", async () => {
|
||||
const table = await config.createTable(userTable())
|
||||
const expectedRows = []
|
||||
for (let i = 0; i < 10; i++)
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
name: generator.name(),
|
||||
age: generator.integer({ min: 10, max: 30 }),
|
||||
})
|
||||
|
||||
for (let i = 0; i < 5; i++)
|
||||
expectedRows.push(
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
name: generator.name(),
|
||||
age: 40,
|
||||
})
|
||||
)
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
query: { equal: { age: 40 } },
|
||||
})
|
||||
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
|
||||
expect(response.body.rows).toHaveLength(5)
|
||||
expect(response.body).toEqual({
|
||||
rows: expect.arrayContaining(expectedRows.map(expect.objectContaining)),
|
||||
})
|
||||
})
|
||||
|
||||
const sortTestOptions: [
|
||||
{
|
||||
field: string
|
||||
order?: SortOrder
|
||||
type?: SortType
|
||||
},
|
||||
string[]
|
||||
][] = [
|
||||
[
|
||||
{
|
||||
field: "name",
|
||||
order: SortOrder.ASCENDING,
|
||||
type: SortType.STRING,
|
||||
},
|
||||
["Alice", "Bob", "Charly", "Danny"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "name",
|
||||
},
|
||||
["Alice", "Bob", "Charly", "Danny"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "name",
|
||||
order: SortOrder.DESCENDING,
|
||||
},
|
||||
["Danny", "Charly", "Bob", "Alice"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "name",
|
||||
order: SortOrder.DESCENDING,
|
||||
type: SortType.STRING,
|
||||
},
|
||||
["Danny", "Charly", "Bob", "Alice"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "age",
|
||||
order: SortOrder.ASCENDING,
|
||||
type: SortType.number,
|
||||
},
|
||||
["Danny", "Alice", "Charly", "Bob"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "age",
|
||||
order: SortOrder.ASCENDING,
|
||||
},
|
||||
["Danny", "Alice", "Charly", "Bob"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "age",
|
||||
order: SortOrder.DESCENDING,
|
||||
},
|
||||
["Bob", "Charly", "Alice", "Danny"],
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "age",
|
||||
order: SortOrder.DESCENDING,
|
||||
type: SortType.number,
|
||||
},
|
||||
["Bob", "Charly", "Alice", "Danny"],
|
||||
],
|
||||
]
|
||||
|
||||
it.each(sortTestOptions)(
|
||||
"allow sorting (%s)",
|
||||
async (sortParams, expected) => {
|
||||
await config.createTable(userTable())
|
||||
const users = [
|
||||
{ name: "Alice", age: 25 },
|
||||
{ name: "Bob", age: 30 },
|
||||
{ name: "Charly", age: 27 },
|
||||
{ name: "Danny", age: 15 },
|
||||
]
|
||||
for (const user of users) {
|
||||
await config.createRow({
|
||||
tableId: config.table!._id,
|
||||
...user,
|
||||
})
|
||||
}
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
sort: sortParams,
|
||||
})
|
||||
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
|
||||
expect(response.body.rows).toHaveLength(4)
|
||||
expect(response.body).toEqual({
|
||||
rows: expected.map(name => expect.objectContaining({ name })),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(sortTestOptions)(
|
||||
"allow override the default view sorting (%s)",
|
||||
async (sortParams, expected) => {
|
||||
await config.createTable(userTable())
|
||||
const users = [
|
||||
{ name: "Alice", age: 25 },
|
||||
{ name: "Bob", age: 30 },
|
||||
{ name: "Charly", age: 27 },
|
||||
{ name: "Danny", age: 15 },
|
||||
]
|
||||
for (const user of users) {
|
||||
await config.createRow({
|
||||
tableId: config.table!._id,
|
||||
...user,
|
||||
})
|
||||
}
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
sort: {
|
||||
field: "name",
|
||||
order: SortOrder.ASCENDING,
|
||||
type: SortType.STRING,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await config.api.viewV2.search(createViewResponse.id, {
|
||||
sort: {
|
||||
column: sortParams.field,
|
||||
order: sortParams.order,
|
||||
type: sortParams.type,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.body.rows).toHaveLength(4)
|
||||
expect(response.body).toEqual({
|
||||
rows: expected.map(name => expect.objectContaining({ name })),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it("when schema is defined, defined columns and row attributes are returned", async () => {
|
||||
const table = await config.createTable(userTable())
|
||||
const rows = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rows.push(
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
name: generator.name(),
|
||||
age: generator.age(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
columns: { name: { visible: true } },
|
||||
})
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
|
||||
expect(response.body.rows).toHaveLength(10)
|
||||
expect(response.body.rows).toEqual(
|
||||
expect.arrayContaining(
|
||||
rows.map(r => ({
|
||||
...expectAnyInternalColsAttributes,
|
||||
name: r.name,
|
||||
}))
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("views without data can be returned", async () => {
|
||||
const table = await config.createTable(userTable())
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create()
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
|
||||
expect(response.body.rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
|
||||
const setup = require("./utilities")
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
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 { events, context } = require("@budibase/backend-core")
|
||||
import sdk from "../../../sdk"
|
||||
|
||||
describe("/tables", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
let appId
|
||||
let appId: string
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
|
@ -16,12 +19,11 @@ describe("/tables", () => {
|
|||
})
|
||||
|
||||
describe("create", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
const createTable = (table) => {
|
||||
const createTable = (table?: Table) => {
|
||||
if (!table) {
|
||||
table = basicTable()
|
||||
}
|
||||
|
@ -29,15 +31,16 @@ describe("/tables", () => {
|
|||
.post(`/api/tables`)
|
||||
.send(table)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
}
|
||||
|
||||
it("returns a success message when the table is successfully created", async () => {
|
||||
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(events.table.created).toBeCalledTimes(1)
|
||||
expect(events.table.created).toBeCalledWith(res.body)
|
||||
|
@ -45,7 +48,7 @@ describe("/tables", () => {
|
|||
|
||||
it("creates a table via data import", async () => {
|
||||
const table = basicTable()
|
||||
table.rows = [{ name: 'test-name', description: 'test-desc' }]
|
||||
table.rows = [{ name: "test-name", description: "test-desc" }]
|
||||
|
||||
const res = await createTable(table)
|
||||
|
||||
|
@ -62,7 +65,7 @@ describe("/tables", () => {
|
|||
config,
|
||||
method: "POST",
|
||||
url: `/api/tables`,
|
||||
body: basicTable()
|
||||
body: basicTable(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -75,7 +78,7 @@ describe("/tables", () => {
|
|||
.post(`/api/tables`)
|
||||
.send(testTable)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(events.table.updated).toBeCalledTimes(1)
|
||||
|
@ -94,10 +97,10 @@ describe("/tables", () => {
|
|||
const testRow = await request
|
||||
.post(`/api/${testTable._id}/rows`)
|
||||
.send({
|
||||
name: "test"
|
||||
name: "test",
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
const updatedTable = await request
|
||||
|
@ -109,22 +112,24 @@ describe("/tables", () => {
|
|||
key: "name",
|
||||
_rename: {
|
||||
old: "name",
|
||||
updated: "updatedName"
|
||||
updated: "updatedName",
|
||||
},
|
||||
schema: {
|
||||
updatedName: { type: "string" }
|
||||
}
|
||||
updatedName: { type: "string" },
|
||||
},
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.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")
|
||||
|
||||
const res = await request
|
||||
.get(`/api/${testTable._id}/rows/${testRow.body._id}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.updatedName).toEqual("test")
|
||||
|
@ -140,7 +145,7 @@ describe("/tables", () => {
|
|||
_id: "ta_users",
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.schema.email).toBeDefined()
|
||||
expect(res.body.schema.roleId).toBeDefined()
|
||||
|
@ -153,7 +158,7 @@ describe("/tables", () => {
|
|||
const table = await config.createTable()
|
||||
const importRequest = {
|
||||
schema: table.schema,
|
||||
rows: [{ name: 'test-name', description: 'test-desc' }]
|
||||
rows: [{ name: "test-name", description: "test-desc" }],
|
||||
}
|
||||
|
||||
jest.clearAllMocks()
|
||||
|
@ -162,20 +167,23 @@ describe("/tables", () => {
|
|||
.post(`/api/tables/${table._id}/import`)
|
||||
.send(importRequest)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(events.table.created).not.toHaveBeenCalled()
|
||||
expect(events.rows.imported).toBeCalledTimes(1)
|
||||
expect(events.rows.imported).toBeCalledWith(expect.objectContaining({
|
||||
name: "TestTable",
|
||||
_id: table._id
|
||||
}), 1)
|
||||
expect(events.rows.imported).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "TestTable",
|
||||
_id: table._id,
|
||||
}),
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
let testTable
|
||||
let testTable: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
testTable = await config.createTable(testTable)
|
||||
|
@ -189,7 +197,7 @@ describe("/tables", () => {
|
|||
const res = await request
|
||||
.get(`/api/tables`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
const fetchedTable = res.body[0]
|
||||
expect(fetchedTable.name).toEqual(testTable.name)
|
||||
|
@ -203,6 +211,70 @@ describe("/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", () => {
|
||||
|
@ -216,7 +288,7 @@ describe("/tables", () => {
|
|||
.post(`/api/tables`)
|
||||
.send(table)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body._id).toBeDefined()
|
||||
expect(res.body._rev).toBeDefined()
|
||||
|
@ -231,7 +303,7 @@ describe("/tables", () => {
|
|||
_rev: res.body._rev,
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
// shouldn't have created a new index
|
||||
expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1)
|
||||
|
@ -240,7 +312,7 @@ describe("/tables", () => {
|
|||
})
|
||||
|
||||
describe("destroy", () => {
|
||||
let testTable
|
||||
let testTable: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
testTable = await config.createTable(testTable)
|
||||
|
@ -254,40 +326,44 @@ describe("/tables", () => {
|
|||
const res = await request
|
||||
.delete(`/api/tables/${testTable._id}/${testTable._rev}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
|
||||
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 () => {
|
||||
const linkedTable = await config.createTable({
|
||||
name: "LinkedTable",
|
||||
type: "table",
|
||||
key: "name",
|
||||
schema: {
|
||||
name: {
|
||||
type: "string",
|
||||
type: FieldType.STRING,
|
||||
name: "name",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
TestTable: {
|
||||
type: "link",
|
||||
type: FieldType.LINK,
|
||||
name: "TestTable",
|
||||
fieldName: "TestTable",
|
||||
tableId: testTable._id,
|
||||
constraints: {
|
||||
type: "array"
|
||||
}
|
||||
}
|
||||
type: "array",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const res = await request
|
||||
.delete(`/api/tables/${testTable._id}/${testTable._rev}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
|
||||
const dependentTable = await config.getTable(linkedTable._id)
|
|
@ -0,0 +1,227 @@
|
|||
import * as setup from "./utilities"
|
||||
import {
|
||||
CreateViewRequest,
|
||||
FieldType,
|
||||
SortOrder,
|
||||
SortType,
|
||||
Table,
|
||||
ViewV2,
|
||||
} from "@budibase/types"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
|
||||
function priceTable(): Table {
|
||||
return {
|
||||
name: "table",
|
||||
type: "table",
|
||||
schema: {
|
||||
Price: {
|
||||
type: FieldType.NUMBER,
|
||||
name: "Price",
|
||||
constraints: {},
|
||||
},
|
||||
Category: {
|
||||
type: FieldType.STRING,
|
||||
name: "Category",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("/v2/views", () => {
|
||||
const config = setup.getConfig()
|
||||
|
||||
const viewFilters: Omit<CreateViewRequest, "name" | "tableId"> = {
|
||||
query: { allOr: false, equal: { field: "value" } },
|
||||
sort: {
|
||||
field: "fieldToSort",
|
||||
order: SortOrder.DESCENDING,
|
||||
type: SortType.STRING,
|
||||
},
|
||||
columns: {
|
||||
name: {
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
await config.createTable(priceTable())
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("persist the view when the view is successfully created", async () => {
|
||||
const newView: CreateViewRequest = {
|
||||
name: generator.name(),
|
||||
tableId: config.table!._id!,
|
||||
}
|
||||
const res = await config.api.viewV2.create(newView)
|
||||
|
||||
expect(res).toEqual({
|
||||
...newView,
|
||||
id: expect.stringMatching(new RegExp(`${config.table?._id!}_`)),
|
||||
version: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("can persist views with queries", async () => {
|
||||
const newView: CreateViewRequest = {
|
||||
name: generator.name(),
|
||||
tableId: config.table!._id!,
|
||||
...viewFilters,
|
||||
}
|
||||
const res = await config.api.viewV2.create(newView)
|
||||
|
||||
expect(res).toEqual({
|
||||
...newView,
|
||||
...viewFilters,
|
||||
id: expect.any(String),
|
||||
version: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
let view: ViewV2
|
||||
|
||||
beforeEach(async () => {
|
||||
await config.createTable(priceTable())
|
||||
view = await config.api.viewV2.create({ name: "View A" })
|
||||
})
|
||||
|
||||
it("can update an existing view data", async () => {
|
||||
const tableId = config.table!._id!
|
||||
await config.api.viewV2.update({
|
||||
...view,
|
||||
query: { equal: { newField: "thatValue" } },
|
||||
})
|
||||
|
||||
expect(await config.api.table.get(tableId)).toEqual({
|
||||
...config.table,
|
||||
views: {
|
||||
[view.name]: {
|
||||
...view,
|
||||
query: { equal: { newField: "thatValue" } },
|
||||
schema: expect.anything(),
|
||||
},
|
||||
},
|
||||
_rev: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
it("can update an existing view name", async () => {
|
||||
const tableId = config.table!._id!
|
||||
await config.api.viewV2.update({ ...view, name: "View B" })
|
||||
|
||||
expect(await config.api.table.get(tableId)).toEqual(
|
||||
expect.objectContaining({
|
||||
views: {
|
||||
"View B": { ...view, name: "View B", schema: expect.anything() },
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("cannot update an unexisting views nor edit ids", async () => {
|
||||
const tableId = config.table!._id!
|
||||
await config.api.viewV2.update(
|
||||
{ ...view, id: generator.guid() },
|
||||
{ expectStatus: 404 }
|
||||
)
|
||||
|
||||
expect(await config.api.table.get(tableId)).toEqual(
|
||||
expect.objectContaining({
|
||||
views: {
|
||||
[view.name]: {
|
||||
...view,
|
||||
schema: expect.anything(),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("cannot update views with the wrong tableId", async () => {
|
||||
const tableId = config.table!._id!
|
||||
await config.api.viewV2.update(
|
||||
{
|
||||
...view,
|
||||
tableId: generator.guid(),
|
||||
query: { equal: { newField: "thatValue" } },
|
||||
},
|
||||
{ expectStatus: 404 }
|
||||
)
|
||||
|
||||
expect(await config.api.table.get(tableId)).toEqual(
|
||||
expect.objectContaining({
|
||||
views: {
|
||||
[view.name]: {
|
||||
...view,
|
||||
schema: expect.anything(),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("cannot update views v1", async () => {
|
||||
const viewV1 = await config.createView()
|
||||
await config.api.viewV2.update(
|
||||
{
|
||||
...viewV1,
|
||||
},
|
||||
{
|
||||
expectStatus: 400,
|
||||
handleResponse: r => {
|
||||
expect(r.body).toEqual({
|
||||
message: "Only views V2 can be updated",
|
||||
status: 400,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("cannot update the a view with unmatching ids between url and body", async () => {
|
||||
const anotherView = await config.api.viewV2.create()
|
||||
const result = await config
|
||||
.request!.put(`/api/v2/views/${anotherView.id}`)
|
||||
.send(view)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
|
||||
expect(result.body).toEqual({
|
||||
message: "View id does not match between the body and the uri path",
|
||||
status: 400,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
let view: ViewV2
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.createTable(priceTable())
|
||||
view = await config.api.viewV2.create()
|
||||
})
|
||||
|
||||
it("can delete an existing view", async () => {
|
||||
const tableId = config.table!._id!
|
||||
const getPersistedView = async () =>
|
||||
(await config.api.table.get(tableId)).views![view.name]
|
||||
|
||||
expect(await getPersistedView()).toBeDefined()
|
||||
|
||||
await config.api.viewV2.delete(view.id)
|
||||
|
||||
expect(await getPersistedView()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -7,11 +7,28 @@ import { permissions } from "@budibase/backend-core"
|
|||
|
||||
const router: Router = new Router()
|
||||
|
||||
router
|
||||
.post(
|
||||
"/api/v2/views",
|
||||
authorized(permissions.BUILDER),
|
||||
viewController.v2.create
|
||||
)
|
||||
.put(
|
||||
`/api/v2/views/:viewId`,
|
||||
authorized(permissions.BUILDER),
|
||||
viewController.v2.update
|
||||
)
|
||||
.delete(
|
||||
`/api/v2/views/:viewId`,
|
||||
authorized(permissions.BUILDER),
|
||||
viewController.v2.remove
|
||||
)
|
||||
|
||||
router
|
||||
.get(
|
||||
"/api/views/export",
|
||||
authorized(permissions.BUILDER),
|
||||
viewController.exportView
|
||||
viewController.v1.exportView
|
||||
)
|
||||
.get(
|
||||
"/api/views/:viewName",
|
||||
|
@ -22,13 +39,13 @@ router
|
|||
),
|
||||
rowController.fetchView
|
||||
)
|
||||
.get("/api/views", authorized(permissions.BUILDER), viewController.fetch)
|
||||
.get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch)
|
||||
.delete(
|
||||
"/api/views/:viewName",
|
||||
paramResource("viewName"),
|
||||
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
|
||||
|
|
|
@ -195,6 +195,13 @@ export function getDatasourceParams(
|
|||
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.
|
||||
* @returns {string} The new query ID which the query doc can be stored under.
|
||||
|
@ -286,3 +293,19 @@ export function getMultiIDParams(ids: string[]) {
|
|||
include_docs: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT,
|
||||
FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main",
|
||||
OFFLINE_MODE: process.env.OFFLINE_MODE,
|
||||
// old
|
||||
CLIENT_ID: process.env.CLIENT_ID,
|
||||
_set(key: string, value: any) {
|
||||
|
|
|
@ -10,6 +10,10 @@ export const backfill = async (appDb: Database, timestamp: string | number) => {
|
|||
|
||||
if (table.views) {
|
||||
for (const view of Object.values(table.views)) {
|
||||
if (sdk.views.isV2(view)) {
|
||||
continue
|
||||
}
|
||||
|
||||
await events.view.created(view, timestamp)
|
||||
|
||||
if (view.calculation) {
|
||||
|
|
|
@ -19,6 +19,7 @@ import _ from "lodash"
|
|||
import {
|
||||
BudibaseInternalDB,
|
||||
getDatasourceParams,
|
||||
getDatasourcePlusParams,
|
||||
getTableParams,
|
||||
} from "../../../db/utils"
|
||||
import sdk from "../../index"
|
||||
|
@ -243,3 +244,15 @@ export function mergeConfigs(update: Datasource, old: Datasource) {
|
|||
|
||||
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,4 +1,4 @@
|
|||
import { SearchFilters } from "@budibase/types"
|
||||
import { SearchFilters, SortOrder, SortType } from "@budibase/types"
|
||||
import { isExternalTable } from "../../../integrations/utils"
|
||||
import * as internal from "./search/internal"
|
||||
import * as external from "./search/external"
|
||||
|
@ -11,10 +11,11 @@ export interface SearchParams {
|
|||
bookmark?: string
|
||||
limit?: number
|
||||
sort?: string
|
||||
sortOrder?: string
|
||||
sortType?: string
|
||||
sortOrder?: SortOrder
|
||||
sortType?: SortType
|
||||
version?: string
|
||||
disableEscaping?: boolean
|
||||
fields?: string[]
|
||||
}
|
||||
|
||||
export interface ViewParams {
|
||||
|
@ -30,7 +31,11 @@ function pickApi(tableId: any) {
|
|||
return internal
|
||||
}
|
||||
|
||||
export async function search(options: SearchParams) {
|
||||
export async function search(options: SearchParams): Promise<{
|
||||
rows: any[]
|
||||
hasNextPage?: boolean
|
||||
bookmark?: number | null
|
||||
}> {
|
||||
return pickApi(options.tableId).search(options)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,8 @@ import { breakExternalTableId } from "../../../../integrations/utils"
|
|||
import { cleanExportRows } from "../utils"
|
||||
import { utils } from "@budibase/shared-core"
|
||||
import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search"
|
||||
import { HTTPError } from "@budibase/backend-core"
|
||||
import { HTTPError, db } from "@budibase/backend-core"
|
||||
import pick from "lodash/pick"
|
||||
|
||||
export async function search(options: SearchParams) {
|
||||
const { tableId } = options
|
||||
|
@ -48,7 +49,7 @@ export async function search(options: SearchParams) {
|
|||
}
|
||||
}
|
||||
try {
|
||||
const rows = (await handleRequest(Operation.READ, tableId, {
|
||||
let rows = (await handleRequest(Operation.READ, tableId, {
|
||||
filters: query,
|
||||
sort,
|
||||
paginate: paginateObj as PaginationJson,
|
||||
|
@ -67,6 +68,12 @@ export async function search(options: SearchParams) {
|
|||
})) as Row[]
|
||||
hasNextPage = nextRows.length > 0
|
||||
}
|
||||
|
||||
if (options.fields) {
|
||||
const fields = [...options.fields, ...db.CONSTANT_EXTERNAL_ROW_COLS]
|
||||
rows = rows.map((r: any) => pick(r, fields))
|
||||
}
|
||||
|
||||
// need wrapper object for bookmarks etc when paginating
|
||||
return { rows, hasNextPage, bookmark: bookmark && bookmark + 1 }
|
||||
} catch (err: any) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
context,
|
||||
db,
|
||||
SearchParams as InternalSearchParams,
|
||||
} from "@budibase/backend-core"
|
||||
import env from "../../../../environment"
|
||||
|
@ -27,6 +28,7 @@ import {
|
|||
} from "../../../../api/controllers/view/utils"
|
||||
import sdk from "../../../../sdk"
|
||||
import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search"
|
||||
import pick from "lodash/pick"
|
||||
|
||||
export async function search(options: SearchParams) {
|
||||
const { tableId } = options
|
||||
|
@ -71,6 +73,12 @@ export async function search(options: SearchParams) {
|
|||
response.rows = await getGlobalUsersFromMetadata(response.rows)
|
||||
}
|
||||
table = table || (await sdk.tables.getTable(tableId))
|
||||
|
||||
if (options.fields) {
|
||||
const fields = [...options.fields, ...db.CONSTANT_INTERNAL_ROW_COLS]
|
||||
response.rows = response.rows.map((r: any) => pick(r, fields))
|
||||
}
|
||||
|
||||
response.rows = await outputProcessing(table, response.rows)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
import { GenericContainer } from "testcontainers"
|
||||
|
||||
import { Datasource, FieldType, Row, SourceName, Table } from "@budibase/types"
|
||||
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
|
||||
import { SearchParams } from "../../search"
|
||||
import { search } from "../external"
|
||||
import {
|
||||
expectAnyExternalColsAttributes,
|
||||
generator,
|
||||
} from "@budibase/backend-core/tests"
|
||||
|
||||
jest.unmock("mysql2/promise")
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("external", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
let externalDatasource: Datasource
|
||||
|
||||
const tableData: Table = {
|
||||
name: generator.word(),
|
||||
type: "external",
|
||||
primary: ["id"],
|
||||
schema: {
|
||||
id: {
|
||||
name: "id",
|
||||
type: FieldType.AUTO,
|
||||
autocolumn: true,
|
||||
},
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
surname: {
|
||||
name: "surname",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
age: {
|
||||
name: "age",
|
||||
type: FieldType.NUMBER,
|
||||
},
|
||||
address: {
|
||||
name: "address",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const container = await new GenericContainer("mysql")
|
||||
.withExposedPorts(3306)
|
||||
.withEnv("MYSQL_ROOT_PASSWORD", "admin")
|
||||
.withEnv("MYSQL_DATABASE", "db")
|
||||
.withEnv("MYSQL_USER", "user")
|
||||
.withEnv("MYSQL_PASSWORD", "password")
|
||||
.start()
|
||||
|
||||
const host = container.getContainerIpAddress()
|
||||
const port = container.getMappedPort(3306)
|
||||
|
||||
await config.init()
|
||||
|
||||
externalDatasource = await config.createDatasource({
|
||||
datasource: {
|
||||
type: "datasource",
|
||||
name: "Test",
|
||||
source: SourceName.MYSQL,
|
||||
plus: true,
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
user: "user",
|
||||
database: "db",
|
||||
password: "password",
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("search", () => {
|
||||
const rows: Row[] = []
|
||||
beforeAll(async () => {
|
||||
const table = await config.createTable({
|
||||
...tableData,
|
||||
sourceId: externalDatasource._id,
|
||||
})
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rows.push(
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
name: generator.first(),
|
||||
surname: generator.last(),
|
||||
age: generator.age(),
|
||||
address: generator.address(),
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("default search returns all the data", async () => {
|
||||
await config.doInContext(config.appId, async () => {
|
||||
const tableId = config.table!._id!
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
tableId,
|
||||
query: {},
|
||||
}
|
||||
const result = await search(searchParams)
|
||||
|
||||
expect(result.rows).toHaveLength(10)
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining(rows.map(r => expect.objectContaining(r)))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("querying by fields will always return data attribute columns", async () => {
|
||||
await config.doInContext(config.appId, async () => {
|
||||
const tableId = config.table!._id!
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
tableId,
|
||||
query: {},
|
||||
fields: ["name", "age"],
|
||||
}
|
||||
const result = await search(searchParams)
|
||||
|
||||
expect(result.rows).toHaveLength(10)
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining(
|
||||
rows.map(r => ({
|
||||
...expectAnyExternalColsAttributes,
|
||||
name: r.name,
|
||||
age: r.age,
|
||||
}))
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,109 @@
|
|||
import { FieldType, Row, Table } from "@budibase/types"
|
||||
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
|
||||
import { SearchParams } from "../../search"
|
||||
import { search } from "../internal"
|
||||
import {
|
||||
expectAnyInternalColsAttributes,
|
||||
generator,
|
||||
} from "@budibase/backend-core/tests"
|
||||
|
||||
describe("internal", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
const tableData: Table = {
|
||||
name: generator.word(),
|
||||
type: "table",
|
||||
schema: {
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
surname: {
|
||||
name: "surname",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
age: {
|
||||
name: "age",
|
||||
type: FieldType.NUMBER,
|
||||
constraints: {
|
||||
type: FieldType.NUMBER,
|
||||
},
|
||||
},
|
||||
address: {
|
||||
name: "address",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
describe("search", () => {
|
||||
const rows: Row[] = []
|
||||
beforeAll(async () => {
|
||||
await config.createTable(tableData)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rows.push(
|
||||
await config.createRow({
|
||||
name: generator.first(),
|
||||
surname: generator.last(),
|
||||
age: generator.age(),
|
||||
address: generator.address(),
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("default search returns all the data", async () => {
|
||||
await config.doInContext(config.appId, async () => {
|
||||
const tableId = config.table!._id!
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
tableId,
|
||||
query: {},
|
||||
}
|
||||
const result = await search(searchParams)
|
||||
|
||||
expect(result.rows).toHaveLength(10)
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining(rows.map(r => expect.objectContaining(r)))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("querying by fields will always return data attribute columns", async () => {
|
||||
await config.doInContext(config.appId, async () => {
|
||||
const tableId = config.table!._id!
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
tableId,
|
||||
query: {},
|
||||
fields: ["name", "age"],
|
||||
}
|
||||
const result = await search(searchParams)
|
||||
|
||||
expect(result.rows).toHaveLength(10)
|
||||
expect(result.rows).toEqual(
|
||||
expect.arrayContaining(
|
||||
rows.map(r => ({
|
||||
...expectAnyInternalColsAttributes,
|
||||
name: r.name,
|
||||
age: r.age,
|
||||
}))
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -5,9 +5,16 @@ import {
|
|||
isExternalTable,
|
||||
isSQL,
|
||||
} from "../../../integrations/utils"
|
||||
import { Table, Database } from "@budibase/types"
|
||||
import {
|
||||
Table,
|
||||
Database,
|
||||
TableResponse,
|
||||
TableViewsResponse,
|
||||
} from "@budibase/types"
|
||||
import datasources from "../datasources"
|
||||
import { populateExternalTableSchemas, isEditableColumn } from "./validation"
|
||||
import sdk from "../../../sdk"
|
||||
import _ from "lodash"
|
||||
|
||||
async function getAllInternalTables(db?: Database): Promise<Table[]> {
|
||||
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 {
|
||||
getAllInternalTables,
|
||||
getAllExternalTables,
|
||||
|
@ -62,4 +83,5 @@ export default {
|
|||
getTable,
|
||||
populateExternalTableSchemas,
|
||||
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,109 @@
|
|||
import { HTTPError, context } from "@budibase/backend-core"
|
||||
import { TableSchema, UIFieldMetadata, View, ViewV2 } from "@budibase/types"
|
||||
|
||||
import sdk from "../../../sdk"
|
||||
import * as utils from "../../../db/utils"
|
||||
import _ from "lodash"
|
||||
|
||||
export async function get(viewId: string): Promise<ViewV2 | undefined> {
|
||||
const { tableId } = utils.extractViewInfoFromID(viewId)
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
const views = Object.values(table.views!)
|
||||
const view = views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
export async function create(
|
||||
tableId: string,
|
||||
viewRequest: Omit<ViewV2, "id" | "version">
|
||||
): Promise<ViewV2> {
|
||||
const view: ViewV2 = {
|
||||
...viewRequest,
|
||||
id: utils.generateViewID(tableId),
|
||||
version: 2,
|
||||
}
|
||||
|
||||
const db = context.getAppDB()
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
table.views ??= {}
|
||||
|
||||
table.views[view.name] = view
|
||||
await db.put(table)
|
||||
return view
|
||||
}
|
||||
|
||||
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
||||
const db = context.getAppDB()
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
table.views ??= {}
|
||||
|
||||
const existingView = Object.values(table.views).find(
|
||||
v => isV2(v) && v.id === view.id
|
||||
)
|
||||
if (!existingView) {
|
||||
throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404)
|
||||
}
|
||||
|
||||
delete table.views[existingView.name]
|
||||
table.views[view.name] = view
|
||||
await db.put(table)
|
||||
return view
|
||||
}
|
||||
|
||||
export function isV2(view: View | ViewV2): view is ViewV2 {
|
||||
return (view as ViewV2).version === 2
|
||||
}
|
||||
|
||||
export async function remove(viewId: string): Promise<void> {
|
||||
const db = context.getAppDB()
|
||||
|
||||
const view = await get(viewId)
|
||||
const table = await sdk.tables.getTable(view?.tableId)
|
||||
if (!view) {
|
||||
throw new HTTPError(`View ${viewId} not found`, 404)
|
||||
}
|
||||
|
||||
delete table.views![view?.name]
|
||||
await db.put(table)
|
||||
}
|
||||
|
||||
export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) {
|
||||
if (!sdk.views.isV2(view)) {
|
||||
return view
|
||||
}
|
||||
|
||||
return {
|
||||
...view,
|
||||
schema:
|
||||
!view?.columns || !Object.entries(view?.columns).length
|
||||
? tableSchema
|
||||
: enrichViewV2Schema(tableSchema, view.columns),
|
||||
}
|
||||
}
|
||||
|
||||
function enrichViewV2Schema(
|
||||
tableSchema: TableSchema,
|
||||
viewOverrides: Record<string, UIFieldMetadata>
|
||||
) {
|
||||
const result: TableSchema = {}
|
||||
const viewOverridesEntries = Object.entries(viewOverrides)
|
||||
const viewSetsOrder = viewOverridesEntries.some(([_, v]) => v.order)
|
||||
for (const [columnName, columnUIMetadata] of viewOverridesEntries) {
|
||||
if (!columnUIMetadata.visible) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!tableSchema[columnName]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tableFieldSchema = tableSchema[columnName]
|
||||
if (viewSetsOrder) {
|
||||
delete tableFieldSchema.order
|
||||
}
|
||||
|
||||
result[columnName] = _.merge(tableFieldSchema, columnUIMetadata)
|
||||
}
|
||||
return result
|
||||
}
|
|
@ -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 users } from "./users"
|
||||
import { default as plugins } from "./plugins"
|
||||
import * as views from "./app/views"
|
||||
|
||||
const sdk = {
|
||||
backups,
|
||||
|
@ -18,6 +19,7 @@ const sdk = {
|
|||
datasources,
|
||||
queries,
|
||||
plugins,
|
||||
views,
|
||||
}
|
||||
|
||||
// default export for TS
|
||||
|
|
|
@ -53,6 +53,8 @@ import {
|
|||
} from "@budibase/types"
|
||||
import { BUILTIN_ROLE_IDS } from "@budibase/backend-core/src/security/roles"
|
||||
|
||||
import API from "./api"
|
||||
|
||||
type DefaultUserValues = {
|
||||
globalUserId: string
|
||||
email: string
|
||||
|
@ -73,12 +75,13 @@ class TestConfiguration {
|
|||
user: any
|
||||
globalUserId: any
|
||||
userMetadataId: any
|
||||
table: any
|
||||
table?: Table
|
||||
linkedTable: any
|
||||
automation: any
|
||||
datasource: any
|
||||
tenantId?: string
|
||||
defaultUserValues: DefaultUserValues
|
||||
api: API
|
||||
|
||||
constructor(openServer = true) {
|
||||
if (openServer) {
|
||||
|
@ -94,6 +97,8 @@ class TestConfiguration {
|
|||
this.appId = null
|
||||
this.allApps = []
|
||||
this.defaultUserValues = this.populateDefaultUserValues()
|
||||
|
||||
this.api = new API(this)
|
||||
}
|
||||
|
||||
populateDefaultUserValues(): DefaultUserValues {
|
||||
|
@ -242,7 +247,7 @@ class TestConfiguration {
|
|||
const db = tenancy.getTenantDB(this.getTenantId())
|
||||
let existing
|
||||
try {
|
||||
existing = await db.get(id)
|
||||
existing = await db.get<any>(id)
|
||||
} catch (err) {
|
||||
existing = { email }
|
||||
}
|
||||
|
@ -460,7 +465,7 @@ class TestConfiguration {
|
|||
async generateApiKey(userId = this.defaultUserValues.globalUserId) {
|
||||
const db = tenancy.getTenantDB(this.getTenantId())
|
||||
const id = dbCore.generateDevInfoID(userId)
|
||||
let devInfo
|
||||
let devInfo: any
|
||||
try {
|
||||
devInfo = await db.get(id)
|
||||
} catch (err) {
|
||||
|
@ -522,21 +527,27 @@ class TestConfiguration {
|
|||
|
||||
// TABLE
|
||||
|
||||
async updateTable(config?: any): Promise<Table> {
|
||||
async updateTable(
|
||||
config?: any,
|
||||
{ skipReassigning } = { skipReassigning: false }
|
||||
): Promise<Table> {
|
||||
config = config || basicTable()
|
||||
this.table = await this._req(config, null, controllers.table.save)
|
||||
return this.table
|
||||
const response = await this._req(config, null, controllers.table.save)
|
||||
if (!skipReassigning) {
|
||||
this.table = response
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async createTable(config?: Table) {
|
||||
async createTable(config?: Table, options = { skipReassigning: false }) {
|
||||
if (config != null && config._id) {
|
||||
delete config._id
|
||||
}
|
||||
return this.updateTable(config)
|
||||
return this.updateTable(config, options)
|
||||
}
|
||||
|
||||
async getTable(tableId?: string) {
|
||||
tableId = tableId || this.table._id
|
||||
tableId = tableId || this.table?._id
|
||||
return this._req(null, { tableId }, controllers.table.find)
|
||||
}
|
||||
|
||||
|
@ -577,7 +588,7 @@ class TestConfiguration {
|
|||
throw "Test requires table to be configured."
|
||||
}
|
||||
const tableId = (config && config.tableId) || this.table._id
|
||||
config = config || basicRow(tableId)
|
||||
config = config || basicRow(tableId!)
|
||||
return this._req(config, { tableId }, controllers.row.save)
|
||||
}
|
||||
|
||||
|
@ -587,14 +598,14 @@ class TestConfiguration {
|
|||
|
||||
async getRows(tableId: string) {
|
||||
if (!tableId && this.table) {
|
||||
tableId = this.table._id
|
||||
tableId = this.table._id!
|
||||
}
|
||||
return this._req(null, { tableId }, controllers.row.fetch)
|
||||
}
|
||||
|
||||
async searchRows(tableId: string, searchParams: SearchFilters = {}) {
|
||||
if (!tableId && this.table) {
|
||||
tableId = this.table._id
|
||||
tableId = this.table._id!
|
||||
}
|
||||
const body = {
|
||||
query: searchParams,
|
||||
|
@ -631,7 +642,7 @@ class TestConfiguration {
|
|||
tableId: this.table._id,
|
||||
name: "ViewTest",
|
||||
}
|
||||
return this._req(view, null, controllers.view.save)
|
||||
return this._req(view, null, controllers.view.v1.save)
|
||||
}
|
||||
|
||||
// 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,96 @@
|
|||
import { CreateViewRequest, SortOrder, SortType, ViewV2 } from "@budibase/types"
|
||||
import TestConfiguration from "../TestConfiguration"
|
||||
import { TestAPI } from "./base"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import { Response } from "superagent"
|
||||
|
||||
export class ViewV2API extends TestAPI {
|
||||
constructor(config: TestConfiguration) {
|
||||
super(config)
|
||||
}
|
||||
|
||||
create = async (
|
||||
viewData?: Partial<CreateViewRequest>,
|
||||
{ expectStatus } = { expectStatus: 201 }
|
||||
): Promise<ViewV2> => {
|
||||
let tableId = viewData?.tableId
|
||||
if (!tableId && !this.config.table) {
|
||||
throw "Test requires table to be configured."
|
||||
}
|
||||
tableId = this.config.table!._id!
|
||||
const view = {
|
||||
tableId,
|
||||
name: generator.guid(),
|
||||
...viewData,
|
||||
}
|
||||
const result = await this.request
|
||||
.post(`/api/v2/views`)
|
||||
.send(view)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(expectStatus)
|
||||
return result.body.data as ViewV2
|
||||
}
|
||||
|
||||
update = async (
|
||||
view: ViewV2,
|
||||
{
|
||||
expectStatus,
|
||||
handleResponse,
|
||||
}: {
|
||||
expectStatus: number
|
||||
handleResponse?: (response: Response) => void
|
||||
} = { expectStatus: 200 }
|
||||
): Promise<ViewV2> => {
|
||||
const result = await this.request
|
||||
.put(`/api/v2/views/${view.id}`)
|
||||
.send(view)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(expectStatus)
|
||||
|
||||
if (handleResponse) {
|
||||
handleResponse(result)
|
||||
}
|
||||
return result.body.data as ViewV2
|
||||
}
|
||||
|
||||
delete = async (viewId: string, { expectStatus } = { expectStatus: 204 }) => {
|
||||
return this.request
|
||||
.delete(`/api/v2/views/${viewId}`)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect(expectStatus)
|
||||
}
|
||||
|
||||
search = async (
|
||||
viewId: string,
|
||||
options?: {
|
||||
sort: {
|
||||
column: string
|
||||
order?: SortOrder
|
||||
type?: SortType
|
||||
}
|
||||
},
|
||||
{ expectStatus } = { expectStatus: 200 }
|
||||
) => {
|
||||
const qs: [string, any][] = []
|
||||
if (options?.sort.column) {
|
||||
qs.push(["sort_column", options.sort.column])
|
||||
}
|
||||
if (options?.sort.order) {
|
||||
qs.push(["sort_order", options.sort.order])
|
||||
}
|
||||
if (options?.sort.type) {
|
||||
qs.push(["sort_type", options.sort.type])
|
||||
}
|
||||
let url = `/api/v2/views/${viewId}/search`
|
||||
if (qs.length) {
|
||||
url += "?" + qs.map(q => q.join("=")).join("&")
|
||||
}
|
||||
return this.request
|
||||
.get(url)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(expectStatus)
|
||||
}
|
||||
}
|
|
@ -16,23 +16,26 @@ import {
|
|||
AutomationTrigger,
|
||||
AutomationTriggerStepId,
|
||||
Datasource,
|
||||
FieldType,
|
||||
SourceName,
|
||||
Table,
|
||||
} from "@budibase/types"
|
||||
|
||||
export function basicTable() {
|
||||
export function basicTable(): Table {
|
||||
return {
|
||||
name: "TestTable",
|
||||
type: "table",
|
||||
key: "name",
|
||||
schema: {
|
||||
name: {
|
||||
type: "string",
|
||||
type: FieldType.STRING,
|
||||
name: "name",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
type: FieldType.STRING,
|
||||
name: "description",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
|
|
|
@ -94,4 +94,5 @@ export enum BuilderSocketEvent {
|
|||
}
|
||||
|
||||
export const SocketSessionTTL = 60
|
||||
export const ValidQueryNameRegex = /^[^()]*$/
|
||||
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { LicenseOverrides, QuotaUsage } from "../../documents"
|
||||
import { PlanType } from "../../sdk"
|
||||
import { OfflineLicense, PlanType } from "../../sdk"
|
||||
import { ISO8601 } from "../../shared"
|
||||
|
||||
export interface GetLicenseRequest {
|
||||
// All fields should be optional to cater for
|
||||
|
@ -26,3 +27,13 @@ export interface UpdateLicenseRequest {
|
|||
planType?: PlanType
|
||||
overrides?: LicenseOverrides
|
||||
}
|
||||
|
||||
export interface CreateOfflineLicenseRequest {
|
||||
installationIdentifierBase64: string
|
||||
expireAt: ISO8601
|
||||
}
|
||||
|
||||
export interface GetOfflineLicenseResponse {
|
||||
offlineLicenseToken: string
|
||||
license: OfflineLicense
|
||||
}
|
||||
|
|
|
@ -1,2 +1,6 @@
|
|||
export * from "./backup"
|
||||
export * from "./datasource"
|
||||
export * from "./row"
|
||||
export * from "./view"
|
||||
export * from "./rows"
|
||||
export * from "./table"
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { Row } from "../../../documents/app/row"
|
||||
|
||||
export interface DeleteRows {
|
||||
rows: (Row | string)[]
|
||||
}
|
||||
|
||||
export interface DeleteRow {
|
||||
_id: string
|
||||
}
|
||||
|
||||
export type DeleteRowRequest = DeleteRows | DeleteRow
|
|
@ -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,9 @@
|
|||
import { TableSchema, ViewV2 } from "../../../documents"
|
||||
|
||||
export interface ViewResponse {
|
||||
data: ViewV2
|
||||
}
|
||||
|
||||
export type CreateViewRequest = Omit<ViewV2, "version" | "id">
|
||||
|
||||
export type UpdateViewRequest = ViewV2
|
|
@ -3,3 +3,4 @@ export * from "./auditLogs"
|
|||
export * from "./events"
|
||||
export * from "./configs"
|
||||
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
|
||||
licenseOverrides?: LicenseOverrides
|
||||
quotaUsage?: QuotaUsage
|
||||
offlineLicenseToken?: string
|
||||
}
|
||||
|
||||
export interface PasswordAccount extends Account {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Document } from "../../document"
|
||||
import { View } from "../view"
|
||||
import { View, ViewV2 } from "../view"
|
||||
import { RenameColumn } from "../../../sdk"
|
||||
import { TableSchema } from "./schema"
|
||||
|
||||
export interface Table extends Document {
|
||||
type?: string
|
||||
views?: { [key: string]: View }
|
||||
views?: { [key: string]: View | ViewV2 }
|
||||
name: string
|
||||
originalName?: string
|
||||
primary?: string[]
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import { SortOrder, SortType } from "../../api"
|
||||
import { SearchFilters } from "../../sdk"
|
||||
import { TableSchema, UIFieldMetadata } from "./table"
|
||||
|
||||
export interface View {
|
||||
name: string
|
||||
tableId: string
|
||||
|
@ -10,6 +14,20 @@ export interface View {
|
|||
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 interface ViewCountOrSumSchema {
|
||||
|
|
|
@ -9,6 +9,7 @@ export enum Feature {
|
|||
BRANDING = "branding",
|
||||
SCIM = "scim",
|
||||
SYNC_AUTOMATIONS = "syncAutomations",
|
||||
OFFLINE = "offline",
|
||||
}
|
||||
|
||||
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }
|
||||
|
|
|
@ -1,4 +1,15 @@
|
|||
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 {
|
||||
features: Feature[]
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
|
||||
}
|
||||
|
||||
export type ISO8601 = string
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
const actual = jest.requireActual("@budibase/pro")
|
||||
const pro = {
|
||||
...actual,
|
||||
features: {
|
||||
...actual.features,
|
||||
isSSOEnforced: jest.fn(),
|
||||
},
|
||||
licensing: {
|
||||
keys: {
|
||||
activateLicenseKey: jest.fn(),
|
||||
getLicenseKey: jest.fn(),
|
||||
deleteLicenseKey: jest.fn(),
|
||||
},
|
||||
offline: {
|
||||
activateOfflineLicenseToken: jest.fn(),
|
||||
getOfflineLicenseToken: jest.fn(),
|
||||
deleteOfflineLicenseToken: jest.fn(),
|
||||
getIdentifierBase64: jest.fn(),
|
||||
},
|
||||
cache: {
|
||||
...actual.licensing.cache,
|
||||
refresh: jest.fn(),
|
||||
},
|
||||
},
|
||||
quotas: {
|
||||
...actual.quotas,
|
||||
getQuotaUsage: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
export = pro
|
|
@ -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
|
|
@ -104,5 +104,19 @@
|
|||
"typescript": "4.7.3",
|
||||
"update-dotenv": "1.1.1"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"dev:builder": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@budibase/backend-core"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
|
||||
}
|
||||
|
|
|
@ -1,34 +1,83 @@
|
|||
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
|
||||
if (!licenseKey) {
|
||||
ctx.throw(400, "licenseKey is required")
|
||||
}
|
||||
|
||||
await licensing.activateLicenseKey(licenseKey)
|
||||
await licensing.keys.activateLicenseKey(licenseKey)
|
||||
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) => {
|
||||
await licensing.cache.refresh()
|
||||
ctx.status = 200
|
||||
}
|
||||
|
||||
export const getInfo = async (ctx: any) => {
|
||||
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
|
||||
}
|
||||
// USAGE
|
||||
|
||||
export const getQuotaUsage = async (ctx: any) => {
|
||||
ctx.body = await quotas.getQuotaUsage()
|
||||
ctx.status = 200
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue