diff --git a/lerna.json b/lerna.json
index 54e106cd5a..623fbf6d43 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
{
- "version": "2.20.10",
+ "version": "2.20.11",
"npmClient": "yarn",
"packages": [
"packages/*",
diff --git a/packages/account-portal b/packages/account-portal
index ab324e35d8..de6d44c372 160000
--- a/packages/account-portal
+++ b/packages/account-portal
@@ -1 +1 @@
-Subproject commit ab324e35d855012bd0f49caa53c6dd765223c6fa
+Subproject commit de6d44c372a7f48ca0ce8c6c0c19311d4bc21646
diff --git a/packages/backend-core/src/objectStore/buckets/plugins.ts b/packages/backend-core/src/objectStore/buckets/plugins.ts
index 6f1b7116ae..02be9345ab 100644
--- a/packages/backend-core/src/objectStore/buckets/plugins.ts
+++ b/packages/backend-core/src/objectStore/buckets/plugins.ts
@@ -6,7 +6,7 @@ import { Plugin } from "@budibase/types"
// URLS
-export function enrichPluginURLs(plugins: Plugin[]) {
+export function enrichPluginURLs(plugins?: Plugin[]): Plugin[] {
if (!plugins || !plugins.length) {
return []
}
diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte
index f6c8479b4e..5955cc762d 100644
--- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte
+++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte
@@ -1,9 +1,9 @@
@@ -67,13 +95,29 @@
options={componentOptions}
on:change={() => (parameters.columns = [])}
/>
+
+
- {
+ const columns = e.detail
+ parameters.customHeaders = columns.reduce((headerMap, column) => {
+ return {
+ [column.name]: column.displayName,
+ ...headerMap,
+ }
+ }, {})
+ }}
/>
@@ -97,8 +141,8 @@
.params {
display: grid;
column-gap: var(--spacing-xs);
- row-gap: var(--spacing-s);
- grid-template-columns: 90px 1fr;
+ row-gap: var(--spacing-m);
+ grid-template-columns: 90px 1fr 90px;
align-items: center;
}
diff --git a/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte b/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte
index 2b9fa573c2..742ab785a1 100644
--- a/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte
+++ b/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte
@@ -29,6 +29,12 @@
allowLinks: true,
})
+ $: {
+ value = (value || []).filter(
+ column => (schema || {})[column.name || column] !== undefined
+ )
+ }
+
const getText = value => {
if (!value?.length) {
return "All columns"
diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js
index b2068ad152..68478b76ac 100644
--- a/packages/client/src/utils/buttonActions.js
+++ b/packages/client/src/utils/buttonActions.js
@@ -341,7 +341,11 @@ const exportDataHandler = async action => {
tableId: selection.tableId,
rows: selection.selectedRows,
format: action.parameters.type,
- columns: action.parameters.columns,
+ columns: action.parameters.columns?.map(
+ column => column.name || column
+ ),
+ delimiter: action.parameters.delimiter,
+ customHeaders: action.parameters.customHeaders,
})
download(
new Blob([data], { type: "text/plain" }),
diff --git a/packages/frontend-core/src/api/rows.js b/packages/frontend-core/src/api/rows.js
index 79f837e864..0a0d48da43 100644
--- a/packages/frontend-core/src/api/rows.js
+++ b/packages/frontend-core/src/api/rows.js
@@ -89,13 +89,24 @@ export const buildRowEndpoints = API => ({
* @param rows the array of rows to export
* @param format the format to export (csv or json)
* @param columns which columns to export (all if undefined)
+ * @param delimiter how values should be separated in a CSV (default is comma)
*/
- exportRows: async ({ tableId, rows, format, columns, search }) => {
+ exportRows: async ({
+ tableId,
+ rows,
+ format,
+ columns,
+ search,
+ delimiter,
+ customHeaders,
+ }) => {
return await API.post({
url: `/api/${tableId}/rows/exportRows?format=${format}`,
body: {
rows,
columns,
+ delimiter,
+ customHeaders,
...search,
},
parseResponse: async response => {
diff --git a/packages/server/scripts/test.sh b/packages/server/scripts/test.sh
index 9efef05526..3ecf8bb794 100644
--- a/packages/server/scripts/test.sh
+++ b/packages/server/scripts/test.sh
@@ -3,12 +3,12 @@ set -e
if [[ -n $CI ]]
then
- export NODE_OPTIONS="--max-old-space-size=4096 --no-node-snapshot"
+ export NODE_OPTIONS="--max-old-space-size=4096 --no-node-snapshot $NODE_OPTIONS"
echo "jest --coverage --maxWorkers=2 --forceExit --workerIdleMemoryLimit=2000MB --bail $@"
jest --coverage --maxWorkers=2 --forceExit --workerIdleMemoryLimit=2000MB --bail $@
else
# --maxWorkers performs better in development
- export NODE_OPTIONS="--no-node-snapshot"
+ export NODE_OPTIONS="--no-node-snapshot $NODE_OPTIONS"
echo "jest --coverage --maxWorkers=2 --forceExit $@"
jest --coverage --maxWorkers=2 --forceExit $@
fi
\ No newline at end of file
diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts
index 33582cf656..0bc93888ae 100644
--- a/packages/server/src/api/controllers/application.ts
+++ b/packages/server/src/api/controllers/application.ts
@@ -47,6 +47,9 @@ import {
PlanType,
Screen,
UserCtx,
+ CreateAppRequest,
+ FetchAppDefinitionResponse,
+ FetchAppPackageResponse,
} from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk"
@@ -58,23 +61,23 @@ import * as appMigrations from "../../appMigrations"
async function getLayouts() {
const db = context.getAppDB()
return (
- await db.allDocs(
+ await db.allDocs(
getLayoutParams(null, {
include_docs: true,
})
)
- ).rows.map((row: any) => row.doc)
+ ).rows.map(row => row.doc!)
}
async function getScreens() {
const db = context.getAppDB()
return (
- await db.allDocs(
+ await db.allDocs(
getScreenParams(null, {
include_docs: true,
})
)
- ).rows.map((row: any) => row.doc)
+ ).rows.map(row => row.doc!)
}
function getUserRoleId(ctx: UserCtx) {
@@ -116,8 +119,8 @@ function checkAppName(
}
interface AppTemplate {
- templateString: string
- useTemplate: string
+ templateString?: string
+ useTemplate?: string
file?: {
type: string
path: string
@@ -174,14 +177,16 @@ export const addSampleData = async (ctx: UserCtx) => {
ctx.status = 200
}
-export async function fetch(ctx: UserCtx) {
+export async function fetch(ctx: UserCtx) {
ctx.body = await sdk.applications.fetch(
ctx.query.status as AppStatus,
ctx.user
)
}
-export async function fetchAppDefinition(ctx: UserCtx) {
+export async function fetchAppDefinition(
+ ctx: UserCtx
+) {
const layouts = await getLayouts()
const userRoleId = getUserRoleId(ctx)
const accessController = new roles.AccessController()
@@ -196,10 +201,12 @@ export async function fetchAppDefinition(ctx: UserCtx) {
}
}
-export async function fetchAppPackage(ctx: UserCtx) {
+export async function fetchAppPackage(
+ ctx: UserCtx
+) {
const db = context.getAppDB()
const appId = context.getAppId()
- let application = await db.get(DocumentType.APP_METADATA)
+ let application = await db.get(DocumentType.APP_METADATA)
const layouts = await getLayouts()
let screens = await getScreens()
const license = await licensing.cache.getCachedLicense()
@@ -231,17 +238,21 @@ export async function fetchAppPackage(ctx: UserCtx) {
}
}
-async function performAppCreate(ctx: UserCtx) {
+async function performAppCreate(ctx: UserCtx) {
const apps = (await dbCore.getAllApps({ dev: true })) as App[]
- const name = ctx.request.body.name,
- possibleUrl = ctx.request.body.url,
- encryptionPassword = ctx.request.body.encryptionPassword
+ const {
+ name,
+ url,
+ encryptionPassword,
+ useTemplate,
+ templateKey,
+ templateString,
+ } = ctx.request.body
checkAppName(ctx, apps, name)
- const url = sdk.applications.getAppUrl({ name, url: possibleUrl })
- checkAppUrl(ctx, apps, url)
+ const appUrl = sdk.applications.getAppUrl({ name, url })
+ checkAppUrl(ctx, apps, appUrl)
- const { useTemplate, templateKey, templateString } = ctx.request.body
const instanceConfig: AppTemplate = {
useTemplate,
key: templateKey,
@@ -268,7 +279,7 @@ async function performAppCreate(ctx: UserCtx) {
version: envCore.VERSION,
componentLibraries: ["@budibase/standard-components"],
name: name,
- url: url,
+ url: appUrl,
template: templateKey,
instance,
tenantId: tenancy.getTenantId(),
@@ -420,7 +431,9 @@ export async function create(ctx: UserCtx) {
// This endpoint currently operates as a PATCH rather than a PUT
// Thus name and url fields are handled only if present
-export async function update(ctx: UserCtx) {
+export async function update(
+ ctx: UserCtx<{ name?: string; url?: string }, App>
+) {
const apps = (await dbCore.getAllApps({ dev: true })) as App[]
// validation
const name = ctx.request.body.name,
@@ -493,7 +506,7 @@ export async function revertClient(ctx: UserCtx) {
const revertedToVersion = application.revertableVersion
const appPackageUpdates = {
version: revertedToVersion,
- revertableVersion: null,
+ revertableVersion: undefined,
}
const app = await updateAppPackage(appPackageUpdates, ctx.params.appId)
await events.app.versionReverted(app, currentVersion, revertedToVersion)
@@ -613,12 +626,15 @@ export async function importToApp(ctx: UserCtx) {
ctx.body = { message: "app updated" }
}
-export async function updateAppPackage(appPackage: any, appId: any) {
+export async function updateAppPackage(
+ appPackage: Partial,
+ appId: string
+) {
return context.doInAppContext(appId, async () => {
const db = context.getAppDB()
const application = await db.get(DocumentType.APP_METADATA)
- const newAppPackage = { ...application, ...appPackage }
+ const newAppPackage: App = { ...application, ...appPackage }
if (appPackage._rev !== application._rev) {
newAppPackage._rev = application._rev
}
diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts
index 1ad8a2a695..ec56919d12 100644
--- a/packages/server/src/api/controllers/row/index.ts
+++ b/packages/server/src/api/controllers/row/index.ts
@@ -223,7 +223,8 @@ export const exportRows = async (
const format = ctx.query.format
- const { rows, columns, query, sort, sortOrder } = ctx.request.body
+ const { rows, columns, query, sort, sortOrder, delimiter, customHeaders } =
+ ctx.request.body
if (typeof format !== "string" || !exporters.isFormat(format)) {
ctx.throw(
400,
@@ -241,6 +242,8 @@ export const exportRows = async (
query,
sort,
sortOrder,
+ delimiter,
+ customHeaders,
})
ctx.attachment(fileName)
ctx.body = apiFileReturn(content)
diff --git a/packages/server/src/api/controllers/view/exporters.ts b/packages/server/src/api/controllers/view/exporters.ts
index d6caff6035..3b5f951dca 100644
--- a/packages/server/src/api/controllers/view/exporters.ts
+++ b/packages/server/src/api/controllers/view/exporters.ts
@@ -1,7 +1,19 @@
import { Row, TableSchema } from "@budibase/types"
-export function csv(headers: string[], rows: Row[]) {
- let csv = headers.map(key => `"${key}"`).join(",")
+function getHeaders(
+ headers: string[],
+ customHeaders: { [key: string]: string }
+) {
+ return headers.map(header => `"${customHeaders[header] || header}"`)
+}
+
+export function csv(
+ headers: string[],
+ rows: Row[],
+ delimiter: string = ",",
+ customHeaders: { [key: string]: string } = {}
+) {
+ let csv = getHeaders(headers, customHeaders).join(delimiter)
for (let row of rows) {
csv = `${csv}\n${headers
@@ -15,7 +27,7 @@ export function csv(headers: string[], rows: Row[]) {
: ""
return val.trim()
})
- .join(",")}`
+ .join(delimiter)}`
}
return csv
}
diff --git a/packages/server/src/api/routes/application.ts b/packages/server/src/api/routes/application.ts
index babcb1b44b..7e01a3c2ef 100644
--- a/packages/server/src/api/routes/application.ts
+++ b/packages/server/src/api/routes/application.ts
@@ -4,7 +4,6 @@ import * as deploymentController from "../controllers/deploy"
import authorized from "../../middleware/authorized"
import { permissions } from "@budibase/backend-core"
import { applicationValidator } from "./utils/validators"
-import { importToApp } from "../controllers/application"
const router: Router = new Router()
diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts
index fa5cb0a983..dbe4eb51ae 100644
--- a/packages/server/src/api/routes/tests/application.spec.ts
+++ b/packages/server/src/api/routes/tests/application.spec.ts
@@ -11,65 +11,54 @@ jest.mock("../../../utilities/redis", () => ({
checkDebounce: jest.fn(),
shutdown: jest.fn(),
}))
-import { clearAllApps, checkBuilderEndpoint } from "./utilities/TestFunctions"
+import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils"
import { events, utils, context } from "@budibase/backend-core"
import env from "../../../environment"
-
-jest.setTimeout(15000)
+import type { App } from "@budibase/types"
+import tk from "timekeeper"
describe("/applications", () => {
- let request = setup.getRequest()
let config = setup.getConfig()
+ let app: App
afterAll(setup.afterAll)
-
- beforeAll(async () => {
- await config.init()
- })
+ beforeAll(async () => await config.init())
beforeEach(async () => {
+ app = await config.api.application.create({ name: utils.newid() })
+ const deployment = await config.api.application.publish(app.appId)
+ expect(deployment.status).toBe("SUCCESS")
jest.clearAllMocks()
})
describe("create", () => {
it("creates empty app", async () => {
- const res = await request
- .post("/api/applications")
- .field("name", utils.newid())
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body._id).toBeDefined()
+ const app = await config.api.application.create({ name: utils.newid() })
+ expect(app._id).toBeDefined()
expect(events.app.created).toBeCalledTimes(1)
})
it("creates app from template", async () => {
- const res = await request
- .post("/api/applications")
- .field("name", utils.newid())
- .field("useTemplate", "true")
- .field("templateKey", "test")
- .field("templateString", "{}") // override the file download
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body._id).toBeDefined()
+ const app = await config.api.application.create({
+ name: utils.newid(),
+ useTemplate: "true",
+ templateKey: "test",
+ templateString: "{}",
+ })
+ expect(app._id).toBeDefined()
expect(events.app.created).toBeCalledTimes(1)
expect(events.app.templateImported).toBeCalledTimes(1)
})
it("creates app from file", async () => {
- const res = await request
- .post("/api/applications")
- .field("name", utils.newid())
- .field("useTemplate", "true")
- .set(config.defaultHeaders())
- .attach("templateFile", "src/api/routes/tests/data/export.txt")
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body._id).toBeDefined()
+ const app = await config.api.application.create({
+ name: utils.newid(),
+ useTemplate: "true",
+ templateFile: "src/api/routes/tests/data/export.txt",
+ })
+ expect(app._id).toBeDefined()
expect(events.app.created).toBeCalledTimes(1)
expect(events.app.fileImported).toBeCalledTimes(1)
})
@@ -84,24 +73,21 @@ describe("/applications", () => {
})
it("migrates navigation settings from old apps", async () => {
- const res = await request
- .post("/api/applications")
- .field("name", "Old App")
- .field("useTemplate", "true")
- .set(config.defaultHeaders())
- .attach("templateFile", "src/api/routes/tests/data/old-app.txt")
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body._id).toBeDefined()
- expect(res.body.navigation).toBeDefined()
- expect(res.body.navigation.hideLogo).toBe(true)
- expect(res.body.navigation.title).toBe("Custom Title")
- expect(res.body.navigation.hideLogo).toBe(true)
- expect(res.body.navigation.navigation).toBe("Left")
- expect(res.body.navigation.navBackground).toBe(
+ const app = await config.api.application.create({
+ name: utils.newid(),
+ useTemplate: "true",
+ templateFile: "src/api/routes/tests/data/old-app.txt",
+ })
+ expect(app._id).toBeDefined()
+ expect(app.navigation).toBeDefined()
+ expect(app.navigation!.hideLogo).toBe(true)
+ expect(app.navigation!.title).toBe("Custom Title")
+ expect(app.navigation!.hideLogo).toBe(true)
+ expect(app.navigation!.navigation).toBe("Left")
+ expect(app.navigation!.navBackground).toBe(
"var(--spectrum-global-color-blue-600)"
)
- expect(res.body.navigation.navTextColor).toBe(
+ expect(app.navigation!.navTextColor).toBe(
"var(--spectrum-global-color-gray-50)"
)
expect(events.app.created).toBeCalledTimes(1)
@@ -110,164 +96,106 @@ describe("/applications", () => {
})
describe("fetch", () => {
- beforeEach(async () => {
- // Clean all apps but the onde from config
- await clearAllApps(config.getTenantId(), [config.getAppId()!])
- })
-
it("lists all applications", async () => {
- await config.createApp("app1")
- await config.createApp("app2")
-
- const res = await request
- .get(`/api/applications?status=${AppStatus.DEV}`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
-
- // two created apps + the inited app
- expect(res.body.length).toBe(3)
+ const apps = await config.api.application.fetch({ status: AppStatus.DEV })
+ expect(apps.length).toBeGreaterThan(0)
})
})
describe("fetchAppDefinition", () => {
it("should be able to get an apps definition", async () => {
- const res = await request
- .get(`/api/applications/${config.getAppId()}/definition`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body.libraries.length).toEqual(1)
+ const res = await config.api.application.getDefinition(app.appId)
+ expect(res.libraries.length).toEqual(1)
})
})
describe("fetchAppPackage", () => {
it("should be able to fetch the app package", async () => {
- const res = await request
- .get(`/api/applications/${config.getAppId()}/appPackage`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body.application).toBeDefined()
- expect(res.body.application.appId).toEqual(config.getAppId())
+ const res = await config.api.application.getAppPackage(app.appId)
+ expect(res.application).toBeDefined()
+ expect(res.application.appId).toEqual(config.getAppId())
})
})
describe("update", () => {
it("should be able to update the app package", async () => {
- const res = await request
- .put(`/api/applications/${config.getAppId()}`)
- .send({
- name: "TEST_APP",
- })
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body._rev).toBeDefined()
+ const updatedApp = await config.api.application.update(app.appId, {
+ name: "TEST_APP",
+ })
+ expect(updatedApp._rev).toBeDefined()
expect(events.app.updated).toBeCalledTimes(1)
})
})
describe("publish", () => {
it("should publish app with dev app ID", async () => {
- const appId = config.getAppId()
- await request
- .post(`/api/applications/${appId}/publish`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.publish(app.appId)
expect(events.app.published).toBeCalledTimes(1)
})
it("should publish app with prod app ID", async () => {
- const appId = config.getProdAppId()
- await request
- .post(`/api/applications/${appId}/publish`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.publish(app.appId.replace("_dev", ""))
expect(events.app.published).toBeCalledTimes(1)
})
})
describe("manage client library version", () => {
it("should be able to update the app client library version", async () => {
- await request
- .post(`/api/applications/${config.getAppId()}/client/update`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.updateClient(app.appId)
expect(events.app.versionUpdated).toBeCalledTimes(1)
})
it("should be able to revert the app client library version", async () => {
- // We need to first update the version so that we can then revert
- await request
- .post(`/api/applications/${config.getAppId()}/client/update`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- await request
- .post(`/api/applications/${config.getAppId()}/client/revert`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.updateClient(app.appId)
+ await config.api.application.revertClient(app.appId)
expect(events.app.versionReverted).toBeCalledTimes(1)
})
})
describe("edited at", () => {
- it("middleware should set edited at", async () => {
- const headers = config.defaultHeaders()
- headers["referer"] = `/${config.getAppId()}/test`
- const res = await request
- .put(`/api/applications/${config.getAppId()}`)
- .send({
- name: "UPDATED_NAME",
- })
- .set(headers)
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body._rev).toBeDefined()
- // retrieve the app to check it
- const getRes = await request
- .get(`/api/applications/${config.getAppId()}/appPackage`)
- .set(headers)
- .expect("Content-Type", /json/)
- .expect(200)
- expect(getRes.body.application.updatedAt).toBeDefined()
+ it("middleware should set updatedAt", async () => {
+ const app = await tk.withFreeze(
+ "2021-01-01",
+ async () => await config.api.application.create({ name: utils.newid() })
+ )
+ expect(app.updatedAt).toEqual("2021-01-01T00:00:00.000Z")
+
+ const updatedApp = await tk.withFreeze(
+ "2021-02-01",
+ async () =>
+ await config.api.application.update(app.appId, {
+ name: "UPDATED_NAME",
+ })
+ )
+ expect(updatedApp._rev).toBeDefined()
+ expect(updatedApp.updatedAt).toEqual("2021-02-01T00:00:00.000Z")
+
+ const fetchedApp = await config.api.application.get(app.appId)
+ expect(fetchedApp.updatedAt).toEqual("2021-02-01T00:00:00.000Z")
})
})
describe("sync", () => {
it("app should sync correctly", async () => {
- const res = await request
- .post(`/api/applications/${config.getAppId()}/sync`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body.message).toEqual("App sync completed successfully.")
+ const { message } = await config.api.application.sync(app.appId)
+ expect(message).toEqual("App sync completed successfully.")
})
it("app should not sync if production", async () => {
- const res = await request
- .post(`/api/applications/app_123456/sync`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(400)
- expect(res.body.message).toEqual(
+ const { message } = await config.api.application.sync(
+ app.appId.replace("_dev", ""),
+ { statusCode: 400 }
+ )
+
+ expect(message).toEqual(
"This action cannot be performed for production apps"
)
})
it("app should not sync if sync is disabled", async () => {
env._set("DISABLE_AUTO_PROD_APP_SYNC", true)
- const res = await request
- .post(`/api/applications/${config.getAppId()}/sync`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- expect(res.body.message).toEqual(
+ const { message } = await config.api.application.sync(app.appId)
+ expect(message).toEqual(
"App sync disabled. You can reenable with the DISABLE_AUTO_PROD_APP_SYNC environment variable."
)
env._set("DISABLE_AUTO_PROD_APP_SYNC", false)
@@ -275,51 +203,26 @@ describe("/applications", () => {
})
describe("unpublish", () => {
- beforeEach(async () => {
- // We want to republish as the unpublish will delete the prod app
- await config.publish()
- })
-
it("should unpublish app with dev app ID", async () => {
- const appId = config.getAppId()
- await request
- .post(`/api/applications/${appId}/unpublish`)
- .set(config.defaultHeaders())
- .expect(204)
+ await config.api.application.unpublish(app.appId)
expect(events.app.unpublished).toBeCalledTimes(1)
})
it("should unpublish app with prod app ID", async () => {
- const appId = config.getProdAppId()
- await request
- .post(`/api/applications/${appId}/unpublish`)
- .set(config.defaultHeaders())
- .expect(204)
+ await config.api.application.unpublish(app.appId.replace("_dev", ""))
expect(events.app.unpublished).toBeCalledTimes(1)
})
})
describe("delete", () => {
it("should delete published app and dev apps with dev app ID", async () => {
- await config.createApp("to-delete")
- const appId = config.getAppId()
- await request
- .delete(`/api/applications/${appId}`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.delete(app.appId)
expect(events.app.deleted).toBeCalledTimes(1)
expect(events.app.unpublished).toBeCalledTimes(1)
})
it("should delete published app and dev app with prod app ID", async () => {
- await config.createApp("to-delete")
- const appId = config.getProdAppId()
- await request
- .delete(`/api/applications/${appId}`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.delete(app.appId.replace("_dev", ""))
expect(events.app.deleted).toBeCalledTimes(1)
expect(events.app.unpublished).toBeCalledTimes(1)
})
@@ -327,28 +230,18 @@ describe("/applications", () => {
describe("POST /api/applications/:appId/sync", () => {
it("should not sync automation logs", async () => {
- // setup the apps
- await config.createApp("testing-auto-logs")
const automation = await config.createAutomation()
- await config.publish()
- await context.doInAppContext(config.getProdAppId(), () => {
- return config.createAutomationLog(automation)
- })
+ await context.doInAppContext(app.appId, () =>
+ config.createAutomationLog(automation)
+ )
- // do the sync
- const appId = config.getAppId()
- await request
- .post(`/api/applications/${appId}/sync`)
- .set(config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
+ await config.api.application.sync(app.appId)
// does exist in prod
const prodLogs = await config.getAutomationLogs()
expect(prodLogs.data.length).toBe(1)
- // delete prod app so we revert to dev log search
- await config.unpublish()
+ await config.api.application.unpublish(app.appId)
// doesn't exist in dev
const devLogs = await config.getAutomationLogs()
diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts
index 4b71179839..8b24f9bc5f 100644
--- a/packages/server/src/sdk/app/rows/search.ts
+++ b/packages/server/src/sdk/app/rows/search.ts
@@ -36,11 +36,13 @@ export async function search(options: SearchParams): Promise<{
export interface ExportRowsParams {
tableId: string
format: Format
+ delimiter?: string
rowIds?: string[]
columns?: string[]
query?: SearchFilters
sort?: string
sortOrder?: SortOrder
+ customHeaders?: { [key: string]: string }
}
export interface ExportRowsResult {
diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts
index 8465f997e3..e2d1a1b32c 100644
--- a/packages/server/src/sdk/app/rows/search/external.ts
+++ b/packages/server/src/sdk/app/rows/search/external.ts
@@ -101,7 +101,17 @@ export async function search(options: SearchParams) {
export async function exportRows(
options: ExportRowsParams
): Promise {
- const { tableId, format, columns, rowIds, query, sort, sortOrder } = options
+ const {
+ tableId,
+ format,
+ columns,
+ rowIds,
+ query,
+ sort,
+ sortOrder,
+ delimiter,
+ customHeaders,
+ } = options
const { datasourceId, tableName } = breakExternalTableId(tableId)
let requestQuery: SearchFilters = {}
@@ -153,12 +163,17 @@ export async function exportRows(
rows = result.rows
}
- let exportRows = cleanExportRows(rows, schema, format, columns)
+ let exportRows = cleanExportRows(rows, schema, format, columns, customHeaders)
let content: string
switch (format) {
case exporters.Format.CSV:
- content = exporters.csv(headers ?? Object.keys(schema), exportRows)
+ content = exporters.csv(
+ headers ?? Object.keys(schema),
+ exportRows,
+ delimiter,
+ customHeaders
+ )
break
case exporters.Format.JSON:
content = exporters.json(exportRows)
diff --git a/packages/server/src/sdk/app/rows/search/internal.ts b/packages/server/src/sdk/app/rows/search/internal.ts
index 22cb3985b7..2d3c32e02e 100644
--- a/packages/server/src/sdk/app/rows/search/internal.ts
+++ b/packages/server/src/sdk/app/rows/search/internal.ts
@@ -84,7 +84,17 @@ export async function search(options: SearchParams) {
export async function exportRows(
options: ExportRowsParams
): Promise {
- const { tableId, format, rowIds, columns, query, sort, sortOrder } = options
+ const {
+ tableId,
+ format,
+ rowIds,
+ columns,
+ query,
+ sort,
+ sortOrder,
+ delimiter,
+ customHeaders,
+ } = options
const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId)
@@ -124,11 +134,16 @@ export async function exportRows(
rows = result
}
- let exportRows = cleanExportRows(rows, schema, format, columns)
+ let exportRows = cleanExportRows(rows, schema, format, columns, customHeaders)
if (format === Format.CSV) {
return {
fileName: "export.csv",
- content: csv(headers ?? Object.keys(rows[0]), exportRows),
+ content: csv(
+ headers ?? Object.keys(rows[0]),
+ exportRows,
+ delimiter,
+ customHeaders
+ ),
}
} else if (format === Format.JSON) {
return {
diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts
index 14868a4013..0ff85f40ac 100644
--- a/packages/server/src/sdk/app/rows/utils.ts
+++ b/packages/server/src/sdk/app/rows/utils.ts
@@ -16,7 +16,8 @@ export function cleanExportRows(
rows: any[],
schema: TableSchema,
format: string,
- columns?: string[]
+ columns?: string[],
+ customHeaders: { [key: string]: string } = {}
) {
let cleanRows = [...rows]
@@ -44,11 +45,27 @@ export function cleanExportRows(
}
}
}
+ } else if (format === Format.JSON) {
+ // Replace row keys with custom headers
+ for (let row of cleanRows) {
+ renameKeys(customHeaders, row)
+ }
}
return cleanRows
}
+function renameKeys(keysMap: { [key: string]: any }, row: any) {
+ for (const key in keysMap) {
+ Object.defineProperty(
+ row,
+ keysMap[key],
+ Object.getOwnPropertyDescriptor(row, key) || {}
+ )
+ delete row[key]
+ }
+}
+
function isForeignKey(key: string, table: Table) {
const relationships = Object.values(table.schema).filter(isRelationshipColumn)
return relationships.some(
diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts
index 9c784bade1..3951bba667 100644
--- a/packages/server/src/tests/utilities/api/application.ts
+++ b/packages/server/src/tests/utilities/api/application.ts
@@ -1,17 +1,96 @@
import { Response } from "supertest"
-import { App } from "@budibase/types"
+import {
+ App,
+ type CreateAppRequest,
+ type FetchAppDefinitionResponse,
+ type FetchAppPackageResponse,
+} from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
+import { AppStatus } from "../../../db/utils"
+import { constants } from "@budibase/backend-core"
export class ApplicationAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
+ create = async (app: CreateAppRequest): Promise => {
+ const request = this.request
+ .post("/api/applications")
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+
+ for (const key of Object.keys(app)) {
+ request.field(key, (app as any)[key])
+ }
+
+ if (app.templateFile) {
+ request.attach("templateFile", app.templateFile)
+ }
+
+ const result = await request
+
+ if (result.statusCode !== 200) {
+ throw new Error(JSON.stringify(result.body))
+ }
+
+ return result.body as App
+ }
+
+ delete = async (appId: string): Promise => {
+ await this.request
+ .delete(`/api/applications/${appId}`)
+ .set(this.config.defaultHeaders())
+ .expect(200)
+ }
+
+ publish = async (
+ appId: string
+ ): Promise<{ _id: string; status: string; appUrl: string }> => {
+ // While the publish endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ let headers = {
+ ...this.config.defaultHeaders(),
+ [constants.Header.APP_ID]: appId,
+ }
+ const result = await this.request
+ .post(`/api/applications/${appId}/publish`)
+ .set(headers)
+ .expect("Content-Type", /json/)
+ .expect(200)
+ return result.body as { _id: string; status: string; appUrl: string }
+ }
+
+ unpublish = async (appId: string): Promise => {
+ await this.request
+ .post(`/api/applications/${appId}/unpublish`)
+ .set(this.config.defaultHeaders())
+ .expect(204)
+ }
+
+ sync = async (
+ appId: string,
+ { statusCode }: { statusCode: number } = { statusCode: 200 }
+ ): Promise<{ message: string }> => {
+ const result = await this.request
+ .post(`/api/applications/${appId}/sync`)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(statusCode)
+ return result.body
+ }
+
getRaw = async (appId: string): Promise => {
+ // While the appPackage endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ let headers = {
+ ...this.config.defaultHeaders(),
+ [constants.Header.APP_ID]: appId,
+ }
const result = await this.request
.get(`/api/applications/${appId}/appPackage`)
- .set(this.config.defaultHeaders())
+ .set(headers)
.expect("Content-Type", /json/)
.expect(200)
return result
@@ -21,4 +100,94 @@ export class ApplicationAPI extends TestAPI {
const result = await this.getRaw(appId)
return result.body.application as App
}
+
+ getDefinition = async (
+ appId: string
+ ): Promise => {
+ const result = await this.request
+ .get(`/api/applications/${appId}/definition`)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(200)
+ return result.body as FetchAppDefinitionResponse
+ }
+
+ getAppPackage = async (appId: string): Promise => {
+ const result = await this.request
+ .get(`/api/applications/${appId}/appPackage`)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(200)
+ return result.body
+ }
+
+ update = async (
+ appId: string,
+ app: { name?: string; url?: string }
+ ): Promise => {
+ const request = this.request
+ .put(`/api/applications/${appId}`)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+
+ for (const key of Object.keys(app)) {
+ request.field(key, (app as any)[key])
+ }
+
+ const result = await request
+
+ if (result.statusCode !== 200) {
+ throw new Error(JSON.stringify(result.body))
+ }
+
+ return result.body as App
+ }
+
+ updateClient = async (appId: string): Promise => {
+ // While the updateClient endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ let headers = {
+ ...this.config.defaultHeaders(),
+ [constants.Header.APP_ID]: appId,
+ }
+ const response = await this.request
+ .post(`/api/applications/${appId}/client/update`)
+ .set(headers)
+ .expect("Content-Type", /json/)
+
+ if (response.statusCode !== 200) {
+ throw new Error(JSON.stringify(response.body))
+ }
+ }
+
+ revertClient = async (appId: string): Promise => {
+ // While the revertClient endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ let headers = {
+ ...this.config.defaultHeaders(),
+ [constants.Header.APP_ID]: appId,
+ }
+ const response = await this.request
+ .post(`/api/applications/${appId}/client/revert`)
+ .set(headers)
+ .expect("Content-Type", /json/)
+
+ if (response.statusCode !== 200) {
+ throw new Error(JSON.stringify(response.body))
+ }
+ }
+
+ fetch = async ({ status }: { status?: AppStatus } = {}): Promise => {
+ let query = []
+ if (status) {
+ query.push(`status=${status}`)
+ }
+
+ const result = await this.request
+ .get(`/api/applications${query.length ? `?${query.join("&")}` : ""}`)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(200)
+ return result.body as App[]
+ }
}
diff --git a/packages/types/src/api/web/app/rows.ts b/packages/types/src/api/web/app/rows.ts
index dad3286754..14e28e4a01 100644
--- a/packages/types/src/api/web/app/rows.ts
+++ b/packages/types/src/api/web/app/rows.ts
@@ -37,6 +37,8 @@ export interface ExportRowsRequest {
query?: SearchFilters
sort?: string
sortOrder?: SortOrder
+ delimiter?: string
+ customHeaders?: { [key: string]: string }
}
export type ExportRowsResponse = ReadStream
diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts
new file mode 100644
index 0000000000..87a0bd6ef9
--- /dev/null
+++ b/packages/types/src/api/web/application.ts
@@ -0,0 +1,29 @@
+import type { PlanType } from "../../sdk"
+import type { Layout, App, Screen } from "../../documents"
+
+export interface CreateAppRequest {
+ name: string
+ url?: string
+ useTemplate?: string
+ templateName?: string
+ templateKey?: string
+ templateFile?: string
+ includeSampleData?: boolean
+ encryptionPassword?: string
+ templateString?: string
+}
+
+export interface FetchAppDefinitionResponse {
+ layouts: Layout[]
+ screens: Screen[]
+ libraries: string[]
+}
+
+export interface FetchAppPackageResponse {
+ application: App
+ licenseType: PlanType
+ screens: Screen[]
+ layouts: Layout[]
+ clientLibPath: string
+ hasLock: boolean
+}
diff --git a/packages/types/src/api/web/index.ts b/packages/types/src/api/web/index.ts
index 75c246ab9b..ab18add208 100644
--- a/packages/types/src/api/web/index.ts
+++ b/packages/types/src/api/web/index.ts
@@ -1,3 +1,4 @@
+export * from "./application"
export * from "./analytics"
export * from "./auth"
export * from "./user"
diff --git a/packages/types/src/documents/app/app.ts b/packages/types/src/documents/app/app.ts
index 08aafc6527..ae4f3fa6da 100644
--- a/packages/types/src/documents/app/app.ts
+++ b/packages/types/src/documents/app/app.ts
@@ -1,4 +1,4 @@
-import { User, Document } from "../"
+import { User, Document, Plugin } from "../"
import { SocketSession } from "../../sdk"
export type AppMetadataErrors = { [key: string]: string[] }
@@ -24,6 +24,8 @@ export interface App extends Document {
icon?: AppIcon
features?: AppFeatures
automations?: AutomationSettings
+ usedPlugins?: Plugin[]
+ upgradableVersion?: string
}
export interface AppInstance {
diff --git a/packages/worker/scripts/test.sh b/packages/worker/scripts/test.sh
index eba95c4916..17b3ee17f4 100644
--- a/packages/worker/scripts/test.sh
+++ b/packages/worker/scripts/test.sh
@@ -4,10 +4,10 @@ set -e
if [[ -n $CI ]]
then
# Running in ci, where resources are limited
- echo "jest --coverage --maxWorkers=2 --forceExit --bail"
- jest --coverage --maxWorkers=2 --forceExit --bail
+ echo "jest --coverage --maxWorkers=2 --forceExit --bail $@"
+ jest --coverage --maxWorkers=2 --forceExit --bail $@
else
# --maxWorkers performs better in development
- echo "jest --coverage --maxWorkers=2 --forceExit"
- jest --coverage --maxWorkers=2 --forceExit
+ echo "jest --coverage --maxWorkers=2 --forceExit $@"
+ jest --coverage --maxWorkers=2 --forceExit $@
fi
\ No newline at end of file
diff --git a/qa-core/src/internal-api/api/apis/AppAPI.ts b/qa-core/src/internal-api/api/apis/AppAPI.ts
index a9f9a6a841..8b291a628e 100644
--- a/qa-core/src/internal-api/api/apis/AppAPI.ts
+++ b/qa-core/src/internal-api/api/apis/AppAPI.ts
@@ -1,11 +1,10 @@
-import { App } from "@budibase/types"
+import { App, CreateAppRequest } from "@budibase/types"
import { Response } from "node-fetch"
import {
RouteConfig,
AppPackageResponse,
DeployConfig,
MessageResponse,
- CreateAppRequest,
} from "../../../types"
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
import BaseAPI from "./BaseAPI"
diff --git a/qa-core/src/internal-api/fixtures/applications.ts b/qa-core/src/internal-api/fixtures/applications.ts
index 01dd18fc6a..59f73ba863 100644
--- a/qa-core/src/internal-api/fixtures/applications.ts
+++ b/qa-core/src/internal-api/fixtures/applications.ts
@@ -1,5 +1,5 @@
import { generator } from "../../shared"
-import { CreateAppRequest } from "../../types"
+import { CreateAppRequest } from "@budibase/types"
function uniqueWord() {
return generator.word() + generator.hash()
diff --git a/qa-core/src/internal-api/tests/tables/tables.spec.ts b/qa-core/src/internal-api/tests/tables/tables.spec.ts
index 09d8f68e86..a38b8e6059 100644
--- a/qa-core/src/internal-api/tests/tables/tables.spec.ts
+++ b/qa-core/src/internal-api/tests/tables/tables.spec.ts
@@ -13,17 +13,6 @@ describe("Internal API - Table Operations", () => {
await config.afterAll()
})
- async function createAppFromTemplate() {
- return config.api.apps.create({
- name: generator.word(),
- url: `/${generator.word()}`,
- useTemplate: "true",
- templateName: "Near Miss Register",
- templateKey: "app/near-miss-register",
- templateFile: undefined,
- })
- }
-
it("Create and delete table, columns and rows", async () => {
// create the app
await config.createApp(fixtures.apps.appFromTemplate())
diff --git a/qa-core/src/shared/BudibaseTestConfiguration.ts b/qa-core/src/shared/BudibaseTestConfiguration.ts
index 18b7c89ec8..9a12f3e65d 100644
--- a/qa-core/src/shared/BudibaseTestConfiguration.ts
+++ b/qa-core/src/shared/BudibaseTestConfiguration.ts
@@ -1,8 +1,8 @@
import { BudibaseInternalAPI } from "../internal-api"
import { AccountInternalAPI } from "../account-api"
-import { APIRequestOpts, CreateAppRequest, State } from "../types"
+import { APIRequestOpts, State } from "../types"
import * as fixtures from "../internal-api/fixtures"
-import { CreateAccountRequest } from "@budibase/types"
+import { CreateAccountRequest, CreateAppRequest } from "@budibase/types"
export default class BudibaseTestConfiguration {
// apis
diff --git a/qa-core/src/types/app.ts b/qa-core/src/types/app.ts
deleted file mode 100644
index 7159112024..0000000000
--- a/qa-core/src/types/app.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-// TODO: Integrate with budibase
-export interface CreateAppRequest {
- name: string
- url: string
- useTemplate?: string
- templateName?: string
- templateKey?: string
- templateFile?: string
- includeSampleData?: boolean
-}
diff --git a/qa-core/src/types/index.ts b/qa-core/src/types/index.ts
index 9bde46c66e..a44df4ef3c 100644
--- a/qa-core/src/types/index.ts
+++ b/qa-core/src/types/index.ts
@@ -1,6 +1,5 @@
export * from "./api"
export * from "./apiKeyResponse"
-export * from "./app"
export * from "./appPackage"
export * from "./deploy"
export * from "./newAccount"