From 678033cc8b7276e5823536925cfebc577527f764 Mon Sep 17 00:00:00 2001 From: Mitch-Budibase Date: Thu, 5 Oct 2023 17:39:40 +0100 Subject: [PATCH 01/68] License Key - Activate & Manage Tests There are two test files, license.activate.spec.ts and license.manage.spec.ts These test files each contain a test: - Creates, activates, and deletes an online license for a self hosted account - license.activate.spec.ts - Retrieves plans, creates checkout session, and updates license - license.manage.spec.ts Updated and created API files - StripeAPI - LicenseAPI - internal-api LicenseAPI - index & AccountInternalAPI also updated to reflect API file changes --- .../src/account-api/api/AccountInternalAPI.ts | 4 +- .../src/account-api/api/apis/LicenseAPI.ts | 137 +++++++++++++----- qa-core/src/account-api/api/apis/StripeAPI.ts | 64 ++++++++ qa-core/src/account-api/api/apis/index.ts | 1 + .../tests/licensing/license.activate.spec.ts | 74 ++++++++++ .../tests/licensing/license.manage.spec.ts | 57 ++++++++ .../src/internal-api/api/apis/LicenseAPI.ts | 40 +++-- 7 files changed, 329 insertions(+), 48 deletions(-) create mode 100644 qa-core/src/account-api/api/apis/StripeAPI.ts create mode 100644 qa-core/src/account-api/tests/licensing/license.activate.spec.ts create mode 100644 qa-core/src/account-api/tests/licensing/license.manage.spec.ts diff --git a/qa-core/src/account-api/api/AccountInternalAPI.ts b/qa-core/src/account-api/api/AccountInternalAPI.ts index 3813ad2c9e..ef2c39d5a4 100644 --- a/qa-core/src/account-api/api/AccountInternalAPI.ts +++ b/qa-core/src/account-api/api/AccountInternalAPI.ts @@ -1,5 +1,5 @@ import AccountInternalAPIClient from "./AccountInternalAPIClient" -import { AccountAPI, LicenseAPI, AuthAPI } from "./apis" +import { AccountAPI, LicenseAPI, AuthAPI, StripeAPI } from "./apis" import { State } from "../../types" export default class AccountInternalAPI { @@ -8,11 +8,13 @@ export default class AccountInternalAPI { auth: AuthAPI accounts: AccountAPI licenses: LicenseAPI + stripe: StripeAPI constructor(state: State) { this.client = new AccountInternalAPIClient(state) this.auth = new AuthAPI(this.client) this.accounts = new AccountAPI(this.client) this.licenses = new LicenseAPI(this.client) + this.stripe = new StripeAPI((this.client)) } } diff --git a/qa-core/src/account-api/api/apis/LicenseAPI.ts b/qa-core/src/account-api/api/apis/LicenseAPI.ts index 44579f867b..9f06ec7198 100644 --- a/qa-core/src/account-api/api/apis/LicenseAPI.ts +++ b/qa-core/src/account-api/api/apis/LicenseAPI.ts @@ -2,25 +2,23 @@ import AccountInternalAPIClient from "../AccountInternalAPIClient" import { Account, CreateOfflineLicenseRequest, + GetLicenseKeyResponse, GetOfflineLicenseResponse, UpdateLicenseRequest, } from "@budibase/types" import { Response } from "node-fetch" import BaseAPI from "./BaseAPI" import { APIRequestOpts } from "../../../types" - export default class LicenseAPI extends BaseAPI { client: AccountInternalAPIClient - constructor(client: AccountInternalAPIClient) { super() this.client = client } - async updateLicense( - accountId: string, - body: UpdateLicenseRequest, - opts: APIRequestOpts = { status: 200 } + accountId: string, + body: UpdateLicenseRequest, + opts: APIRequestOpts = { status: 200 } ): Promise<[Response, Account]> { return this.doRequest(() => { return this.client.put(`/api/accounts/${accountId}/license`, { @@ -29,44 +27,111 @@ export default class LicenseAPI extends BaseAPI { }) }, opts) } - // TODO: Better approach for setting tenant id header - async createOfflineLicense( - accountId: string, - tenantId: string, - body: CreateOfflineLicenseRequest, - opts: { status?: number } = {} + accountId: string, + tenantId: string, + body: CreateOfflineLicenseRequest, + opts: { status?: number } = {} ): Promise { const [response, json] = await this.client.post( - `/api/internal/accounts/${accountId}/license/offline`, - { - body, - internal: true, - headers: { - "x-budibase-tenant-id": tenantId, + `/api/internal/accounts/${accountId}/license/offline`, + { + body, + internal: true, + headers: { + "x-budibase-tenant-id": tenantId, + }, + } + ) + expect(response.status).toBe(opts.status ? opts.status : 201) + return response + } + async getOfflineLicense( + accountId: string, + tenantId: string, + opts: { status?: number } = {} + ): Promise<[Response, GetOfflineLicenseResponse]> { + const [response, json] = await this.client.get( + `/api/internal/accounts/${accountId}/license/offline`, + { + internal: true, + headers: { + "x-budibase-tenant-id": tenantId, + }, + } + ) + expect(response.status).toBe(opts.status ? opts.status : 200) + return [response, json] + } + async getLicenseKey( + opts: { status?: number } = {} + ): Promise<[Response, GetLicenseKeyResponse]> { + const [response, json] = await this.client.get(`/api/license/key`) + expect(response.status).toBe(opts.status ? opts.status : 200) + return [response, json] + } + async activateLicense( + apiKey: string, + tenantId: string, + licenseKey: string, + opts: APIRequestOpts = { status: 200 } + ) { + return this.doRequest(() => { + return this.client.post(`/api/license/activate`, { + body: { + apiKey: apiKey, + tenantId: tenantId, + licenseKey: licenseKey, }, - } + }) + }, opts) + } + async regenerateLicenseKey(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.post(`/api/license/key/regenerate`, {}) + }, opts) + } + + async getPlans(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/plans`) + }, opts) + } + + async updatePlan(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.put(`/api/license/plan`) + }, opts) + } + + async refreshAccountLicense( + accountId: string, + opts: { status?: number } = {} + ): Promise { + const [response, json] = await this.client.post( + `/api/accounts/${accountId}/license/refresh`, + { + internal: true, + } ) expect(response.status).toBe(opts.status ? opts.status : 201) return response } - async getOfflineLicense( - accountId: string, - tenantId: string, - opts: { status?: number } = {} - ): Promise<[Response, GetOfflineLicenseResponse]> { - const [response, json] = await this.client.get( - `/api/internal/accounts/${accountId}/license/offline`, - { - internal: true, - headers: { - "x-budibase-tenant-id": tenantId, - }, - } - ) - expect(response.status).toBe(opts.status ? opts.status : 200) - return [response, json] + async getLicenseUsage(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/license/usage`) + }, opts) } -} + + async licenseUsageTriggered( + opts: { status?: number } = {} + ): Promise { + const [response, json] = await this.client.post( + `/api/license/usage/triggered` + ) + expect(response.status).toBe(opts.status ? opts.status : 201) + return response + } +} \ No newline at end of file diff --git a/qa-core/src/account-api/api/apis/StripeAPI.ts b/qa-core/src/account-api/api/apis/StripeAPI.ts new file mode 100644 index 0000000000..ffa96b3c2b --- /dev/null +++ b/qa-core/src/account-api/api/apis/StripeAPI.ts @@ -0,0 +1,64 @@ +import AccountInternalAPIClient from "../AccountInternalAPIClient" +import BaseAPI from "./BaseAPI" +import { APIRequestOpts } from "../../../types" + +export default class StripeAPI extends BaseAPI { + client: AccountInternalAPIClient + + constructor(client: AccountInternalAPIClient) { + super() + this.client = client + } + + async createCheckoutSession( + priceId: string, + opts: APIRequestOpts = { status: 200 } + ) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/checkout-session`, { + body: { priceId }, + }) + }, opts) + } + + async checkoutSuccess(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/checkout-success`) + }, opts) + } + + async createPortalSession( + stripeCustomerId: string, + opts: APIRequestOpts = { status: 200 } + ) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/portal-session`, { + body: { stripeCustomerId }, + }) + }, opts) + } + + async linkStripeCustomer(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/link`) + }, opts) + } + + async getInvoices(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/stripe/invoices`) + }, opts) + } + + async getUpcomingInvoice(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/stripe/upcoming-invoice`) + }, opts) + } + + async getStripeCustomers(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/stripe/customers`) + }, opts) + } +} diff --git a/qa-core/src/account-api/api/apis/index.ts b/qa-core/src/account-api/api/apis/index.ts index 1137ac3e36..5b0cf55110 100644 --- a/qa-core/src/account-api/api/apis/index.ts +++ b/qa-core/src/account-api/api/apis/index.ts @@ -1,3 +1,4 @@ export { default as AuthAPI } from "./AuthAPI" export { default as AccountAPI } from "./AccountAPI" export { default as LicenseAPI } from "./LicenseAPI" +export { default as StripeAPI } from "./StripeAPI" diff --git a/qa-core/src/account-api/tests/licensing/license.activate.spec.ts b/qa-core/src/account-api/tests/licensing/license.activate.spec.ts new file mode 100644 index 0000000000..709e2c33f0 --- /dev/null +++ b/qa-core/src/account-api/tests/licensing/license.activate.spec.ts @@ -0,0 +1,74 @@ +import TestConfiguration from "../../config/TestConfiguration" +import * as fixures from "../../fixtures" +import { Feature, Hosting } from "@budibase/types" + +describe("license activation", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.beforeAll() + }) + + afterAll(async () => { + await config.afterAll() + }) + + it("creates, activates and deletes online license - self host", async () => { + // Remove existing license key + await config.internalApi.license.deleteLicenseKey() + + // Verify license key not found + await config.internalApi.license.getLicenseKey({ status: 404 }) + + // Create self host account + const createAccountRequest = fixures.accounts.generateAccount({ + hosting: Hosting.SELF, + }) + const [createAccountRes, account] = + await config.accountsApi.accounts.create(createAccountRequest, { autoVerify: true }) + + let licenseKey: string = " " + await config.doInNewState(async () => { + await config.loginAsAccount(createAccountRequest) + // Retrieve license key + const [res, body] = + await config.accountsApi.licenses.getLicenseKey() + licenseKey = body.licenseKey + }) + + const accountId = account.accountId! + + // Update license to have paid feature + const [res, acc] = await config.accountsApi.licenses.updateLicense( + accountId, + { + overrides: { + features: [Feature.APP_BACKUPS], + }, + } + ) + + // Activate license key + await config.internalApi.license.activateLicenseKey({licenseKey}) + + // Verify license updated with new feature + await config.doInNewState(async () => { + await config.loginAsAccount(createAccountRequest) + const [selfRes, body] = await config.api.accounts.self() + expect(body.license.features[0]).toBe("appBackups") + }) + + // Remove license key + await config.internalApi.license.deleteLicenseKey() + + // Verify license key not found + await config.internalApi.license.getLicenseKey({ status: 404 }) + + // Verify user downgraded to free license + await config.doInNewState(async () => { + await config.loginAsAccount(createAccountRequest) + const [selfRes, body] = await config.api.accounts.self() + expect(body.license.plan.type).toBe("free") + }) + }) +}) diff --git a/qa-core/src/account-api/tests/licensing/license.manage.spec.ts b/qa-core/src/account-api/tests/licensing/license.manage.spec.ts new file mode 100644 index 0000000000..967252a0f9 --- /dev/null +++ b/qa-core/src/account-api/tests/licensing/license.manage.spec.ts @@ -0,0 +1,57 @@ +import TestConfiguration from "../../config/TestConfiguration" +import * as fixtures from "../../fixtures" +import { Hosting, PlanType } from "@budibase/types" + +describe("license management", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.beforeAll() + }) + + afterAll(async () => { + await config.afterAll() + }) + + it("retrieves plans, creates checkout session, and updates license", async () => { + // Create cloud account + const createAccountRequest = fixtures.accounts.generateAccount({ + hosting: Hosting.CLOUD, + }) + + // Self response has free license + const [selfRes, selfBody] = await config.api.accounts.self() + expect(selfBody.license.plan.type).toBe(PlanType.FREE) + + // Retrieve plans + const [plansRes, planBody] = await config.api.licenses.getPlans() + + // Select priceId from premium plan + let premiumPriceId = null + for (const plan of planBody) { + if (plan.type === PlanType.PREMIUM) { + premiumPriceId = plan.prices[0].priceId + break + } + } + + // Create checkout session for price + const checkoutSessionRes = await config.api.stripe.createCheckoutSession( + premiumPriceId + ) + const checkoutSessionUrl = checkoutSessionRes[1].url + expect(checkoutSessionUrl).toContain("checkout.stripe.com") + + // TODO: Mimic checkout success + // Create stripe customer + // Create subscription for premium plan + // Assert license updated from free to premium + + // Create portal session + //await config.api.stripe.createPortalSession() + + // Update from free to business license + + // License updated + }) +}) diff --git a/qa-core/src/internal-api/api/apis/LicenseAPI.ts b/qa-core/src/internal-api/api/apis/LicenseAPI.ts index 4c9d14c55e..268f8781c3 100644 --- a/qa-core/src/internal-api/api/apis/LicenseAPI.ts +++ b/qa-core/src/internal-api/api/apis/LicenseAPI.ts @@ -1,45 +1,63 @@ import { Response } from "node-fetch" import { + ActivateLicenseKeyRequest, ActivateOfflineLicenseTokenRequest, + GetLicenseKeyResponse, GetOfflineIdentifierResponse, GetOfflineLicenseTokenResponse, } from "@budibase/types" import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient" import BaseAPI from "./BaseAPI" +import { APIRequestOpts } from "../../../types" export default class LicenseAPI extends BaseAPI { constructor(client: BudibaseInternalAPIClient) { super(client) } - async getOfflineLicenseToken( - opts: { status?: number } = {} + opts: { status?: number } = {} ): Promise<[Response, GetOfflineLicenseTokenResponse]> { const [response, body] = await this.get( - `/global/license/offline`, - opts.status + `/global/license/offline`, + opts.status ) return [response, body] } - async deleteOfflineLicenseToken(): Promise<[Response]> { const [response] = await this.del(`/global/license/offline`, 204) return [response] } - async activateOfflineLicenseToken( - body: ActivateOfflineLicenseTokenRequest + body: ActivateOfflineLicenseTokenRequest ): Promise<[Response]> { const [response] = await this.post(`/global/license/offline`, body) return [response] } - async getOfflineIdentifier(): Promise< - [Response, GetOfflineIdentifierResponse] + [Response, GetOfflineIdentifierResponse] > { const [response, body] = await this.get( - `/global/license/offline/identifier` + `/global/license/offline/identifier` ) return [response, body] } -} + + async getLicenseKey( + opts: { status?: number } = {} + ): Promise<[Response, GetLicenseKeyResponse]> { + const [response, body] = await this.get(`/global/license/key`, opts.status) + return [response, body] + } + + async activateLicenseKey( + body: ActivateLicenseKeyRequest + ): Promise<[Response]> { + const [response] = await this.post(`/global/license/key`, body) + return [response] + } + + async deleteLicenseKey(): Promise<[Response]> { + const [response] = await this.del(`/global/license/key`, 204) + return [response] + } +} \ No newline at end of file From 5e16d0451936ed2b1c2c0c6d1928c146b45c65c1 Mon Sep 17 00:00:00 2001 From: Mitch-Budibase Date: Thu, 5 Oct 2023 17:43:25 +0100 Subject: [PATCH 02/68] lint --- .../src/account-api/api/AccountInternalAPI.ts | 2 +- .../src/account-api/api/apis/LicenseAPI.ts | 78 +++++------ qa-core/src/account-api/api/apis/StripeAPI.ts | 100 +++++++-------- .../tests/licensing/license.activate.spec.ts | 121 +++++++++--------- .../tests/licensing/license.manage.spec.ts | 82 ++++++------ .../src/internal-api/api/apis/LicenseAPI.ts | 18 +-- 6 files changed, 201 insertions(+), 200 deletions(-) diff --git a/qa-core/src/account-api/api/AccountInternalAPI.ts b/qa-core/src/account-api/api/AccountInternalAPI.ts index ef2c39d5a4..f89bf556f2 100644 --- a/qa-core/src/account-api/api/AccountInternalAPI.ts +++ b/qa-core/src/account-api/api/AccountInternalAPI.ts @@ -15,6 +15,6 @@ export default class AccountInternalAPI { this.auth = new AuthAPI(this.client) this.accounts = new AccountAPI(this.client) this.licenses = new LicenseAPI(this.client) - this.stripe = new StripeAPI((this.client)) + this.stripe = new StripeAPI(this.client) } } diff --git a/qa-core/src/account-api/api/apis/LicenseAPI.ts b/qa-core/src/account-api/api/apis/LicenseAPI.ts index 9f06ec7198..dba1a661d4 100644 --- a/qa-core/src/account-api/api/apis/LicenseAPI.ts +++ b/qa-core/src/account-api/api/apis/LicenseAPI.ts @@ -16,9 +16,9 @@ export default class LicenseAPI extends BaseAPI { this.client = client } async updateLicense( - accountId: string, - body: UpdateLicenseRequest, - opts: APIRequestOpts = { status: 200 } + accountId: string, + body: UpdateLicenseRequest, + opts: APIRequestOpts = { status: 200 } ): Promise<[Response, Account]> { return this.doRequest(() => { return this.client.put(`/api/accounts/${accountId}/license`, { @@ -29,53 +29,53 @@ export default class LicenseAPI extends BaseAPI { } // TODO: Better approach for setting tenant id header async createOfflineLicense( - accountId: string, - tenantId: string, - body: CreateOfflineLicenseRequest, - opts: { status?: number } = {} + accountId: string, + tenantId: string, + body: CreateOfflineLicenseRequest, + opts: { status?: number } = {} ): Promise { const [response, json] = await this.client.post( - `/api/internal/accounts/${accountId}/license/offline`, - { - body, - internal: true, - headers: { - "x-budibase-tenant-id": tenantId, - }, - } + `/api/internal/accounts/${accountId}/license/offline`, + { + body, + internal: true, + headers: { + "x-budibase-tenant-id": tenantId, + }, + } ) expect(response.status).toBe(opts.status ? opts.status : 201) return response } async getOfflineLicense( - accountId: string, - tenantId: string, - opts: { status?: number } = {} + accountId: string, + tenantId: string, + opts: { status?: number } = {} ): Promise<[Response, GetOfflineLicenseResponse]> { const [response, json] = await this.client.get( - `/api/internal/accounts/${accountId}/license/offline`, - { - internal: true, - headers: { - "x-budibase-tenant-id": tenantId, - }, - } + `/api/internal/accounts/${accountId}/license/offline`, + { + internal: true, + headers: { + "x-budibase-tenant-id": tenantId, + }, + } ) expect(response.status).toBe(opts.status ? opts.status : 200) return [response, json] } async getLicenseKey( - opts: { status?: number } = {} + opts: { status?: number } = {} ): Promise<[Response, GetLicenseKeyResponse]> { const [response, json] = await this.client.get(`/api/license/key`) expect(response.status).toBe(opts.status ? opts.status : 200) return [response, json] } async activateLicense( - apiKey: string, - tenantId: string, - licenseKey: string, - opts: APIRequestOpts = { status: 200 } + apiKey: string, + tenantId: string, + licenseKey: string, + opts: APIRequestOpts = { status: 200 } ) { return this.doRequest(() => { return this.client.post(`/api/license/activate`, { @@ -106,14 +106,14 @@ export default class LicenseAPI extends BaseAPI { } async refreshAccountLicense( - accountId: string, - opts: { status?: number } = {} + accountId: string, + opts: { status?: number } = {} ): Promise { const [response, json] = await this.client.post( - `/api/accounts/${accountId}/license/refresh`, - { - internal: true, - } + `/api/accounts/${accountId}/license/refresh`, + { + internal: true, + } ) expect(response.status).toBe(opts.status ? opts.status : 201) return response @@ -126,12 +126,12 @@ export default class LicenseAPI extends BaseAPI { } async licenseUsageTriggered( - opts: { status?: number } = {} + opts: { status?: number } = {} ): Promise { const [response, json] = await this.client.post( - `/api/license/usage/triggered` + `/api/license/usage/triggered` ) expect(response.status).toBe(opts.status ? opts.status : 201) return response } -} \ No newline at end of file +} diff --git a/qa-core/src/account-api/api/apis/StripeAPI.ts b/qa-core/src/account-api/api/apis/StripeAPI.ts index ffa96b3c2b..c9c776e89b 100644 --- a/qa-core/src/account-api/api/apis/StripeAPI.ts +++ b/qa-core/src/account-api/api/apis/StripeAPI.ts @@ -3,62 +3,62 @@ import BaseAPI from "./BaseAPI" import { APIRequestOpts } from "../../../types" export default class StripeAPI extends BaseAPI { - client: AccountInternalAPIClient + client: AccountInternalAPIClient - constructor(client: AccountInternalAPIClient) { - super() - this.client = client - } + constructor(client: AccountInternalAPIClient) { + super() + this.client = client + } - async createCheckoutSession( - priceId: string, - opts: APIRequestOpts = { status: 200 } - ) { - return this.doRequest(() => { - return this.client.post(`/api/stripe/checkout-session`, { - body: { priceId }, - }) - }, opts) - } + async createCheckoutSession( + priceId: string, + opts: APIRequestOpts = { status: 200 } + ) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/checkout-session`, { + body: { priceId }, + }) + }, opts) + } - async checkoutSuccess(opts: APIRequestOpts = { status: 200 }) { - return this.doRequest(() => { - return this.client.post(`/api/stripe/checkout-success`) - }, opts) - } + async checkoutSuccess(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/checkout-success`) + }, opts) + } - async createPortalSession( - stripeCustomerId: string, - opts: APIRequestOpts = { status: 200 } - ) { - return this.doRequest(() => { - return this.client.post(`/api/stripe/portal-session`, { - body: { stripeCustomerId }, - }) - }, opts) - } + async createPortalSession( + stripeCustomerId: string, + opts: APIRequestOpts = { status: 200 } + ) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/portal-session`, { + body: { stripeCustomerId }, + }) + }, opts) + } - async linkStripeCustomer(opts: APIRequestOpts = { status: 200 }) { - return this.doRequest(() => { - return this.client.post(`/api/stripe/link`) - }, opts) - } + async linkStripeCustomer(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.post(`/api/stripe/link`) + }, opts) + } - async getInvoices(opts: APIRequestOpts = { status: 200 }) { - return this.doRequest(() => { - return this.client.get(`/api/stripe/invoices`) - }, opts) - } + async getInvoices(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/stripe/invoices`) + }, opts) + } - async getUpcomingInvoice(opts: APIRequestOpts = { status: 200 }) { - return this.doRequest(() => { - return this.client.get(`/api/stripe/upcoming-invoice`) - }, opts) - } + async getUpcomingInvoice(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/stripe/upcoming-invoice`) + }, opts) + } - async getStripeCustomers(opts: APIRequestOpts = { status: 200 }) { - return this.doRequest(() => { - return this.client.get(`/api/stripe/customers`) - }, opts) - } + async getStripeCustomers(opts: APIRequestOpts = { status: 200 }) { + return this.doRequest(() => { + return this.client.get(`/api/stripe/customers`) + }, opts) + } } diff --git a/qa-core/src/account-api/tests/licensing/license.activate.spec.ts b/qa-core/src/account-api/tests/licensing/license.activate.spec.ts index 709e2c33f0..a494ceb354 100644 --- a/qa-core/src/account-api/tests/licensing/license.activate.spec.ts +++ b/qa-core/src/account-api/tests/licensing/license.activate.spec.ts @@ -3,72 +3,73 @@ import * as fixures from "../../fixtures" import { Feature, Hosting } from "@budibase/types" describe("license activation", () => { - const config = new TestConfiguration() + const config = new TestConfiguration() - beforeAll(async () => { - await config.beforeAll() + beforeAll(async () => { + await config.beforeAll() + }) + + afterAll(async () => { + await config.afterAll() + }) + + it("creates, activates and deletes online license - self host", async () => { + // Remove existing license key + await config.internalApi.license.deleteLicenseKey() + + // Verify license key not found + await config.internalApi.license.getLicenseKey({ status: 404 }) + + // Create self host account + const createAccountRequest = fixures.accounts.generateAccount({ + hosting: Hosting.SELF, + }) + const [createAccountRes, account] = + await config.accountsApi.accounts.create(createAccountRequest, { + autoVerify: true, + }) + + let licenseKey: string = " " + await config.doInNewState(async () => { + await config.loginAsAccount(createAccountRequest) + // Retrieve license key + const [res, body] = await config.accountsApi.licenses.getLicenseKey() + licenseKey = body.licenseKey }) - afterAll(async () => { - await config.afterAll() + const accountId = account.accountId! + + // Update license to have paid feature + const [res, acc] = await config.accountsApi.licenses.updateLicense( + accountId, + { + overrides: { + features: [Feature.APP_BACKUPS], + }, + } + ) + + // Activate license key + await config.internalApi.license.activateLicenseKey({ licenseKey }) + + // Verify license updated with new feature + await config.doInNewState(async () => { + await config.loginAsAccount(createAccountRequest) + const [selfRes, body] = await config.api.accounts.self() + expect(body.license.features[0]).toBe("appBackups") }) - it("creates, activates and deletes online license - self host", async () => { - // Remove existing license key - await config.internalApi.license.deleteLicenseKey() + // Remove license key + await config.internalApi.license.deleteLicenseKey() - // Verify license key not found - await config.internalApi.license.getLicenseKey({ status: 404 }) + // Verify license key not found + await config.internalApi.license.getLicenseKey({ status: 404 }) - // Create self host account - const createAccountRequest = fixures.accounts.generateAccount({ - hosting: Hosting.SELF, - }) - const [createAccountRes, account] = - await config.accountsApi.accounts.create(createAccountRequest, { autoVerify: true }) - - let licenseKey: string = " " - await config.doInNewState(async () => { - await config.loginAsAccount(createAccountRequest) - // Retrieve license key - const [res, body] = - await config.accountsApi.licenses.getLicenseKey() - licenseKey = body.licenseKey - }) - - const accountId = account.accountId! - - // Update license to have paid feature - const [res, acc] = await config.accountsApi.licenses.updateLicense( - accountId, - { - overrides: { - features: [Feature.APP_BACKUPS], - }, - } - ) - - // Activate license key - await config.internalApi.license.activateLicenseKey({licenseKey}) - - // Verify license updated with new feature - await config.doInNewState(async () => { - await config.loginAsAccount(createAccountRequest) - const [selfRes, body] = await config.api.accounts.self() - expect(body.license.features[0]).toBe("appBackups") - }) - - // Remove license key - await config.internalApi.license.deleteLicenseKey() - - // Verify license key not found - await config.internalApi.license.getLicenseKey({ status: 404 }) - - // Verify user downgraded to free license - await config.doInNewState(async () => { - await config.loginAsAccount(createAccountRequest) - const [selfRes, body] = await config.api.accounts.self() - expect(body.license.plan.type).toBe("free") - }) + // Verify user downgraded to free license + await config.doInNewState(async () => { + await config.loginAsAccount(createAccountRequest) + const [selfRes, body] = await config.api.accounts.self() + expect(body.license.plan.type).toBe("free") }) + }) }) diff --git a/qa-core/src/account-api/tests/licensing/license.manage.spec.ts b/qa-core/src/account-api/tests/licensing/license.manage.spec.ts index 967252a0f9..3f87838ee4 100644 --- a/qa-core/src/account-api/tests/licensing/license.manage.spec.ts +++ b/qa-core/src/account-api/tests/licensing/license.manage.spec.ts @@ -3,55 +3,55 @@ import * as fixtures from "../../fixtures" import { Hosting, PlanType } from "@budibase/types" describe("license management", () => { - const config = new TestConfiguration() + const config = new TestConfiguration() - beforeAll(async () => { - await config.beforeAll() + beforeAll(async () => { + await config.beforeAll() + }) + + afterAll(async () => { + await config.afterAll() + }) + + it("retrieves plans, creates checkout session, and updates license", async () => { + // Create cloud account + const createAccountRequest = fixtures.accounts.generateAccount({ + hosting: Hosting.CLOUD, }) - afterAll(async () => { - await config.afterAll() - }) + // Self response has free license + const [selfRes, selfBody] = await config.api.accounts.self() + expect(selfBody.license.plan.type).toBe(PlanType.FREE) - it("retrieves plans, creates checkout session, and updates license", async () => { - // Create cloud account - const createAccountRequest = fixtures.accounts.generateAccount({ - hosting: Hosting.CLOUD, - }) + // Retrieve plans + const [plansRes, planBody] = await config.api.licenses.getPlans() - // Self response has free license - const [selfRes, selfBody] = await config.api.accounts.self() - expect(selfBody.license.plan.type).toBe(PlanType.FREE) + // Select priceId from premium plan + let premiumPriceId = null + for (const plan of planBody) { + if (plan.type === PlanType.PREMIUM) { + premiumPriceId = plan.prices[0].priceId + break + } + } - // Retrieve plans - const [plansRes, planBody] = await config.api.licenses.getPlans() + // Create checkout session for price + const checkoutSessionRes = await config.api.stripe.createCheckoutSession( + premiumPriceId + ) + const checkoutSessionUrl = checkoutSessionRes[1].url + expect(checkoutSessionUrl).toContain("checkout.stripe.com") - // Select priceId from premium plan - let premiumPriceId = null - for (const plan of planBody) { - if (plan.type === PlanType.PREMIUM) { - premiumPriceId = plan.prices[0].priceId - break - } - } + // TODO: Mimic checkout success + // Create stripe customer + // Create subscription for premium plan + // Assert license updated from free to premium - // Create checkout session for price - const checkoutSessionRes = await config.api.stripe.createCheckoutSession( - premiumPriceId - ) - const checkoutSessionUrl = checkoutSessionRes[1].url - expect(checkoutSessionUrl).toContain("checkout.stripe.com") + // Create portal session + //await config.api.stripe.createPortalSession() - // TODO: Mimic checkout success - // Create stripe customer - // Create subscription for premium plan - // Assert license updated from free to premium + // Update from free to business license - // Create portal session - //await config.api.stripe.createPortalSession() - - // Update from free to business license - - // License updated - }) + // License updated + }) }) diff --git a/qa-core/src/internal-api/api/apis/LicenseAPI.ts b/qa-core/src/internal-api/api/apis/LicenseAPI.ts index 268f8781c3..ef322e069a 100644 --- a/qa-core/src/internal-api/api/apis/LicenseAPI.ts +++ b/qa-core/src/internal-api/api/apis/LicenseAPI.ts @@ -15,11 +15,11 @@ export default class LicenseAPI extends BaseAPI { super(client) } async getOfflineLicenseToken( - opts: { status?: number } = {} + opts: { status?: number } = {} ): Promise<[Response, GetOfflineLicenseTokenResponse]> { const [response, body] = await this.get( - `/global/license/offline`, - opts.status + `/global/license/offline`, + opts.status ) return [response, body] } @@ -28,29 +28,29 @@ export default class LicenseAPI extends BaseAPI { return [response] } async activateOfflineLicenseToken( - body: ActivateOfflineLicenseTokenRequest + body: ActivateOfflineLicenseTokenRequest ): Promise<[Response]> { const [response] = await this.post(`/global/license/offline`, body) return [response] } async getOfflineIdentifier(): Promise< - [Response, GetOfflineIdentifierResponse] + [Response, GetOfflineIdentifierResponse] > { const [response, body] = await this.get( - `/global/license/offline/identifier` + `/global/license/offline/identifier` ) return [response, body] } async getLicenseKey( - opts: { status?: number } = {} + opts: { status?: number } = {} ): Promise<[Response, GetLicenseKeyResponse]> { const [response, body] = await this.get(`/global/license/key`, opts.status) return [response, body] } async activateLicenseKey( - body: ActivateLicenseKeyRequest + body: ActivateLicenseKeyRequest ): Promise<[Response]> { const [response] = await this.post(`/global/license/key`, body) return [response] @@ -60,4 +60,4 @@ export default class LicenseAPI extends BaseAPI { const [response] = await this.del(`/global/license/key`, 204) return [response] } -} \ No newline at end of file +} From f3234f6bd63e0bf5307ffc752c9992bfc4813b0d Mon Sep 17 00:00:00 2001 From: Mitch-Budibase Date: Fri, 6 Oct 2023 16:22:56 +0100 Subject: [PATCH 03/68] Update license activate test I have removed the end of the test which was to 'Verify user downgraded to free license' - This is not needed I have also updated getLicenseKey - specifically how it handles the expected 200 response --- qa-core/src/account-api/api/apis/LicenseAPI.ts | 2 +- .../account-api/tests/licensing/license.activate.spec.ts | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/qa-core/src/account-api/api/apis/LicenseAPI.ts b/qa-core/src/account-api/api/apis/LicenseAPI.ts index dba1a661d4..b371f00f05 100644 --- a/qa-core/src/account-api/api/apis/LicenseAPI.ts +++ b/qa-core/src/account-api/api/apis/LicenseAPI.ts @@ -68,7 +68,7 @@ export default class LicenseAPI extends BaseAPI { opts: { status?: number } = {} ): Promise<[Response, GetLicenseKeyResponse]> { const [response, json] = await this.client.get(`/api/license/key`) - expect(response.status).toBe(opts.status ? opts.status : 200) + expect(response.status).toBe(opts.status || 200) return [response, json] } async activateLicense( diff --git a/qa-core/src/account-api/tests/licensing/license.activate.spec.ts b/qa-core/src/account-api/tests/licensing/license.activate.spec.ts index a494ceb354..96c6eaea2a 100644 --- a/qa-core/src/account-api/tests/licensing/license.activate.spec.ts +++ b/qa-core/src/account-api/tests/licensing/license.activate.spec.ts @@ -64,12 +64,5 @@ describe("license activation", () => { // Verify license key not found await config.internalApi.license.getLicenseKey({ status: 404 }) - - // Verify user downgraded to free license - await config.doInNewState(async () => { - await config.loginAsAccount(createAccountRequest) - const [selfRes, body] = await config.api.accounts.self() - expect(body.license.plan.type).toBe("free") - }) }) }) From b4a8f22b2ee54914294eadb0259fde5f1006879a Mon Sep 17 00:00:00 2001 From: Dean Date: Wed, 11 Oct 2023 10:09:14 +0100 Subject: [PATCH 04/68] Initial commit --- .../design/settings/componentSettings.js | 2 + .../ButtonConfiguration.svelte | 104 ++++++++++ .../ButtonConfiguration/ButtonSetting.svelte | 61 ++++++ .../controls/FieldConfiguration.svelte | 91 --------- .../EditFieldPopover.svelte | 38 ++-- .../FieldConfiguration.svelte | 4 +- .../new/_components/componentStructure.json | 1 + packages/client/manifest.json | 183 ++++++++++++++++++ .../components/app/forms/ButtonGroup.svelte | 38 ++++ .../client/src/components/app/forms/index.js | 1 + 10 files changed, 412 insertions(+), 111 deletions(-) create mode 100644 packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte create mode 100644 packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte delete mode 100644 packages/builder/src/components/design/settings/controls/FieldConfiguration.svelte create mode 100644 packages/client/src/components/app/forms/ButtonGroup.svelte diff --git a/packages/builder/src/components/design/settings/componentSettings.js b/packages/builder/src/components/design/settings/componentSettings.js index 4c49587372..232b4bef31 100644 --- a/packages/builder/src/components/design/settings/componentSettings.js +++ b/packages/builder/src/components/design/settings/componentSettings.js @@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte" import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte" import BarButtonList from "./controls/BarButtonList.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" +import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte" import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte" const componentMap = { @@ -48,6 +49,7 @@ const componentMap = { "filter/relationship": RelationshipFilterEditor, url: URLSelect, fieldConfiguration: FieldConfiguration, + buttonConfiguration: ButtonConfiguration, columns: ColumnEditor, "columns/basic": BasicColumnEditor, "columns/grid": GridColumnEditor, diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte new file mode 100644 index 0000000000..baa8ad733c --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte @@ -0,0 +1,104 @@ + + +
+ {#if buttonList?.length} + + {/if} +
+ + diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte new file mode 100644 index 0000000000..851cbe289c --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte @@ -0,0 +1,61 @@ + + +
+
+ +
{item.text}
+
+
+ {console.log("REMOVE ME")}} + /> +
+
+ + diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration.svelte deleted file mode 100644 index 80f4829d71..0000000000 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -Configure columns - - - Configure the columns in your {subject.toLowerCase()}. - - - - diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte index 7d2eaae478..72e7784727 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte @@ -61,6 +61,25 @@ dispatch("change", update) } + + const customPositionHandler = (anchorBounds, eleBounds, cfg) => { + let { left, top } = cfg + let percentageOffset = 30 + // left-outside + left = anchorBounds.left - eleBounds.width - 18 + + // shift up from the anchor, if space allows + let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset + let defaultTop = anchorBounds.top - offsetPos + + if (window.innerHeight - defaultTop < eleBounds.height) { + top = window.innerHeight - eleBounds.height - 5 + } else { + top = anchorBounds.top - offsetPos + } + + return { ...cfg, left, top } + } 0} maxHeight={600} - handlePostionUpdate={(anchorBounds, eleBounds, cfg) => { - let { left, top } = cfg - let percentageOffset = 30 - // left-outside - left = anchorBounds.left - eleBounds.width - 18 - - // shift up from the anchor, if space allows - let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset - let defaultTop = anchorBounds.top - offsetPos - - if (window.innerHeight - defaultTop < eleBounds.height) { - top = window.innerHeight - eleBounds.height - 5 - } else { - top = anchorBounds.top - offsetPos - } - - return { ...cfg, left, top } - }} + handlePostionUpdate={customPositionHandler} > diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte index 4169cb7d3d..42651a4d84 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte @@ -50,7 +50,7 @@ updateSanitsedFields(sanitisedValue) unconfigured = buildUnconfiguredOptions(schema, sanitisedFields) fieldList = [...sanitisedFields, ...unconfigured] - .map(buildSudoInstance) + .map(buildPseudoInstance) .filter(x => x != null) } @@ -104,7 +104,7 @@ }) } - const buildSudoInstance = instance => { + const buildPseudoInstance = instance => { if (instance._component) { return instance } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json index 11a130490a..dd129be11e 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json @@ -36,6 +36,7 @@ "heading", "text", "button", + "buttongroup", "tag", "spectrumcard", "cardstat", diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 4e56ca758d..27e56d94db 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -258,6 +258,189 @@ "description": "Contains your app screens", "static": true }, + + "buttongroup": { + "name": "Button group", + "icon": "Button", + "hasChildren": false, + "settings": [ + { + "section": true, + "name": "Buttons", + "settings": [ + { + "type": "buttonConfiguration", + "key": "buttons", + "nested": true, + "defaultValue" : [{ + "component" : "button", + "props" : { + "type": "cta" + } + },{ + "component" : "button", + "props" : { + "type" : "primary" + } + }] + } + ] + }, + { + "section": true, + "name": "Layout", + "settings": [ + { + "type": "select", + "label": "Direction", + "key": "direction", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Column", + "value": "column", + "barIcon": "ViewColumn", + "barTitle": "Column layout" + }, + { + "label": "Row", + "value": "row", + "barIcon": "ViewRow", + "barTitle": "Row layout" + } + ], + "defaultValue": "column" + }, + { + "type": "select", + "label": "Horiz. align", + "key": "hAlign", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Left", + "value": "left", + "barIcon": "AlignLeft", + "barTitle": "Align left" + }, + { + "label": "Center", + "value": "center", + "barIcon": "AlignCenter", + "barTitle": "Align center" + }, + { + "label": "Right", + "value": "right", + "barIcon": "AlignRight", + "barTitle": "Align right" + }, + { + "label": "Stretch", + "value": "stretch", + "barIcon": "MoveLeftRight", + "barTitle": "Align stretched horizontally" + } + ], + "defaultValue": "left" + }, + { + "type": "select", + "label": "Vert. align", + "key": "vAlign", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Top", + "value": "top", + "barIcon": "AlignTop", + "barTitle": "Align top" + }, + { + "label": "Middle", + "value": "middle", + "barIcon": "AlignMiddle", + "barTitle": "Align middle" + }, + { + "label": "Bottom", + "value": "bottom", + "barIcon": "AlignBottom", + "barTitle": "Align bottom" + }, + { + "label": "Stretch", + "value": "stretch", + "barIcon": "MoveUpDown", + "barTitle": "Align stretched vertically" + } + ], + "defaultValue": "top" + }, + { + "type": "select", + "label": "Size", + "key": "size", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Shrink", + "value": "shrink", + "barIcon": "Minimize", + "barTitle": "Shrink container" + }, + { + "label": "Grow", + "value": "grow", + "barIcon": "Maximize", + "barTitle": "Grow container" + } + ], + "defaultValue": "shrink" + }, + { + "type": "select", + "label": "Gap", + "key": "gap", + "showInBar": true, + "barStyle": "picker", + "options": [ + { + "label": "None", + "value": "N" + }, + { + "label": "Small", + "value": "S" + }, + { + "label": "Medium", + "value": "M" + }, + { + "label": "Large", + "value": "L" + } + ], + "defaultValue": "M" + }, + { + "type": "boolean", + "label": "Wrap", + "key": "wrap", + "showInBar": true, + "barIcon": "ModernGridView", + "barTitle": "Wrap" + } + ] + } + ] + }, + "button": { "name": "Button", "description": "A basic html button that is ready for styling", diff --git a/packages/client/src/components/app/forms/ButtonGroup.svelte b/packages/client/src/components/app/forms/ButtonGroup.svelte new file mode 100644 index 0000000000..222e91a55f --- /dev/null +++ b/packages/client/src/components/app/forms/ButtonGroup.svelte @@ -0,0 +1,38 @@ + + + + + + {#each buttons as { text, type, quiet, disabled, onClick, size }} + + {/each} + + + diff --git a/packages/client/src/components/app/forms/index.js b/packages/client/src/components/app/forms/index.js index 5804d3a79d..24d7f11c0c 100644 --- a/packages/client/src/components/app/forms/index.js +++ b/packages/client/src/components/app/forms/index.js @@ -16,3 +16,4 @@ export { default as jsonfield } from "./JSONField.svelte" export { default as s3upload } from "./S3Upload.svelte" export { default as codescanner } from "./CodeScannerField.svelte" export { default as bbreferencefield } from "./BBReferenceField.svelte" +export { default as buttongroup } from "./ButtonGroup.svelte" \ No newline at end of file From 789bb528f43f7cd28eca2e52a309eddcbb3c370b Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 12 Oct 2023 11:58:25 +0100 Subject: [PATCH 05/68] Add basic inline searching and fix create first row popup --- .../components/grid/cells/HeaderCell.svelte | 164 ++++++++++++++---- .../src/components/grid/layout/NewRow.svelte | 6 +- .../src/components/grid/stores/filter.js | 40 +++++ .../src/components/grid/stores/rows.js | 6 + 4 files changed, 185 insertions(+), 31 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 5ac70c93c8..1abddfe1ff 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -3,6 +3,7 @@ import GridCell from "./GridCell.svelte" import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui" import { getColumnIcon } from "../lib/utils" + import { debounce } from "../../../utils/utils" export let column export let idx @@ -23,6 +24,8 @@ definition, datasource, schema, + focusedCellId, + filter, } = getContext("grid") const bannedDisplayColumnTypes = [ @@ -32,12 +35,15 @@ "boolean", "json", ] + const searchableTypes = ["string", "options", "number"] let anchor let open = false let editIsOpen = false let timeout let popover + let searchValue + let input $: sortedBy = column.name === $sort.column $: canMoveLeft = orderable && idx > 0 @@ -48,6 +54,9 @@ $: descendingLabel = ["number", "bigint"].includes(column.schema?.type) ? "high-low" : "Z-A" + $: searchable = searchableTypes.includes(column.schema.type) + $: searching = searchValue != null + $: debouncedUpdateFilter(searchValue) const editColumn = async () => { editIsOpen = true @@ -148,12 +157,46 @@ }) } + const startSearching = async () => { + $focusedCellId = null + searchValue = "" + await tick() + input?.focus() + } + + const onInputKeyDown = e => { + if (e.key === "Enter") { + updateFilter() + } else if (e.key === "Escape") { + input?.blur() + } + } + + const stopSearching = () => { + searchValue = null + updateFilter() + } + + const onBlurInput = () => { + if (searchValue === "") { + searchValue = null + } + updateFilter() + } + + const updateFilter = () => { + filter.actions.addInlineFilter(column, searchValue) + } + const debouncedUpdateFilter = debounce(updateFilter, 250) + onMount(() => subscribe("close-edit-column", cancelEdit))
- + {#if searching} + focusedCellId.set(null)} + on:keydown={onInputKeyDown} + /> + {/if} + +
+ +
+
+ +
+
{column.label}
- {#if sortedBy} -
- + + {#if searching} +
+ +
+ {:else} + {#if sortedBy} +
+ +
+ {/if} +
(open = true)}> +
{/if} -
(open = true)}> - -
@@ -289,6 +350,29 @@ background: var(--grid-background-alt); } + /* Icon colors */ + .header-cell :global(.spectrum-Icon) { + color: var(--spectrum-global-color-gray-600); + } + .header-cell :global(.spectrum-Icon.hoverable:hover) { + color: var(--spectrum-global-color-gray-800) !important; + cursor: pointer; + } + + /* Search icon */ + .search-icon { + display: none; + } + .header-cell.searchable:not(.open):hover .search-icon, + .header-cell.searchable.searching .search-icon { + display: block; + } + .header-cell.searchable:not(.open):hover .column-icon, + .header-cell.searchable.searching .column-icon { + display: none; + } + + /* Main center content */ .name { flex: 1 1 auto; width: 0; @@ -296,23 +380,45 @@ text-overflow: ellipsis; overflow: hidden; } + .header-cell.searching .name { + opacity: 0; + pointer-events: none; + } + input { + display: none; + font-family: var(--font-sans); + outline: none; + border: 1px solid transparent; + background: transparent; + color: var(--ink); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0 30px; + border-radius: 2px; + } + input:focus { + border: 1px solid var(--accent-color); + } + input:not(:focus) { + background: var(--spectrum-global-color-gray-200); + } + .header-cell.searching input { + display: block; + } - .more { + /* Right icons */ + .more-icon { display: none; padding: 4px; margin: 0 -4px; } - .header-cell.open .more, - .header-cell:hover .more { + .header-cell.open .more-icon, + .header-cell:hover .more-icon { display: block; } - .more:hover { - cursor: pointer; - } - .more:hover :global(.spectrum-Icon) { - color: var(--spectrum-global-color-gray-800) !important; - } - .header-cell.open .sort-indicator, .header-cell:hover .sort-indicator { display: none; diff --git a/packages/frontend-core/src/components/grid/layout/NewRow.svelte b/packages/frontend-core/src/components/grid/layout/NewRow.svelte index aa9b6fa051..440e15ee0c 100644 --- a/packages/frontend-core/src/components/grid/layout/NewRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewRow.svelte @@ -27,8 +27,10 @@ rowVerticalInversionIndex, columnHorizontalInversionIndex, selectedRows, - loading, + loaded, + refreshing, config, + filter, } = getContext("grid") let visible = false @@ -153,7 +155,7 @@ {#if !visible && !selectedRowCount && $config.canAddRows} diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index a59c98ccdd..a2de0ca2d0 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -11,6 +11,46 @@ export const createStores = context => { } } +export const createActions = context => { + const { filter } = context + + const addInlineFilter = (column, value) => { + const filterId = `inline-${column}` + + const inlineFilter = { + field: column.name, + id: filterId, + operator: "equal", + type: "string", + valueType: "value", + value, + } + + filter.update($filter => { + // Remove any existing inline filter + if ($filter?.length) { + $filter = $filter?.filter(x => x.id !== filterId) + } + + // Add new one if a value exists + if (value) { + $filter = [...($filter || []), inlineFilter] + } + + return $filter + }) + } + + return { + filter: { + ...filter, + actions: { + addInlineFilter, + }, + }, + } +} + export const initialise = context => { const { filter, initialFilter } = context diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index 49adb62936..98e64d7acb 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -8,6 +8,7 @@ export const createStores = () => { const rows = writable([]) const loading = writable(false) const loaded = writable(false) + const refreshing = writable(false) const rowChangeCache = writable({}) const inProgressChanges = writable({}) const hasNextPage = writable(false) @@ -53,6 +54,7 @@ export const createStores = () => { fetch, rowLookupMap, loaded, + refreshing, loading, rowChangeCache, inProgressChanges, @@ -82,6 +84,7 @@ export const createActions = context => { notifications, fetch, isDatasourcePlus, + refreshing, } = context const instanceLoaded = writable(false) @@ -176,6 +179,9 @@ export const createActions = context => { // Notify that we're loaded loading.set(false) } + + // Update refreshing state + refreshing.set($fetch.loading) }) fetch.set(newFetch) From 2ef2d07cab6f3f563d0d3afd62e6e7e5bf201f42 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 12 Oct 2023 14:28:05 +0100 Subject: [PATCH 06/68] Add inline searching for formula and longform columns, and improve searching operators where possible --- .../components/grid/cells/HeaderCell.svelte | 12 ++++++++-- .../src/components/grid/stores/filter.js | 22 ++++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 1abddfe1ff..314db21fc5 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -35,7 +35,7 @@ "boolean", "json", ] - const searchableTypes = ["string", "options", "number"] + const searchableTypes = ["string", "options", "number", "array", "longform"] let anchor let open = false @@ -54,10 +54,18 @@ $: descendingLabel = ["number", "bigint"].includes(column.schema?.type) ? "high-low" : "Z-A" - $: searchable = searchableTypes.includes(column.schema.type) + $: searchable = isColumnSearchable(column) $: searching = searchValue != null $: debouncedUpdateFilter(searchValue) + const isColumnSearchable = col => { + const type = col.schema.type + return ( + searchableTypes.includes(type) || + (type === "formula" && col.schema.formulaType === "static") + ) + } + const editColumn = async () => { editIsOpen = true await tick() diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index a2de0ca2d0..25b61161fa 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -16,27 +16,39 @@ export const createActions = context => { const addInlineFilter = (column, value) => { const filterId = `inline-${column}` - - const inlineFilter = { + let inlineFilter = { field: column.name, id: filterId, operator: "equal", - type: "string", + type: column.schema.type, valueType: "value", value, } + // Add overrides specific so the certain column type + switch (column.schema.type) { + case "string": + case "formula": + case "longform": + inlineFilter.operator = "string" + break + case "number": + inlineFilter.value = parseFloat(value) + break + case "array": + inlineFilter.operator = "contains" + } + + // Add this filter filter.update($filter => { // Remove any existing inline filter if ($filter?.length) { $filter = $filter?.filter(x => x.id !== filterId) } - // Add new one if a value exists if (value) { $filter = [...($filter || []), inlineFilter] } - return $filter }) } From cfdaa3564c9b4e507a79fa75e85eb22778628626 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 12 Oct 2023 14:30:41 +0100 Subject: [PATCH 07/68] Improve options inline searching --- .../src/components/grid/stores/filter.js | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index 25b61161fa..7e8cb364a8 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -16,27 +16,22 @@ export const createActions = context => { const addInlineFilter = (column, value) => { const filterId = `inline-${column}` + const type = column.schema.type let inlineFilter = { field: column.name, id: filterId, - operator: "equal", - type: column.schema.type, + operator: "string", valueType: "value", + type, value, } // Add overrides specific so the certain column type - switch (column.schema.type) { - case "string": - case "formula": - case "longform": - inlineFilter.operator = "string" - break - case "number": - inlineFilter.value = parseFloat(value) - break - case "array": - inlineFilter.operator = "contains" + if (type === "number") { + inlineFilter.value = parseFloat(value) + inlineFilter.operator = "equal" + } else if (type === "array") { + inlineFilter.operator = "contains" } // Add this filter From c906efb972b211ba02dbd2e1f64c6e9a8002ec61 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 12 Oct 2023 14:37:13 +0100 Subject: [PATCH 08/68] Fix text colour for inline searching in grid block --- .../frontend-core/src/components/grid/cells/HeaderCell.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 573030b7b4..cdd8afb57e 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -398,7 +398,7 @@ outline: none; border: 1px solid transparent; background: transparent; - color: var(--ink); + color: var(--spectrum-global-color-gray-800); position: absolute; top: 0; left: 0; From 6dfe2c22af340af73429121cf35c86874c60c3cc Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 12 Oct 2023 15:46:18 +0100 Subject: [PATCH 09/68] Fix issue with multiple filters at the same time and remove unused variable --- .../src/components/grid/cells/HeaderCell.svelte | 7 ------- .../frontend-core/src/components/grid/stores/filter.js | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index cdd8afb57e..7d2b5d5941 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -29,13 +29,6 @@ filter, } = getContext("grid") - const bannedDisplayColumnTypes = [ - "link", - "array", - "attachment", - "boolean", - "json", - ] const searchableTypes = ["string", "options", "number", "array", "longform"] let anchor diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index 7e8cb364a8..984c2115ee 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -15,7 +15,7 @@ export const createActions = context => { const { filter } = context const addInlineFilter = (column, value) => { - const filterId = `inline-${column}` + const filterId = `inline-${column.name}` const type = column.schema.type let inlineFilter = { field: column.name, From 804aab3e43a0f9a4bb154d54925bdfa29ea37d41 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 13 Oct 2023 09:36:50 +0100 Subject: [PATCH 10/68] Refactor to use types for fields and add support for searching bigint columns --- .../components/grid/cells/HeaderCell.svelte | 25 ++++++++++++------- .../src/components/grid/stores/filter.js | 7 ++++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 7d2b5d5941..d4ed41efd3 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -5,6 +5,7 @@ import GridCell from "./GridCell.svelte" import { getColumnIcon } from "../lib/utils" import { debounce } from "../../../utils/utils" + import { FieldType, FormulaTypes } from "@budibase/types" export let column export let idx @@ -29,7 +30,14 @@ filter, } = getContext("grid") - const searchableTypes = ["string", "options", "number", "array", "longform"] + const searchableTypes = [ + FieldType.STRING, + FieldType.OPTIONS, + FieldType.NUMBER, + FieldType.BIGINT, + FieldType.ARRAY, + FieldType.LONGFORM, + ] let anchor let open = false @@ -42,21 +50,20 @@ $: sortedBy = column.name === $sort.column $: canMoveLeft = orderable && idx > 0 $: canMoveRight = orderable && idx < $renderedColumns.length - 1 - $: ascendingLabel = ["number", "bigint"].includes(column.schema?.type) - ? "low-high" - : "A-Z" - $: descendingLabel = ["number", "bigint"].includes(column.schema?.type) - ? "high-low" - : "Z-A" + $: numericType = [FieldType.NUMBER, FieldType.BIGINT].includes( + column.schema?.type + ) + $: ascendingLabel = numericType ? "low-high" : "A-Z" + $: descendingLabel = numericType ? "high-low" : "Z-A" $: searchable = isColumnSearchable(column) $: searching = searchValue != null $: debouncedUpdateFilter(searchValue) const isColumnSearchable = col => { - const type = col.schema.type + const { type, formulaType } = col.schema return ( searchableTypes.includes(type) || - (type === "formula" && col.schema.formulaType === "static") + (type === FieldType.FORMULA && formulaType === FormulaTypes.STATIC) ) } diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index 984c2115ee..76c8c5d3ec 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -1,4 +1,5 @@ import { writable, get } from "svelte/store" +import { FieldType } from "@budibase/types" export const createStores = context => { const { props } = context @@ -27,10 +28,12 @@ export const createActions = context => { } // Add overrides specific so the certain column type - if (type === "number") { + if (type === FieldType.NUMBER) { inlineFilter.value = parseFloat(value) inlineFilter.operator = "equal" - } else if (type === "array") { + } else if (type === FieldType.BIGINT) { + inlineFilter.operator = "equal" + } else if (type === FieldType.ARRAY) { inlineFilter.operator = "contains" } From b8c87224f76b0ec77e65072fd78df917bff7a7bf Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 13 Oct 2023 14:53:39 +0100 Subject: [PATCH 11/68] Add/Remove button behaviour, binding label support and default text behaviour. Refactoring and renaming of EditComponentPopoever component --- .../ButtonConfiguration.svelte | 112 ++++++++++++++---- .../ButtonConfiguration/ButtonSetting.svelte | 33 +++--- .../{ => DraggableList}/DraggableList.svelte | 32 ++++- .../controls/DraggableList/drag-handle.svelte | 31 +++++ ...ver.svelte => EditComponentPopover.svelte} | 65 ++++------ .../FieldConfiguration.svelte | 2 +- .../FieldConfiguration/FieldSetting.svelte | 56 ++++++++- packages/client/manifest.json | 22 ++-- .../src/components/app/ButtonGroup.svelte | 37 ++++++ .../components/app/forms/ButtonGroup.svelte | 38 ------ .../client/src/components/app/forms/index.js | 3 +- packages/client/src/components/app/index.js | 1 + 12 files changed, 281 insertions(+), 151 deletions(-) rename packages/builder/src/components/design/settings/controls/{ => DraggableList}/DraggableList.svelte (82%) create mode 100644 packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte rename packages/builder/src/components/design/settings/controls/{FieldConfiguration/EditFieldPopover.svelte => EditComponentPopover.svelte} (63%) create mode 100644 packages/client/src/components/app/ButtonGroup.svelte delete mode 100644 packages/client/src/components/app/forms/ButtonGroup.svelte diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte index baa8ad733c..fde888d17b 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte @@ -1,5 +1,5 @@
- -
{item.text}
+
{readableText || "Button"}
- {console.log("REMOVE ME")}} - /> + removeButton(item._id)} + />
diff --git a/packages/builder/src/components/design/settings/controls/DraggableList.svelte b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte similarity index 82% rename from packages/builder/src/components/design/settings/controls/DraggableList.svelte rename to packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte index c8395b2a1f..1992299e90 100644 --- a/packages/builder/src/components/design/settings/controls/DraggableList.svelte +++ b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte @@ -1,10 +1,10 @@
- -
{item.label || item.field}
+ > +
+ + {item.field} +
+ +
{readableText}
@@ -53,4 +81,20 @@ .list-item-body { justify-content: space-between; } + .type-icon { + display: flex; + gap: var(--spacing-m); + margin: var(--spacing-xl); + margin-bottom: 0px; + height: var(--spectrum-alias-item-height-m); + padding: 0px var(--spectrum-alias-item-padding-m); + border-width: var(--spectrum-actionbutton-border-size); + border-radius: var(--spectrum-alias-border-radius-regular); + border: 1px solid + var( + --spectrum-actionbutton-m-border-color, + var(--spectrum-alias-border-color) + ); + align-items: center; + } diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 27e56d94db..a8559d4f79 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -258,7 +258,6 @@ "description": "Contains your app screens", "static": true }, - "buttongroup": { "name": "Button group", "icon": "Button", @@ -272,17 +271,14 @@ "type": "buttonConfiguration", "key": "buttons", "nested": true, - "defaultValue" : [{ - "component" : "button", - "props" : { + "defaultValue": [ + { "type": "cta" + }, + { + "type": "primary" } - },{ - "component" : "button", - "props" : { - "type" : "primary" - } - }] + ] } ] }, @@ -310,7 +306,7 @@ "barTitle": "Row layout" } ], - "defaultValue": "column" + "defaultValue": "row" }, { "type": "select", @@ -440,7 +436,6 @@ } ] }, - "button": { "name": "Button", "description": "A basic html button that is ready for styling", @@ -2592,7 +2587,6 @@ "key": "disabled", "defaultValue": false }, - { "type": "text", "label": "Initial form step", @@ -5875,4 +5869,4 @@ } ] } -} +} \ No newline at end of file diff --git a/packages/client/src/components/app/ButtonGroup.svelte b/packages/client/src/components/app/ButtonGroup.svelte new file mode 100644 index 0000000000..8d6cacd7a5 --- /dev/null +++ b/packages/client/src/components/app/ButtonGroup.svelte @@ -0,0 +1,37 @@ + + + + + {#each buttons as { text, type, quiet, disabled, onClick, size }, idx} + + {/each} + + diff --git a/packages/client/src/components/app/forms/ButtonGroup.svelte b/packages/client/src/components/app/forms/ButtonGroup.svelte deleted file mode 100644 index 222e91a55f..0000000000 --- a/packages/client/src/components/app/forms/ButtonGroup.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - {#each buttons as { text, type, quiet, disabled, onClick, size }} - - {/each} - - - diff --git a/packages/client/src/components/app/forms/index.js b/packages/client/src/components/app/forms/index.js index 24d7f11c0c..38372f32d4 100644 --- a/packages/client/src/components/app/forms/index.js +++ b/packages/client/src/components/app/forms/index.js @@ -15,5 +15,4 @@ export { default as formstep } from "./FormStep.svelte" export { default as jsonfield } from "./JSONField.svelte" export { default as s3upload } from "./S3Upload.svelte" export { default as codescanner } from "./CodeScannerField.svelte" -export { default as bbreferencefield } from "./BBReferenceField.svelte" -export { default as buttongroup } from "./ButtonGroup.svelte" \ No newline at end of file +export { default as bbreferencefield } from "./BBReferenceField.svelte" \ No newline at end of file diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js index 060c15a857..97df3741e1 100644 --- a/packages/client/src/components/app/index.js +++ b/packages/client/src/components/app/index.js @@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte" export { default as divider } from "./Divider.svelte" export { default as screenslot } from "./ScreenSlot.svelte" export { default as button } from "./Button.svelte" +export { default as buttongroup } from "./ButtonGroup.svelte" export { default as repeater } from "./Repeater.svelte" export { default as text } from "./Text.svelte" export { default as layout } from "./Layout.svelte" From 166c517ff82d4644f37f5e6fe3b4ff2fef2b0d94 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 13 Oct 2023 15:02:18 +0100 Subject: [PATCH 12/68] Fix Block import locations --- packages/client/src/components/app/ButtonGroup.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/components/app/ButtonGroup.svelte b/packages/client/src/components/app/ButtonGroup.svelte index 8d6cacd7a5..0af16e6950 100644 --- a/packages/client/src/components/app/ButtonGroup.svelte +++ b/packages/client/src/components/app/ButtonGroup.svelte @@ -1,6 +1,6 @@
- {#if buttonList?.length} + {#if buttonCount} - {#each buttons as { text, type, quiet, disabled, onClick, size }, idx} + {#each buttons as { text, type, quiet, disabled, onClick, size }} Date: Mon, 16 Oct 2023 17:12:25 +0100 Subject: [PATCH 18/68] Ensure keyboard events while inline searching are not captured by the main grid keyboard manager --- .../frontend-core/src/components/grid/cells/HeaderCell.svelte | 1 + .../src/components/grid/overlays/KeyboardManager.svelte | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index a053b2a6f0..6648ba1a69 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -234,6 +234,7 @@ on:blur={onBlurInput} on:click={() => focusedCellId.set(null)} on:keydown={onInputKeyDown} + data-grid-ignore /> {/if} diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index cd23f154b5..8b0a0f0942 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -21,6 +21,7 @@ const ignoredOriginSelectors = [ ".spectrum-Modal", "#builder-side-panel-container", + "[data-grid-ignore]", ] // Global key listener which intercepts all key events From 74cab111917c91c64b39f8d913be228259d42982 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 16 Oct 2023 17:17:14 +0100 Subject: [PATCH 19/68] Improve grid sorting labels to account for date types and provide better labels --- .../components/grid/cells/HeaderCell.svelte | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 6648ba1a69..f367e3427f 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -51,16 +51,33 @@ $: sortedBy = column.name === $sort.column $: canMoveLeft = orderable && idx > 0 $: canMoveRight = orderable && idx < $renderedColumns.length - 1 - $: numericType = [FieldType.NUMBER, FieldType.BIGINT].includes( - column.schema?.type - ) - $: ascendingLabel = numericType ? "low-high" : "A-Z" - $: descendingLabel = numericType ? "high-low" : "Z-A" + $: sortingLabels = getSortingLabels(column.schema?.type) $: searchable = isColumnSearchable(column) $: resetSearchValue(column.name) $: searching = searchValue != null $: debouncedUpdateFilter(searchValue) + const getSortingLabels = type => { + switch (type) { + case FieldType.NUMBER: + case FieldType.BIGINT: + return { + ascending: "low-high", + descending: "high-low", + } + case FieldType.DATETIME: + return { + ascending: "old-new", + descending: "new-old", + } + default: + return { + ascending: "A-Z", + descending: "Z-A", + } + } + } + const resetSearchValue = name => { searchValue = $inlineFilters?.find(x => x.id === `inline-${name}`)?.value } @@ -318,14 +335,14 @@ on:click={sortAscending} disabled={column.name === $sort.column && $sort.order === "ascending"} > - Sort {ascendingLabel} + Sort {sortingLabels.ascending} - Sort {descendingLabel} + Sort {sortingLabels.descending} Move left From 71712d422a99531fe531a86978eadd62c16e6693 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 17 Oct 2023 10:51:06 +0100 Subject: [PATCH 20/68] Fix alert when duplicate auto columns exist --- .../app/[application]/data/table/[tableId]/index.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte index 414722a177..a68a782bed 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte @@ -53,7 +53,8 @@ } .alert-wrap { display: flex; - width: 100%; + flex: 0 0 auto; + margin: -28px -40px 14px -40px; } .alert-wrap :global(> *) { flex: 1; From 5e8e4add4a650604357d5c78c77c7ee22a25c399 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 17 Oct 2023 10:53:08 +0100 Subject: [PATCH 21/68] Make it less painful to delete columns --- .../backend/DataTable/modals/CreateEditColumn.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 7b51e6c839..ff9500f226 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -777,7 +777,8 @@ disabled={deleteColName !== originalName} >

- Are you sure you wish to delete the column {originalName}? + Are you sure you wish to delete the column + (deleteColName = originalName)}>{originalName}? Your data will be deleted and this action cannot be undone - enter the column name to confirm.

From 4e703fdfcc0e644d08fc6de3c73633dcf39bb39b Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 17 Oct 2023 11:16:15 +0100 Subject: [PATCH 22/68] Remove concept of rendered columns from grid and instead render all columns --- .../components/grid/cells/HeaderCell.svelte | 4 +- .../grid/cells/RelationshipCell.svelte | 7 ++ .../components/grid/layout/GridBody.svelte | 6 +- .../src/components/grid/layout/GridRow.svelte | 4 +- .../grid/layout/GridScrollWrapper.svelte | 7 +- .../components/grid/layout/HeaderRow.svelte | 4 +- .../grid/layout/NewColumnButton.svelte | 9 +-- .../src/components/grid/layout/NewRow.svelte | 46 ++++++------ .../grid/overlays/ResizeOverlay.svelte | 4 +- .../src/components/grid/stores/viewport.js | 75 ++----------------- 10 files changed, 53 insertions(+), 113 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index f367e3427f..2397f964e8 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -17,7 +17,7 @@ isResizing, rand, sort, - renderedColumns, + visibleColumns, dispatch, subscribe, config, @@ -50,7 +50,7 @@ $: sortedBy = column.name === $sort.column $: canMoveLeft = orderable && idx > 0 - $: canMoveRight = orderable && idx < $renderedColumns.length - 1 + $: canMoveRight = orderable && idx < $visibleColumns.length - 1 $: sortingLabels = getSortingLabels(column.schema?.type) $: searchable = isColumnSearchable(column) $: resetSearchValue(column.name) diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index 925c840478..d15acce4fc 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -444,6 +444,13 @@ text-decoration: underline; } + .remove { + display: none; + } + .remove.visible { + display: block; + } + .add { background: var(--spectrum-global-color-gray-200); padding: 4px; diff --git a/packages/frontend-core/src/components/grid/layout/GridBody.svelte b/packages/frontend-core/src/components/grid/layout/GridBody.svelte index 762985a4db..0bb2a51fb4 100644 --- a/packages/frontend-core/src/components/grid/layout/GridBody.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridBody.svelte @@ -7,7 +7,7 @@ const { bounds, renderedRows, - renderedColumns, + visibleColumns, rowVerticalInversionIndex, hoveredRowId, dispatch, @@ -17,7 +17,7 @@ let body - $: renderColumnsWidth = $renderedColumns.reduce( + $: columnsWidth = $visibleColumns.reduce( (total, col) => (total += col.width), 0 ) @@ -47,7 +47,7 @@
($hoveredRowId = BlankRowID)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:click={() => dispatch("add-row-inline")} diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index 4754d493bf..93dc11f6ed 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -10,7 +10,7 @@ focusedCellId, reorder, selectedRows, - renderedColumns, + visibleColumns, hoveredRowId, selectedCellMap, focusedRow, @@ -34,7 +34,7 @@ on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))} > - {#each $renderedColumns as column, columnIdx (column.name)} + {#each $visibleColumns as column, columnIdx} {@const cellId = `${row._id}-${column.name}`} { - const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0 + const generateStyle = (scroll, rowHeight) => { + const offsetX = scrollHorizontally ? -1 * scroll.left : 0 const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0 return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);` } diff --git a/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte b/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte index 97b7d054f3..b8655b98b3 100644 --- a/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte @@ -5,14 +5,14 @@ import HeaderCell from "../cells/HeaderCell.svelte" import { TempTooltip, TooltipType } from "@budibase/bbui" - const { renderedColumns, config, hasNonAutoColumn, datasource, loading } = + const { visibleColumns, config, hasNonAutoColumn, datasource, loading } = getContext("grid")
- {#each $renderedColumns as column, idx} + {#each $visibleColumns as column, idx} diff --git a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte index d131df26e5..46e9b40fb6 100644 --- a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte @@ -2,17 +2,16 @@ import { getContext, onMount } from "svelte" import { Icon, Popover, clickOutside } from "@budibase/bbui" - const { renderedColumns, scroll, hiddenColumnsWidth, width, subscribe } = - getContext("grid") + const { visibleColumns, scroll, width, subscribe } = getContext("grid") let anchor let open = false - $: columnsWidth = $renderedColumns.reduce( + $: columnsWidth = $visibleColumns.reduce( (total, col) => (total += col.width), 0 ) - $: end = $hiddenColumnsWidth + columnsWidth - 1 - $scroll.left + $: end = columnsWidth - 1 - $scroll.left $: left = Math.min($width - 40, end) const close = () => { @@ -34,7 +33,7 @@
- {#each $renderedColumns as column, columnIdx} + {#each $visibleColumns as column, columnIdx} {@const cellId = `new-${column.name}`} - {#key cellId} - = $columnHorizontalInversionIndex} - {invertY} - > - {#if column?.schema?.autocolumn} -
Can't edit auto column
- {/if} - {#if isAdding} -
- {/if} - - {/key} + = $columnHorizontalInversionIndex} + {invertY} + > + {#if column?.schema?.autocolumn} +
Can't edit auto column
+ {/if} + {#if isAdding} +
+ {/if} + {/each}
diff --git a/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte index 13e158b300..9e584ab610 100644 --- a/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte @@ -2,7 +2,7 @@ import { getContext } from "svelte" import { GutterWidth } from "../lib/constants" - const { resize, renderedColumns, stickyColumn, isReordering, scrollLeft } = + const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } = getContext("grid") $: offset = GutterWidth + ($stickyColumn?.width || 0) @@ -26,7 +26,7 @@
{/if} - {#each $renderedColumns as column} + {#each $visibleColumns as column}
{ [] ) - // Derive visible columns - const scrollLeftRounded = derived(scrollLeft, $scrollLeft => { - const interval = MinColumnWidth - return Math.round($scrollLeft / interval) * interval - }) - const renderedColumns = derived( - [visibleColumns, scrollLeftRounded, width], - ([$visibleColumns, $scrollLeft, $width], set) => { - if (!$visibleColumns.length) { - set([]) - return - } - let startColIdx = 0 - let rightEdge = $visibleColumns[0].width - while ( - rightEdge < $scrollLeft && - startColIdx < $visibleColumns.length - 1 - ) { - startColIdx++ - rightEdge += $visibleColumns[startColIdx].width - } - let endColIdx = startColIdx + 1 - let leftEdge = rightEdge - while ( - leftEdge < $width + $scrollLeft && - endColIdx < $visibleColumns.length - ) { - leftEdge += $visibleColumns[endColIdx].width - endColIdx++ - } - // Render an additional column on either side to account for - // debounce column updates based on scroll position - const next = $visibleColumns.slice( - Math.max(0, startColIdx - 1), - endColIdx + 1 - ) - const current = get(renderedColumns) - if (JSON.stringify(next) !== JSON.stringify(current)) { - set(next) - } - } - ) - - const hiddenColumnsWidth = derived( - [renderedColumns, visibleColumns], - ([$renderedColumns, $visibleColumns]) => { - const idx = $visibleColumns.findIndex( - col => col.name === $renderedColumns[0]?.name - ) - let width = 0 - if (idx > 0) { - for (let i = 0; i < idx; i++) { - width += $visibleColumns[i].width - } - } - return width - }, - 0 - ) - // Determine the row index at which we should start vertically inverting cell // dropdowns const rowVerticalInversionIndex = derived( @@ -130,12 +69,12 @@ export const deriveStores = context => { // Determine the column index at which we should start horizontally inverting // cell dropdowns const columnHorizontalInversionIndex = derived( - [renderedColumns, scrollLeft, width], - ([$renderedColumns, $scrollLeft, $width]) => { + [visibleColumns, scrollLeft, width], + ([$visibleColumns, $scrollLeft, $width]) => { const cutoff = $width + $scrollLeft - ScrollBarSize * 3 - let inversionIdx = $renderedColumns.length - for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) { - const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width + let inversionIdx = $visibleColumns.length + for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) { + const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) { break } @@ -148,8 +87,6 @@ export const deriveStores = context => { scrolledRowCount, visualRowCapacity, renderedRows, - renderedColumns, - hiddenColumnsWidth, rowVerticalInversionIndex, columnHorizontalInversionIndex, } From c37538d61125734a32aeb0a178843c61f5b63e25 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 17 Oct 2023 11:51:42 +0100 Subject: [PATCH 23/68] Use CSS content-visibility to improve rendering performance by hiding offscreen grid cells --- .../src/components/grid/cells/DataCell.svelte | 2 + .../src/components/grid/cells/GridCell.svelte | 5 +++ .../src/components/grid/layout/GridRow.svelte | 2 + .../src/components/grid/layout/NewRow.svelte | 2 + .../src/components/grid/stores/viewport.js | 45 ++++++++++++++++++- 5 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index f9cdef3756..cdaf28978a 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -21,6 +21,7 @@ export let invertX = false export let invertY = false export let contentLines = 1 + export let hidden = false const emptyError = writable(null) @@ -78,6 +79,7 @@ {focused} {selectedUser} {readonly} + {hidden} error={$error} on:click={() => focusedCellId.set(cellId)} on:contextmenu={e => menu.actions.open(cellId, e)} diff --git a/packages/frontend-core/src/components/grid/cells/GridCell.svelte b/packages/frontend-core/src/components/grid/cells/GridCell.svelte index fe4bd70ba4..dcc76b9c75 100644 --- a/packages/frontend-core/src/components/grid/cells/GridCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GridCell.svelte @@ -10,6 +10,7 @@ export let defaultHeight = false export let center = false export let readonly = false + export let hidden = false $: style = getStyle(width, selectedUser) @@ -30,6 +31,7 @@ class:error class:center class:readonly + class:hidden class:default-height={defaultHeight} class:selected-other={selectedUser != null} class:alt={rowIdx % 2 === 1} @@ -81,6 +83,9 @@ .cell.center { align-items: center; } + .cell.hidden { + content-visibility: hidden; + } /* Cell border */ .cell.focused:after, diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index 93dc11f6ed..4a0db40ee8 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -19,6 +19,7 @@ isDragging, dispatch, rows, + columnRenderMap, } = getContext("grid") $: rowSelected = !!$selectedRows[row._id] @@ -51,6 +52,7 @@ selectedUser={$selectedCellMap[cellId]} width={column.width} contentLines={$contentLines} + hidden={!$columnRenderMap[column.name]} /> {/each}
diff --git a/packages/frontend-core/src/components/grid/layout/NewRow.svelte b/packages/frontend-core/src/components/grid/layout/NewRow.svelte index cc2d76f536..980f86326d 100644 --- a/packages/frontend-core/src/components/grid/layout/NewRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewRow.svelte @@ -31,6 +31,7 @@ refreshing, config, filter, + columnRenderMap, } = getContext("grid") let visible = false @@ -224,6 +225,7 @@ topRow={offset === 0} invertX={columnIdx >= $columnHorizontalInversionIndex} {invertY} + hidden={!$columnRenderMap[column.name]} > {#if column?.schema?.autocolumn}
Can't edit auto column
diff --git a/packages/frontend-core/src/components/grid/stores/viewport.js b/packages/frontend-core/src/components/grid/stores/viewport.js index 3a1b65e2b9..0890792989 100644 --- a/packages/frontend-core/src/components/grid/stores/viewport.js +++ b/packages/frontend-core/src/components/grid/stores/viewport.js @@ -1,7 +1,8 @@ -import { derived } from "svelte/store" +import { derived, get } from "svelte/store" import { MaxCellRenderHeight, MaxCellRenderWidthOverflow, + MinColumnWidth, ScrollBarSize, } from "../lib/constants" @@ -44,6 +45,47 @@ export const deriveStores = context => { [] ) + // Derive visible columns + const scrollLeftRounded = derived(scrollLeft, $scrollLeft => { + const interval = MinColumnWidth + return Math.round($scrollLeft / interval) * interval + }) + const columnRenderMap = derived( + [visibleColumns, scrollLeftRounded, width], + ([$visibleColumns, $scrollLeft, $width]) => { + if (!$visibleColumns.length) { + return {} + } + let startColIdx = 0 + let rightEdge = $visibleColumns[0].width + while ( + rightEdge < $scrollLeft && + startColIdx < $visibleColumns.length - 1 + ) { + startColIdx++ + rightEdge += $visibleColumns[startColIdx].width + } + let endColIdx = startColIdx + 1 + let leftEdge = rightEdge + while ( + leftEdge < $width + $scrollLeft && + endColIdx < $visibleColumns.length + ) { + leftEdge += $visibleColumns[endColIdx].width + endColIdx++ + } + + // Only update the store if different + let next = {} + $visibleColumns + .slice(Math.max(0, startColIdx), endColIdx) + .forEach(col => { + next[col.name] = true + }) + return next + } + ) + // Determine the row index at which we should start vertically inverting cell // dropdowns const rowVerticalInversionIndex = derived( @@ -87,6 +129,7 @@ export const deriveStores = context => { scrolledRowCount, visualRowCapacity, renderedRows, + columnRenderMap, rowVerticalInversionIndex, columnHorizontalInversionIndex, } From dadb36827932c16bd5f71ea1a65ed469488855bb Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 18 Oct 2023 08:31:29 +0100 Subject: [PATCH 24/68] Lint and add hover styles for deleting prompts --- .../backend/DataTable/modals/CreateEditColumn.svelte | 7 +++++++ .../src/components/grid/cells/RelationshipCell.svelte | 7 ------- .../frontend-core/src/components/grid/stores/viewport.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index ff9500f226..467ae413c3 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -811,4 +811,11 @@ gap: 8px; display: flex; } + b { + transition: color 130ms ease-out; + } + b:hover { + cursor: pointer; + color: var(--spectrum-global-color-gray-900); + } diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index d15acce4fc..925c840478 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -444,13 +444,6 @@ text-decoration: underline; } - .remove { - display: none; - } - .remove.visible { - display: block; - } - .add { background: var(--spectrum-global-color-gray-200); padding: 4px; diff --git a/packages/frontend-core/src/components/grid/stores/viewport.js b/packages/frontend-core/src/components/grid/stores/viewport.js index 0890792989..8df8acd0f4 100644 --- a/packages/frontend-core/src/components/grid/stores/viewport.js +++ b/packages/frontend-core/src/components/grid/stores/viewport.js @@ -1,4 +1,4 @@ -import { derived, get } from "svelte/store" +import { derived } from "svelte/store" import { MaxCellRenderHeight, MaxCellRenderWidthOverflow, From a06451222400312d9928e67618e870cc28f2dbf7 Mon Sep 17 00:00:00 2001 From: Dean Date: Wed, 18 Oct 2023 11:14:16 +0100 Subject: [PATCH 25/68] Added description field to the formblock --- packages/client/manifest.json | 6 ++ .../app/blocks/form/FormBlock.svelte | 2 + .../app/blocks/form/InnerFormBlock.svelte | 101 ++++++++++-------- 3 files changed, 67 insertions(+), 42 deletions(-) diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 8d0a4e456f..ed69b8868f 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -5305,6 +5305,12 @@ "key": "title", "nested": true }, + { + "type": "text", + "label": "Description", + "key": "description", + "nested": true + }, { "section": true, "dependsOn": { diff --git a/packages/client/src/components/app/blocks/form/FormBlock.svelte b/packages/client/src/components/app/blocks/form/FormBlock.svelte index 5d57d10ab6..f905227af9 100644 --- a/packages/client/src/components/app/blocks/form/FormBlock.svelte +++ b/packages/client/src/components/app/blocks/form/FormBlock.svelte @@ -12,6 +12,7 @@ export let fields export let labelPosition export let title + export let description export let showDeleteButton export let showSaveButton export let saveButtonLabel @@ -98,6 +99,7 @@ fields: fieldsOrDefault, labelPosition, title, + description, saveButtonLabel: saveLabel, deleteButtonLabel: deleteLabel, schema, diff --git a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte index ec5daa21b1..e65d2cf90b 100644 --- a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte +++ b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte @@ -11,6 +11,7 @@ export let fields export let labelPosition export let title + export let description export let saveButtonLabel export let deleteButtonLabel export let schema @@ -160,55 +161,71 @@ - {#if renderButtons} + > + {#if renderButtons} + + {#if renderDeleteButton} + + {/if} + {#if renderSaveButton} + + {/if} + + {/if} + + {#if description} + - {#if renderDeleteButton} - - {/if} - {#if renderSaveButton} - - {/if} - + /> {/if} {/if} From a069b343e9137a441d5af9305efc83e7ab23c42f Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 19 Oct 2023 11:29:37 +0100 Subject: [PATCH 26/68] Allow settings sections to be collapsible --- .../_components/Component/ComponentSettingsSection.svelte | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte index b4ed8995a0..a1ea13ce2b 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte @@ -123,10 +123,7 @@ {#each sections as section, idx (section.name)} {#if section.visible} - + {#if section.info}
From 8db0ff79a6c6d8b5061b1e55a5aa30a6ab0af07e Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Thu, 26 Oct 2023 12:44:36 +0200 Subject: [PATCH 57/68] Increase yarn timeouts --- packages/server/Dockerfile.v2 | 4 ++-- packages/worker/Dockerfile.v2 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/Dockerfile.v2 b/packages/server/Dockerfile.v2 index 881c21299e..f737570fcd 100644 --- a/packages/server/Dockerfile.v2 +++ b/packages/server/Dockerfile.v2 @@ -44,7 +44,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh WORKDIR /string-templates COPY packages/string-templates/package.json package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 COPY packages/string-templates . @@ -57,7 +57,7 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies. RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN ./scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true \ +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \ # Remove unneeded data from file system to reduce image size && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \ && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp diff --git a/packages/worker/Dockerfile.v2 b/packages/worker/Dockerfile.v2 index a8be432827..4706ca155a 100644 --- a/packages/worker/Dockerfile.v2 +++ b/packages/worker/Dockerfile.v2 @@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh WORKDIR /string-templates COPY packages/string-templates/package.json package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 COPY packages/string-templates . @@ -30,7 +30,7 @@ RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-te RUN ../scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 # Remove unneeded data from file system to reduce image size RUN apk del .gyp \ && yarn cache clean From 44f9c64ed72b87c8a62e6b4754f107e1545bf07a Mon Sep 17 00:00:00 2001 From: Gerard Burns Date: Thu, 26 Oct 2023 13:06:25 +0100 Subject: [PATCH 58/68] Prevent the key user being used in rest queries (#12072) * Add warning about unusable user binding * linting * remove unnecessary safe nav operators * change regex to capture property access of user binding --- .../integration/RestQueryViewer.svelte | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/builder/src/components/integration/RestQueryViewer.svelte b/packages/builder/src/components/integration/RestQueryViewer.svelte index 254f65fcaf..e6913b0953 100644 --- a/packages/builder/src/components/integration/RestQueryViewer.svelte +++ b/packages/builder/src/components/integration/RestQueryViewer.svelte @@ -196,8 +196,36 @@ } } + const validateQuery = async () => { + const forbiddenBindings = /{{\s?user(\.(\w|\$)*\s?|\s?)}}/g + const bindingError = new Error( + "'user' is a protected binding and cannot be used" + ) + + if (forbiddenBindings.test(url)) { + throw bindingError + } + + if (forbiddenBindings.test(query.fields.requestBody ?? "")) { + throw bindingError + } + + Object.values(requestBindings).forEach(bindingValue => { + if (forbiddenBindings.test(bindingValue)) { + throw bindingError + } + }) + + Object.values(query.fields.headers).forEach(headerValue => { + if (forbiddenBindings.test(headerValue)) { + throw bindingError + } + }) + } + async function runQuery() { try { + await validateQuery() response = await queries.preview(buildQuery()) if (response.rows.length === 0) { notifications.info("Request did not return any data") From 9616e8e5519cc59ca7dabd286a4d97190eecdb9c Mon Sep 17 00:00:00 2001 From: melohagan <101575380+melohagan@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:28:12 +0100 Subject: [PATCH 59/68] Custom role navigation links (#12056) * Refactor routing unit tests * Filter out higher level roles in preview * unit test * Refactor --------- Co-authored-by: Michael Drury --- packages/server/src/api/controllers/role.ts | 24 +++++++++++++++++- .../server/src/api/routes/tests/role.spec.js | 20 +++++++++++++++ .../src/api/routes/tests/routing.spec.js | 25 ++++++++----------- packages/server/src/constants/screens.ts | 14 ++++++++--- .../server/src/tests/utilities/structures.ts | 19 ++++++++++++-- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts index ed23009706..3697bbe925 100644 --- a/packages/server/src/api/controllers/role.ts +++ b/packages/server/src/api/controllers/role.ts @@ -1,4 +1,10 @@ -import { context, db as dbCore, events, roles } from "@budibase/backend-core" +import { + context, + db as dbCore, + events, + roles, + Header, +} from "@budibase/backend-core" import { getUserMetadataParams, InternalTables } from "../../db/utils" import { Database, Role, UserCtx, UserRoles } from "@budibase/types" import { sdk as sharedSdk } from "@budibase/shared-core" @@ -143,4 +149,20 @@ export async function accessible(ctx: UserCtx) { } else { ctx.body = await roles.getUserRoleIdHierarchy(roleId!) } + + // If a custom role is provided in the header, filter out higher level roles + const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string + if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) { + const inherits = (await roles.getRole(roleHeader))?.inherits + const orderedRoles = ctx.body.reverse() + let filteredRoles = [roleHeader] + for (let role of orderedRoles) { + filteredRoles = [role, ...filteredRoles] + if (role === inherits) { + break + } + } + filteredRoles.pop() + ctx.body = [roleHeader, ...filteredRoles] + } } diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js index c8e383d5ed..d133a69d64 100644 --- a/packages/server/src/api/routes/tests/role.spec.js +++ b/packages/server/src/api/routes/tests/role.spec.js @@ -158,5 +158,25 @@ describe("/roles", () => { expect(res.body.length).toBe(1) expect(res.body[0]).toBe("PUBLIC") }) + + it("should not fetch higher level accessible roles when a custom role header is provided", async () => { + await createRole({ + name: `CUSTOM_ROLE`, + inherits: roles.BUILTIN_ROLE_IDS.BASIC, + permissionId: permissions.BuiltinPermissionID.READ_ONLY, + version: "name", + }) + const res = await request + .get("/api/roles/accessible") + .set({ + ...config.defaultHeaders(), + "x-budibase-role": "CUSTOM_ROLE" + }) + .expect(200) + expect(res.body.length).toBe(3) + expect(res.body[0]).toBe("CUSTOM_ROLE") + expect(res.body[1]).toBe("BASIC") + expect(res.body[2]).toBe("PUBLIC") + }) }) }) diff --git a/packages/server/src/api/routes/tests/routing.spec.js b/packages/server/src/api/routes/tests/routing.spec.js index ff6d7aba1d..4076f4879c 100644 --- a/packages/server/src/api/routes/tests/routing.spec.js +++ b/packages/server/src/api/routes/tests/routing.spec.js @@ -1,5 +1,5 @@ const setup = require("./utilities") -const { basicScreen } = setup.structures +const { basicScreen, powerScreen } = setup.structures const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions") const { roles } = require("@budibase/backend-core") const { BUILTIN_ROLE_IDS } = roles @@ -12,19 +12,14 @@ const route = "/test" describe("/routing", () => { let request = setup.getRequest() let config = setup.getConfig() - let screen, screen2 + let basic, power afterAll(setup.afterAll) beforeAll(async () => { await config.init() - screen = basicScreen() - screen.routing.route = route - screen = await config.createScreen(screen) - screen2 = basicScreen() - screen2.routing.roleId = BUILTIN_ROLE_IDS.POWER - screen2.routing.route = route - screen2 = await config.createScreen(screen2) + basic = await config.createScreen(basicScreen(route)) + power = await config.createScreen(powerScreen(route)) await config.publish() }) @@ -61,8 +56,8 @@ describe("/routing", () => { expect(res.body.routes[route]).toEqual({ subpaths: { [route]: { - screenId: screen._id, - roleId: screen.routing.roleId + screenId: basic._id, + roleId: basic.routing.roleId } } }) @@ -80,8 +75,8 @@ describe("/routing", () => { expect(res.body.routes[route]).toEqual({ subpaths: { [route]: { - screenId: screen2._id, - roleId: screen2.routing.roleId + screenId: power._id, + roleId: power.routing.roleId } } }) @@ -101,8 +96,8 @@ describe("/routing", () => { expect(res.body.routes).toBeDefined() expect(res.body.routes[route].subpaths[route]).toBeDefined() const subpath = res.body.routes[route].subpaths[route] - expect(subpath.screens[screen2.routing.roleId]).toEqual(screen2._id) - expect(subpath.screens[screen.routing.roleId]).toEqual(screen._id) + expect(subpath.screens[power.routing.roleId]).toEqual(power._id) + expect(subpath.screens[basic.routing.roleId]).toEqual(basic._id) }) it("make sure it is a builder only endpoint", async () => { diff --git a/packages/server/src/constants/screens.ts b/packages/server/src/constants/screens.ts index 23e36a65b8..6c88b0f957 100644 --- a/packages/server/src/constants/screens.ts +++ b/packages/server/src/constants/screens.ts @@ -1,7 +1,15 @@ import { roles } from "@budibase/backend-core" import { BASE_LAYOUT_PROP_IDS } from "./layouts" -export function createHomeScreen() { +export function createHomeScreen( + config: { + roleId: string + route: string + } = { + roleId: roles.BUILTIN_ROLE_IDS.BASIC, + route: "/", + } +) { return { description: "", url: "", @@ -40,8 +48,8 @@ export function createHomeScreen() { gap: "M", }, routing: { - route: "/", - roleId: roles.BUILTIN_ROLE_IDS.BASIC, + route: config.route, + roleId: config.roleId, }, name: "home-screen", } diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index d3e92ea34d..6d236062a8 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -20,6 +20,7 @@ import { SourceName, Table, } from "@budibase/types" +const { BUILTIN_ROLE_IDS } = roles export function basicTable(): Table { return { @@ -322,8 +323,22 @@ export function basicUser(role: string) { } } -export function basicScreen() { - return createHomeScreen() +export function basicScreen(route: string = "/") { + return createHomeScreen({ + roleId: BUILTIN_ROLE_IDS.BASIC, + route, + }) +} + +export function powerScreen(route: string = "/") { + return createHomeScreen({ + roleId: BUILTIN_ROLE_IDS.POWER, + route, + }) +} + +export function customScreen(config: { roleId: string; route: string }) { + return createHomeScreen(config) } export function basicLayout() { From 5a80487c77f331ddf054825fa0b04d4e32cec179 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Thu, 26 Oct 2023 17:54:26 +0100 Subject: [PATCH 60/68] Fix redirect loop when accessing a group as a global builder but not an admin. --- packages/pro | 2 +- .../api/routes/global/tests/groups.spec.ts | 37 ++++++++++++++++++- .../worker/src/tests/TestConfiguration.ts | 17 +++++++-- .../worker/src/tests/structures/groups.ts | 8 ++-- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/pro b/packages/pro index d24c0dc3a3..1911442b93 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376 +Subproject commit 1911442b93670d71edb4d9e19b8e0677bbad6c47 diff --git a/packages/worker/src/api/routes/global/tests/groups.spec.ts b/packages/worker/src/api/routes/global/tests/groups.spec.ts index afeaae952c..8f0739a812 100644 --- a/packages/worker/src/api/routes/global/tests/groups.spec.ts +++ b/packages/worker/src/api/routes/global/tests/groups.spec.ts @@ -1,7 +1,7 @@ import { events } from "@budibase/backend-core" import { generator } from "@budibase/backend-core/tests" import { structures, TestConfiguration, mocks } from "../../../../tests" -import { UserGroup } from "@budibase/types" +import { User, UserGroup } from "@budibase/types" mocks.licenses.useGroups() @@ -231,4 +231,39 @@ describe("/api/global/groups", () => { }) }) }) + + describe("with global builder role", () => { + let builder: User + let group: UserGroup + + beforeAll(async () => { + builder = await config.createUser({ + builder: { global: true }, + admin: { global: false }, + }) + await config.createSession(builder) + + let resp = await config.api.groups.saveGroup( + structures.groups.UserGroup() + ) + group = resp.body as UserGroup + }) + + it("find should return 200", async () => { + await config.withUser(builder, async () => { + await config.api.groups.searchUsers(group._id!, { + emailSearch: `user1`, + }) + }) + }) + + it("update should return 200", async () => { + await config.withUser(builder, async () => { + await config.api.groups.updateGroupUsers(group._id!, { + add: [builder._id!], + remove: [], + }) + }) + }) + }) }) diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts index 7e9792c9e3..d4fcbeebd6 100644 --- a/packages/worker/src/tests/TestConfiguration.ts +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -190,6 +190,16 @@ class TestConfiguration { } } + async withUser(user: User, f: () => Promise) { + const oldUser = this.user + this.user = user + try { + await f() + } finally { + this.user = oldUser + } + } + authHeaders(user: User) { const authToken: AuthToken = { userId: user._id!, @@ -257,9 +267,10 @@ class TestConfiguration { }) } - async createUser(user?: User) { - if (!user) { - user = structures.users.user() + async createUser(opts?: Partial) { + let user = structures.users.user() + if (user) { + user = { ...user, ...opts } } const response = await this._req(user, null, controllers.users.save) const body = response as SaveUserResponse diff --git a/packages/worker/src/tests/structures/groups.ts b/packages/worker/src/tests/structures/groups.ts index b0d6bb8fc0..d39dd74eb8 100644 --- a/packages/worker/src/tests/structures/groups.ts +++ b/packages/worker/src/tests/structures/groups.ts @@ -1,8 +1,8 @@ import { generator } from "@budibase/backend-core/tests" import { db } from "@budibase/backend-core" -import { UserGroupRoles } from "@budibase/types" +import { UserGroup as UserGroupType, UserGroupRoles } from "@budibase/types" -export const UserGroup = () => { +export function UserGroup(): UserGroupType { const appsCount = generator.integer({ min: 0, max: 3 }) const roles = Array.from({ length: appsCount }).reduce( (p: UserGroupRoles, v) => { @@ -14,13 +14,11 @@ export const UserGroup = () => { {} ) - let group = { - apps: [], + return { color: generator.color(), icon: generator.word(), name: generator.word(), roles: roles, users: [], } - return group } From 8b9a8bf5f2ddaaa14b52c74aa00329958f7fca6a Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 27 Oct 2023 10:46:00 +0100 Subject: [PATCH 61/68] Updating pro reference. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index d24c0dc3a3..5ed0ee2aca 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376 +Subproject commit 5ed0ee2aca9d754d80cd46bae412b24621afa47e From 09052cb1a6bca50f18dec6578719247c06699dfc Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 27 Oct 2023 10:47:09 +0100 Subject: [PATCH 62/68] Revert "Updating pro reference." This reverts commit 8b9a8bf5f2ddaaa14b52c74aa00329958f7fca6a. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 5ed0ee2aca..d24c0dc3a3 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 5ed0ee2aca9d754d80cd46bae412b24621afa47e +Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376 From ae69f9dd920a85a48fcc848bf4de1ee28eac2f6b Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 27 Oct 2023 10:47:50 +0100 Subject: [PATCH 63/68] Update pro reference. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 1911442b93..5ed0ee2aca 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 1911442b93670d71edb4d9e19b8e0677bbad6c47 +Subproject commit 5ed0ee2aca9d754d80cd46bae412b24621afa47e From 5dd61f8994c477a4c804c939557c959e543bef17 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 27 Oct 2023 11:47:01 +0100 Subject: [PATCH 64/68] Remove APIDoc comments. --- packages/server/src/api/routes/row.ts | 190 ------------------------ packages/server/src/api/routes/table.ts | 115 -------------- 2 files changed, 305 deletions(-) diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts index c29cb65eac..516bfd20c6 100644 --- a/packages/server/src/api/routes/row.ts +++ b/packages/server/src/api/routes/row.ts @@ -11,128 +11,24 @@ const { PermissionType, PermissionLevel } = permissions const router: Router = new Router() router - /** - * @api {get} /api/:sourceId/:rowId/enrich Get an enriched row - * @apiName Get an enriched row - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This API is only useful when dealing with rows that have relationships. - * Normally when a row is a returned from the API relationships will only have the structure - * `{ primaryDisplay: "name", _id: ... }` but this call will return the full related rows - * for each relationship instead. - * - * @apiParam {string} rowId The ID of the row which is to be retrieved and enriched. - * - * @apiSuccess {object} row The response body will be the enriched row. - */ .get( "/api/:sourceId/:rowId/enrich", paramSubResource("sourceId", "rowId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.fetchEnrichedRow ) - /** - * @api {get} /api/:sourceId/rows Get all rows in a table - * @apiName Get all rows in a table - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This is a deprecated endpoint that should not be used anymore, instead use the search endpoint. - * This endpoint gets all of the rows within the specified table - it is not heavily used - * due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then - * will simply stop. - * - * @apiParam {string} sourceId The ID of the table to retrieve all rows within. - * - * @apiSuccess {object[]} rows The response body will be an array of all rows found. - */ .get( "/api/:sourceId/rows", paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.fetch ) - /** - * @api {get} /api/:sourceId/rows/:rowId Retrieve a single row - * @apiName Retrieve a single row - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve - * a row by anything other than its _id field, use the search endpoint. - * - * @apiParam {string} sourceId The ID of the table to retrieve a row from. - * @apiParam {string} rowId The ID of the row to retrieve. - * - * @apiSuccess {object} body The response body will be the row that was found. - */ .get( "/api/:sourceId/rows/:rowId", paramSubResource("sourceId", "rowId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.find ) - /** - * @api {post} /api/:sourceId/search Search for rows in a table - * @apiName Search for rows in a table - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This is the primary method of accessing rows in Budibase, the data provider - * and data UI in the builder are built atop this. All filtering, sorting and pagination is - * handled through this, for internal and external (datasource plus, e.g. SQL) tables. - * - * @apiParam {string} sourceId The ID of the table to retrieve rows from. - * - * @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true, - * defaults to false. - * @apiParam (Body) {object} [query] This contains a set of filters which should be applied, if none - * specified then the request will be unfiltered. An example with all of the possible query - * options has been supplied below. - * @apiParam (Body) {number} [limit] This sets a limit for the number of rows that will be returned, - * this will be implemented at the database level if supported for performance reasons. This - * is useful when paginating to set exactly how many rows per page. - * @apiParam (Body) {string} [bookmark] If pagination is enabled then a bookmark will be returned - * with each successful search request, this should be supplied back to get the next page. - * @apiParam (Body) {object} [sort] If sort is desired this should contain the name of the column to - * sort on. - * @apiParam (Body) {string} [sortOrder] If sort is enabled then this can be either "descending" or - * "ascending" as required. - * @apiParam (Body) {string} [sortType] If sort is enabled then you must specify the type of search - * being used, either "string" or "number". This is only used for internal tables. - * - * @apiParamExample {json} Example: - * { - * "tableId": "ta_70260ff0b85c467ca74364aefc46f26d", - * "query": { - * "string": {}, - * "fuzzy": {}, - * "range": { - * "columnName": { - * "high": 20, - * "low": 10, - * } - * }, - * "equal": { - * "columnName": "someValue" - * }, - * "notEqual": {}, - * "empty": {}, - * "notEmpty": {}, - * "oneOf": { - * "columnName": ["value"] - * } - * }, - * "limit": 10, - * "sort": "name", - * "sortOrder": "descending", - * "sortType": "string", - * "paginate": true - * } - * - * @apiSuccess {object[]} rows An array of rows that was found based on the supplied parameters. - * @apiSuccess {boolean} hasNextPage If pagination was enabled then this specifies whether or - * not there is another page after this request. - * @apiSuccess {string} bookmark The bookmark to be sent with the next request to get the next - * page. - */ .post( "/api/:sourceId/search", internalSearchValidator(), @@ -148,30 +44,6 @@ router authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.search ) - /** - * @api {post} /api/:sourceId/rows Creates a new row - * @apiName Creates a new row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This API will create a new row based on the supplied body. If the - * body includes an "_id" field then it will update an existing row if the field - * links to one. Please note that "_id", "_rev" and "tableId" are fields that are - * already used by Budibase tables and cannot be used for columns. - * - * @apiParam {string} sourceId The ID of the table to save a row to. - * - * @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided. - * @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision - * must also be provided. - * @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself. - * @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches - * a column in the specified table. All other fields will be dropped and not stored. - * - * @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this - * is the rows new ID. - * @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned. - * @apiSuccess {object} body The contents of the row that was saved will be returned as well. - */ .post( "/api/:sourceId/rows", paramResource("sourceId"), @@ -179,14 +51,6 @@ router trimViewRowInfo, rowController.save ) - /** - * @api {patch} /api/:sourceId/rows Updates a row - * @apiName Update a row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This endpoint is identical to the row creation endpoint but instead it will - * error if an _id isn't provided, it will only function for existing rows. - */ .patch( "/api/:sourceId/rows", paramResource("sourceId"), @@ -194,52 +58,12 @@ router trimViewRowInfo, rowController.patch ) - /** - * @api {post} /api/:sourceId/rows/validate Validate inputs for a row - * @apiName Validate inputs for a row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription When attempting to save a row you may want to check if the row is valid - * given the table schema, this will iterate through all the constraints on the table and - * check if the request body is valid. - * - * @apiParam {string} sourceId The ID of the table the row is to be validated for. - * - * @apiParam (Body) {any} [any] Any fields provided in the request body will be tested - * against the table schema and constraints. - * - * @apiSuccess {boolean} valid If inputs provided are acceptable within the table schema this - * will be true, if it is not then then errors property will be populated. - * @apiSuccess {object} [errors] A key value map of information about fields on the input - * which do not match the table schema. The key name will be the column names that have breached - * the schema. - */ .post( "/api/:sourceId/rows/validate", paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), rowController.validate ) - /** - * @api {delete} /api/:sourceId/rows Delete rows - * @apiName Delete rows - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This endpoint can delete a single row, or delete them in a bulk - * fashion. - * - * @apiParam {string} sourceId The ID of the table the row is to be deleted from. - * - * @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this - * key of the request body that are to be deleted. - * @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field. - * @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its - * revision here. - * - * @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array - * of the deleted rows, if deleting a single row then the body will contain a "row" property which - * is the deleted row. - */ .delete( "/api/:sourceId/rows", paramResource("sourceId"), @@ -247,20 +71,6 @@ router trimViewRowInfo, rowController.destroy ) - - /** - * @api {post} /api/:sourceId/rows/exportRows Export Rows - * @apiName Export rows - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This API can export a number of provided rows - * - * @apiParam {string} sourceId The ID of the table the row is to be deleted from. - * - * @apiParam (Body) {object[]} [rows] The row IDs which are to be exported - * - * @apiSuccess {object[]|object} - */ .post( "/api/:sourceId/rows/exportRows", paramResource("sourceId"), diff --git a/packages/server/src/api/routes/table.ts b/packages/server/src/api/routes/table.ts index 7ffa5acb3e..0172d9844d 100644 --- a/packages/server/src/api/routes/table.ts +++ b/packages/server/src/api/routes/table.ts @@ -9,99 +9,13 @@ const { BUILDER, PermissionLevel, PermissionType } = permissions const router: Router = new Router() router - /** - * @api {get} /api/tables Fetch all tables - * @apiName Fetch all tables - * @apiGroup tables - * @apiPermission table read access - * @apiDescription This endpoint retrieves all of the tables which have been created in - * an app. This includes all of the external and internal tables; to tell the difference - * between these look for the "type" property on each table, either being "internal" or "external". - * - * @apiSuccess {object[]} body The response body will be the list of tables that was found - as - * this does not take any parameters the only error scenario is no access. - */ .get("/api/tables", authorized(BUILDER), tableController.fetch) - /** - * @api {get} /api/tables/:id Fetch a single table - * @apiName Fetch a single table - * @apiGroup tables - * @apiPermission table read access - * @apiDescription Retrieves a single table this could be be internal or external based on - * the provided table ID. - * - * @apiParam {string} id The ID of the table which is to be retrieved. - * - * @apiSuccess {object[]} body The response body will be the table that was found. - */ .get( "/api/tables/:tableId", paramResource("tableId"), authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }), tableController.find ) - /** - * @api {post} /api/tables Save a table - * @apiName Save a table - * @apiGroup tables - * @apiPermission builder - * @apiDescription Create or update a table with this endpoint, this will function for both internal - * external tables. - * - * @apiParam (Body) {string} [_id] If updating an existing table then the ID of the table must be specified. - * @apiParam (Body) {string} [_rev] If updating an existing internal table then the revision must also be specified. - * @apiParam (Body) {string} type] This should either be "internal" or "external" depending on the table type - - * this will default to internal. - * @apiParam (Body) {string} [sourceId] If creating an external table then this should be set to the datasource ID. If - * building an internal table this does not need to be set, although it will be returned as "bb_internal". - * @apiParam (Body) {string} name The name of the table, this will be used in the UI. To rename the table simply - * supply the table structure to this endpoint with the name changed. - * @apiParam (Body) {object} schema A key value object which has all of the columns in the table as the keys in this - * object. For each column a "type" and "constraints" must be specified, with some types requiring further information. - * More information about the schema structure can be found in the Typescript definitions. - * @apiParam (Body) {string} [primaryDisplay] The name of the column which should be used when displaying rows - * from this table as relationships. - * @apiParam (Body) {object[]} [indexes] Specifies the search indexes - this is deprecated behaviour with the introduction - * of lucene indexes. This functionality is only available for internal tables. - * @apiParam (Body) {object} [_rename] If a column is to be renamed then the "old" column name should be set in this - * structure, and the "updated", new column name should also be supplied. The schema should also be updated, this field - * lets the server know that a field hasn't just been deleted, that the data has moved to a new name, this will fix - * the rows in the table. This functionality is only available for internal tables. - * @apiParam (Body) {object[]} [rows] When creating a table using a compatible data source, an array of objects to be imported into the new table can be provided. - * - * @apiParamExample {json} Example: - * { - * "_id": "ta_05541307fa0f4044abee071ca2a82119", - * "_rev": "10-0fbe4e78f69b255d79f1017e2eeef807", - * "type": "internal", - * "views": {}, - * "name": "tableName", - * "schema": { - * "column": { - * "type": "string", - * "constraints": { - * "type": "string", - * "length": { - * "maximum": null - * }, - * "presence": false - * }, - * "name": "column" - * }, - * }, - * "primaryDisplay": "column", - * "indexes": [], - * "sourceId": "bb_internal", - * "_rename": { - * "old": "columnName", - * "updated": "newColumnName", - * }, - * "rows": [] - * } - * - * @apiSuccess {object} table The response body will contain the table structure after being cleaned up and - * saved to the database. - */ .post( "/api/tables", // allows control over updating a table @@ -125,41 +39,12 @@ router authorized(BUILDER), tableController.validateExistingTableImport ) - /** - * @api {post} /api/tables/:tableId/:revId Delete a table - * @apiName Delete a table - * @apiGroup tables - * @apiPermission builder - * @apiDescription This endpoint will delete a table and all of its associated data, for this reason it is - * quite dangerous - it will work for internal and external tables. - * - * @apiParam {string} tableId The ID of the table which is to be deleted. - * @apiParam {string} [revId] If deleting an internal table then the revision must also be supplied (_rev), for - * external tables this can simply be set to anything, e.g. "external". - * - * @apiSuccess {string} message A message stating that the table was deleted successfully. - */ .delete( "/api/tables/:tableId/:revId", paramResource("tableId"), authorized(BUILDER), tableController.destroy ) - /** - * @api {post} /api/tables/:tableId/:revId Import CSV to existing table - * @apiName Import CSV to existing table - * @apiGroup tables - * @apiPermission builder - * @apiDescription This endpoint will import data to existing tables, internal or external. It is used in combination - * with the CSV validation endpoint. Take the output of the CSV validation endpoint and pass it to this endpoint to - * import the data; please note this will only import fields that already exist on the table/match the type. - * - * @apiParam {string} tableId The ID of the table which the data should be imported to. - * - * @apiParam (Body) {object[]} rows An array of objects representing the rows to be imported, key-value pairs not matching the table schema will be ignored. - * - * @apiSuccess {string} message A message stating that the data was imported successfully. - */ .post( "/api/tables/:tableId/import", paramResource("tableId"), From 6ecf831f028c650d6df68fa54c9fa518e68f70ac Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 27 Oct 2023 17:10:03 +0100 Subject: [PATCH 65/68] Updating pro submodule reference. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 5ed0ee2aca..4506399e0d 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 5ed0ee2aca9d754d80cd46bae412b24621afa47e +Subproject commit 4506399e0d0297554cacbef1f436884aabdb9741 From d4929ea3b61e5774aabf4f7fe758a66b5e872f21 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 27 Oct 2023 18:03:06 +0100 Subject: [PATCH 66/68] Fixing an issue where unpublished apps with custom roles, when used in groups would cause users to be unable to login. --- packages/backend-core/src/security/roles.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index b05cf79c8c..02421fd1d0 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -122,7 +122,9 @@ export async function roleToNumber(id?: string) { if (isBuiltin(id)) { return builtinRoleToNumber(id) } - const hierarchy = (await getUserRoleHierarchy(id)) as RoleDoc[] + const hierarchy = (await getUserRoleHierarchy(id, { + defaultPublic: true, + })) as RoleDoc[] for (let role of hierarchy) { if (isBuiltin(role?.inherits)) { return builtinRoleToNumber(role.inherits) + 1 @@ -177,7 +179,7 @@ export async function getRole( role = Object.assign(role, dbRole) // finalise the ID role._id = getExternalRoleID(role._id, role.version) - } catch (err) { + } catch (err: any) { if (!isBuiltin(roleId) && opts?.defaultPublic) { return cloneDeep(BUILTIN_ROLES.PUBLIC) } @@ -192,12 +194,15 @@ export async function getRole( /** * Simple function to get all the roles based on the top level user role ID. */ -async function getAllUserRoles(userRoleId?: string): Promise { +async function getAllUserRoles( + userRoleId?: string, + opts?: { defaultPublic?: boolean } +): Promise { // admins have access to all roles if (userRoleId === BUILTIN_IDS.ADMIN) { return getAllRoles() } - let currentRole = await getRole(userRoleId) + let currentRole = await getRole(userRoleId, opts) let roles = currentRole ? [currentRole] : [] let roleIds = [userRoleId] // get all the inherited roles @@ -226,12 +231,16 @@ export async function getUserRoleIdHierarchy( * Returns an ordered array of the user's inherited role IDs, this can be used * to determine if a user can access something that requires a specific role. * @param userRoleId The user's role ID, this can be found in their access token. + * @param opts optional - if want to default to public use this. * @returns returns an ordered array of the roles, with the first being their * highest level of access and the last being the lowest level. */ -export async function getUserRoleHierarchy(userRoleId?: string) { +export async function getUserRoleHierarchy( + userRoleId?: string, + opts?: { defaultPublic?: boolean } +) { // special case, if they don't have a role then they are a public user - return getAllUserRoles(userRoleId) + return getAllUserRoles(userRoleId, opts) } // this function checks that the provided permissions are in an array format From 330059991e0d6f6f0cf0af5c3ad3199c044b5509 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Fri, 27 Oct 2023 18:04:28 +0100 Subject: [PATCH 67/68] Removing any. --- packages/backend-core/src/security/roles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index 02421fd1d0..0d33031de5 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -179,7 +179,7 @@ export async function getRole( role = Object.assign(role, dbRole) // finalise the ID role._id = getExternalRoleID(role._id, role.version) - } catch (err: any) { + } catch (err) { if (!isBuiltin(roleId) && opts?.defaultPublic) { return cloneDeep(BUILTIN_ROLES.PUBLIC) } From 887383bdb6a4b54d7ab747571415f112f83f2e14 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Fri, 27 Oct 2023 18:12:21 +0100 Subject: [PATCH 68/68] Update README.md Removing dead link. --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 9deb16cd4f..7827d4e48a 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,6 @@ You can learn more about the Budibase API at the following places: - [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/) -

- Budibase data -

-

- -


- ## 🏁 Get started Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.