Merge branch 'master' into BUDI-8270/validation-for-search-api

This commit is contained in:
Adria Navarro 2024-11-28 16:01:47 +01:00
commit 840e5e27df
102 changed files with 5110 additions and 1140 deletions

View File

@ -27,9 +27,8 @@
"extends": "plugin:svelte/recommended", "extends": "plugin:svelte/recommended",
"parser": "svelte-eslint-parser", "parser": "svelte-eslint-parser",
"parserOptions": { "parserOptions": {
"parser": "@babel/eslint-parser", "parser": "@typescript-eslint/parser",
"ecmaVersion": 2019, "ecmaVersion": 2019,
"sourceType": "module",
"allowImportExportEverywhere": true "allowImportExportEverywhere": true
} }
}, },

28
.github/workflows/readme-openapi.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: ReadMe GitHub Action 🦉
on:
push:
branches:
- master
jobs:
rdme-openapi:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v3
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: yarn
- run: yarn --frozen-lockfile
- name: update specs
run: cd packages/server && yarn specs
- name: Run `openapi` command
uses: readmeio/rdme@v8
with:
rdme: openapi specs/openapi.yaml --key=${{ secrets.README_API_KEY }} --id=6728a74f5918b50036c61841

View File

@ -12,12 +12,12 @@ metadata:
type: Opaque type: Opaque
data: data:
{{- if $existingSecret }} {{- if $existingSecret }}
internalApiKey: {{ index $existingSecret.data "internalApiKey" }} internalApiKey: {{ index $existingSecret.data "internalApiKey" | quote }}
jwtSecret: {{ index $existingSecret.data "jwtSecret" }} jwtSecret: {{ index $existingSecret.data "jwtSecret" | quote }}
objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" }} objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" | quote }}
objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" }} objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" | quote }}
bbEncryptionKey: {{ index $existingSecret.data "bbEncryptionKey" }} bbEncryptionKey: {{ index $existingSecret.data "bbEncryptionKey" | quote }}
apiEncryptionKey: {{ index $existingSecret.data "apiEncryptionKey" }} apiEncryptionKey: {{ index $existingSecret.data "apiEncryptionKey" | quote }}
{{- else }} {{- else }}
internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }} internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }}
jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }} jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }}

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.11", "version": "3.2.14",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

View File

@ -25,11 +25,12 @@
"prettier": "2.8.8", "prettier": "2.8.8",
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"svelte": "^4.2.10", "svelte": "4.2.19",
"svelte-eslint-parser": "^0.33.1", "svelte-eslint-parser": "^0.33.1",
"typescript": "5.5.2", "typescript": "5.5.2",
"typescript-eslint": "^7.3.1", "typescript-eslint": "^7.3.1",
"yargs": "^17.7.2" "yargs": "^17.7.2",
"cross-spawn": "7.0.6"
}, },
"scripts": { "scripts": {
"get-past-client-version": "node scripts/getPastClientVersion.js", "get-past-client-version": "node scripts/getPastClientVersion.js",

View File

@ -40,7 +40,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.10.1", "bull": "4.10.1",
"correlation-id": "4.0.0", "correlation-id": "4.0.0",
"dd-trace": "5.23.0", "dd-trace": "5.26.0",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"google-auth-library": "^8.0.1", "google-auth-library": "^8.0.1",
"google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5", "google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5",

View File

@ -190,7 +190,7 @@ export class DatabaseImpl implements Database {
} }
} }
private async performCall<T>(call: DBCallback<T>): Promise<any> { private async performCall<T>(call: DBCallback<T>): Promise<T> {
const db = this.getDb() const db = this.getDb()
const fnc = await call(db) const fnc = await call(db)
try { try {
@ -467,7 +467,7 @@ export class DatabaseImpl implements Database {
} catch (err: any) { } catch (err: any) {
// didn't exist, don't worry // didn't exist, don't worry
if (err.statusCode === 404) { if (err.statusCode === 404) {
return return { ok: true }
} else { } else {
throw new CouchDBError(err.message, err) throw new CouchDBError(err.message, err)
} }

View File

@ -27,7 +27,7 @@ export class DDInstrumentedDatabase implements Database {
exists(docId?: string): Promise<boolean> { exists(docId?: string): Promise<boolean> {
return tracer.trace("db.exists", span => { return tracer.trace("db.exists", span => {
span?.addTags({ db_name: this.name, doc_id: docId }) span.addTags({ db_name: this.name, doc_id: docId })
if (docId) { if (docId) {
return this.db.exists(docId) return this.db.exists(docId)
} }
@ -37,15 +37,17 @@ export class DDInstrumentedDatabase implements Database {
get<T extends Document>(id?: string | undefined): Promise<T> { get<T extends Document>(id?: string | undefined): Promise<T> {
return tracer.trace("db.get", span => { return tracer.trace("db.get", span => {
span?.addTags({ db_name: this.name, doc_id: id }) span.addTags({ db_name: this.name, doc_id: id })
return this.db.get(id) return this.db.get(id)
}) })
} }
tryGet<T extends Document>(id?: string | undefined): Promise<T | undefined> { tryGet<T extends Document>(id?: string | undefined): Promise<T | undefined> {
return tracer.trace("db.tryGet", span => { return tracer.trace("db.tryGet", async span => {
span?.addTags({ db_name: this.name, doc_id: id }) span.addTags({ db_name: this.name, doc_id: id })
return this.db.tryGet(id) const doc = await this.db.tryGet<T>(id)
span.addTags({ doc_found: doc !== undefined })
return doc
}) })
} }
@ -53,13 +55,15 @@ export class DDInstrumentedDatabase implements Database {
ids: string[], ids: string[],
opts?: { allowMissing?: boolean | undefined } | undefined opts?: { allowMissing?: boolean | undefined } | undefined
): Promise<T[]> { ): Promise<T[]> {
return tracer.trace("db.getMultiple", span => { return tracer.trace("db.getMultiple", async span => {
span?.addTags({ span.addTags({
db_name: this.name, db_name: this.name,
num_docs: ids.length, num_docs: ids.length,
allow_missing: opts?.allowMissing, allow_missing: opts?.allowMissing,
}) })
return this.db.getMultiple(ids, opts) const docs = await this.db.getMultiple<T>(ids, opts)
span.addTags({ num_docs_found: docs.length })
return docs
}) })
} }
@ -69,12 +73,14 @@ export class DDInstrumentedDatabase implements Database {
idOrDoc: string | Document, idOrDoc: string | Document,
rev?: string rev?: string
): Promise<DocumentDestroyResponse> { ): Promise<DocumentDestroyResponse> {
return tracer.trace("db.remove", span => { return tracer.trace("db.remove", async span => {
span?.addTags({ db_name: this.name, doc_id: idOrDoc }) span.addTags({ db_name: this.name, doc_id: idOrDoc, rev })
const isDocument = typeof idOrDoc === "object" const isDocument = typeof idOrDoc === "object"
const id = isDocument ? idOrDoc._id! : idOrDoc const id = isDocument ? idOrDoc._id! : idOrDoc
rev = isDocument ? idOrDoc._rev : rev rev = isDocument ? idOrDoc._rev : rev
return this.db.remove(id, rev) const resp = await this.db.remove(id, rev)
span.addTags({ ok: resp.ok })
return resp
}) })
} }
@ -83,7 +89,11 @@ export class DDInstrumentedDatabase implements Database {
opts?: { silenceErrors?: boolean } opts?: { silenceErrors?: boolean }
): Promise<void> { ): Promise<void> {
return tracer.trace("db.bulkRemove", span => { return tracer.trace("db.bulkRemove", span => {
span?.addTags({ db_name: this.name, num_docs: documents.length }) span.addTags({
db_name: this.name,
num_docs: documents.length,
silence_errors: opts?.silenceErrors,
})
return this.db.bulkRemove(documents, opts) return this.db.bulkRemove(documents, opts)
}) })
} }
@ -92,15 +102,21 @@ export class DDInstrumentedDatabase implements Database {
document: AnyDocument, document: AnyDocument,
opts?: DatabasePutOpts | undefined opts?: DatabasePutOpts | undefined
): Promise<DocumentInsertResponse> { ): Promise<DocumentInsertResponse> {
return tracer.trace("db.put", span => { return tracer.trace("db.put", async span => {
span?.addTags({ db_name: this.name, doc_id: document._id }) span.addTags({
return this.db.put(document, opts) db_name: this.name,
doc_id: document._id,
force: opts?.force,
})
const resp = await this.db.put(document, opts)
span.addTags({ ok: resp.ok })
return resp
}) })
} }
bulkDocs(documents: AnyDocument[]): Promise<DocumentBulkResponse[]> { bulkDocs(documents: AnyDocument[]): Promise<DocumentBulkResponse[]> {
return tracer.trace("db.bulkDocs", span => { return tracer.trace("db.bulkDocs", span => {
span?.addTags({ db_name: this.name, num_docs: documents.length }) span.addTags({ db_name: this.name, num_docs: documents.length })
return this.db.bulkDocs(documents) return this.db.bulkDocs(documents)
}) })
} }
@ -108,9 +124,15 @@ export class DDInstrumentedDatabase implements Database {
allDocs<T extends Document | RowValue>( allDocs<T extends Document | RowValue>(
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {
return tracer.trace("db.allDocs", span => { return tracer.trace("db.allDocs", async span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name, ...params })
return this.db.allDocs(params) const resp = await this.db.allDocs<T>(params)
span.addTags({
total_rows: resp.total_rows,
rows_length: resp.rows.length,
offset: resp.offset,
})
return resp
}) })
} }
@ -118,57 +140,75 @@ export class DDInstrumentedDatabase implements Database {
viewName: string, viewName: string,
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {
return tracer.trace("db.query", span => { return tracer.trace("db.query", async span => {
span?.addTags({ db_name: this.name, view_name: viewName }) span.addTags({ db_name: this.name, view_name: viewName, ...params })
return this.db.query(viewName, params) const resp = await this.db.query<T>(viewName, params)
span.addTags({
total_rows: resp.total_rows,
rows_length: resp.rows.length,
offset: resp.offset,
})
return resp
}) })
} }
destroy(): Promise<void | OkResponse> { destroy(): Promise<OkResponse> {
return tracer.trace("db.destroy", span => { return tracer.trace("db.destroy", async span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name })
return this.db.destroy() const resp = await this.db.destroy()
span.addTags({ ok: resp.ok })
return resp
}) })
} }
compact(): Promise<void | OkResponse> { compact(): Promise<OkResponse> {
return tracer.trace("db.compact", span => { return tracer.trace("db.compact", async span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name })
return this.db.compact() const resp = await this.db.compact()
span.addTags({ ok: resp.ok })
return resp
}) })
} }
dump(stream: Writable, opts?: DatabaseDumpOpts | undefined): Promise<any> { dump(stream: Writable, opts?: DatabaseDumpOpts | undefined): Promise<any> {
return tracer.trace("db.dump", span => { return tracer.trace("db.dump", span => {
span?.addTags({ db_name: this.name }) span.addTags({
db_name: this.name,
batch_limit: opts?.batch_limit,
batch_size: opts?.batch_size,
style: opts?.style,
timeout: opts?.timeout,
num_doc_ids: opts?.doc_ids?.length,
view: opts?.view,
})
return this.db.dump(stream, opts) return this.db.dump(stream, opts)
}) })
} }
load(...args: any[]): Promise<any> { load(...args: any[]): Promise<any> {
return tracer.trace("db.load", span => { return tracer.trace("db.load", span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name, num_args: args.length })
return this.db.load(...args) return this.db.load(...args)
}) })
} }
createIndex(...args: any[]): Promise<any> { createIndex(...args: any[]): Promise<any> {
return tracer.trace("db.createIndex", span => { return tracer.trace("db.createIndex", span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name, num_args: args.length })
return this.db.createIndex(...args) return this.db.createIndex(...args)
}) })
} }
deleteIndex(...args: any[]): Promise<any> { deleteIndex(...args: any[]): Promise<any> {
return tracer.trace("db.deleteIndex", span => { return tracer.trace("db.deleteIndex", span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name, num_args: args.length })
return this.db.deleteIndex(...args) return this.db.deleteIndex(...args)
}) })
} }
getIndexes(...args: any[]): Promise<any> { getIndexes(...args: any[]): Promise<any> {
return tracer.trace("db.getIndexes", span => { return tracer.trace("db.getIndexes", span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name, num_args: args.length })
return this.db.getIndexes(...args) return this.db.getIndexes(...args)
}) })
} }
@ -177,22 +217,27 @@ export class DDInstrumentedDatabase implements Database {
sql: string, sql: string,
parameters?: SqlQueryBinding parameters?: SqlQueryBinding
): Promise<T[]> { ): Promise<T[]> {
return tracer.trace("db.sql", span => { return tracer.trace("db.sql", async span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name, num_bindings: parameters?.length })
return this.db.sql(sql, parameters) const resp = await this.db.sql<T>(sql, parameters)
span.addTags({ num_rows: resp.length })
return resp
}) })
} }
sqlPurgeDocument(docIds: string[] | string): Promise<void> { sqlPurgeDocument(docIds: string[] | string): Promise<void> {
return tracer.trace("db.sqlPurgeDocument", span => { return tracer.trace("db.sqlPurgeDocument", span => {
span?.addTags({ db_name: this.name }) span.addTags({
db_name: this.name,
num_docs: Array.isArray(docIds) ? docIds.length : 1,
})
return this.db.sqlPurgeDocument(docIds) return this.db.sqlPurgeDocument(docIds)
}) })
} }
sqlDiskCleanup(): Promise<void> { sqlDiskCleanup(): Promise<void> {
return tracer.trace("db.sqlDiskCleanup", span => { return tracer.trace("db.sqlDiskCleanup", span => {
span?.addTags({ db_name: this.name }) span.addTags({ db_name: this.name })
return this.db.sqlDiskCleanup() return this.db.sqlDiskCleanup()
}) })
} }

View File

