From b2c4f04aa60f3e19e70eb9eaf63b870f5843d961 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Wed, 21 Feb 2024 17:52:58 +0000 Subject: [PATCH 1/8] Typing and config.api'ing application.spec.ts, WIP --- .../server/src/api/controllers/application.ts | 25 +++--- .../src/api/routes/tests/application.spec.ts | 79 +++++++------------ .../src/tests/utilities/api/application.ts | 41 +++++++++- packages/types/src/documents/app/app.ts | 12 +++ packages/worker/scripts/test.sh | 8 +- qa-core/src/internal-api/api/apis/AppAPI.ts | 3 +- .../src/internal-api/fixtures/applications.ts | 2 +- .../internal-api/tests/tables/tables.spec.ts | 11 --- .../src/shared/BudibaseTestConfiguration.ts | 4 +- qa-core/src/types/app.ts | 10 --- qa-core/src/types/index.ts | 1 - 11 files changed, 105 insertions(+), 91 deletions(-) delete mode 100644 qa-core/src/types/app.ts diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 33582cf656..2d8b4b8686 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -47,6 +47,7 @@ import { PlanType, Screen, UserCtx, + CreateAppRequest, } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import sdk from "../../sdk" @@ -116,8 +117,8 @@ function checkAppName( } interface AppTemplate { - templateString: string - useTemplate: string + templateString?: string + useTemplate?: string file?: { type: string path: string @@ -231,17 +232,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 +273,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(), diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index fa5cb0a983..7340166e67 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -35,41 +35,30 @@ describe("/applications", () => { 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: "Old App", + 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) @@ -118,15 +104,10 @@ describe("/applications", () => { 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) + const apps = await config.api.application.fetch({ status: AppStatus.DEV }) // two created apps + the inited app - expect(res.body.length).toBe(3) + expect(apps.length).toBe(3) }) }) diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts index 9c784bade1..7cc88d9eea 100644 --- a/packages/server/src/tests/utilities/api/application.ts +++ b/packages/server/src/tests/utilities/api/application.ts @@ -1,13 +1,38 @@ import { Response } from "supertest" -import { App } from "@budibase/types" +import { App, type CreateAppRequest } from "@budibase/types" import TestConfiguration from "../TestConfiguration" import { TestAPI } from "./base" +import { AppStatus } from "../../../db/utils" +import { dbObjectAsPojo } from "oracledb" 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) { + fail(JSON.stringify(result.body)) + } + + return result.body as App + } + getRaw = async (appId: string): Promise => { const result = await this.request .get(`/api/applications/${appId}/appPackage`) @@ -21,4 +46,18 @@ export class ApplicationAPI extends TestAPI { const result = await this.getRaw(appId) return result.body.application as App } + + 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/documents/app/app.ts b/packages/types/src/documents/app/app.ts index 08aafc6527..8571895fcc 100644 --- a/packages/types/src/documents/app/app.ts +++ b/packages/types/src/documents/app/app.ts @@ -73,3 +73,15 @@ export interface AppFeatures { export interface AutomationSettings { chainAutomations?: boolean } + +export interface CreateAppRequest { + name: string + url?: string + useTemplate?: string + templateName?: string + templateKey?: string + templateFile?: string + includeSampleData?: boolean + encryptionPassword?: string + templateString?: string +} 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" From b9600d83302b32c9d2cc9fad82c22c79995578c6 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 26 Feb 2024 11:57:56 +0000 Subject: [PATCH 2/8] More progress on modernising application tests. --- .../src/objectStore/buckets/plugins.ts | 2 +- packages/server/scripts/test.sh | 4 +- .../server/src/api/controllers/application.ts | 37 +++-- .../src/api/routes/tests/application.spec.ts | 153 +++++------------- .../src/tests/utilities/api/application.ts | 96 ++++++++++- packages/types/src/documents/app/app.ts | 21 ++- 6 files changed, 179 insertions(+), 134 deletions(-) diff --git a/packages/backend-core/src/objectStore/buckets/plugins.ts b/packages/backend-core/src/objectStore/buckets/plugins.ts index 6f1b7116ae..2d17a0562c 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/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 2d8b4b8686..f5a121fea2 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -48,6 +48,8 @@ import { Screen, UserCtx, CreateAppRequest, + FetchAppDefinitionResponse, + type FetchAppPackageResponse, } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import sdk from "../../sdk" @@ -59,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) { @@ -175,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() @@ -197,17 +201,19 @@ 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() // Enrich plugin URLs application.usedPlugins = objectStore.enrichPluginURLs( - application.usedPlugins + application.usedPlugins || [] ) // Only filter screens if the user is not a builder @@ -425,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, @@ -498,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) @@ -618,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/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index 7340166e67..5fcff9c770 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -11,25 +11,27 @@ 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" +import type { App } from "@budibase/types" -jest.setTimeout(15000) +jest.setTimeout(150000000) 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() }) @@ -74,7 +76,7 @@ describe("/applications", () => { it("migrates navigation settings from old apps", async () => { const app = await config.api.application.create({ - name: "Old App", + name: utils.newid(), useTemplate: "true", templateFile: "src/api/routes/tests/data/old-app.txt", }) @@ -96,77 +98,45 @@ 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 apps = await config.api.application.fetch({ status: AppStatus.DEV }) - - // two created apps + the inited app - expect(apps.length).toBe(3) + 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) }) }) @@ -222,33 +192,25 @@ describe("/applications", () => { 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) @@ -256,51 +218,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) }) @@ -308,28 +245,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/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts index 7cc88d9eea..83e42db1b2 100644 --- a/packages/server/src/tests/utilities/api/application.ts +++ b/packages/server/src/tests/utilities/api/application.ts @@ -1,9 +1,14 @@ import { Response } from "supertest" -import { App, type CreateAppRequest } 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 { dbObjectAsPojo } from "oracledb" +import { constants } from "@budibase/backend-core" export class ApplicationAPI extends TestAPI { constructor(config: TestConfiguration) { @@ -27,12 +32,55 @@ export class ApplicationAPI extends TestAPI { const result = await request if (result.statusCode !== 200) { - fail(JSON.stringify(result.body)) + 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 publsih 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 => { const result = await this.request .get(`/api/applications/${appId}/appPackage`) @@ -47,6 +95,48 @@ export class ApplicationAPI extends TestAPI { 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 + } + fetch = async ({ status }: { status?: AppStatus } = {}): Promise => { let query = [] if (status) { diff --git a/packages/types/src/documents/app/app.ts b/packages/types/src/documents/app/app.ts index 8571895fcc..cdd825b777 100644 --- a/packages/types/src/documents/app/app.ts +++ b/packages/types/src/documents/app/app.ts @@ -1,5 +1,5 @@ -import { User, Document } from "../" -import { SocketSession } from "../../sdk" +import { User, Document, Layout, Screen, Plugin } from "../" +import { SocketSession, PlanType } 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 { @@ -85,3 +87,18 @@ export interface CreateAppRequest { 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 +} From 04e5699c9c222f17ee6e0a3aea095d020586ed08 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 26 Feb 2024 16:00:12 +0000 Subject: [PATCH 3/8] Finish modernising application.spec.ts --- packages/server/src/api/routes/application.ts | 1 - .../src/api/routes/tests/application.spec.ts | 61 +++++++------------ .../src/tests/utilities/api/application.ts | 44 ++++++++++++- 3 files changed, 65 insertions(+), 41 deletions(-) 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 5fcff9c770..dbe4eb51ae 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -17,11 +17,9 @@ import { AppStatus } from "../../../db/utils" import { events, utils, context } from "@budibase/backend-core" import env from "../../../environment" import type { App } from "@budibase/types" - -jest.setTimeout(150000000) +import tk from "timekeeper" describe("/applications", () => { - let request = setup.getRequest() let config = setup.getConfig() let app: App @@ -143,50 +141,37 @@ describe("/applications", () => { 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") }) }) diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts index 83e42db1b2..3951bba667 100644 --- a/packages/server/src/tests/utilities/api/application.ts +++ b/packages/server/src/tests/utilities/api/application.ts @@ -48,7 +48,7 @@ export class ApplicationAPI extends TestAPI { publish = async ( appId: string ): Promise<{ _id: string; status: string; appUrl: string }> => { - // While the publsih endpoint does take an :appId parameter, it doesn't + // 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(), @@ -82,9 +82,15 @@ export class ApplicationAPI extends TestAPI { } 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 @@ -137,6 +143,40 @@ export class ApplicationAPI extends TestAPI { 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) { From c15554547bd313b70b98811fbd102f7632da64b2 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 26 Feb 2024 17:28:37 +0000 Subject: [PATCH 4/8] Respond to PR feedback. --- .../src/objectStore/buckets/plugins.ts | 2 +- .../server/src/api/controllers/application.ts | 10 +++--- packages/types/src/api/web/application.ts | 29 +++++++++++++++++ packages/types/src/api/web/index.ts | 1 + packages/types/src/documents/app/app.ts | 31 ++----------------- 5 files changed, 38 insertions(+), 35 deletions(-) create mode 100644 packages/types/src/api/web/application.ts diff --git a/packages/backend-core/src/objectStore/buckets/plugins.ts b/packages/backend-core/src/objectStore/buckets/plugins.ts index 2d17a0562c..cade60aa09 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[]): Plugin[] { +export function enrichPluginURLs(plugins: Plugin[] | undefined): Plugin[] { if (!plugins || !plugins.length) { return [] } diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index f5a121fea2..0bc93888ae 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -49,7 +49,7 @@ import { UserCtx, CreateAppRequest, FetchAppDefinitionResponse, - type FetchAppPackageResponse, + FetchAppPackageResponse, } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import sdk from "../../sdk" @@ -177,7 +177,7 @@ 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 @@ -185,7 +185,7 @@ export async function fetch(ctx: UserCtx) { } export async function fetchAppDefinition( - ctx: UserCtx + ctx: UserCtx ) { const layouts = await getLayouts() const userRoleId = getUserRoleId(ctx) @@ -202,7 +202,7 @@ export async function fetchAppDefinition( } export async function fetchAppPackage( - ctx: UserCtx + ctx: UserCtx ) { const db = context.getAppDB() const appId = context.getAppId() @@ -213,7 +213,7 @@ export async function fetchAppPackage( // Enrich plugin URLs application.usedPlugins = objectStore.enrichPluginURLs( - application.usedPlugins || [] + application.usedPlugins ) // Only filter screens if the user is not a builder diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts new file mode 100644 index 0000000000..8b1db534bb --- /dev/null +++ b/packages/types/src/api/web/application.ts @@ -0,0 +1,29 @@ +import type { PlanType } from "../../sdk" +import type { Layout, App } 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 cdd825b777..ae4f3fa6da 100644 --- a/packages/types/src/documents/app/app.ts +++ b/packages/types/src/documents/app/app.ts @@ -1,5 +1,5 @@ -import { User, Document, Layout, Screen, Plugin } from "../" -import { SocketSession, PlanType } from "../../sdk" +import { User, Document, Plugin } from "../" +import { SocketSession } from "../../sdk" export type AppMetadataErrors = { [key: string]: string[] } @@ -75,30 +75,3 @@ export interface AppFeatures { export interface AutomationSettings { chainAutomations?: boolean } - -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 -} From 2e8eda47f10fb47f59449df2a83d50354142264a Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 26 Feb 2024 17:38:33 +0000 Subject: [PATCH 5/8] Respond to PR feedback. --- packages/backend-core/src/objectStore/buckets/plugins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-core/src/objectStore/buckets/plugins.ts b/packages/backend-core/src/objectStore/buckets/plugins.ts index cade60aa09..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[] | undefined): Plugin[] { +export function enrichPluginURLs(plugins?: Plugin[]): Plugin[] { if (!plugins || !plugins.length) { return [] } From c1a88f12795fff1ce4bcdc2c4922e80397d51b96 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 26 Feb 2024 17:55:32 +0000 Subject: [PATCH 6/8] Fix type checks. --- packages/types/src/api/web/application.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts index 8b1db534bb..87a0bd6ef9 100644 --- a/packages/types/src/api/web/application.ts +++ b/packages/types/src/api/web/application.ts @@ -1,5 +1,5 @@ import type { PlanType } from "../../sdk" -import type { Layout, App } from "../../documents" +import type { Layout, App, Screen } from "../../documents" export interface CreateAppRequest { name: string From 6e4c2b7242c7a67049a43eaf433543babf1a6a40 Mon Sep 17 00:00:00 2001 From: melohagan <101575380+melohagan@users.noreply.github.com> Date: Tue, 27 Feb 2024 09:23:49 +0000 Subject: [PATCH 7/8] Export data make CSV delimiter configurable (#13028) * Add delimiter option * Add custom delimiter * external export delimiter * Custom headers for row export * External export rows custom headers * Support custom JSON export labels * Handle export table source switch * update account portal * Add space as delimiter * Refactor * update account portal --- packages/account-portal | 2 +- .../actions/ExportData.svelte | 102 +++++++++++++----- .../controls/ColumnEditor/ColumnEditor.svelte | 6 ++ packages/client/src/utils/buttonActions.js | 6 +- packages/frontend-core/src/api/rows.js | 13 ++- .../server/src/api/controllers/row/index.ts | 5 +- .../src/api/controllers/view/exporters.ts | 18 +++- packages/server/src/sdk/app/rows/search.ts | 2 + .../src/sdk/app/rows/search/external.ts | 21 +++- .../src/sdk/app/rows/search/internal.ts | 21 +++- packages/server/src/sdk/app/rows/utils.ts | 19 +++- packages/types/src/api/web/app/rows.ts | 2 + 12 files changed, 174 insertions(+), 43 deletions(-) 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/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/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/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/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 From b1dd8999cb9ff7d542277fe02d33586064abfdf1 Mon Sep 17 00:00:00 2001 From: Budibase Staging Release Bot <> Date: Tue, 27 Feb 2024 09:33:44 +0000 Subject: [PATCH 8/8] Bump version to 2.20.11 --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/*",