@ -22,7 +22,6 @@ export function price(): PurchasedPrice {
currency: "usd", currency: "usd",
duration: PriceDuration.MONTHLY, duration: PriceDuration.MONTHLY,
priceId: "price_123", priceId: "price_123",
dayPasses: undefined,
isPerUser: true, isPerUser: true,
} }
} }
@ -50,11 +49,6 @@ export function quotas(): Quotas {
value: 1, value: 1,
triggers: [], triggers: [],
}, },
dayPasses: {
name: "Queries",
value: 1,
triggers: [],
},
budibaseAICredits: { budibaseAICredits: {
name: "Budibase AI Credits", name: "Budibase AI Credits",
value: 1, value: 1,

View File

@ -15,7 +15,6 @@ export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
monthly: { monthly: {
"01-2023": { "01-2023": {
automations: 0, automations: 0,
dayPasses: 0,
queries: 0, queries: 0,
budibaseAICredits: 0, budibaseAICredits: 0,
triggers: {}, triggers: {},
@ -45,14 +44,12 @@ export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
}, },
"02-2023": { "02-2023": {
automations: 0, automations: 0,
dayPasses: 0,
queries: 0, queries: 0,
budibaseAICredits: 0, budibaseAICredits: 0,
triggers: {}, triggers: {},
}, },
current: { current: {
automations: 0, automations: 0,
dayPasses: 0,
queries: 0, queries: 0,
budibaseAICredits: 0, budibaseAICredits: 0,
triggers: {}, triggers: {},

View File

@ -3,7 +3,7 @@
import AbsTooltip from "../Tooltip/AbsTooltip.svelte" import AbsTooltip from "../Tooltip/AbsTooltip.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let type export let type = undefined
export let disabled = false export let disabled = false
export let size = "M" export let size = "M"
export let cta = false export let cta = false
@ -16,8 +16,8 @@
export let active = false export let active = false
export let tooltip = undefined export let tooltip = undefined
export let newStyles = true export let newStyles = true
export let id export let id = undefined
export let ref export let ref = undefined
export let reverse = false export let reverse = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -2,13 +2,6 @@
import CoreDatePicker from "./DatePicker/DatePicker.svelte" import CoreDatePicker from "./DatePicker/DatePicker.svelte"
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
export let value = null
export let disabled = false
export let readonly = false
export let error = null
export let appendTo = undefined
export let ignoreTimezones = false
let fromDate let fromDate
let toDate let toDate
</script> </script>

View File

@ -10,7 +10,7 @@
export let disabled = false export let disabled = false
export let updateOnChange = true export let updateOnChange = true
export let quiet = false export let quiet = false
export let inputRef export let inputRef = undefined
export let helpText = null export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -17,18 +17,18 @@
export let getOptionIcon = option => option?.icon export let getOptionIcon = option => option?.icon
export let getOptionColour = option => option?.colour export let getOptionColour = option => option?.colour
export let useOptionIconImage = false export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled = undefined
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
export let sort = false export let sort = false
export let tooltip = "" export let tooltip = ""
export let autocomplete = false export let autocomplete = false
export let customPopoverHeight export let customPopoverHeight = undefined
export let align export let align = undefined
export let footer = null export let footer = null
export let tag = null export let tag = null
export let helpText = null export let helpText = null
export let compare export let compare = undefined
export let onOptionMouseenter = () => {} export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {} export let onOptionMouseleave = () => {}

View File

@ -43,7 +43,7 @@
export let showHeaderBorder = true export let showHeaderBorder = true
export let placeholderText = "No rows found" export let placeholderText = "No rows found"
export let snippets = [] export let snippets = []
export let defaultSortColumn export let defaultSortColumn = undefined
export let defaultSortOrder = "Ascending" export let defaultSortOrder = "Ascending"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -1,9 +1,9 @@
<script> <script lang="ts">
import "@spectrum-css/typography/dist/index-vars.css" import "@spectrum-css/typography/dist/index-vars.css"
// Sizes // Sizes
export let size = "M" export let size = "M"
export let textAlign export let textAlign = undefined
export let noPadding = false export let noPadding = false
export let weight = "default" // light, heavy, default export let weight = "default" // light, heavy, default
</script> </script>

View File

@ -6,4 +6,4 @@ release/
dist/ dist/
routify routify
.routify/ .routify/
svelte.config.js .rollup.cache

View File

@ -4,13 +4,14 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "routify -b && vite build --emptyOutDir", "build": "routify -b && NODE_OPTIONS=\"--max_old_space_size=4096\" vite build --emptyOutDir",
"start": "routify -c rollup", "start": "routify -c rollup",
"dev": "routify -c dev:vite", "dev": "routify -c dev:vite",
"dev:vite": "vite --host 0.0.0.0", "dev:vite": "vite --host 0.0.0.0",
"rollup": "rollup -c -w", "rollup": "rollup -c -w",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"check:types": "yarn svelte-check"
}, },
"jest": { "jest": {
"globals": { "globals": {
@ -88,6 +89,7 @@
"@babel/plugin-transform-runtime": "^7.13.10", "@babel/plugin-transform-runtime": "^7.13.10",
"@babel/preset-env": "^7.13.12", "@babel/preset-env": "^7.13.12",
"@rollup/plugin-replace": "^5.0.3", "@rollup/plugin-replace": "^5.0.3",
"@rollup/plugin-typescript": "8.3.0",
"@roxi/routify": "2.18.12", "@roxi/routify": "2.18.12",
"@sveltejs/vite-plugin-svelte": "1.4.0", "@sveltejs/vite-plugin-svelte": "1.4.0",
"@testing-library/jest-dom": "6.4.2", "@testing-library/jest-dom": "6.4.2",
@ -97,6 +99,7 @@
"jest": "29.7.0", "jest": "29.7.0",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"svelte-check": "^4.1.0",
"svelte-jester": "^1.3.2", "svelte-jester": "^1.3.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-static-copy": "^0.17.0", "vite-plugin-static-copy": "^0.17.0",

View File

@ -8,7 +8,7 @@ import { get } from "svelte/store"
import { auth, navigation } from "./stores/portal" import { auth, navigation } from "./stores/portal"
export const API = createAPIClient({ export const API = createAPIClient({
attachHeaders: headers => { attachHeaders: (headers: Record<string, string>) => {
// Attach app ID header from store // Attach app ID header from store
let appId = get(appStore).appId let appId = get(appStore).appId
if (appId) { if (appId) {
@ -16,13 +16,13 @@ export const API = createAPIClient({
} }
// Add csrf token if authenticated // Add csrf token if authenticated
const user = get(auth).user const user: any = get(auth).user
if (user?.csrfToken) { if (user?.csrfToken) {
headers["x-csrf-token"] = user.csrfToken headers["x-csrf-token"] = user.csrfToken
} }
}, },
onError: error => { onError: (error: any) => {
const { url, message, status, method, handled } = error || {} const { url, message, status, method, handled } = error || {}
// Log any errors that we haven't manually handled // Log any errors that we haven't manually handled
@ -45,14 +45,14 @@ export const API = createAPIClient({
} }
} }
}, },
onMigrationDetected: appId => { onMigrationDetected: (appId: string) => {
const updatingUrl = `/builder/app/updating/${appId}` const updatingUrl = `/builder/app/updating/${appId}`
if (window.location.pathname === updatingUrl) { if (window.location.pathname === updatingUrl) {
return return
} }
get(navigation).goto( get(navigation)?.goto(
`${updatingUrl}?returnUrl=${encodeURIComponent(window.location.pathname)}` `${updatingUrl}?returnUrl=${encodeURIComponent(window.location.pathname)}`
) )
}, },

View File

@ -114,7 +114,7 @@
$: schemaFields = search.getFields( $: schemaFields = search.getFields(
$tables.list, $tables.list,
Object.values(schema || {}), Object.values(schema || {}),
{ allowLinks: true } { allowLinks: false }
) )
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = $memoBlock?.type === AutomationStepType.TRIGGER $: isTrigger = $memoBlock?.type === AutomationStepType.TRIGGER

View File

@ -1,81 +0,0 @@
<script>
import { Modal, ModalContent, Body, TooltipWrapper } from "@budibase/bbui"
import { licensing, auth, admin } from "stores/portal"
export let onDismiss = () => {}
export let onShow = () => {}
let dayPassModal
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
$: daysRemaining = $licensing.quotaResetDaysRemaining
$: quotaResetDate = $licensing.quotaResetDate
$: dayPassesUsed =
$licensing.usageMetrics?.dayPasses > 100
? 100
: $licensing.usageMetrics?.dayPasses
$: dayPassesTitle =
dayPassesUsed >= 100
? "You have run out of Day Passes"
: "You are almost out of Day Passes"
$: dayPassesBody =
dayPassesUsed >= 100
? "Upgrade your account to bring your apps back online."
: "Upgrade your account to prevent your apps from going offline."
export function show() {
dayPassModal.show()
}
export function hide() {
dayPassModal.hide()
}
</script>
<Modal bind:this={dayPassModal} on:show={onShow} on:hide={onDismiss}>
{#if $auth.user.accountPortalAccess}
<ModalContent
title={dayPassesTitle}
size="M"
confirmText="Upgrade"
onConfirm={() => {
window.location.href = upgradeUrl
}}
>
<Body>
You have used <span class="daypass_percent">{dayPassesUsed}%</span> of
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
? ""
: "s"} remaining.
<span class="tooltip">
<TooltipWrapper tooltip={quotaResetDate} size="S" />
</span>
</Body>
<Body>{dayPassesBody}</Body>
</ModalContent>
{:else}
<ModalContent title={dayPassesTitle} size="M" showCancelButton={false}>
<Body>
You have used <span class="daypass_percent">{dayPassesUsed}%</span> of
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
? ""
: "s"} remaining.
<span class="tooltip">
<TooltipWrapper tooltip={quotaResetDate} size="S" />
</span>
</Body>
<Body>Please contact your account holder to upgrade.</Body>
</ModalContent>
{/if}
</Modal>
<style>
.tooltip {
display: inline-block;
}
.tooltip :global(.icon-container) {
margin: 0px;
}
</style>

View File

@ -1,7 +1,6 @@
<script> <script>
import { licensing, auth, temporalStore } from "stores/portal" import { licensing, auth, temporalStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import DayPassWarningModal from "./DayPassWarningModal.svelte"
import PaymentFailedModal from "./PaymentFailedModal.svelte" import PaymentFailedModal from "./PaymentFailedModal.svelte"
import AccountDowngradedModal from "./AccountDowngradedModal.svelte" import AccountDowngradedModal from "./AccountDowngradedModal.svelte"
import { ExpiringKeys } from "./constants" import { ExpiringKeys } from "./constants"
@ -12,7 +11,6 @@
let queuedBanners = [] let queuedBanners = []
let queuedModals = [] let queuedModals = []
let dayPassModal
let paymentFailedModal let paymentFailedModal
let accountDowngradeModal let accountDowngradeModal
let userLoaded = false let userLoaded = false
@ -26,18 +24,6 @@
} }
const dismissableModals = [ const dismissableModals = [
{
key: ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL,
criteria: () => {
return $licensing?.usageMetrics?.dayPasses >= 90
},
action: () => {
dayPassModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL)
},
},
{ {
key: ExpiringKeys.LICENSING_PAYMENT_FAILED, key: ExpiringKeys.LICENSING_PAYMENT_FAILED,
criteria: () => { criteria: () => {
@ -102,7 +88,6 @@
}) })
</script> </script>
<DayPassWarningModal bind:this={dayPassModal} onDismiss={showNextModal} />
<PaymentFailedModal bind:this={paymentFailedModal} onDismiss={showNextModal} /> <PaymentFailedModal bind:this={paymentFailedModal} onDismiss={showNextModal} />
<AccountDowngradedModal <AccountDowngradedModal
bind:this={accountDowngradeModal} bind:this={accountDowngradeModal}

View File

@ -1,6 +1,4 @@
export const ExpiringKeys = { export const ExpiringKeys = {
LICENSING_DAYPASS_WARNING_MODAL: "licensing_daypass_warning_90_modal",
LICENSING_DAYPASS_WARNING_BANNER: "licensing_daypass_warning_90_banner",
LICENSING_PAYMENT_FAILED: "licensing_payment_failed", LICENSING_PAYMENT_FAILED: "licensing_payment_failed",
LICENSING_ACCOUNT_DOWNGRADED_MODAL: "licensing_account_downgraded_modal", LICENSING_ACCOUNT_DOWNGRADED_MODAL: "licensing_account_downgraded_modal",
LICENSING_APP_LIMIT_MODAL: "licensing_app_limit_modal", LICENSING_APP_LIMIT_MODAL: "licensing_app_limit_modal",

View File

@ -84,45 +84,6 @@ const buildUsageInfoBanner = (
} }
} }
const buildDayPassBanner = () => {
const appAuth = get(auth)
const appLicensing = get(licensing)
if (get(licensing)?.usageMetrics["dayPasses"] >= 100) {
return {
key: "max_dayPasses",
type: BANNER_TYPES.NEGATIVE,
criteria: () => {
return true
},
message: `Your apps are currently offline. You have exceeded your plans limit for Day Passes. ${
appAuth.user.accountPortalAccess
? ""
: "Please contact your account holder to upgrade."
}`,
...upgradeAction(),
showCloseButton: false,
}
}
return buildUsageInfoBanner(
"dayPasses",
"Day Passes",
ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER,
90,
`You have used ${
appLicensing?.usageMetrics["dayPasses"]
}% of your monthly usage of Day Passes with ${
appLicensing?.quotaResetDaysRemaining
} day${
get(licensing).quotaResetDaysRemaining == 1 ? "" : "s"
} remaining. All apps will be taken offline if this limit is reached. ${
appAuth.user.accountPortalAccess
? ""
: "Please contact your account holder to upgrade."
}`
)
}
const buildPaymentFailedBanner = () => { const buildPaymentFailedBanner = () => {
return { return {
key: "payment_Failed", key: "payment_Failed",
@ -166,7 +127,6 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => {
export const getBanners = () => { export const getBanners = () => {
return [ return [
buildPaymentFailedBanner(), buildPaymentFailedBanner(),
buildDayPassBanner(ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER),
buildUsageInfoBanner( buildUsageInfoBanner(
"rows", "rows",
"Rows", "Rows",

View File

@ -68,7 +68,6 @@ export const OnboardingType = {
export const PlanModel = { export const PlanModel = {
PER_USER: "perUser", PER_USER: "perUser",
DAY_PASS: "dayPass",
} }
export const ChangelogURL = "https://docs.budibase.com/changelog" export const ChangelogURL = "https://docs.budibase.com/changelog"

View File

@ -1141,10 +1141,11 @@ export const buildFormSchema = (component, asset) => {
const fieldSetting = settings.find( const fieldSetting = settings.find(
setting => setting.key === "field" && setting.type.startsWith("field/") setting => setting.key === "field" && setting.type.startsWith("field/")
) )
if (fieldSetting && component.field) { if (fieldSetting) {
const type = fieldSetting.type.split("field/")[1] const type = fieldSetting.type.split("field/")[1]
if (type) { const key = component.field || component._instanceName
schema[component.field] = { type } if (type && key) {
schema[key] = { type }
} }
} }
component._children?.forEach(child => { component._children?.forEach(child => {

8
packages/builder/src/index.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
declare module "api" {
const API: {
getPlugins: () => Promise<any>
createPlugin: (plugin: object) => Promise<any>
uploadPlugin: (plugin: FormData) => Promise<any>
deletePlugin: (id: string) => Promise<void>
}
}

View File

@ -117,7 +117,4 @@
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
} }
.tabs {
}
</style> </style>

View File

@ -50,8 +50,6 @@
border-radius: 4px; border-radius: 4px;
pointer-events: none; pointer-events: none;
} }
.indicator.above {
}
.indicator.below { .indicator.below {
margin-top: 32px; margin-top: 32px;
} }

View File

@ -136,7 +136,7 @@
</Body> </Body>
</Layout> </Layout>
<Divider /> <Divider />
{#if $licensing.usageMetrics?.dayPasses >= 100 || $licensing.errUserLimit} {#if $licensing.errUserLimit}
<div> <div>
<Layout gap="S" justifyItems="center"> <Layout gap="S" justifyItems="center">
<img class="spaceman" alt="spaceman" src={Spaceman} /> <img class="spaceman" alt="spaceman" src={Spaceman} />

View File

@ -28,7 +28,7 @@
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
const manageUrl = `${$admin.accountPortalUrl}/portal/billing` const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"] const WARN_USAGE = ["Queries", "Automations", "Rows", "Users"]
const oneDayInSeconds = 86400 const oneDayInSeconds = 86400
const EXCLUDE_QUOTAS = { const EXCLUDE_QUOTAS = {
@ -36,9 +36,6 @@
Users: license => { Users: license => {
return license.plan.model !== PlanModel.PER_USER return license.plan.model !== PlanModel.PER_USER
}, },
"Day Passes": license => {
return license.plan.model !== PlanModel.DAY_PASS
},
} }
function excludeQuota(name) { function excludeQuota(name) {

View File

@ -1,4 +1,4 @@
<script> <script lang="ts">
import { import {
Layout, Layout,
Heading, Heading,
@ -42,23 +42,25 @@
{ column: "edit", component: EditPluginRenderer }, { column: "edit", component: EditPluginRenderer },
] ]
let modal let modal: any
let searchTerm = "" let searchTerm: any = ""
let filter = "all" let filter: any = "all"
let filterOptions = [ let filterOptions = [
{ label: "All plugins", value: "all" }, { label: "All plugins", value: "all" },
{ label: "Components", value: "component" }, { label: "Components", value: "component" },
] ]
const searchPlaceholder: any = "Search"
if (!$admin.cloud) { if (!$admin.cloud) {
filterOptions.push({ label: "Datasources", value: "datasource" }) filterOptions.push({ label: "Datasources", value: "datasource" })
} }
$: filteredPlugins = $plugins $: filteredPlugins = $plugins
.filter(plugin => { .filter((plugin: any) => {
return filter === "all" || plugin.schema.type === filter return filter === "all" || plugin.schema.type === filter
}) })
.filter(plugin => { .filter((plugin: any) => {
return ( return (
!searchTerm || !searchTerm ||
plugin?.name?.toLowerCase().includes(searchTerm.toLowerCase()) plugin?.name?.toLowerCase().includes(searchTerm.toLowerCase())
@ -85,8 +87,8 @@
<Button <Button
on:click={() => on:click={() =>
window window
.open("https://github.com/Budibase/plugins", "_blank") ?.open("https://github.com/Budibase/plugins", "_blank")
.focus()} ?.focus()}
secondary secondary
> >
GitHub repo GitHub repo
@ -98,12 +100,12 @@
<div class="select"> <div class="select">
<Select <Select
bind:value={filter} bind:value={filter}
placeholder={null} placeholder={undefined}
options={filterOptions} options={filterOptions}
autoWidth autoWidth
/> />
</div> </div>
<Search bind:value={searchTerm} placeholder="Search" /> <Search bind:value={searchTerm} placeholder={searchPlaceholder} />
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -16,6 +16,7 @@ import {
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType, AutomationEventType,
AutomationStepType, AutomationStepType,
AutomationActionStepId,
} from "@budibase/types" } from "@budibase/types"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
@ -466,9 +467,13 @@ const automationActions = store => ({
.getPathSteps(block.pathTo, automation) .getPathSteps(block.pathTo, automation)
.slice(0, -1) .slice(0, -1)
// Current step will always be the last step of the path
const currentBlock = store.actions
.getPathSteps(block.pathTo, automation)
.at(-1)
// Extract all outputs from all previous steps as available bindingsx§x // Extract all outputs from all previous steps as available bindingsx§x
let bindings = [] let bindings = []
const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => { const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => {
if (!name) return if (!name) return
const runtimeBinding = determineRuntimeBinding( const runtimeBinding = determineRuntimeBinding(
@ -519,9 +524,24 @@ const automationActions = store => ({
runtimeName = `loop.${name}` runtimeName = `loop.${name}`
} else if (idx === 0) { } else if (idx === 0) {
runtimeName = `trigger.${name}` runtimeName = `trigger.${name}`
} else if (
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT
) {
const stepId = pathSteps[idx].id
if (!stepId) {
notifications.error("Error generating binding: Step ID not found.")
return null
}
runtimeName = `steps["${stepId}"].${name}`
} else { } else {
runtimeName = `steps.${pathSteps[idx]?.id}.${name}` const stepId = pathSteps[idx].id
if (!stepId) {
notifications.error("Error generating binding: Step ID not found.")
return null
}
runtimeName = `steps.${stepId}.${name}`
} }
return runtimeName return runtimeName
} }
@ -637,7 +657,6 @@ const automationActions = store => ({
console.error("Loop block missing.") console.error("Loop block missing.")
} }
} }
Object.entries(schema).forEach(([name, value]) => { Object.entries(schema).forEach(([name, value]) => {
addBinding(name, value, icon, blockIdx, isLoopBlock, bindingName) addBinding(name, value, icon, blockIdx, isLoopBlock, bindingName)
}) })

View File

@ -1,13 +1,20 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
type GotoFuncType = (path: string) => void
interface Store {
initialisated: boolean
goto: GotoFuncType
}
export function createNavigationStore() { export function createNavigationStore() {
const store = writable({ const store = writable<Store>({
initialisated: false, initialisated: false,
goto: undefined, goto: undefined as any,
}) })
const { set, subscribe } = store const { set, subscribe } = store
const init = gotoFunc => { const init = (gotoFunc: GotoFuncType) => {
if (typeof gotoFunc !== "function") { if (typeof gotoFunc !== "function") {
throw new Error( throw new Error(
`gotoFunc must be a function, found a "${typeof gotoFunc}" instead` `gotoFunc must be a function, found a "${typeof gotoFunc}" instead`

View File

@ -1,16 +1,21 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { PluginSource } from "constants/index"
import { API } from "api" import { API } from "api"
import { PluginSource } from "constants"
interface Plugin {
_id: string
}
export function createPluginsStore() { export function createPluginsStore() {
const { subscribe, set, update } = writable([]) const { subscribe, set, update } = writable<Plugin[]>([])
async function load() { async function load() {
const plugins = await API.getPlugins() const plugins = await API.getPlugins()
set(plugins) set(plugins)
} }
async function deletePlugin(pluginId) { async function deletePlugin(pluginId: string) {
await API.deletePlugin(pluginId) await API.deletePlugin(pluginId)
update(state => { update(state => {
state = state.filter(existing => existing._id !== pluginId) state = state.filter(existing => existing._id !== pluginId)
@ -18,8 +23,8 @@ export function createPluginsStore() {
}) })
} }
async function createPlugin(source, url, auth = null) { async function createPlugin(source: string, url: string, auth = null) {
let pluginData = { let pluginData: any = {
source, source,
url, url,
} }
@ -46,7 +51,7 @@ export function createPluginsStore() {
}) })
} }
async function uploadPlugin(file) { async function uploadPlugin(file: File) {
if (!file) { if (!file) {
return return
} }

View File

@ -0,0 +1,7 @@
const { vitePreprocess } = require("@sveltejs/vite-plugin-svelte")
const config = {
preprocess: vitePreprocess(),
}
module.exports = config

View File

@ -9,15 +9,9 @@
"noImplicitAny": true, "noImplicitAny": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"incremental": true "incremental": true,
"skipLibCheck": true
}, },
"include": [ "include": ["./src/**/*"],
"./src/**/*" "exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"]
],
"exclude": [
"node_modules",
"**/*.json",
"**/*.spec.ts",
"**/*.spec.js"
]
} }

View File

@ -3,6 +3,7 @@ import replace from "@rollup/plugin-replace"
import { defineConfig, loadEnv } from "vite" import { defineConfig, loadEnv } from "vite"
import { viteStaticCopy } from "vite-plugin-static-copy" import { viteStaticCopy } from "vite-plugin-static-copy"
import path from "path" import path from "path"
import typescript from "@rollup/plugin-typescript"
const ignoredWarnings = [ const ignoredWarnings = [
"unused-export-let", "unused-export-let",
@ -35,7 +36,7 @@ export default defineConfig(({ mode }) => {
// Copy fonts to an additional path so that svelte's automatic // Copy fonts to an additional path so that svelte's automatic
// prefixing of the base URL path can still resolve assets // prefixing of the base URL path can still resolve assets
copyFonts("builder/fonts"), copyFonts("builder/fonts"),
] ]
return { return {
test: { test: {
@ -61,6 +62,7 @@ export default defineConfig(({ mode }) => {
sourcemap: !isProduction, sourcemap: !isProduction,
}, },
plugins: [ plugins: [
typescript({ outDir: "../server/builder/dist" }),
svelte({ svelte({
hot: !isProduction, hot: !isProduction,
emitCss: true, emitCss: true,

View File

@ -3096,7 +3096,6 @@
"name": "Text Field", "name": "Text Field",
"icon": "Text", "icon": "Text",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -3106,8 +3105,7 @@
{ {
"type": "field/string", "type": "field/string",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -3226,13 +3224,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
}, },
"numberfield": { "numberfield": {
"name": "Number Field", "name": "Number Field",
"icon": "123", "icon": "123",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -3242,8 +3249,7 @@
{ {
"type": "field/number", "type": "field/number",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -3328,13 +3334,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "number"
}
]
}
}, },
"bigintfield": { "bigintfield": {
"name": "BigInt Field", "name": "BigInt Field",
"icon": "TagBold", "icon": "TagBold",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -3344,8 +3359,7 @@
{ {
"type": "field/bigint", "type": "field/bigint",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -3414,13 +3428,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "number"
}
]
}
}, },
"passwordfield": { "passwordfield": {
"name": "Password Field", "name": "Password Field",
"icon": "LockClosed", "icon": "LockClosed",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -3430,8 +3453,7 @@
{ {
"type": "field/string", "type": "field/string",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -3500,13 +3522,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
}, },
"optionsfield": { "optionsfield": {
"name": "Options Picker", "name": "Options Picker",
"icon": "Menu", "icon": "Menu",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -3516,8 +3547,7 @@
{ {
"type": "field/options", "type": "field/options",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -3714,13 +3744,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
}, },
"multifieldselect": { "multifieldselect": {
"name": "Multi-select Picker", "name": "Multi-select Picker",
"icon": "ViewList", "icon": "ViewList",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -3730,8 +3769,7 @@
{ {
"type": "field/array", "type": "field/array",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -3922,13 +3960,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "array"
}
]
}
}, },
"booleanfield": { "booleanfield": {
"name": "Checkbox", "name": "Checkbox",
"icon": "SelectBox", "icon": "SelectBox",
"editable": true, "editable": true,
"requiredAncestors": ["form"],
"size": { "size": {
"width": 400, "width": 400,
"height": 60 "height": 60
@ -3937,8 +3984,7 @@
{ {
"type": "field/boolean", "type": "field/boolean",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -4047,13 +4093,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "boolean"
}
]
}
}, },
"longformfield": { "longformfield": {
"name": "Long Form Field", "name": "Long Form Field",
"icon": "TextAlignLeft", "icon": "TextAlignLeft",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -4063,8 +4118,7 @@
{ {
"type": "field/longform", "type": "field/longform",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -4171,13 +4225,22 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
}, },
"datetimefield": { "datetimefield": {
"name": "Date Picker", "name": "Date Picker",
"icon": "Date", "icon": "Date",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -4187,8 +4250,7 @@
{ {
"type": "field/datetime", "type": "field/datetime",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -4291,7 +4353,17 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "datetime"
}
]
}
}, },
"codescanner": { "codescanner": {
"name": "Barcode/QR Scanner", "name": "Barcode/QR Scanner",
@ -4305,8 +4377,7 @@
{ {
"type": "field/barcodeqr", "type": "field/barcodeqr",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -4451,7 +4522,17 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
}, },
"signaturesinglefield": { "signaturesinglefield": {
"name": "Signature", "name": "Signature",
@ -4924,7 +5005,6 @@
"icon": "Brackets", "icon": "Brackets",
"styles": ["size"], "styles": ["size"],
"editable": true, "editable": true,
"requiredAncestors": ["form"],
"size": { "size": {
"width": 400, "width": 400,
"height": 100 "height": 100
@ -4933,8 +5013,7 @@
{ {
"type": "field/json", "type": "field/json",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -5014,7 +5093,17 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
}, },
"s3upload": { "s3upload": {
"name": "S3 File Upload", "name": "S3 File Upload",
@ -5029,8 +5118,7 @@
{ {
"type": "field/s3", "type": "field/s3",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -5075,7 +5163,17 @@
"label": "Validation", "label": "Validation",
"key": "validation" "key": "validation"
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "array"
}
]
}
}, },
"dataprovider": { "dataprovider": {
"name": "Data Provider", "name": "Data Provider",
@ -7643,7 +7741,6 @@
"name": "User List Field", "name": "User List Field",
"icon": "UserGroup", "icon": "UserGroup",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -7653,8 +7750,7 @@
{ {
"type": "field/bb_reference", "type": "field/bb_reference",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -7744,14 +7840,23 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "array"
}
]
}
}, },
"bbreferencesinglefield": { "bbreferencesinglefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels", "devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field", "name": "User Field",
"icon": "User", "icon": "User",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"],
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
@ -7761,8 +7866,7 @@
{ {
"type": "field/bb_reference_single", "type": "field/bb_reference_single",
"label": "Field", "label": "Field",
"key": "field", "key": "field"
"required": true
}, },
{ {
"type": "text", "type": "text",
@ -7852,6 +7956,16 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"values": [
{
"label": "Value",
"key": "value",
"type": "string"
}
]
}
} }
} }

View File

@ -15,8 +15,8 @@
"./manifest.json": "./manifest.json" "./manifest.json": "./manifest.json"
}, },
"scripts": { "scripts": {
"build": "rollup -c", "build": "vite build",
"dev": "rollup -cw" "dev": "vite build --watch"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "0.0.0", "@budibase/bbui": "0.0.0",
@ -36,19 +36,9 @@
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-alias": "^5.1.0", "@sveltejs/vite-plugin-svelte": "1.4.0",
"@rollup/plugin-commonjs": "^25.0.7", "vite": "^4.5.0",
"@rollup/plugin-image": "^3.0.3", "vite-plugin-css-injected-by-js": "3.5.2"
"@rollup/plugin-node-resolve": "^15.2.3",
"postcss": "^8.4.35",
"rollup": "^4.9.6",
"rollup-plugin-json": "^4.0.0",
"rollup-plugin-polyfill-node": "^0.13.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-svelte": "^7.1.6",
"rollup-plugin-svg": "^2.0.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-visualizer": "^5.12.0"
}, },
"resolutions": { "resolutions": {
"loader-utils": "1.4.1" "loader-utils": "1.4.1"

View File

@ -1,117 +0,0 @@
import commonjs from "@rollup/plugin-commonjs"
import resolve from "@rollup/plugin-node-resolve"
import alias from "@rollup/plugin-alias"
import svelte from "rollup-plugin-svelte"
import { terser } from "rollup-plugin-terser"
import postcss from "rollup-plugin-postcss"
import svg from "rollup-plugin-svg"
import image from "@rollup/plugin-image"
import json from "rollup-plugin-json"
import nodePolyfills from "rollup-plugin-polyfill-node"
import path from "path"
import { visualizer } from "rollup-plugin-visualizer"
const production = !process.env.ROLLUP_WATCH
const ignoredWarnings = [
"unused-export-let",
"css-unused-selector",
"module-script-reactive-declaration",
"a11y-no-onchange",
"a11y-click-events-have-key-events",
]
const devPaths = production
? []
: [
{
find: "@budibase/shared-core",
replacement: path.resolve("../shared-core/dist/index"),
},
{
find: "@budibase/types",
replacement: path.resolve("../types/dist/index"),
},
]
export default {
input: "src/index.js",
output: [
{
sourcemap: false,
format: "iife",
file: `./dist/budibase-client.js`,
},
],
onwarn(warning, warn) {
if (
warning.code === "THIS_IS_UNDEFINED" ||
warning.code === "CIRCULAR_DEPENDENCY" ||
warning.code === "EVAL"
) {
return
}
warn(warning)
},
plugins: [
alias({
entries: [
{
find: "manifest.json",
replacement: path.resolve("./manifest.json"),
},
{
find: "api",
replacement: path.resolve("./src/api"),
},
{
find: "components",
replacement: path.resolve("./src/components"),
},
{
find: "stores",
replacement: path.resolve("./src/stores"),
},
{
find: "utils",
replacement: path.resolve("./src/utils"),
},
{
find: "constants",
replacement: path.resolve("./src/constants"),
},
{
find: "sdk",
replacement: path.resolve("./src/sdk"),
},
...devPaths,
],
}),
svelte({
emitCss: true,
onwarn: (warning, handler) => {
// Ignore some warnings
if (!ignoredWarnings.includes(warning.code)) {
handler(warning)
}
},
}),
postcss(),
commonjs(),
nodePolyfills(),
resolve({
preferBuiltins: true,
browser: true,
dedupe: ["svelte", "svelte/internal"],
}),
svg(),
image({
exclude: "**/*.svg",
}),
json(),
production && terser(),
!production && visualizer(),
],
watch: {
clearScreen: false,
},
}

View File

@ -18,8 +18,6 @@
export let palette export let palette
export let c1, c2, c3, c4, c5 export let c1, c2, c3, c4, c5
// Area specific props
export let area
export let stacked export let stacked
export let gradient export let gradient

View File

@ -1,7 +1,10 @@
<script> <script>
import Placeholder from "../Placeholder.svelte"
import { getContext, onDestroy } from "svelte" import { getContext, onDestroy } from "svelte"
import { writable } from "svelte/store"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { memo } from "@budibase/frontend-core"
import Placeholder from "../Placeholder.svelte"
import InnerForm from "./InnerForm.svelte"
export let label export let label
export let field export let field
@ -20,26 +23,39 @@
const formContext = getContext("form") const formContext = getContext("form")
const formStepContext = getContext("form-step") const formStepContext = getContext("form-step")
const fieldGroupContext = getContext("field-group") const fieldGroupContext = getContext("field-group")
const { styleable, builderStore } = getContext("sdk") const { styleable, builderStore, Provider } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
// Register field with form // Register field with form
const formApi = formContext?.formApi const formApi = formContext?.formApi
const labelPos = fieldGroupContext?.labelPosition || "above" const labelPos = fieldGroupContext?.labelPosition || "above"
let formField
let touched = false let touched = false
let labelNode let labelNode
$: formStep = formStepContext ? $formStepContext || 1 : 1 // Memoize values required to register the field to avoid loops
$: formField = formApi?.registerField( const formStep = formStepContext || writable(1)
field, const fieldInfo = memo({
field: field || $component.name,
type, type,
defaultValue, defaultValue,
disabled, disabled,
readonly, readonly,
validation, validation,
formStep formStep: $formStep || 1,
) })
$: fieldInfo.set({
field: field || $component.name,
type,
defaultValue,
disabled,
readonly,
validation,
formStep: $formStep || 1,
})
$: registerField($fieldInfo)
$: schemaType = $: schemaType =
fieldSchema?.type !== "formula" && fieldSchema?.type !== "bigint" fieldSchema?.type !== "formula" && fieldSchema?.type !== "bigint"
? fieldSchema?.type ? fieldSchema?.type
@ -58,6 +74,18 @@
// Determine label class from position // Determine label class from position
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}` $: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
const registerField = info => {
formField = formApi?.registerField(
info.field,
info.type,
info.defaultValue,
info.disabled,
info.readonly,
info.validation,
info.formStep
)
}
const updateLabel = e => { const updateLabel = e => {
if (touched) { if (touched) {
builderStore.actions.updateProp("label", e.target.textContent) builderStore.actions.updateProp("label", e.target.textContent)
@ -71,52 +99,65 @@
}) })
</script> </script>
<div <Provider data={{ value: fieldState?.value }}>
class="spectrum-Form-item" {#if !formContext}
class:span-2={span === 2} <InnerForm
class:span-3={span === 3} {disabled}
class:span-6={span === 6 || !span} {readonly}
use:styleable={$component.styles} currentStep={writable(1)}
class:above={labelPos === "above"} provideContext={false}
>
{#key $component.editing}
<label
bind:this={labelNode}
contenteditable={$component.editing}
on:blur={$component.editing ? updateLabel : null}
on:input={() => (touched = true)}
class:hidden={!label}
class:readonly
for={fieldState?.fieldId}
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelClass}`}
> >
{label || " "} <svelte:self {...$$props} bind:fieldState bind:fieldApi bind:fieldSchema>
</label> <slot />
{/key} </svelte:self>
<div class="spectrum-Form-itemField"> </InnerForm>
{#if !formContext} {:else}
<Placeholder text="Form components need to be wrapped in a form" /> <div
{:else if !fieldState} class="spectrum-Form-item"
<Placeholder /> class:span-2={span === 2}
{:else if schemaType && schemaType !== type && !["options", "longform"].includes(type)} class:span-3={span === 3}
<Placeholder class:span-6={span === 6 || !span}
text="This Field setting is the wrong data type for this component" use:styleable={$component.styles}
/> class:above={labelPos === "above"}
{:else} >
<slot /> {#key $component.editing}
{#if fieldState.error} <label
<div class="error"> bind:this={labelNode}
<Icon name="Alert" /> contenteditable={$component.editing}
<span>{fieldState.error}</span> on:blur={$component.editing ? updateLabel : null}
</div> on:input={() => (touched = true)}
{:else if helpText} class:hidden={!label}
<div class="helpText"> class:readonly
<Icon name="HelpOutline" /> <span>{helpText}</span> for={fieldState?.fieldId}
</div> class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelClass}`}
{/if} >
{/if} {label || " "}
</div> </label>
</div> {/key}
<div class="spectrum-Form-itemField">
{#if !fieldState}
<Placeholder />
{:else if schemaType && schemaType !== type && !["options", "longform"].includes(type)}
<Placeholder
text="This Field setting is the wrong data type for this component"
/>
{:else}
<slot />
{#if fieldState.error}
<div class="error">
<Icon name="Alert" />
<span>{fieldState.error}</span>
</div>
{:else if helpText}
<div class="helpText">
<Icon name="HelpOutline" /> <span>{helpText}</span>
</div>
{/if}
{/if}
</div>
</div>
{/if}
</Provider>
<style> <style>
:global(.form-block .spectrum-Form-item.span-2) { :global(.form-block .spectrum-Form-item.span-2) {

View File

@ -5,7 +5,6 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
export let dataSource export let dataSource
export let theme
export let size export let size
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
@ -113,11 +112,9 @@
{#key resetKey} {#key resetKey}
<InnerForm <InnerForm
{dataSource} {dataSource}
{theme}
{size} {size}
{disabled} {disabled}
{readonly} {readonly}
{actionType}
{schema} {schema}
{definition} {definition}
{initialValues} {initialValues}

View File

@ -14,6 +14,10 @@
export let disableSchemaValidation = false export let disableSchemaValidation = false
export let editAutoColumns = false export let editAutoColumns = false
// For internal use only, to disable context when being used with standalone
// fields
export let provideContext = true
// We export this store so that when we remount the inner form we can still // We export this store so that when we remount the inner form we can still
// persist what step we're on // persist what step we're on
export let currentStep export let currentStep
@ -442,8 +446,14 @@
] ]
</script> </script>
<Provider {actions} data={dataContext}> {#if provideContext}
<Provider {actions} data={dataContext}>
<div use:styleable={$component.styles} class={size}>
<slot />
</div>
</Provider>
{:else}
<div use:styleable={$component.styles} class={size}> <div use:styleable={$component.styles} class={size}>
<slot /> <slot />
</div> </div>
</Provider> {/if}

View File

@ -10,7 +10,7 @@ import {
eventStore, eventStore,
hoverStore, hoverStore,
} from "./stores" } from "./stores"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-vite.js"
import { get } from "svelte/store" import { get } from "svelte/store"
import { initWebsocket } from "./websocket.js" import { initWebsocket } from "./websocket.js"

View File

@ -3,6 +3,10 @@
"allowJs": true, "allowJs": true,
"strict": true, "strict": true,
"outDir": "dist", "outDir": "dist",
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"paths": { "paths": {
"@budibase/*": [ "@budibase/*": [
"../*/src/index.ts", "../*/src/index.ts",
@ -12,6 +16,5 @@
], ],
"*": ["./src/*"] "*": ["./src/*"]
} }
}, }
"include": ["src/**/*"]
} }

View File

@ -0,0 +1,89 @@
import { svelte } from "@sveltejs/vite-plugin-svelte"
import { defineConfig } from "vite"
import path from "path"
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"
const ignoredWarnings = [
"unused-export-let",
"css-unused-selector",
"module-script-reactive-declaration",
"a11y-no-onchange",
"a11y-click-events-have-key-events",
]
export default defineConfig(({ mode }) => {
const isProduction = mode === "production"
return {
server: {
open: false,
},
build: {
lib: {
entry: "src/index.js",
formats: ["iife"],
outDir: "dist",
name: "budibase_client",
fileName: () => "budibase-client.js",
minify: isProduction,
},
},
plugins: [
svelte({
emitCss: true,
onwarn: (warning, handler) => {
// Ignore some warnings
if (!ignoredWarnings.includes(warning.code)) {
handler(warning)
}
},
}),
cssInjectedByJsPlugin(),
],
resolve: {
dedupe: ["svelte", "svelte/internal"],
alias: [
{
find: "manifest.json",
replacement: path.resolve("./manifest.json"),
},
{
find: "api",
replacement: path.resolve("./src/api"),
},
{
find: "components",
replacement: path.resolve("./src/components"),
},
{
find: "stores",
replacement: path.resolve("./src/stores"),
},
{
find: "utils",
replacement: path.resolve("./src/utils"),
},
{
find: "constants",
replacement: path.resolve("./src/constants"),
},
{
find: "sdk",
replacement: path.resolve("./src/sdk"),
},
{
find: "@budibase/types",
replacement: path.resolve("../types/src"),
},
{
find: "@budibase/shared-core",
replacement: path.resolve("../shared-core/src"),
},
{
find: "@budibase/bbui",
replacement: path.resolve("../bbui/src"),
},
],
},
}
})

@ -1 +1 @@
Subproject commit 4facf6a44ee52a405794845f71584168b9db652c Subproject commit e8ef2205de8bca5adcf18d07573096086aa9a606

View File

@ -63,6 +63,7 @@
"@bull-board/koa": "5.10.2", "@bull-board/koa": "5.10.2",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
"@google-cloud/firestore": "7.8.0", "@google-cloud/firestore": "7.8.0",
"@koa/cors": "5.0.0",
"@koa/router": "13.1.0", "@koa/router": "13.1.0",
"@socket.io/redis-adapter": "^8.2.1", "@socket.io/redis-adapter": "^8.2.1",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
@ -80,8 +81,8 @@
"cookies": "0.8.0", "cookies": "0.8.0",
"csvtojson": "2.0.10", "csvtojson": "2.0.10",
"curlconverter": "3.21.0", "curlconverter": "3.21.0",
"dd-trace": "5.23.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"dd-trace": "5.26.0",
"dotenv": "8.2.0", "dotenv": "8.2.0",
"form-data": "4.0.0", "form-data": "4.0.0",
"global-agent": "3.0.0", "global-agent": "3.0.0",
@ -142,6 +143,7 @@
"@types/jest": "29.5.5", "@types/jest": "29.5.5",
"@types/koa": "2.13.4", "@types/koa": "2.13.4",
"@types/koa-send": "^4.1.6", "@types/koa-send": "^4.1.6",
"@types/koa__cors": "5.0.0",
"@types/koa__router": "12.0.4", "@types/koa__router": "12.0.4",
"@types/lodash": "4.14.200", "@types/lodash": "4.14.200",
"@types/mssql": "9.1.5", "@types/mssql": "9.1.5",
@ -174,7 +176,7 @@
"tsconfig-paths": "4.0.0", "tsconfig-paths": "4.0.0",
"typescript": "5.5.2", "typescript": "5.5.2",
"update-dotenv": "1.1.1", "update-dotenv": "1.1.1",
"yargs": "13.2.4", "yargs": "^13.2.4",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"nx": { "nx": {

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,13 @@ components:
description: The ID of the table which this request is targeting. description: The ID of the table which this request is targeting.
schema: schema:
type: string type: string
viewId:
in: path
name: viewId
required: true
description: The ID of the view which this request is targeting.
schema:
type: string
rowId: rowId:
in: path in: path
name: rowId name: rowId
@ -36,7 +43,7 @@ components:
required: true required: true
description: The ID of the app which this request is targeting. description: The ID of the app which this request is targeting.
schema: schema:
default: "{{ appId }}" default: "{{appId}}"
type: string type: string
appIdUrl: appIdUrl:
in: path in: path
@ -44,7 +51,7 @@ components:
required: true required: true
description: The ID of the app which this request is targeting. description: The ID of the app which this request is targeting.
schema: schema:
default: "{{ appId }}" default: "{{appId}}"
type: string type: string
queryId: queryId:
in: path in: path
@ -442,6 +449,74 @@ components:
# TYPE budibase_quota_limit_automations gauge # TYPE budibase_quota_limit_automations gauge
budibase_quota_limit_automations 9007199254740991 budibase_quota_limit_automations 9007199254740991
view:
value:
data:
name: peopleView
tableId: ta_896a325f7e8147d2a2cda93c5d236511
schema:
name:
visible: true
readonly: false
order: 1
width: 300
age:
visible: true
readonly: true
order: 2
width: 200
salary:
visible: false
readonly: false
query:
logicalOperator: all
onEmptyFilter: none
groups:
- logicalOperator: any
filters:
- operator: string
field: name
value: John
- operator: range
field: age
value:
low: 18
high: 100
primaryDisplay: name
views:
value:
data:
- name: peopleView
tableId: ta_896a325f7e8147d2a2cda93c5d236511
schema:
name:
visible: true
readonly: false
order: 1
width: 300
age:
visible: true
readonly: true
order: 2
width: 200
salary:
visible: false
readonly: false
query:
logicalOperator: all
onEmptyFilter: none
groups:
- logicalOperator: any
filters:
- operator: string
field: name
value: John
- operator: range
field: age
value:
low: 18
high: 100
primaryDisplay: name
securitySchemes: securitySchemes:
ApiKeyAuth: ApiKeyAuth:
type: apiKey type: apiKey
@ -761,7 +836,6 @@ components:
enum: enum:
- static - static
- dynamic - dynamic
- ai
description: Defines whether this is a static or dynamic formula. description: Defines whether this is a static or dynamic formula.
- type: object - type: object
properties: properties:
@ -931,7 +1005,6 @@ components:
enum: enum:
- static - static
- dynamic - dynamic
- ai
description: Defines whether this is a static or dynamic formula. description: Defines whether this is a static or dynamic formula.
- type: object - type: object
properties: properties:
@ -1108,7 +1181,6 @@ components:
enum: enum:
- static - static
- dynamic - dynamic
- ai
description: Defines whether this is a static or dynamic formula. description: Defines whether this is a static or dynamic formula.
- type: object - type: object
properties: properties:
@ -1704,6 +1776,641 @@ components:
- userIds - userIds
required: required:
- data - data
view:
description: The view to be created/updated.
type: object
required:
- name
- schema
- tableId
properties:
name:
description: The name of the view.
type: string
tableId:
description: The ID of the table this view is based on.
type: string
type:
description: The type of view - standard (empty value) or calculation.
type: string
enum:
- calculation
primaryDisplay:
type: string
description: A column used to display rows from this view - usually used when
rendered in tables.
query:
description: Search parameters for view
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
onEmptyFilter:
description: If no filters match, should the view return all rows, or no rows.
type: string
enum:
- all
- none
groups:
description: A grouping of filters to be applied.
type: array
items:
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
filters:
description: A list of filters to apply
type: array
items:
type: object
properties:
operator:
type: string
description: The type of search operation which is being performed.
enum:
- equal
- notEqual
- empty
- notEmpty
- fuzzy
- string
- contains
- notContains
- containsAny
- oneOf
- range
field:
type: string
description: The field in the view to perform the search on.
value:
description: The value to search for - the type will depend on the operator in
use.
oneOf:
- type: string
- type: number
- type: boolean
- type: object
- type: array
groups:
description: A grouping of filters to be applied.
type: array
items:
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
filters:
description: A list of filters to apply
type: array
items:
type: object
properties:
operator:
type: string
description: The type of search operation which is being performed.
enum:
- equal
- notEqual
- empty
- notEmpty
- fuzzy
- string
- contains
- notContains
- containsAny
- oneOf
- range
field:
type: string
description: The field in the view to perform the search on.
value:
description: The value to search for - the type will depend on the operator in
use.
oneOf:
- type: string
- type: number
- type: boolean
- type: object
- type: array
sort:
type: object
required:
- field
properties:
field:
type: string
description: The field from the table/view schema to sort on.
order:
type: string
description: The order in which to sort.
enum:
- ascending
- descending
type:
type: string
description: The type of sort to perform (by number, or by alphabetically).
enum:
- string
- number
schema:
type: object
additionalProperties:
oneOf:
- type: object
properties:
visible:
type: boolean
description: Defines whether the column is visible or not - rows
retrieved/updated through this view will not be able to
access it.
readonly:
type: boolean
description: "When used in combination with 'visible: true' the column will be
visible in row responses but cannot be updated."
order:
type: integer
description: A number defining where the column shows up in tables, lowest being
first.
width:
type: integer
description: A width for the column, defined in pixels - this affects rendering
in tables.
column:
type: array
description: If this is a relationship column, we can set the columns we wish to
include
items:
type: object
properties:
readonly:
type: boolean
- type: object
properties:
calculationType:
type: string
description: This column should be built from a calculation, specifying a type
and field. It is important to note when a calculation is
configured all non-calculation columns will be used for
grouping.
enum:
- sum
- avg
- count
- min
- max
field:
type: string
description: The field from the table to perform the calculation on.
distinct:
type: boolean
description: Can be used in tandem with the count calculation type, to count
unique entries.
viewOutput:
type: object
properties:
data:
description: The view to be created/updated.
type: object
required:
- name
- schema
- tableId
- id
properties:
name:
description: The name of the view.
type: string
tableId:
description: The ID of the table this view is based on.
type: string
type:
description: The type of view - standard (empty value) or calculation.
type: string
enum:
- calculation
primaryDisplay:
type: string
description: A column used to display rows from this view - usually used when
rendered in tables.
query:
description: Search parameters for view
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
onEmptyFilter:
description: If no filters match, should the view return all rows, or no rows.
type: string
enum:
- all
- none
groups:
description: A grouping of filters to be applied.
type: array
items:
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
filters:
description: A list of filters to apply
type: array
items:
type: object
properties:
operator:
type: string
description: The type of search operation which is being performed.
enum:
- equal
- notEqual
- empty
- notEmpty
- fuzzy
- string
- contains
- notContains
- containsAny
- oneOf
- range
field:
type: string
description: The field in the view to perform the search on.
value:
description: The value to search for - the type will depend on the operator in
use.
oneOf:
- type: string
- type: number
- type: boolean
- type: object
- type: array
groups:
description: A grouping of filters to be applied.
type: array
items:
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
filters:
description: A list of filters to apply
type: array
items:
type: object
properties:
operator:
type: string
description: The type of search operation which is being performed.
enum:
- equal
- notEqual
- empty
- notEmpty
- fuzzy
- string
- contains
- notContains
- containsAny
- oneOf
- range
field:
type: string
description: The field in the view to perform the search on.
value:
description: The value to search for - the type will depend on the operator in
use.
oneOf:
- type: string
- type: number
- type: boolean
- type: object
- type: array
sort:
type: object
required:
- field
properties:
field:
type: string
description: The field from the table/view schema to sort on.
order:
type: string
description: The order in which to sort.
enum:
- ascending
- descending
type:
type: string
description: The type of sort to perform (by number, or by alphabetically).
enum:
- string
- number
schema:
type: object
additionalProperties:
oneOf:
- type: object
properties:
visible:
type: boolean
description: Defines whether the column is visible or not - rows
retrieved/updated through this view will not be able
to access it.
readonly:
type: boolean
description: "When used in combination with 'visible: true' the column will be
visible in row responses but cannot be updated."
order:
type: integer
description: A number defining where the column shows up in tables, lowest being
first.
width:
type: integer
description: A width for the column, defined in pixels - this affects rendering
in tables.
column:
type: array
description: If this is a relationship column, we can set the columns we wish to
include
items:
type: object
properties:
readonly:
type: boolean
- type: object
properties:
calculationType:
type: string
description: This column should be built from a calculation, specifying a type
and field. It is important to note when a calculation
is configured all non-calculation columns will be used
for grouping.
enum:
- sum
- avg
- count
- min
- max
field:
type: string
description: The field from the table to perform the calculation on.
distinct:
type: boolean
description: Can be used in tandem with the count calculation type, to count
unique entries.
id:
description: The ID of the view.
type: string
required:
- data
viewSearch:
type: object
properties:
data:
type: array
items:
description: The view to be created/updated.
type: object
required:
- name
- schema
- tableId
- id
properties:
name:
description: The name of the view.
type: string
tableId:
description: The ID of the table this view is based on.
type: string
type:
description: The type of view - standard (empty value) or calculation.
type: string
enum:
- calculation
primaryDisplay:
type: string
description: A column used to display rows from this view - usually used when
rendered in tables.
query:
description: Search parameters for view
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
onEmptyFilter:
description: If no filters match, should the view return all rows, or no rows.
type: string
enum:
- all
- none
groups:
description: A grouping of filters to be applied.
type: array
items:
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
filters:
description: A list of filters to apply
type: array
items:
type: object
properties:
operator:
type: string
description: The type of search operation which is being performed.
enum:
- equal
- notEqual
- empty
- notEmpty
- fuzzy
- string
- contains
- notContains
- containsAny
- oneOf
- range
field:
type: string
description: The field in the view to perform the search on.
value:
description: The value to search for - the type will depend on the operator in
use.
oneOf:
- type: string
- type: number
- type: boolean
- type: object
- type: array
groups:
description: A grouping of filters to be applied.
type: array
items:
type: object
properties:
logicalOperator:
description: When using groups this defines whether all of the filters must
match, or only one of them.
type: string
enum:
- all
- any
filters:
description: A list of filters to apply
type: array
items:
type: object
properties:
operator:
type: string
description: The type of search operation which is being performed.
enum:
- equal
- notEqual
- empty
- notEmpty
- fuzzy
- string
- contains
- notContains
- containsAny
- oneOf
- range
field:
type: string
description: The field in the view to perform the search on.
value:
description: The value to search for - the type will depend on the operator in
use.
oneOf:
- type: string
- type: number
- type: boolean
- type: object
- type: array
sort:
type: object
required:
- field
properties:
field:
type: string
description: The field from the table/view schema to sort on.
order:
type: string
description: The order in which to sort.
enum:
- ascending
- descending
type:
type: string
description: The type of sort to perform (by number, or by alphabetically).
enum:
- string
- number
schema:
type: object
additionalProperties:
oneOf:
- type: object
properties:
visible:
type: boolean
description: Defines whether the column is visible or not - rows
retrieved/updated through this view will not be able
to access it.
readonly:
type: boolean
description: "When used in combination with 'visible: true' the column will be
visible in row responses but cannot be updated."
order:
type: integer
description: A number defining where the column shows up in tables, lowest being
first.
width:
type: integer
description: A width for the column, defined in pixels - this affects rendering
in tables.
column:
type: array
description: If this is a relationship column, we can set the columns we wish to
include
items:
type: object
properties:
readonly:
type: boolean
- type: object
properties:
calculationType:
type: string
description: This column should be built from a calculation, specifying a type
and field. It is important to note when a
calculation is configured all non-calculation
columns will be used for grouping.
enum:
- sum
- avg
- count
- min
- max
field:
type: string
description: The field from the table to perform the calculation on.
distinct:
type: boolean
description: Can be used in tandem with the count calculation type, to count
unique entries.
id:
description: The ID of the view.
type: string
required:
- data
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
paths: paths:
@ -2136,6 +2843,32 @@ paths:
examples: examples:
search: search:
$ref: "#/components/examples/rows" $ref: "#/components/examples/rows"
"/views/{viewId}/rows/search":
post:
operationId: rowViewSearch
summary: Search for rows in a view
tags:
- rows
parameters:
- $ref: "#/components/parameters/viewId"
- $ref: "#/components/parameters/appId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/rowSearch"
responses:
"200":
description: The response will contain an array of rows that match the search
parameters.
content:
application/json:
schema:
$ref: "#/components/schemas/searchOutput"
examples:
search:
$ref: "#/components/examples/rows"
/tables: /tables:
post: post:
operationId: tableCreate operationId: tableCreate
@ -2359,4 +3092,123 @@ paths:
examples: examples:
users: users:
$ref: "#/components/examples/users" $ref: "#/components/examples/users"
/views:
post:
operationId: viewCreate
summary: Create a view
description: Create a view, this can be against an internal or external table.
tags:
- views
parameters:
- $ref: "#/components/parameters/appId"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/view"
examples:
view:
$ref: "#/components/examples/view"
responses:
"200":
description: Returns the created view, including the ID which has been generated
for it.
content:
application/json:
schema:
$ref: "#/components/schemas/viewOutput"
examples:
view:
$ref: "#/components/examples/view"
"/views/{viewId}":
put:
operationId: viewUpdate
summary: Update a view
description: Update a view, this can be against an internal or external table.
tags:
- views
parameters:
- $ref: "#/components/parameters/viewId"
- $ref: "#/components/parameters/appId"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/view"
examples:
view:
$ref: "#/components/examples/view"
responses:
"200":
description: Returns the updated view.
content:
application/json:
schema:
$ref: "#/components/schemas/viewOutput"
examples:
view:
$ref: "#/components/examples/view"
delete:
operationId: viewDestroy
summary: Delete a view
description: Delete a view, this can be against an internal or external table.
tags:
- views
parameters:
- $ref: "#/components/parameters/viewId"
- $ref: "#/components/parameters/appId"
responses:
"200":
description: Returns the deleted view.
content:
application/json:
schema:
$ref: "#/components/schemas/viewOutput"
examples:
view:
$ref: "#/components/examples/view"
get:
operationId: viewGetById
summary: Retrieve a view
description: Lookup a view, this could be internal or external.
tags:
- views
parameters:
- $ref: "#/components/parameters/viewId"
- $ref: "#/components/parameters/appId"
responses:
"200":
description: Returns the retrieved view.
content:
application/json:
schema:
$ref: "#/components/schemas/viewOutput"
examples:
view:
$ref: "#/components/examples/view"
/views/search:
post:
operationId: viewSearch
summary: Search for views
description: Based on view properties (currently only name) search for views.
tags:
- views
parameters:
- $ref: "#/components/parameters/appId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/nameSearch"
responses:
"200":
description: Returns the found views, based on the search parameters.
content:
application/json:
schema:
$ref: "#/components/schemas/viewSearch"
examples:
views:
$ref: "#/components/examples/views"
tags: [] tags: []

View File

@ -8,6 +8,16 @@ export const tableId = {
}, },
} }
export const viewId = {
in: "path",
name: "viewId",
required: true,
description: "The ID of the view which this request is targeting.",
schema: {
type: "string",
},
}
export const rowId = { export const rowId = {
in: "path", in: "path",
name: "rowId", name: "rowId",

View File

@ -6,6 +6,7 @@ import user from "./user"
import metrics from "./metrics" import metrics from "./metrics"
import misc from "./misc" import misc from "./misc"
import roles from "./roles" import roles from "./roles"
import view from "./view"
export const examples = { export const examples = {
...application.getExamples(), ...application.getExamples(),
@ -16,6 +17,7 @@ export const examples = {
...misc.getExamples(), ...misc.getExamples(),
...metrics.getExamples(), ...metrics.getExamples(),
...roles.getExamples(), ...roles.getExamples(),
...view.getExamples(),
} }
export const schemas = { export const schemas = {
@ -26,4 +28,5 @@ export const schemas = {
...user.getSchemas(), ...user.getSchemas(),
...misc.getSchemas(), ...misc.getSchemas(),
...roles.getSchemas(), ...roles.getSchemas(),
...view.getSchemas(),
} }

View File

@ -1,99 +1,101 @@
import { object } from "./utils" import { object } from "./utils"
import Resource from "./utils/Resource" import Resource from "./utils/Resource"
export const searchSchema = {
type: "object",
properties: {
allOr: {
type: "boolean",
description:
"Specifies that a row should be returned if it satisfies any of the specified options, rather than requiring it to fulfill all the search parameters. This defaults to false, meaning AND logic will be used.",
},
string: {
type: "object",
example: {
columnName1: "value",
columnName2: "value",
},
description:
"A map of field name to the string to search for, this will look for rows that have a value starting with the string value.",
additionalProperties: {
type: "string",
description: "The value to search for in the column.",
},
},
fuzzy: {
type: "object",
description:
"Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.",
},
range: {
type: "object",
description:
'Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.',
example: {
columnName1: {
low: 10,
high: 20,
},
},
},
equal: {
type: "object",
description:
"Searches for rows that have a column value that is exactly the value set.",
},
notEqual: {
type: "object",
description:
"Searches for any row which does not contain the specified column value.",
},
empty: {
type: "object",
description:
"Searches for rows which do not contain the specified column. The object should simply contain keys of the column names, these can map to any value.",
example: {
columnName1: "",
},
},
notEmpty: {
type: "object",
description: "Searches for rows which have the specified column.",
},
oneOf: {
type: "object",
description:
"Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].",
},
contains: {
type: "object",
description:
"Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.",
example: {
arrayColumn: ["a", "b"],
},
},
notContains: {
type: "object",
description:
"The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.",
example: {
arrayColumn: ["a", "b"],
},
},
containsAny: {
type: "object",
description:
"As with the contains search, only works on array column types and searches for any of the provided values when given an array.",
example: {
arrayColumn: ["a", "b"],
},
},
},
}
export default new Resource().setSchemas({ export default new Resource().setSchemas({
rowSearch: object( rowSearch: object(
{ {
query: { query: searchSchema,
type: "object",
properties: {
allOr: {
type: "boolean",
description:
"Specifies that a row should be returned if it satisfies any of the specified options, rather than requiring it to fulfill all the search parameters. This defaults to false, meaning AND logic will be used.",
},
string: {
type: "object",
example: {
columnName1: "value",
columnName2: "value",
},
description:
"A map of field name to the string to search for, this will look for rows that have a value starting with the string value.",
additionalProperties: {
type: "string",
description: "The value to search for in the column.",
},
},
fuzzy: {
type: "object",
description:
"Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.",
},
range: {
type: "object",
description:
'Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.',
example: {
columnName1: {
low: 10,
high: 20,
},
},
},
equal: {
type: "object",
description:
"Searches for rows that have a column value that is exactly the value set.",
},
notEqual: {
type: "object",
description:
"Searches for any row which does not contain the specified column value.",
},
empty: {
type: "object",
description:
"Searches for rows which do not contain the specified column. The object should simply contain keys of the column names, these can map to any value.",
example: {
columnName1: "",
},
},
notEmpty: {
type: "object",
description: "Searches for rows which have the specified column.",
},
oneOf: {
type: "object",
description:
"Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].",
},
contains: {
type: "object",
description:
"Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.",
example: {
arrayColumn: ["a", "b"],
},
},
notContains: {
type: "object",
description:
"The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.",
example: {
arrayColumn: ["a", "b"],
},
},
containsAny: {
type: "object",
description:
"As with the contains search, only works on array column types and searches for any of the provided values when given an array.",
example: {
arrayColumn: ["a", "b"],
},
},
},
},
paginate: { paginate: {
type: "boolean", type: "boolean",
description: "Enables pagination, by default this is disabled.", description: "Enables pagination, by default this is disabled.",

View File

@ -0,0 +1,273 @@
import { object } from "./utils"
import Resource from "./utils/Resource"
import {
ArrayOperator,
BasicOperator,
CalculationType,
RangeOperator,
SortOrder,
SortType,
} from "@budibase/types"
import { cloneDeep } from "lodash"
const view = {
name: "peopleView",
tableId: "ta_896a325f7e8147d2a2cda93c5d236511",
schema: {
name: {
visible: true,
readonly: false,
order: 1,
width: 300,
},
age: {
visible: true,
readonly: true,
order: 2,
width: 200,
},
salary: {
visible: false,
readonly: false,
},
},
query: {
logicalOperator: "all",
onEmptyFilter: "none",
groups: [
{
logicalOperator: "any",
filters: [
{ operator: "string", field: "name", value: "John" },
{ operator: "range", field: "age", value: { low: 18, high: 100 } },
],
},
],
},
primaryDisplay: "name",
}
const baseColumnDef = {
visible: {
type: "boolean",
description:
"Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it.",
},
readonly: {
type: "boolean",
description:
"When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated.",
},
order: {
type: "integer",
description:
"A number defining where the column shows up in tables, lowest being first.",
},
width: {
type: "integer",
description:
"A width for the column, defined in pixels - this affects rendering in tables.",
},
column: {
type: "array",
description:
"If this is a relationship column, we can set the columns we wish to include",
items: {
type: "object",
properties: {
readonly: {
type: "boolean",
},
},
},
},
}
const logicalOperator = {
description:
"When using groups this defines whether all of the filters must match, or only one of them.",
type: "string",
enum: ["all", "any"],
}
const filterGroup = {
description: "A grouping of filters to be applied.",
type: "array",
items: {
type: "object",
properties: {
logicalOperator,
filters: {
description: "A list of filters to apply",
type: "array",
items: {
type: "object",
properties: {
operator: {
type: "string",
description:
"The type of search operation which is being performed.",
enum: [
...Object.values(BasicOperator),
...Object.values(ArrayOperator),
...Object.values(RangeOperator),
],
},
field: {
type: "string",
description: "The field in the view to perform the search on.",
},
value: {
description:
"The value to search for - the type will depend on the operator in use.",
oneOf: [
{ type: "string" },
{ type: "number" },
{ type: "boolean" },
{ type: "object" },
{ type: "array" },
],
},
},
},
},
},
},
}
// have to clone to avoid constantly recursive structure - we can't represent this easily
const layeredFilterGroup: any = cloneDeep(filterGroup)
layeredFilterGroup.items.properties.groups = filterGroup
const viewQuerySchema = {
description: "Search parameters for view",
type: "object",
properties: {
logicalOperator,
onEmptyFilter: {
description:
"If no filters match, should the view return all rows, or no rows.",
type: "string",
enum: ["all", "none"],
},
groups: layeredFilterGroup,
},
}
const viewSchema = {
description: "The view to be created/updated.",
type: "object",
required: ["name", "schema", "tableId"],
properties: {
name: {
description: "The name of the view.",
type: "string",
},
tableId: {
description: "The ID of the table this view is based on.",
type: "string",
},
type: {
description: "The type of view - standard (empty value) or calculation.",
type: "string",
enum: ["calculation"],
},
primaryDisplay: {
type: "string",
description:
"A column used to display rows from this view - usually used when rendered in tables.",
},
query: viewQuerySchema,
sort: {
type: "object",
required: ["field"],
properties: {
field: {
type: "string",
description: "The field from the table/view schema to sort on.",
},
order: {
type: "string",
description: "The order in which to sort.",
enum: Object.values(SortOrder),
},
type: {
type: "string",
description:
"The type of sort to perform (by number, or by alphabetically).",
enum: Object.values(SortType),
},
},
},
schema: {
type: "object",
additionalProperties: {
oneOf: [
{
type: "object",
properties: baseColumnDef,
},
{
type: "object",
properties: {
calculationType: {
type: "string",
description:
"This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.",
enum: Object.values(CalculationType),
},
field: {
type: "string",
description:
"The field from the table to perform the calculation on.",
},
distinct: {
type: "boolean",
description:
"Can be used in tandem with the count calculation type, to count unique entries.",
},
},
},
],
},
},
},
}
const viewOutputSchema = {
...viewSchema,
properties: {
...viewSchema.properties,
id: {
description: "The ID of the view.",
type: "string",
},
},
required: [...viewSchema.required, "id"],
}
export default new Resource()
.setExamples({
view: {
value: {
data: view,
},
},
views: {
value: {
data: [view],
},
},
})
.setSchemas({
view: viewSchema,
viewOutput: object({
data: viewOutputSchema,
}),
viewSearch: object({
data: {
type: "array",
items: viewOutputSchema,
},
}),
})

View File

@ -1,6 +1,7 @@
import { Application } from "./types" import { Application } from "./types"
import { RequiredKeys } from "@budibase/types"
function application(body: any): Application { function application(body: any): RequiredKeys<Application> {
let app = body?.application ? body.application : body let app = body?.application ? body.application : body
return { return {
_id: app.appId, _id: app.appId,

View File

@ -3,6 +3,7 @@ import applications from "./applications"
import users from "./users" import users from "./users"
import rows from "./rows" import rows from "./rows"
import queries from "./queries" import queries from "./queries"
import views from "./views"
export default { export default {
...tables, ...tables,
@ -10,4 +11,5 @@ export default {
...users, ...users,
...rows, ...rows,
...queries, ...queries,
...views,
} }

View File

@ -1,6 +1,7 @@
import { Query, ExecuteQuery } from "./types" import { Query, ExecuteQuery } from "./types"
import { RequiredKeys } from "@budibase/types"
function query(body: any): Query { function query(body: any): RequiredKeys<Query> {
return { return {
_id: body._id, _id: body._id,
datasourceId: body.datasourceId, datasourceId: body.datasourceId,

View File

@ -1,6 +1,7 @@
import { Row, RowSearch } from "./types" import { Row, RowSearch } from "./types"
import { RequiredKeys } from "@budibase/types"
function row(body: any): Row { function row(body: any): RequiredKeys<Row> {
delete body._rev delete body._rev
// have to input everything, since structure unknown // have to input everything, since structure unknown
return { return {

View File

@ -1,6 +1,7 @@
import { Table } from "./types" import { Table } from "./types"
import { RequiredKeys } from "@budibase/types"
function table(body: any): Table { function table(body: any): RequiredKeys<Table> {
return { return {
_id: body._id, _id: body._id,
name: body.name, name: body.name,

View File

@ -9,6 +9,9 @@ export type CreateApplicationParams = components["schemas"]["application"]
export type Table = components["schemas"]["tableOutput"]["data"] export type Table = components["schemas"]["tableOutput"]["data"]
export type CreateTableParams = components["schemas"]["table"] export type CreateTableParams = components["schemas"]["table"]
export type View = components["schemas"]["viewOutput"]["data"]
export type CreateViewParams = components["schemas"]["view"]
export type Row = components["schemas"]["rowOutput"]["data"] export type Row = components["schemas"]["rowOutput"]["data"]
export type RowSearch = components["schemas"]["searchOutput"] export type RowSearch = components["schemas"]["searchOutput"]
export type CreateRowParams = components["schemas"]["row"] export type CreateRowParams = components["schemas"]["row"]

View File

@ -1,6 +1,7 @@
import { User } from "./types" import { User } from "./types"
import { RequiredKeys } from "@budibase/types"
function user(body: any): User { function user(body: any): RequiredKeys<User> {
return { return {
_id: body._id, _id: body._id,
email: body.email, email: body.email,

View File

@ -0,0 +1,32 @@
import { View } from "./types"
import { ViewV2, Ctx, RequiredKeys } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core"
function view(body: ViewV2): RequiredKeys<View> {
return {
id: body.id,
tableId: body.tableId,
type: body.type,
name: body.name,
schema: body.schema!,
primaryDisplay: body.primaryDisplay,
query: dataFilters.buildQuery(body.query),
sort: body.sort,
}
}
function mapView(ctx: Ctx<{ data: ViewV2 }>): { data: View } {
return {
data: view(ctx.body.data),
}
}
function mapViews(ctx: Ctx<{ data: ViewV2[] }>): { data: View[] } {
const views = ctx.body.data.map((body: ViewV2) => view(body))
return { data: views }
}
export default {
mapView,
mapViews,
}

View File

@ -22,13 +22,13 @@ export function fixRow(row: Row, params: any) {
return row return row
} }
export async function search(ctx: UserCtx, next: Next) { function buildSearchRequestBody(ctx: UserCtx) {
let { sort, paginate, bookmark, limit, query } = ctx.request.body let { sort, paginate, bookmark, limit, query } = ctx.request.body
// update the body to the correct format of the internal search // update the body to the correct format of the internal search
if (!sort) { if (!sort) {
sort = {} sort = {}
} }
ctx.request.body = { return {
sort: sort.column, sort: sort.column,
sortType: sort.type, sortType: sort.type,
sortOrder: sort.order, sortOrder: sort.order,
@ -37,10 +37,23 @@ export async function search(ctx: UserCtx, next: Next) {
limit, limit,
query, query,
} }
}
export async function search(ctx: UserCtx, next: Next) {
ctx.request.body = buildSearchRequestBody(ctx)
await rowController.search(ctx) await rowController.search(ctx)
await next() await next()
} }
export async function viewSearch(ctx: UserCtx, next: Next) {
ctx.request.body = buildSearchRequestBody(ctx)
ctx.params = {
viewId: ctx.params.viewId,
}
await rowController.views.searchView(ctx)
await next()
}
export async function create(ctx: UserCtx, next: Next) { export async function create(ctx: UserCtx, next: Next) {
ctx.request.body = fixRow(ctx.request.body, ctx.params) ctx.request.body = fixRow(ctx.request.body, ctx.params)
await rowController.save(ctx) await rowController.save(ctx)
@ -79,4 +92,5 @@ export default {
update, update,
destroy, destroy,
search, search,
viewSearch,
} }

View File

@ -0,0 +1,95 @@
import { search as stringSearch } from "./utils"
import * as controller from "../view"
import { ViewV2, UserCtx, UISearchFilter, PublicAPIView } from "@budibase/types"
import { Next } from "koa"
import { merge } from "lodash"
function viewRequest(view: PublicAPIView, params?: { viewId: string }) {
const viewV2: ViewV2 = view
if (!viewV2) {
return viewV2
}
if (params?.viewId) {
viewV2.id = params.viewId
}
if (!view.query) {
viewV2.query = {}
} else {
// public API only has one form of query
viewV2.queryUI = viewV2.query as UISearchFilter
}
viewV2.version = 2
return viewV2
}
function viewResponse(view: ViewV2): PublicAPIView {
// remove our internal structure - always un-necessary
delete view.query
return {
...view,
query: view.queryUI,
}
}
function viewsResponse(views: ViewV2[]): PublicAPIView[] {
return views.map(viewResponse)
}
export async function search(ctx: UserCtx, next: Next) {
const { name } = ctx.request.body
await controller.v2.fetch(ctx)
ctx.body.data = viewsResponse(stringSearch(ctx.body.data, name))
await next()
}
export async function create(ctx: UserCtx, next: Next) {
ctx = merge(ctx, {
request: {
body: viewRequest(ctx.request.body),
},
})
await controller.v2.create(ctx)
ctx.body.data = viewResponse(ctx.body.data)
await next()
}
export async function read(ctx: UserCtx, next: Next) {
ctx = merge(ctx, {
params: {
viewId: ctx.params.viewId,
},
})
await controller.v2.get(ctx)
ctx.body.data = viewResponse(ctx.body.data)
await next()
}
export async function update(ctx: UserCtx, next: Next) {
const viewId = ctx.params.viewId
ctx = merge(ctx, {
request: {
body: {
data: viewRequest(ctx.request.body, { viewId }),
},
},
params: {
viewId,
},
})
await controller.v2.update(ctx)
ctx.body.data = viewResponse(ctx.body.data)
await next()
}
export async function destroy(ctx: UserCtx, next: Next) {
await controller.v2.remove(ctx)
await next()
}
export default {
create,
read,
update,
destroy,
search,
}

View File

@ -51,6 +51,7 @@ export async function searchView(
result.rows.forEach(r => (r._viewId = view.id)) result.rows.forEach(r => (r._viewId = view.id))
ctx.body = result ctx.body = result
} }
function getSortOptions(request: SearchViewRowRequest, view: ViewV2) { function getSortOptions(request: SearchViewRowRequest, view: ViewV2) {
if (request.sort) { if (request.sort) {
return { return {

View File

@ -12,6 +12,7 @@ import {
RelationSchemaField, RelationSchemaField,
ViewFieldMetadata, ViewFieldMetadata,
CalculationType, CalculationType,
ViewFetchResponseEnriched,
CountDistinctCalculationFieldMetadata, CountDistinctCalculationFieldMetadata,
CountCalculationFieldMetadata, CountCalculationFieldMetadata,
} from "@budibase/types" } from "@budibase/types"
@ -125,6 +126,12 @@ export async function get(ctx: Ctx<void, ViewResponseEnriched>) {
} }
} }
export async function fetch(ctx: Ctx<void, ViewFetchResponseEnriched>) {
ctx.body = {
data: await sdk.views.getAllEnriched(),
}
}
export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) { export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
const view = ctx.request.body const view = ctx.request.body
const { tableId } = view const { tableId } = view

View File

@ -4,19 +4,21 @@ import queryEndpoints from "./queries"
import tableEndpoints from "./tables" import tableEndpoints from "./tables"
import rowEndpoints from "./rows" import rowEndpoints from "./rows"
import userEndpoints from "./users" import userEndpoints from "./users"
import viewEndpoints from "./views"
import roleEndpoints from "./roles" import roleEndpoints from "./roles"
import authorized from "../../../middleware/authorized" import authorized from "../../../middleware/authorized"
import publicApi from "../../../middleware/publicApi" import publicApi from "../../../middleware/publicApi"
import { paramResource, paramSubResource } from "../../../middleware/resourceId" import { paramResource, paramSubResource } from "../../../middleware/resourceId"
import { PermissionType, PermissionLevel } from "@budibase/types" import { PermissionLevel, PermissionType } from "@budibase/types"
import { CtxFn } from "./utils/Endpoint" import { CtxFn } from "./utils/Endpoint"
import mapperMiddleware from "./middleware/mapper" import mapperMiddleware from "./middleware/mapper"
import env from "../../../environment" import env from "../../../environment"
import { middleware, redis } from "@budibase/backend-core"
import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils"
import cors from "@koa/cors"
// below imports don't have declaration files // below imports don't have declaration files
const Router = require("@koa/router") const Router = require("@koa/router")
const { RateLimit, Stores } = require("koa2-ratelimit") const { RateLimit, Stores } = require("koa2-ratelimit")
import { middleware, redis } from "@budibase/backend-core"
import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils"
interface KoaRateLimitOptions { interface KoaRateLimitOptions {
socket: { socket: {
@ -81,6 +83,7 @@ const publicRouter = new Router({
if (limiter && !env.isDev()) { if (limiter && !env.isDev()) {
publicRouter.use(limiter) publicRouter.use(limiter)
} }
publicRouter.use(cors())
function addMiddleware( function addMiddleware(
endpoints: any, endpoints: any,
@ -149,6 +152,7 @@ applyAdminRoutes(metricEndpoints)
applyAdminRoutes(roleEndpoints) applyAdminRoutes(roleEndpoints)
applyRoutes(appEndpoints, PermissionType.APP, "appId") applyRoutes(appEndpoints, PermissionType.APP, "appId")
applyRoutes(tableEndpoints, PermissionType.TABLE, "tableId") applyRoutes(tableEndpoints, PermissionType.TABLE, "tableId")
applyRoutes(viewEndpoints, PermissionType.VIEW, "viewId")
applyRoutes(userEndpoints, PermissionType.USER, "userId") applyRoutes(userEndpoints, PermissionType.USER, "userId")
applyRoutes(queryEndpoints, PermissionType.QUERY, "queryId") applyRoutes(queryEndpoints, PermissionType.QUERY, "queryId")
// needs to be applied last for routing purposes, don't override other endpoints // needs to be applied last for routing purposes, don't override other endpoints

View File

@ -1,9 +1,10 @@
import { Ctx } from "@budibase/types" import { Ctx } from "@budibase/types"
import mapping from "../../../controllers/public/mapping" import mapping from "../../../controllers/public/mapping"
enum Resources { enum Resource {
APPLICATION = "applications", APPLICATION = "applications",
TABLES = "tables", TABLES = "tables",
VIEWS = "views",
ROWS = "rows", ROWS = "rows",
USERS = "users", USERS = "users",
QUERIES = "queries", QUERIES = "queries",
@ -15,7 +16,7 @@ function isAttachment(ctx: Ctx) {
} }
function isArrayResponse(ctx: Ctx) { function isArrayResponse(ctx: Ctx) {
return ctx.url.endsWith(Resources.SEARCH) || Array.isArray(ctx.body) return ctx.url.endsWith(Resource.SEARCH) || Array.isArray(ctx.body)
} }
function noResponse(ctx: Ctx) { function noResponse(ctx: Ctx) {
@ -38,6 +39,14 @@ function processTables(ctx: Ctx) {
} }
} }
function processViews(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapViews(ctx)
} else {
return mapping.mapView(ctx)
}
}
function processRows(ctx: Ctx) { function processRows(ctx: Ctx) {
if (isArrayResponse(ctx)) { if (isArrayResponse(ctx)) {
return mapping.mapRowSearch(ctx) return mapping.mapRowSearch(ctx)
@ -71,20 +80,27 @@ export default async (ctx: Ctx, next: any) => {
let body = {} let body = {}
switch (urlParts[0]) { switch (urlParts[0]) {
case Resources.APPLICATION: case Resource.APPLICATION:
body = processApplications(ctx) body = processApplications(ctx)
break break
case Resources.TABLES: case Resource.TABLES:
if (urlParts[2] === Resources.ROWS) { if (urlParts[2] === Resource.ROWS) {
body = processRows(ctx) body = processRows(ctx)
} else { } else {
body = processTables(ctx) body = processTables(ctx)
} }
break break
case Resources.USERS: case Resource.VIEWS:
if (urlParts[2] === Resource.ROWS) {
body = processRows(ctx)
} else {
body = processViews(ctx)
}
break
case Resource.USERS:
body = processUsers(ctx) body = processUsers(ctx)
break break
case Resources.QUERIES: case Resource.QUERIES:
body = processQueries(ctx) body = processQueries(ctx)
break break
} }

View File

@ -1,4 +1,4 @@
import controller from "../../controllers/public/rows" import controller, { viewSearch } from "../../controllers/public/rows"
import Endpoint from "./utils/Endpoint" import Endpoint from "./utils/Endpoint"
import { externalSearchValidator } from "../utils/validators" import { externalSearchValidator } from "../utils/validators"
@ -168,4 +168,40 @@ read.push(
).addMiddleware(externalSearchValidator()) ).addMiddleware(externalSearchValidator())
) )
/**
* @openapi
* /views/{viewId}/rows/search:
* post:
* operationId: rowViewSearch
* summary: Search for rows in a view
* tags:
* - rows
* parameters:
* - $ref: '#/components/parameters/viewId'
* - $ref: '#/components/parameters/appId'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/rowSearch'
* responses:
* 200:
* description: The response will contain an array of rows that match the search parameters.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/searchOutput'
* examples:
* search:
* $ref: '#/components/examples/rows'
*/
read.push(
new Endpoint(
"post",
"/views/:viewId/rows/search",
controller.viewSearch
).addMiddleware(externalSearchValidator())
)
export default { read, write } export default { read, write }

View File

@ -1,13 +1,24 @@
import { User, Table, SearchFilters, Row } from "@budibase/types" import {
User,
Table,
SearchFilters,
Row,
ViewV2Schema,
ViewV2,
ViewV2Type,
PublicAPIView,
} from "@budibase/types"
import { HttpMethod, MakeRequestResponse, generateMakeRequest } from "./utils" import { HttpMethod, MakeRequestResponse, generateMakeRequest } from "./utils"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { Expectations } from "../../../../tests/utilities/api/base"
type RequestOpts = { internal?: boolean; appId?: string } type RequestOpts = { internal?: boolean; appId?: string }
type Response<T> = { data: T }
export interface PublicAPIExpectations { export interface PublicAPIExpectations {
status?: number status?: number
body?: Record<string, any> body?: Record<string, any>
headers?: Record<string, string>
} }
export class PublicAPIRequest { export class PublicAPIRequest {
@ -15,6 +26,7 @@ export class PublicAPIRequest {
private appId: string | undefined private appId: string | undefined
tables: PublicTableAPI tables: PublicTableAPI
views: PublicViewAPI
rows: PublicRowAPI rows: PublicRowAPI
apiKey: string apiKey: string
@ -28,6 +40,7 @@ export class PublicAPIRequest {
this.appId = appId this.appId = appId
this.tables = new PublicTableAPI(this) this.tables = new PublicTableAPI(this)
this.rows = new PublicRowAPI(this) this.rows = new PublicRowAPI(this)
this.views = new PublicViewAPI(this)
} }
static async init(config: TestConfiguration, user: User, opts?: RequestOpts) { static async init(config: TestConfiguration, user: User, opts?: RequestOpts) {
@ -59,6 +72,12 @@ export class PublicAPIRequest {
if (expectations?.body) { if (expectations?.body) {
expect(res.body).toEqual(expectations?.body) expect(res.body).toEqual(expectations?.body)
} }
if (expectations?.headers) {
for (let [header, value] of Object.entries(expectations.headers)) {
const found = res.headers[header]
expect(found?.toLowerCase()).toEqual(value)
}
}
return res.body return res.body
} }
} }
@ -73,9 +92,16 @@ export class PublicTableAPI {
async create( async create(
table: Table, table: Table,
expectations?: PublicAPIExpectations expectations?: PublicAPIExpectations
): Promise<{ data: Table }> { ): Promise<Response<Table>> {
return this.request.send("post", "/tables", table, expectations) return this.request.send("post", "/tables", table, expectations)
} }
async search(
name: string,
expectations?: PublicAPIExpectations
): Promise<Response<Table[]>> {
return this.request.send("post", "/tables/search", { name }, expectations)
}
} }
export class PublicRowAPI { export class PublicRowAPI {
@ -85,11 +111,24 @@ export class PublicRowAPI {
this.request = request this.request = request
} }
async create(
tableId: string,
row: Row,
expectations?: PublicAPIExpectations
): Promise<Response<Row>> {
return this.request.send(
"post",
`/tables/${tableId}/rows`,
row,
expectations
)
}
async search( async search(
tableId: string, tableId: string,
query: SearchFilters, query: SearchFilters,
expectations?: PublicAPIExpectations expectations?: PublicAPIExpectations
): Promise<{ data: Row[] }> { ): Promise<Response<Row[]>> {
return this.request.send( return this.request.send(
"post", "post",
`/tables/${tableId}/rows/search`, `/tables/${tableId}/rows/search`,
@ -99,4 +138,75 @@ export class PublicRowAPI {
expectations expectations
) )
} }
async viewSearch(
viewId: string,
query: SearchFilters,
expectations?: PublicAPIExpectations
): Promise<Response<Row[]>> {
return this.request.send(
"post",
`/views/${viewId}/rows/search`,
{
query,
},
expectations
)
}
}
export class PublicViewAPI {
request: PublicAPIRequest
constructor(request: PublicAPIRequest) {
this.request = request
}
async create(
view: Omit<PublicAPIView, "id" | "version">,
expectations?: PublicAPIExpectations
): Promise<Response<PublicAPIView>> {
return this.request.send("post", "/views", view, expectations)
}
async update(
viewId: string,
view: Omit<PublicAPIView, "id" | "version">,
expectations?: PublicAPIExpectations
): Promise<Response<PublicAPIView>> {
return this.request.send("put", `/views/${viewId}`, view, expectations)
}
async destroy(
viewId: string,
expectations?: PublicAPIExpectations
): Promise<void> {
return this.request.send(
"delete",
`/views/${viewId}`,
undefined,
expectations
)
}
async find(
viewId: string,
expectations?: PublicAPIExpectations
): Promise<Response<PublicAPIView>> {
return this.request.send("get", `/views/${viewId}`, undefined, expectations)
}
async search(
viewName: string,
expectations?: PublicAPIExpectations
): Promise<Response<PublicAPIView[]>> {
return this.request.send(
"post",
"/views/search",
{
name: viewName,
},
expectations
)
}
} }

View File

@ -0,0 +1,21 @@
import * as setup from "../../tests/utilities"
import { PublicAPIRequest } from "./Request"
describe("check public API security", () => {
const config = setup.getConfig()
let request: PublicAPIRequest
beforeAll(async () => {
await config.init()
request = await PublicAPIRequest.init(config, await config.globalUser())
})
it("should have Access-Control-Allow-Origin set to *", async () => {
await request.tables.search("", {
status: 200,
headers: {
"access-control-allow-origin": "*",
},
})
})
})

View File

@ -0,0 +1,95 @@
import * as setup from "../../tests/utilities"
import { basicTable } from "../../../../tests/utilities/structures"
import { BasicOperator, Table, UILogicalOperator } from "@budibase/types"
import { PublicAPIRequest } from "./Request"
import { generator } from "@budibase/backend-core/tests"
describe("check public API security", () => {
const config = setup.getConfig()
let request: PublicAPIRequest, table: Table
beforeAll(async () => {
await config.init()
request = await PublicAPIRequest.init(config, await config.globalUser())
table = (await request.tables.create(basicTable())).data
})
function baseView() {
return {
name: generator.word(),
tableId: table._id!,
query: {},
schema: {
name: {
readonly: true,
visible: true,
},
},
}
}
it("should be able to create a view", async () => {
await request.views.create(baseView(), { status: 201 })
})
it("should be able to update a view", async () => {
const view = await request.views.create(baseView(), { status: 201 })
const response = await request.views.update(view.data.id, {
...view.data,
name: "new name",
})
})
it("should be able to search views", async () => {
const viewName = "view to search for"
const view = await request.views.create(
{
...baseView(),
name: viewName,
},
{ status: 201 }
)
const results = await request.views.search(viewName, {
status: 200,
})
expect(results.data.length).toEqual(1)
expect(results.data[0].id).toEqual(view.data.id)
})
it("should be able to delete a view", async () => {
const view = await request.views.create(baseView(), { status: 201 })
const result = await request.views.destroy(view.data.id, { status: 204 })
expect(result).toBeDefined()
})
it("should be able to search rows through a view", async () => {
const row1 = await request.rows.create(
table._id!,
{ name: "hello world" },
{ status: 200 }
)
await request.rows.create(table._id!, { name: "foo bar" }, { status: 200 })
const response = await request.views.create(
{
...baseView(),
query: {
logicalOperator: UILogicalOperator.ANY,
groups: [
{
filters: [
{
operator: BasicOperator.STRING,
field: "name",
value: "hello",
},
],
},
],
},
},
{ status: 201 }
)
const results = await request.rows.viewSearch(response.data.id, {})
expect(results.data.length).toEqual(1)
})
})

View File

@ -0,0 +1,165 @@
import controller from "../../controllers/public/views"
import Endpoint from "./utils/Endpoint"
import { viewValidator, nameValidator } from "../utils/validators"
const read = [],
write = []
/**
* @openapi
* /views:
* post:
* operationId: viewCreate
* summary: Create a view
* description: Create a view, this can be against an internal or external table.
* tags:
* - views
* parameters:
* - $ref: '#/components/parameters/appId'
* requestBody:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/view'
* examples:
* view:
* $ref: '#/components/examples/view'
* responses:
* 200:
* description: Returns the created view, including the ID which has been generated for it.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/viewOutput'
* examples:
* view:
* $ref: '#/components/examples/view'
*/
write.push(
new Endpoint("post", "/views", controller.create).addMiddleware(
viewValidator()
)
)
/**
* @openapi
* /views/{viewId}:
* put:
* operationId: viewUpdate
* summary: Update a view
* description: Update a view, this can be against an internal or external table.
* tags:
* - views
* parameters:
* - $ref: '#/components/parameters/viewId'
* - $ref: '#/components/parameters/appId'
* requestBody:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/view'
* examples:
* view:
* $ref: '#/components/examples/view'
* responses:
* 200:
* description: Returns the updated view.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/viewOutput'
* examples:
* view:
* $ref: '#/components/examples/view'
*/
write.push(
new Endpoint("put", "/views/:viewId", controller.update).addMiddleware(
viewValidator()
)
)
/**
* @openapi
* /views/{viewId}:
* delete:
* operationId: viewDestroy
* summary: Delete a view
* description: Delete a view, this can be against an internal or external table.
* tags:
* - views
* parameters:
* - $ref: '#/components/parameters/viewId'
* - $ref: '#/components/parameters/appId'
* responses:
* 200:
* description: Returns the deleted view.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/viewOutput'
* examples:
* view:
* $ref: '#/components/examples/view'
*/
write.push(new Endpoint("delete", "/views/:viewId", controller.destroy))
/**
* @openapi
* /views/{viewId}:
* get:
* operationId: viewGetById
* summary: Retrieve a view
* description: Lookup a view, this could be internal or external.
* tags:
* - views
* parameters:
* - $ref: '#/components/parameters/viewId'
* - $ref: '#/components/parameters/appId'
* responses:
* 200:
* description: Returns the retrieved view.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/viewOutput'
* examples:
* view:
* $ref: '#/components/examples/view'
*/
read.push(new Endpoint("get", "/views/:viewId", controller.read))
/**
* @openapi
* /views/search:
* post:
* operationId: viewSearch
* summary: Search for views
* description: Based on view properties (currently only name) search for views.
* tags:
* - views
* parameters:
* - $ref: '#/components/parameters/appId'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/nameSearch'
* responses:
* 200:
* description: Returns the found views, based on the search parameters.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/viewSearch'
* examples:
* views:
* $ref: '#/components/examples/views'
*/
read.push(
new Endpoint("post", "/views/search", controller.search).addMiddleware(
nameValidator()
)
)
export default { read, write }

View File

@ -2592,6 +2592,33 @@ if (descriptions.length) {
}) })
}) })
describe("fetch", () => {
let view: ViewV2, view2: ViewV2
let table: Table, table2: Table
beforeEach(async () => {
table = await config.api.table.save(saveTableRequest())
table2 = await config.api.table.save(saveTableRequest())
view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {},
})
view2 = await config.api.viewV2.create({
tableId: table2._id!,
name: generator.guid(),
schema: {},
})
})
it("should be able to list views", async () => {
const response = await config.api.viewV2.fetch({
status: 200,
})
expect(response.data.find(v => v.id === view.id)).toBeDefined()
expect(response.data.find(v => v.id === view2.id)).toBeDefined()
})
})
describe("destroy", () => { describe("destroy", () => {
const getRowUsage = async () => { const getRowUsage = async () => {
const { total } = await config.doInContext(undefined, () => const { total } = await config.doInContext(undefined, () =>

View File

@ -9,6 +9,13 @@ import {
Table, Table,
WebhookActionType, WebhookActionType,
BuiltinPermissionID, BuiltinPermissionID,
ViewV2Type,
SortOrder,
SortType,
UILogicalOperator,
BasicOperator,
ArrayOperator,
RangeOperator,
} from "@budibase/types" } from "@budibase/types"
import Joi, { CustomValidator } from "joi" import Joi, { CustomValidator } from "joi"
import { ValidSnippetNameRegex, helpers } from "@budibase/shared-core" import { ValidSnippetNameRegex, helpers } from "@budibase/shared-core"
@ -66,6 +73,66 @@ export function tableValidator() {
) )
} }
function searchUIFilterValidator() {
const logicalOperator = Joi.string().valid(
...Object.values(UILogicalOperator)
)
const operators = [
...Object.values(BasicOperator),
...Object.values(ArrayOperator),
...Object.values(RangeOperator),
]
const filters = Joi.array().items(
Joi.object({
operator: Joi.string()
.valid(...operators)
.required(),
field: Joi.string().required(),
// could do with better validation of value based on operator
value: Joi.any().required(),
})
)
return Joi.object({
logicalOperator,
onEmptyFilter: Joi.string().valid(...Object.values(EmptyFilterOption)),
groups: Joi.array().items(
Joi.object({
logicalOperator,
filters,
groups: Joi.array().items(
Joi.object({
filters,
logicalOperator,
})
),
})
),
})
}
export function viewValidator() {
return auth.joiValidator.body(
Joi.object({
id: OPTIONAL_STRING,
tableId: Joi.string().required(),
name: Joi.string().required(),
type: Joi.string().optional().valid(null, ViewV2Type.CALCULATION),
primaryDisplay: OPTIONAL_STRING,
schema: Joi.object().required(),
query: searchUIFilterValidator().optional(),
sort: Joi.object({
field: Joi.string().required(),
order: Joi.string()
.optional()
.valid(...Object.values(SortOrder)),
type: Joi.string()
.optional()
.valid(...Object.values(SortType)),
}).optional(),
})
)
}
export function nameValidator() { export function nameValidator() {
return auth.joiValidator.body( return auth.joiValidator.body(
Joi.object({ Joi.object({
@ -91,8 +158,7 @@ export function datasourceValidator() {
) )
} }
function filterObject(opts?: { unknown: boolean }) { function searchFiltersValidator() {
const { unknown = true } = opts || {}
const conditionalFilteringObject = () => const conditionalFilteringObject = () =>
Joi.object({ Joi.object({
conditions: Joi.array().items(Joi.link("#schema")).required(), conditions: Joi.array().items(Joi.link("#schema")).required(),
@ -119,7 +185,14 @@ function filterObject(opts?: { unknown: boolean }) {
fuzzyOr: Joi.forbidden(), fuzzyOr: Joi.forbidden(),
documentType: Joi.forbidden(), documentType: Joi.forbidden(),
} }
return Joi.object(filtersValidators).unknown(unknown).id("schema")
return Joi.object(filtersValidators)
}
function filterObject(opts?: { unknown: boolean }) {
const { unknown = true } = opts || {}
return searchFiltersValidator().unknown(unknown).id("schema")
} }
export function internalSearchValidator() { export function internalSearchValidator() {

View File

@ -8,6 +8,11 @@ import { permissions } from "@budibase/backend-core"
const router: Router = new Router() const router: Router = new Router()
router router
.get(
"/api/v2/views",
authorized(permissions.BUILDER),
viewController.v2.fetch
)
.get( .get(
"/api/v2/views/:viewId", "/api/v2/views/:viewId",
authorizedResource( authorizedResource(

View File

@ -61,6 +61,9 @@ export async function run({
inputs: ServerLogStepInputs inputs: ServerLogStepInputs
appId: string appId: string
}): Promise<ServerLogStepOutputs> { }): Promise<ServerLogStepOutputs> {
if (typeof inputs.text !== "string") {
inputs.text = JSON.stringify(inputs.text)
}
const message = `App ${appId} - ${inputs.text}` const message = `App ${appId} - ${inputs.text}`
console.log(message) console.log(message)
return { return {

View File

@ -1,6 +1,7 @@
import * as setup from "./utilities" import * as setup from "./utilities"
import { basicTableWithAttachmentField } from "../../tests/utilities/structures" import { basicTableWithAttachmentField } from "../../tests/utilities/structures"
import { objectStore } from "@budibase/backend-core" import { objectStore } from "@budibase/backend-core"
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
async function uploadTestFile(filename: string) { async function uploadTestFile(filename: string) {
let bucket = "testbucket" let bucket = "testbucket"
@ -13,6 +14,7 @@ async function uploadTestFile(filename: string) {
return presignedUrl return presignedUrl
} }
describe("test the create row action", () => { describe("test the create row action", () => {
let table: any let table: any
let row: any let row: any
@ -31,30 +33,78 @@ describe("test the create row action", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
it("should be able to run the action", async () => { it("should be able to run the action", async () => {
const res = await setup.runStep(config, setup.actions.CREATE_ROW.stepId, { const result = await createAutomationBuilder({
row, name: "Test Create Row Flow",
appId: config.getAppId(),
config,
}) })
expect(res.id).toBeDefined() .appAction({ fields: { status: "new" } })
expect(res.revision).toBeDefined() .serverLog({ text: "Starting create row flow" }, { stepName: "StartLog" })
expect(res.success).toEqual(true) .createRow({ row }, { stepName: "CreateRow" })
const gottenRow = await config.api.row.get(table._id, res.id) .serverLog(
{ text: "Row created with ID: {{ stepsByName.CreateRow.row._id }}" },
{ stepName: "CreationLog" }
)
.run()
expect(result.steps[1].outputs.success).toBeDefined()
expect(result.steps[1].outputs.id).toBeDefined()
expect(result.steps[1].outputs.revision).toBeDefined()
const gottenRow = await config.api.row.get(
table._id,
result.steps[1].outputs.id
)
expect(gottenRow.name).toEqual("test") expect(gottenRow.name).toEqual("test")
expect(gottenRow.description).toEqual("test") expect(gottenRow.description).toEqual("test")
expect(result.steps[2].outputs.message).toContain(
"Row created with ID: " + result.steps[1].outputs.id
)
}) })
it("should return an error (not throw) when bad info provided", async () => { it("should return an error (not throw) when bad info provided", async () => {
const res = await setup.runStep(config, setup.actions.CREATE_ROW.stepId, { const result = await createAutomationBuilder({
row: { name: "Test Create Row Error Flow",
tableId: "invalid", appId: config.getAppId(),
invalid: "invalid", config,
},
}) })
expect(res.success).toEqual(false) .appAction({ fields: { status: "error" } })
.serverLog({ text: "Starting error test flow" }, { stepName: "StartLog" })
.createRow(
{
row: {
tableId: "invalid",
invalid: "invalid",
},
},
{ stepName: "CreateRow" }
)
.run()
expect(result.steps[1].outputs.success).toEqual(false)
}) })
it("should check invalid inputs return an error", async () => { it("should check invalid inputs return an error", async () => {
const res = await setup.runStep(config, setup.actions.CREATE_ROW.stepId, {}) const result = await createAutomationBuilder({
expect(res.success).toEqual(false) name: "Test Create Row Invalid Flow",
appId: config.getAppId(),
config,
})
.appAction({ fields: { status: "invalid" } })
.serverLog({ text: "Testing invalid input" }, { stepName: "StartLog" })
.createRow({ row: {} }, { stepName: "CreateRow" })
.filter({
field: "{{ stepsByName.CreateRow.success }}",
condition: "equal",
value: true,
})
.serverLog(
{ text: "This log should not appear" },
{ stepName: "SkippedLog" }
)
.run()
expect(result.steps[1].outputs.success).toEqual(false)
expect(result.steps.length).toBeLessThan(4)
}) })
it("should check that an attachment field is sent to storage and parsed", async () => { it("should check that an attachment field is sent to storage and parsed", async () => {
@ -76,13 +126,33 @@ describe("test the create row action", () => {
] ]
attachmentRow.file_attachment = attachmentObject attachmentRow.file_attachment = attachmentObject
const res = await setup.runStep(config, setup.actions.CREATE_ROW.stepId, { const result = await createAutomationBuilder({
row: attachmentRow, name: "Test Create Row Attachment Flow",
appId: config.getAppId(),
config,
}) })
.appAction({ fields: { type: "attachment" } })
.serverLog(
{ text: "Processing attachment upload" },
{ stepName: "StartLog" }
)
.createRow({ row: attachmentRow }, { stepName: "CreateRow" })
.filter({
field: "{{ stepsByName.CreateRow.success }}",
condition: "equal",
value: true,
})
.serverLog(
{
text: "Attachment uploaded with key: {{ stepsByName.CreateRow.row.file_attachment.0.key }}",
},
{ stepName: "UploadLog" }
)
.run()
expect(res.success).toEqual(true) expect(result.steps[1].outputs.success).toEqual(true)
expect(res.row.file_attachment[0]).toHaveProperty("key") expect(result.steps[1].outputs.row.file_attachment[0]).toHaveProperty("key")
let s3Key = res.row.file_attachment[0].key let s3Key = result.steps[1].outputs.row.file_attachment[0].key
const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS) const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS)
@ -111,13 +181,53 @@ describe("test the create row action", () => {
} }
attachmentRow.single_file_attachment = attachmentObject attachmentRow.single_file_attachment = attachmentObject
const res = await setup.runStep(config, setup.actions.CREATE_ROW.stepId, { const result = await createAutomationBuilder({
row: attachmentRow, name: "Test Create Row Single Attachment Flow",
appId: config.getAppId(),
config,
}) })
.appAction({ fields: { type: "single-attachment" } })
.serverLog(
{ text: "Processing single attachment" },
{ stepName: "StartLog" }
)
.createRow({ row: attachmentRow }, { stepName: "CreateRow" })
.branch({
success: {
steps: stepBuilder =>
stepBuilder
.serverLog(
{ text: "Single attachment processed" },
{ stepName: "ProcessLog" }
)
.serverLog(
{
text: "File key: {{ stepsByName.CreateRow.row.single_file_attachment.key }}",
},
{ stepName: "KeyLog" }
),
condition: {
equal: { "{{ stepsByName.CreateRow.success }}": true },
},
},
error: {
steps: stepBuilder =>
stepBuilder.serverLog(
{ text: "Failed to process attachment" },
{ stepName: "ErrorLog" }
),
condition: {
equal: { "{{ stepsByName.CreateRow.success }}": false },
},
},
})
.run()
expect(res.success).toEqual(true) expect(result.steps[1].outputs.success).toEqual(true)
expect(res.row.single_file_attachment).toHaveProperty("key") expect(result.steps[1].outputs.row.single_file_attachment).toHaveProperty(
let s3Key = res.row.single_file_attachment.key "key"
)
let s3Key = result.steps[1].outputs.row.single_file_attachment.key
const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS) const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS)
@ -146,13 +256,50 @@ describe("test the create row action", () => {
} }
attachmentRow.single_file_attachment = attachmentObject attachmentRow.single_file_attachment = attachmentObject
const res = await setup.runStep(config, setup.actions.CREATE_ROW.stepId, { const result = await createAutomationBuilder({
row: attachmentRow, name: "Test Create Row Invalid Attachment Flow",
appId: config.getAppId(),
config,
}) })
.appAction({ fields: { type: "invalid-attachment" } })
.serverLog(
{ text: "Testing invalid attachment keys" },
{ stepName: "StartLog" }
)
.createRow({ row: attachmentRow }, { stepName: "CreateRow" })
.branch({
success: {
steps: stepBuilder =>
stepBuilder.serverLog(
{ text: "Unexpected success" },
{ stepName: "UnexpectedLog" }
),
condition: {
equal: { "{{ stepsByName.CreateRow.success }}": true },
},
},
error: {
steps: stepBuilder =>
stepBuilder
.serverLog(
{ text: "Expected error occurred" },
{ stepName: "ErrorLog" }
)
.serverLog(
{ text: "Error: {{ stepsByName.CreateRow.response }}" },
{ stepName: "ErrorDetailsLog" }
),
condition: {
equal: { "{{ stepsByName.CreateRow.success }}": false },
},
},
})
.run()
expect(res.success).toEqual(false) expect(result.steps[1].outputs.success).toEqual(false)
expect(res.response).toEqual( expect(result.steps[1].outputs.response).toEqual(
'Error: Attachments must have both "url" and "filename" keys. You have provided: wrongKey, anotherWrongKey' 'Error: Attachments must have both "url" and "filename" keys. You have provided: wrongKey, anotherWrongKey'
) )
expect(result.steps[2].outputs.status).toEqual("No branch condition met")
}) })
}) })

View File

@ -1,52 +1,65 @@
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
import * as setup from "./utilities" import * as setup from "./utilities"
describe("test the delete row action", () => { describe("test the delete row action", () => {
let table: any let table: any,
let row: any row: any,
let inputs: any config = setup.getConfig()
let config = setup.getConfig()
beforeEach(async () => { beforeAll(async () => {
await config.init() await config.init()
table = await config.createTable() table = await config.createTable()
row = await config.createRow() row = await config.createRow()
inputs = {
tableId: table._id,
id: row._id,
revision: row._rev,
}
}) })
afterAll(setup.afterAll) afterAll(setup.afterAll)
it("should be able to run the action", async () => { it("should be able to run the delete row action", async () => {
const res = await setup.runStep( const builder = createAutomationBuilder({
config, name: "Delete Row Automation",
setup.actions.DELETE_ROW.stepId, })
inputs
)
expect(res.success).toEqual(true)
expect(res.response).toBeDefined()
expect(res.row._id).toEqual(row._id)
})
it("check usage quota attempts", async () => { await builder
await setup.runInProd(async () => { .appAction({ fields: {} })
await setup.runStep(config, setup.actions.DELETE_ROW.stepId, inputs) .deleteRow({
tableId: table._id,
id: row._id,
revision: row._rev,
})
.run()
await config.api.row.get(table._id, row._id, {
status: 404,
}) })
}) })
it("should check invalid inputs return an error", async () => { it("should check invalid inputs return an error", async () => {
const res = await setup.runStep(config, setup.actions.DELETE_ROW.stepId, {}) const builder = createAutomationBuilder({
expect(res.success).toEqual(false) name: "Invalid Inputs Automation",
})
const results = await builder
.appAction({ fields: {} })
.deleteRow({ tableId: "", id: "", revision: "" })
.run()
expect(results.steps[0].outputs.success).toEqual(false)
}) })
it("should return an error when table doesn't exist", async () => { it("should return an error when table doesn't exist", async () => {
const res = await setup.runStep(config, setup.actions.DELETE_ROW.stepId, { const builder = createAutomationBuilder({
tableId: "invalid", name: "Nonexistent Table Automation",
id: "invalid",
revision: "invalid",
}) })
expect(res.success).toEqual(false)
const results = await builder
.appAction({ fields: {} })
.deleteRow({
tableId: "invalid",
id: "invalid",
revision: "invalid",
})
.run()
expect(results.steps[0].outputs.success).toEqual(false)
}) })
}) })

View File

@ -1,50 +1,123 @@
import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities" import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
import * as automation from "../index"
import * as setup from "./utilities"
import { Table } from "@budibase/types"
describe("test the execute script action", () => { describe("Execute Script Automations", () => {
let config = getConfig() let config = setup.getConfig(),
table: Table
beforeAll(async () => { beforeEach(async () => {
await automation.init()
await config.init() await config.init()
table = await config.createTable()
await config.createRow()
}) })
afterAll(_afterAll)
it("should be able to execute a script", async () => { afterAll(setup.afterAll)
const res = await runStep(config, actions.EXECUTE_SCRIPT.stepId, {
code: "return 1 + 1", it("should execute a basic script and return the result", async () => {
const builder = createAutomationBuilder({
name: "Basic Script Execution",
}) })
expect(res.value).toEqual(2)
expect(res.success).toEqual(true) const results = await builder
.appAction({ fields: {} })
.executeScript({ code: "return 2 + 2" })
.run()
expect(results.steps[0].outputs.value).toEqual(4)
}) })
it("should handle a null value", async () => { it("should access bindings from previous steps", async () => {
const res = await runStep(config, actions.EXECUTE_SCRIPT.stepId, { const builder = createAutomationBuilder({
code: null, name: "Access Bindings",
}) })
expect(res.response.message).toEqual("Invalid inputs")
expect(res.success).toEqual(false) const results = await builder
.appAction({ fields: { data: [1, 2, 3] } })
.executeScript(
{
code: "return trigger.fields.data.map(x => x * 2)",
},
{ stepId: "binding-script-step" }
)
.run()
expect(results.steps[0].outputs.value).toEqual([2, 4, 6])
}) })
it("should be able to get a value from context", async () => { it("should handle script execution errors gracefully", async () => {
const res = await runStep( const builder = createAutomationBuilder({
config, name: "Handle Script Errors",
actions.EXECUTE_SCRIPT.stepId, })
{
code: "return steps.map(d => d.value)", const results = await builder
}, .appAction({ fields: {} })
{ .executeScript({ code: "return nonexistentVariable.map(x => x)" })
steps: [{ value: 0 }, { value: 1 }], .run()
}
expect(results.steps[0].outputs.response).toContain(
"ReferenceError: nonexistentVariable is not defined"
) )
expect(res.value).toEqual([0, 1]) expect(results.steps[0].outputs.success).toEqual(false)
expect(res.response).toBeUndefined()
expect(res.success).toEqual(true)
}) })
it("should be able to handle an error gracefully", async () => { it("should handle conditional logic in scripts", async () => {
const res = await runStep(config, actions.EXECUTE_SCRIPT.stepId, { const builder = createAutomationBuilder({
code: "return something.map(x => x.name)", name: "Conditional Script Logic",
}) })
expect(res.response).toEqual("ReferenceError: something is not defined")
expect(res.success).toEqual(false) const results = await builder
.appAction({ fields: { value: 10 } })
.executeScript({
code: `
if (trigger.fields.value > 5) {
return "Value is greater than 5";
} else {
return "Value is 5 or less";
}
`,
})
.run()
expect(results.steps[0].outputs.value).toEqual("Value is greater than 5")
})
it("should use multiple steps and validate script execution", async () => {
const builder = createAutomationBuilder({
name: "Multi-Step Script Execution",
})
const results = await builder
.appAction({ fields: {} })
.serverLog(
{ text: "Starting multi-step automation" },
{ stepId: "start-log-step" }
)
.createRow(
{ row: { name: "Test Row", value: 42, tableId: table._id } },
{ stepId: "abc123" }
)
.executeScript(
{
code: `
const createdRow = steps['abc123'];
return createdRow.row.value * 2;
`,
},
{ stepId: "ScriptingStep1" }
)
.serverLog({
text: `Final result is {{ steps.ScriptingStep1.value }}`,
})
.run()
expect(results.steps[0].outputs.message).toContain(
"Starting multi-step automation"
)
expect(results.steps[1].outputs.row.value).toEqual(42)
expect(results.steps[2].outputs.value).toEqual(84)
expect(results.steps[3].outputs.message).toContain("Final result is 84")
}) })
}) })

View File

@ -8,58 +8,83 @@ import {
Table, Table,
TableSourceType, TableSourceType,
} from "@budibase/types" } from "@budibase/types"
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
import * as setup from "./utilities" import * as setup from "./utilities"
import * as uuid from "uuid" import * as uuid from "uuid"
describe("test the update row action", () => { describe("test the update row action", () => {
let table: Table, row: Row, inputs: any let table: Table,
let config = setup.getConfig() row: Row,
config = setup.getConfig()
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
table = await config.createTable() table = await config.createTable()
row = await config.createRow() row = await config.createRow()
inputs = {
rowId: row._id,
row: {
...row,
name: "Updated name",
// put a falsy option in to be removed
description: "",
},
}
}) })
afterAll(setup.afterAll) afterAll(setup.afterAll)
it("should be able to run the action", async () => { it("should be able to run the update row action", async () => {
const res = await setup.runStep( const builder = createAutomationBuilder({
config, name: "Update Row Automation",
setup.actions.UPDATE_ROW.stepId, })
inputs
const results = await builder
.appAction({ fields: {} })
.updateRow({
rowId: row._id!,
row: {
...row,
name: "Updated name",
description: "",
},
meta: {},
})
.run()
expect(results.steps[0].outputs.success).toEqual(true)
const updatedRow = await config.api.row.get(
table._id!,
results.steps[0].outputs.id
) )
expect(res.success).toEqual(true)
const updatedRow = await config.api.row.get(table._id!, res.id)
expect(updatedRow.name).toEqual("Updated name") expect(updatedRow.name).toEqual("Updated name")
expect(updatedRow.description).not.toEqual("") expect(updatedRow.description).not.toEqual("")
}) })
it("should check invalid inputs return an error", async () => { it("should check invalid inputs return an error", async () => {
const res = await setup.runStep(config, setup.actions.UPDATE_ROW.stepId, {}) const builder = createAutomationBuilder({
expect(res.success).toEqual(false) name: "Invalid Inputs Automation",
})
const results = await builder
.appAction({ fields: {} })
.updateRow({ meta: {}, row: {}, rowId: "" })
.run()
expect(results.steps[0].outputs.success).toEqual(false)
}) })
it("should return an error when table doesn't exist", async () => { it("should return an error when table doesn't exist", async () => {
const res = await setup.runStep(config, setup.actions.UPDATE_ROW.stepId, { const builder = createAutomationBuilder({
row: { _id: "invalid" }, name: "Nonexistent Table Automation",
rowId: "invalid",
}) })
expect(res.success).toEqual(false)
const results = await builder
.appAction({ fields: {} })
.updateRow({
row: { _id: "invalid" },
rowId: "invalid",
meta: {},
})
.run()
expect(results.steps[0].outputs.success).toEqual(false)
}) })
it("should not overwrite links if those links are not set", async () => { it("should not overwrite links if those links are not set", async () => {
let linkField: FieldSchema = { const linkField: FieldSchema = {
type: FieldType.LINK, type: FieldType.LINK,
name: "", name: "",
fieldName: "", fieldName: "",
@ -71,7 +96,7 @@ describe("test the update row action", () => {
tableId: InternalTable.USER_METADATA, tableId: InternalTable.USER_METADATA,
} }
let table = await config.api.table.save({ const table = await config.api.table.save({
name: uuid.v4(), name: uuid.v4(),
type: "table", type: "table",
sourceType: TableSourceType.INTERNAL, sourceType: TableSourceType.INTERNAL,
@ -82,23 +107,22 @@ describe("test the update row action", () => {
}, },
}) })
let user1 = await config.createUser() const user1 = await config.createUser()
let user2 = await config.createUser() const user2 = await config.createUser()
let row = await config.api.row.save(table._id!, { const row = await config.api.row.save(table._id!, {
user1: [{ _id: user1._id }], user1: [{ _id: user1._id }],
user2: [{ _id: user2._id }], user2: [{ _id: user2._id }],
}) })
let getResp = await config.api.row.get(table._id!, row._id!) const builder = createAutomationBuilder({
expect(getResp.user1[0]._id).toEqual(user1._id) name: "Link Preservation Automation",
expect(getResp.user2[0]._id).toEqual(user2._id) })
let stepResp = await setup.runStep( const results = await builder
config, .appAction({ fields: {} })
setup.actions.UPDATE_ROW.stepId, .updateRow({
{ rowId: row._id!,
rowId: row._id,
row: { row: {
_id: row._id, _id: row._id,
_rev: row._rev, _rev: row._rev,
@ -106,17 +130,19 @@ describe("test the update row action", () => {
user1: [user2._id], user1: [user2._id],
user2: "", user2: "",
}, },
} meta: {},
) })
expect(stepResp.success).toEqual(true) .run()
getResp = await config.api.row.get(table._id!, row._id!) expect(results.steps[0].outputs.success).toEqual(true)
const getResp = await config.api.row.get(table._id!, row._id!)
expect(getResp.user1[0]._id).toEqual(user2._id) expect(getResp.user1[0]._id).toEqual(user2._id)
expect(getResp.user2[0]._id).toEqual(user2._id) expect(getResp.user2[0]._id).toEqual(user2._id)
}) })
it("should overwrite links if those links are not set and we ask it do", async () => { it("should overwrite links if those links are not set and we ask it to", async () => {
let linkField: FieldSchema = { const linkField: FieldSchema = {
type: FieldType.LINK, type: FieldType.LINK,
name: "", name: "",
fieldName: "", fieldName: "",
@ -128,7 +154,7 @@ describe("test the update row action", () => {
tableId: InternalTable.USER_METADATA, tableId: InternalTable.USER_METADATA,
} }
let table = await config.api.table.save({ const table = await config.api.table.save({
name: uuid.v4(), name: uuid.v4(),
type: "table", type: "table",
sourceType: TableSourceType.INTERNAL, sourceType: TableSourceType.INTERNAL,
@ -139,23 +165,22 @@ describe("test the update row action", () => {
}, },
}) })
let user1 = await config.createUser() const user1 = await config.createUser()
let user2 = await config.createUser() const user2 = await config.createUser()
let row = await config.api.row.save(table._id!, { const row = await config.api.row.save(table._id!, {
user1: [{ _id: user1._id }], user1: [{ _id: user1._id }],
user2: [{ _id: user2._id }], user2: [{ _id: user2._id }],
}) })
let getResp = await config.api.row.get(table._id!, row._id!) const builder = createAutomationBuilder({
expect(getResp.user1[0]._id).toEqual(user1._id) name: "Link Overwrite Automation",
expect(getResp.user2[0]._id).toEqual(user2._id) })
let stepResp = await setup.runStep( const results = await builder
config, .appAction({ fields: {} })
setup.actions.UPDATE_ROW.stepId, .updateRow({
{ rowId: row._id!,
rowId: row._id,
row: { row: {
_id: row._id, _id: row._id,
_rev: row._rev, _rev: row._rev,
@ -170,11 +195,12 @@ describe("test the update row action", () => {
}, },
}, },
}, },
} })
) .run()
expect(stepResp.success).toEqual(true)
getResp = await config.api.row.get(table._id!, row._id!) expect(results.steps[0].outputs.success).toEqual(true)
const getResp = await config.api.row.get(table._id!, row._id!)
expect(getResp.user1[0]._id).toEqual(user2._id) expect(getResp.user1[0]._id).toEqual(user2._id)
expect(getResp.user2).toBeUndefined() expect(getResp.user2).toBeUndefined()
}) })

View File

@ -34,6 +34,7 @@ import {
SearchFilters, SearchFilters,
Branch, Branch,
FilterStepInputs, FilterStepInputs,
ExecuteScriptStepInputs,
} from "@budibase/types" } from "@budibase/types"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import * as setup from "../utilities" import * as setup from "../utilities"
@ -201,6 +202,18 @@ class BaseStepBuilder {
) )
} }
executeScript(
input: ExecuteScriptStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step(
AutomationActionStepId.EXECUTE_SCRIPT,
BUILTIN_ACTION_DEFINITIONS.EXECUTE_SCRIPT,
input,
opts
)
}
filter(input: FilterStepInputs): this { filter(input: FilterStepInputs): this {
return this.step( return this.step(
AutomationActionStepId.FILTER, AutomationActionStepId.FILTER,

View File

@ -65,6 +65,9 @@ export interface paths {
"/tables/{tableId}/rows/search": { "/tables/{tableId}/rows/search": {
post: operations["rowSearch"]; post: operations["rowSearch"];
}; };
"/views/{viewId}/rows/search": {
post: operations["rowViewSearch"];
};
"/tables": { "/tables": {
/** Create a table, this could be internal or external. */ /** Create a table, this could be internal or external. */
post: operations["tableCreate"]; post: operations["tableCreate"];
@ -93,6 +96,22 @@ export interface paths {
/** Based on user properties (currently only name) search for users. */ /** Based on user properties (currently only name) search for users. */
post: operations["userSearch"]; post: operations["userSearch"];
}; };
"/views": {
/** Create a view, this can be against an internal or external table. */
post: operations["viewCreate"];
};
"/views/{viewId}": {
/** Lookup a view, this could be internal or external. */
get: operations["viewGetById"];
/** Update a view, this can be against an internal or external table. */
put: operations["viewUpdate"];
/** Delete a view, this can be against an internal or external table. */
delete: operations["viewDestroy"];
};
"/views/search": {
/** Based on view properties (currently only name) search for views. */
post: operations["viewSearch"];
};
} }
export interface components { export interface components {
@ -813,10 +832,442 @@ export interface components {
userIds: string[]; userIds: string[];
}; };
}; };
/** @description The view to be created/updated. */
view: {
/** @description The name of the view. */
name: string;
/** @description The ID of the table this view is based on. */
tableId: string;
/**
* @description The type of view - standard (empty value) or calculation.
* @enum {string}
*/
type?: "calculation";
/** @description A column used to display rows from this view - usually used when rendered in tables. */
primaryDisplay?: string;
/** @description Search parameters for view */
query?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/**
* @description If no filters match, should the view return all rows, or no rows.
* @enum {string}
*/
onEmptyFilter?: "all" | "none";
/** @description A grouping of filters to be applied. */
groups?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/** @description A list of filters to apply */
filters?: {
/**
* @description The type of search operation which is being performed.
* @enum {string}
*/
operator?:
| "equal"
| "notEqual"
| "empty"
| "notEmpty"
| "fuzzy"
| "string"
| "contains"
| "notContains"
| "containsAny"
| "oneOf"
| "range";
/** @description The field in the view to perform the search on. */
field?: string;
/** @description The value to search for - the type will depend on the operator in use. */
value?:
| string
| number
| boolean
| { [key: string]: unknown }
| unknown[];
}[];
/** @description A grouping of filters to be applied. */
groups?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/** @description A list of filters to apply */
filters?: {
/**
* @description The type of search operation which is being performed.
* @enum {string}
*/
operator?:
| "equal"
| "notEqual"
| "empty"
| "notEmpty"
| "fuzzy"
| "string"
| "contains"
| "notContains"
| "containsAny"
| "oneOf"
| "range";
/** @description The field in the view to perform the search on. */
field?: string;
/** @description The value to search for - the type will depend on the operator in use. */
value?:
| string
| number
| boolean
| { [key: string]: unknown }
| unknown[];
}[];
}[];
}[];
};
sort?: {
/** @description The field from the table/view schema to sort on. */
field: string;
/**
* @description The order in which to sort.
* @enum {string}
*/
order?: "ascending" | "descending";
/**
* @description The type of sort to perform (by number, or by alphabetically).
* @enum {string}
*/
type?: "string" | "number";
};
schema: {
[key: string]:
| {
/** @description Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it. */
visible?: boolean;
/** @description When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated. */
readonly?: boolean;
/** @description A number defining where the column shows up in tables, lowest being first. */
order?: number;
/** @description A width for the column, defined in pixels - this affects rendering in tables. */
width?: number;
/** @description If this is a relationship column, we can set the columns we wish to include */
column?: {
readonly?: boolean;
}[];
}
| {
/**
* @description This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.
* @enum {string}
*/
calculationType?: "sum" | "avg" | "count" | "min" | "max";
/** @description The field from the table to perform the calculation on. */
field?: string;
/** @description Can be used in tandem with the count calculation type, to count unique entries. */
distinct?: boolean;
};
};
};
viewOutput: {
/** @description The view to be created/updated. */
data: {
/** @description The name of the view. */
name: string;
/** @description The ID of the table this view is based on. */
tableId: string;
/**
* @description The type of view - standard (empty value) or calculation.
* @enum {string}
*/
type?: "calculation";
/** @description A column used to display rows from this view - usually used when rendered in tables. */
primaryDisplay?: string;
/** @description Search parameters for view */
query?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/**
* @description If no filters match, should the view return all rows, or no rows.
* @enum {string}
*/
onEmptyFilter?: "all" | "none";
/** @description A grouping of filters to be applied. */
groups?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/** @description A list of filters to apply */
filters?: {
/**
* @description The type of search operation which is being performed.
* @enum {string}
*/
operator?:
| "equal"
| "notEqual"
| "empty"
| "notEmpty"
| "fuzzy"
| "string"
| "contains"
| "notContains"
| "containsAny"
| "oneOf"
| "range";
/** @description The field in the view to perform the search on. */
field?: string;
/** @description The value to search for - the type will depend on the operator in use. */
value?:
| string
| number
| boolean
| { [key: string]: unknown }
| unknown[];
}[];
/** @description A grouping of filters to be applied. */
groups?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/** @description A list of filters to apply */
filters?: {
/**
* @description The type of search operation which is being performed.
* @enum {string}
*/
operator?:
| "equal"
| "notEqual"
| "empty"
| "notEmpty"
| "fuzzy"
| "string"
| "contains"
| "notContains"
| "containsAny"
| "oneOf"
| "range";
/** @description The field in the view to perform the search on. */
field?: string;
/** @description The value to search for - the type will depend on the operator in use. */
value?:
| string
| number
| boolean
| { [key: string]: unknown }
| unknown[];
}[];
}[];
}[];
};
sort?: {
/** @description The field from the table/view schema to sort on. */
field: string;
/**
* @description The order in which to sort.
* @enum {string}
*/
order?: "ascending" | "descending";
/**
* @description The type of sort to perform (by number, or by alphabetically).
* @enum {string}
*/
type?: "string" | "number";
};
schema: {
[key: string]:
| {
/** @description Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it. */
visible?: boolean;
/** @description When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated. */
readonly?: boolean;
/** @description A number defining where the column shows up in tables, lowest being first. */
order?: number;
/** @description A width for the column, defined in pixels - this affects rendering in tables. */
width?: number;
/** @description If this is a relationship column, we can set the columns we wish to include */
column?: {
readonly?: boolean;
}[];
}
| {
/**
* @description This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.
* @enum {string}
*/
calculationType?: "sum" | "avg" | "count" | "min" | "max";
/** @description The field from the table to perform the calculation on. */
field?: string;
/** @description Can be used in tandem with the count calculation type, to count unique entries. */
distinct?: boolean;
};
};
/** @description The ID of the view. */
id: string;
};
};
viewSearch: {
data: {
/** @description The name of the view. */
name: string;
/** @description The ID of the table this view is based on. */
tableId: string;
/**
* @description The type of view - standard (empty value) or calculation.
* @enum {string}
*/
type?: "calculation";
/** @description A column used to display rows from this view - usually used when rendered in tables. */
primaryDisplay?: string;
/** @description Search parameters for view */
query?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/**
* @description If no filters match, should the view return all rows, or no rows.
* @enum {string}
*/
onEmptyFilter?: "all" | "none";
/** @description A grouping of filters to be applied. */
groups?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/** @description A list of filters to apply */
filters?: {
/**
* @description The type of search operation which is being performed.
* @enum {string}
*/
operator?:
| "equal"
| "notEqual"
| "empty"
| "notEmpty"
| "fuzzy"
| "string"
| "contains"
| "notContains"
| "containsAny"
| "oneOf"
| "range";
/** @description The field in the view to perform the search on. */
field?: string;
/** @description The value to search for - the type will depend on the operator in use. */
value?:
| string
| number
| boolean
| { [key: string]: unknown }
| unknown[];
}[];
/** @description A grouping of filters to be applied. */
groups?: {
/**
* @description When using groups this defines whether all of the filters must match, or only one of them.
* @enum {string}
*/
logicalOperator?: "all" | "any";
/** @description A list of filters to apply */
filters?: {
/**
* @description The type of search operation which is being performed.
* @enum {string}
*/
operator?:
| "equal"
| "notEqual"
| "empty"
| "notEmpty"
| "fuzzy"
| "string"
| "contains"
| "notContains"
| "containsAny"
| "oneOf"
| "range";
/** @description The field in the view to perform the search on. */
field?: string;
/** @description The value to search for - the type will depend on the operator in use. */
value?:
| string
| number
| boolean
| { [key: string]: unknown }
| unknown[];
}[];
}[];
}[];
};
sort?: {
/** @description The field from the table/view schema to sort on. */
field: string;
/**
* @description The order in which to sort.
* @enum {string}
*/
order?: "ascending" | "descending";
/**
* @description The type of sort to perform (by number, or by alphabetically).
* @enum {string}
*/
type?: "string" | "number";
};
schema: {
[key: string]:
| {
/** @description Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it. */
visible?: boolean;
/** @description When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated. */
readonly?: boolean;
/** @description A number defining where the column shows up in tables, lowest being first. */
order?: number;
/** @description A width for the column, defined in pixels - this affects rendering in tables. */
width?: number;
/** @description If this is a relationship column, we can set the columns we wish to include */
column?: {
readonly?: boolean;
}[];
}
| {
/**
* @description This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.
* @enum {string}
*/
calculationType?: "sum" | "avg" | "count" | "min" | "max";
/** @description The field from the table to perform the calculation on. */
field?: string;
/** @description Can be used in tandem with the count calculation type, to count unique entries. */
distinct?: boolean;
};
};
/** @description The ID of the view. */
id: string;
}[];
};
}; };
parameters: { parameters: {
/** @description The ID of the table which this request is targeting. */ /** @description The ID of the table which this request is targeting. */
tableId: string; tableId: string;
/** @description The ID of the view which this request is targeting. */
viewId: string;
/** @description The ID of the row which this request is targeting. */ /** @description The ID of the row which this request is targeting. */
rowId: string; rowId: string;
/** @description The ID of the app which this request is targeting. */ /** @description The ID of the app which this request is targeting. */
@ -1213,6 +1664,31 @@ export interface operations {
}; };
}; };
}; };
rowViewSearch: {
parameters: {
path: {
/** The ID of the view which this request is targeting. */
viewId: components["parameters"]["viewId"];
};
header: {
/** The ID of the app which this request is targeting. */
"x-budibase-app-id": components["parameters"]["appId"];
};
};
responses: {
/** The response will contain an array of rows that match the search parameters. */
200: {
content: {
"application/json": components["schemas"]["searchOutput"];
};
};
};
requestBody: {
content: {
"application/json": components["schemas"]["rowSearch"];
};
};
};
/** Create a table, this could be internal or external. */ /** Create a table, this could be internal or external. */
tableCreate: { tableCreate: {
parameters: { parameters: {
@ -1409,6 +1885,118 @@ export interface operations {
}; };
}; };
}; };
/** Create a view, this can be against an internal or external table. */
viewCreate: {
parameters: {
header: {
/** The ID of the app which this request is targeting. */
"x-budibase-app-id": components["parameters"]["appId"];
};
};
responses: {
/** Returns the created view, including the ID which has been generated for it. */
200: {
content: {
"application/json": components["schemas"]["viewOutput"];
};
};
};
requestBody: {
content: {
"application/json": components["schemas"]["view"];
};
};
};
/** Lookup a view, this could be internal or external. */
viewGetById: {
parameters: {
path: {
/** The ID of the view which this request is targeting. */
viewId: components["parameters"]["viewId"];
};
header: {
/** The ID of the app which this request is targeting. */
"x-budibase-app-id": components["parameters"]["appId"];
};
};
responses: {
/** Returns the retrieved view. */
200: {
content: {
"application/json": components["schemas"]["viewOutput"];
};
};
};
};
/** Update a view, this can be against an internal or external table. */
viewUpdate: {
parameters: {
path: {
/** The ID of the view which this request is targeting. */
viewId: components["parameters"]["viewId"];
};
header: {
/** The ID of the app which this request is targeting. */
"x-budibase-app-id": components["parameters"]["appId"];
};
};
responses: {
/** Returns the updated view. */
200: {
content: {
"application/json": components["schemas"]["viewOutput"];
};
};
};
requestBody: {
content: {
"application/json": components["schemas"]["view"];
};
};
};
/** Delete a view, this can be against an internal or external table. */
viewDestroy: {
parameters: {
path: {
/** The ID of the view which this request is targeting. */
viewId: components["parameters"]["viewId"];
};
header: {
/** The ID of the app which this request is targeting. */
"x-budibase-app-id": components["parameters"]["appId"];
};
};
responses: {
/** Returns the deleted view. */
200: {
content: {
"application/json": components["schemas"]["viewOutput"];
};
};
};
};
/** Based on view properties (currently only name) search for views. */
viewSearch: {
parameters: {
header: {
/** The ID of the app which this request is targeting. */
"x-budibase-app-id": components["parameters"]["appId"];
};
};
responses: {
/** Returns the found views, based on the search parameters. */
200: {
content: {
"application/json": components["schemas"]["viewSearch"];
};
};
};
requestBody: {
content: {
"application/json": components["schemas"]["nameSearch"];
};
};
};
} }
export interface external {} export interface external {}

View File

@ -1,8 +1,8 @@
import { constants, utils } from "@budibase/backend-core" import { constants, utils } from "@budibase/backend-core"
import { BBContext } from "@budibase/types" import { Ctx } from "@budibase/types"
export default function ({ requiresAppId }: { requiresAppId?: boolean } = {}) { export default function ({ requiresAppId }: { requiresAppId?: boolean } = {}) {
return async (ctx: BBContext, next: any) => { return async (ctx: Ctx, next: any) => {
const appId = await utils.getAppIdFromCtx(ctx) const appId = await utils.getAppIdFromCtx(ctx)
if (requiresAppId && !appId) { if (requiresAppId && !appId) {
ctx.throw( ctx.throw(

View File

@ -78,8 +78,11 @@ export async function getAllInternalTables(db?: Database): Promise<Table[]> {
} }
async function getAllExternalTables(): Promise<Table[]> { async function getAllExternalTables(): Promise<Table[]> {
// this is all datasources, we'll need to filter out internal
const datasources = await sdk.datasources.fetch({ enriched: true }) const datasources = await sdk.datasources.fetch({ enriched: true })
const allEntities = datasources.map(datasource => datasource.entities) const allEntities = datasources
.filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID)
.map(datasource => datasource.entities)
let final: Table[] = [] let final: Table[] = []
for (let entities of allEntities) { for (let entities of allEntities) {
if (entities) { if (entities) {

View File

@ -26,6 +26,7 @@ import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { ensureQueryUISet } from "./utils"
function pickApi(tableId: any) { function pickApi(tableId: any) {
if (isExternalTableID(tableId)) { if (isExternalTableID(tableId)) {
@ -44,6 +45,24 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
return pickApi(tableId).getEnriched(viewId) return pickApi(tableId).getEnriched(viewId)
} }
export async function getAllEnriched(): Promise<ViewV2Enriched[]> {
const tables = await sdk.tables.getAllTables()
let views: ViewV2Enriched[] = []
for (let table of tables) {
if (!table.views || Object.keys(table.views).length === 0) {
continue
}
const v2Views = Object.values(table.views).filter(isV2)
const enrichedViews = await Promise.all(
v2Views.map(view =>
enrichSchema(ensureQueryUISet(view), table.schema, tables)
)
)
views = views.concat(enrichedViews)
}
return views
}
export async function getTable(view: string | ViewV2): Promise<Table> { export async function getTable(view: string | ViewV2): Promise<Table> {
const viewId = typeof view === "string" ? view : view.id const viewId = typeof view === "string" ? view : view.id
const cached = context.getTableForView(viewId) const cached = context.getTableForView(viewId)
@ -333,13 +352,19 @@ export function allowedFields(
export async function enrichSchema( export async function enrichSchema(
view: ViewV2, view: ViewV2,
tableSchema: TableSchema tableSchema: TableSchema,
tables?: Table[]
): Promise<ViewV2Enriched> { ): Promise<ViewV2Enriched> {
async function populateRelTableSchema( async function populateRelTableSchema(
tableId: string, tableId: string,
viewFields: Record<string, RelationSchemaField> viewFields: Record<string, RelationSchemaField>
) { ) {
const relTable = await sdk.tables.getTable(tableId) let relTable = tables
? tables?.find(t => t._id === tableId)
: await sdk.tables.getTable(tableId)
if (!relTable) {
throw new Error("Cannot enrich relationship, table not found")
}
const result: Record<string, ViewV2ColumnEnriched> = {} const result: Record<string, ViewV2ColumnEnriched> = {}
for (const relTableFieldName of Object.keys(relTable.schema)) { for (const relTableFieldName of Object.keys(relTable.schema)) {
const relTableField = relTable.schema[relTableFieldName] const relTableField = relTable.schema[relTableFieldName]

View File

@ -5,6 +5,7 @@ import {
SearchViewRowRequest, SearchViewRowRequest,
PaginatedSearchRowResponse, PaginatedSearchRowResponse,
ViewResponseEnriched, ViewResponseEnriched,
ViewFetchResponseEnriched,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -49,6 +50,12 @@ export class ViewV2API extends TestAPI {
.data .data
} }
fetch = async (expectations?: Expectations) => {
return await this._get<ViewFetchResponseEnriched>(`/api/v2/views`, {
expectations,
})
}
search = async ( search = async (
viewId: string, viewId: string,
params?: SearchViewRowRequest, params?: SearchViewRowRequest,

View File

@ -385,7 +385,7 @@ class Orchestrator {
stepIdx: number, stepIdx: number,
pathIdx?: number pathIdx?: number
): Promise<number> { ): Promise<number> {
await processObject(loopStep.inputs, this.processContext(this.context)) await processObject(loopStep.inputs, this.mergeContexts(this.context))
const iterations = getLoopIterations(loopStep) const iterations = getLoopIterations(loopStep)
let stepToLoopIndex = stepIdx + 1 let stepToLoopIndex = stepIdx + 1
let pathStepIdx = (pathIdx || stepIdx) + 1 let pathStepIdx = (pathIdx || stepIdx) + 1
@ -573,14 +573,14 @@ class Orchestrator {
for (const [field, value] of Object.entries(filters[filterKey])) { for (const [field, value] of Object.entries(filters[filterKey])) {
const fromContext = processStringSync( const fromContext = processStringSync(
field, field,
this.processContext(this.context) this.mergeContexts(this.context)
) )
toFilter[field] = fromContext toFilter[field] = fromContext
if (typeof value === "string" && findHBSBlocks(value).length > 0) { if (typeof value === "string" && findHBSBlocks(value).length > 0) {
const processedVal = processStringSync( const processedVal = processStringSync(
value, value,
this.processContext(this.context) this.mergeContexts(this.context)
) )
filters[filterKey][field] = processedVal filters[filterKey][field] = processedVal
@ -637,7 +637,7 @@ class Orchestrator {
const stepFn = await this.getStepFunctionality(step.stepId) const stepFn = await this.getStepFunctionality(step.stepId)
let inputs = await processObject( let inputs = await processObject(
originalStepInput, originalStepInput,
this.processContext(this.context) this.mergeContexts(this.context)
) )
inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs) inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs)
@ -645,7 +645,7 @@ class Orchestrator {
inputs: inputs, inputs: inputs,
appId: this.appId, appId: this.appId,
emitter: this.emitter, emitter: this.emitter,
context: this.context, context: this.mergeContexts(this.context),
}) })
this.handleStepOutput(step, outputs, loopIteration) this.handleStepOutput(step, outputs, loopIteration)
} }
@ -665,8 +665,8 @@ class Orchestrator {
return null return null
} }
private processContext(context: AutomationContext) { private mergeContexts(context: AutomationContext) {
const processContext = { const mergeContexts = {
...context, ...context,
steps: { steps: {
...context.steps, ...context.steps,
@ -674,7 +674,7 @@ class Orchestrator {
...context.stepsByName, ...context.stepsByName,
}, },
} }
return processContext return mergeContexts
} }
private handleStepOutput( private handleStepOutput(

View File

@ -9,6 +9,10 @@ export interface ViewResponseEnriched {
data: ViewV2Enriched data: ViewV2Enriched
} }
export interface ViewFetchResponseEnriched {
data: ViewV2Enriched[]
}
export interface CreateViewRequest extends Omit<ViewV2, "version" | "id"> {} export interface CreateViewRequest extends Omit<ViewV2, "version" | "id"> {}
export interface UpdateViewRequest extends ViewV2 {} export interface UpdateViewRequest extends ViewV2 {}

View File

@ -101,6 +101,10 @@ export interface ViewV2 {
schema?: ViewV2Schema schema?: ViewV2Schema
} }
export interface PublicAPIView extends Omit<ViewV2, "query" | "queryUI"> {
query?: UISearchFilter
}
export type ViewV2Schema = Record<string, ViewFieldMetadata> export type ViewV2Schema = Record<string, ViewFieldMetadata>
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema

View File

@ -44,7 +44,6 @@ export interface StaticUsage {
export interface MonthlyUsage { export interface MonthlyUsage {
[MonthlyQuotaName.QUERIES]: number [MonthlyQuotaName.QUERIES]: number
[MonthlyQuotaName.AUTOMATIONS]: number [MonthlyQuotaName.AUTOMATIONS]: number
[MonthlyQuotaName.DAY_PASSES]: number
[MonthlyQuotaName.BUDIBASE_AI_CREDITS]: number [MonthlyQuotaName.BUDIBASE_AI_CREDITS]: number
triggers: { triggers: {
[key in MonthlyQuotaName]?: QuotaTriggers [key in MonthlyQuotaName]?: QuotaTriggers

View File

@ -62,7 +62,6 @@ export interface User extends Document {
password?: string password?: string
status?: UserStatus status?: UserStatus
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now() createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
dayPassRecordedAt?: string
userGroups?: string[] userGroups?: string[]
onboardedAt?: string onboardedAt?: string
freeTrialConfirmedAt?: string freeTrialConfirmedAt?: string

View File

@ -8,7 +8,7 @@ export interface RowValue {
export interface RowResponse<T extends Document | RowValue> { export interface RowResponse<T extends Document | RowValue> {
id: string id: string
key: string key: string
error: string error?: string
value: T value: T
doc?: T doc?: T
} }

View File

@ -163,8 +163,8 @@ export interface Database {
viewName: string, viewName: string,
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> ): Promise<AllDocsResponse<T>>
destroy(): Promise<Nano.OkResponse | void> destroy(): Promise<Nano.OkResponse>
compact(): Promise<Nano.OkResponse | void> compact(): Promise<Nano.OkResponse>
// these are all PouchDB related functions that are rarely used - in future // these are all PouchDB related functions that are rarely used - in future
// should be replaced by better typed/non-pouch implemented methods // should be replaced by better typed/non-pouch implemented methods
dump(stream: Writable, opts?: DatabaseDumpOpts): Promise<any> dump(stream: Writable, opts?: DatabaseDumpOpts): Promise<any>

View File

@ -38,7 +38,6 @@ export interface AvailablePrice {
export enum PlanModel { export enum PlanModel {
PER_USER = "perUser", PER_USER = "perUser",
PER_CREATOR_PER_USER = "per_creator_per_user", PER_CREATOR_PER_USER = "per_creator_per_user",
DAY_PASS = "dayPass",
} }
export interface PurchasedPlan { export interface PurchasedPlan {
@ -49,7 +48,6 @@ export interface PurchasedPlan {
} }
export interface PurchasedPrice extends AvailablePrice { export interface PurchasedPrice extends AvailablePrice {
dayPasses: number | undefined
/** @deprecated - now at the plan level via model */ /** @deprecated - now at the plan level via model */
isPerUser: boolean isPerUser: boolean
} }

View File

@ -23,7 +23,6 @@ export enum StaticQuotaName {
export enum MonthlyQuotaName { export enum MonthlyQuotaName {
QUERIES = "queries", QUERIES = "queries",
AUTOMATIONS = "automations", AUTOMATIONS = "automations",
DAY_PASSES = "dayPasses",
BUDIBASE_AI_CREDITS = "budibaseAICredits", BUDIBASE_AI_CREDITS = "budibaseAICredits",
} }
@ -63,7 +62,6 @@ export type PlanQuotas = { [key in PlanType]: Quotas | undefined }
export type MonthlyQuotas = { export type MonthlyQuotas = {
[MonthlyQuotaName.QUERIES]: Quota [MonthlyQuotaName.QUERIES]: Quota
[MonthlyQuotaName.AUTOMATIONS]: Quota [MonthlyQuotaName.AUTOMATIONS]: Quota
[MonthlyQuotaName.DAY_PASSES]: Quota
[MonthlyQuotaName.BUDIBASE_AI_CREDITS]: Quota [MonthlyQuotaName.BUDIBASE_AI_CREDITS]: Quota
} }

View File

@ -50,8 +50,9 @@
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.10.1", "bull": "4.10.1",
"dd-trace": "5.23.0", "dd-trace": "5.26.0",
"dotenv": "8.6.0", "dotenv": "8.6.0",
"email-validator": "^2.0.4",
"global-agent": "3.0.0", "global-agent": "3.0.0",
"ical-generator": "4.1.0", "ical-generator": "4.1.0",
"joi": "17.6.0", "joi": "17.6.0",

View File

@ -40,6 +40,7 @@ import {
import { checkAnyUserExists } from "../../../utilities/users" import { checkAnyUserExists } from "../../../utilities/users"
import { isEmailConfigured } from "../../../utilities/email" import { isEmailConfigured } from "../../../utilities/email"
import { BpmStatusKey, BpmStatusValue, utils } from "@budibase/shared-core" import { BpmStatusKey, BpmStatusValue, utils } from "@budibase/shared-core"
import emailValidator from "email-validator"
import crypto from "crypto" import crypto from "crypto"
const MAX_USERS_UPLOAD_LIMIT = 1000 const MAX_USERS_UPLOAD_LIMIT = 1000
@ -300,6 +301,10 @@ export const find = async (ctx: any) => {
export const tenantUserLookup = async (ctx: any) => { export const tenantUserLookup = async (ctx: any) => {
const id = ctx.params.id const id = ctx.params.id
// is email, check its valid
if (id.includes("@") && !emailValidator.validate(id)) {
ctx.throw(400, `${id} is not a valid email address to lookup.`)
}
const user = await userSdk.core.getFirstPlatformUser(id) const user = await userSdk.core.getFirstPlatformUser(id)
if (user) { if (user) {
ctx.body = user ctx.body = user

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