From 678033cc8b7276e5823536925cfebc577527f764 Mon Sep 17 00:00:00 2001 From: Mitch-Budibase Date: Thu, 5 Oct 2023 17:39:40 +0100 Subject: [PATCH 01/34] 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/34] 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/34] 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 789bb528f43f7cd28eca2e52a309eddcbb3c370b Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 12 Oct 2023 11:58:25 +0100 Subject: [PATCH 04/34] 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 05/34] 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 06/34] 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 07/34] 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 08/34] 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 09/34] 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 b337bd7435d9263fd7750009a22e653ab3513dac Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 13 Oct 2023 17:03:19 +0100 Subject: [PATCH 10/34] Fix bug in SortableFieldSelect which results in options being available for sort fields --- .../design/settings/controls/SortableFieldSelect.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte b/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte index 74b044e75e..21ed68ce68 100644 --- a/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte @@ -20,9 +20,7 @@ const getSortableFields = schema => { return Object.entries(schema || {}) - .filter( - entry => !UNSORTABLE_TYPES.includes(entry[1].type) && entry[1].sortable - ) + .filter(entry => !UNSORTABLE_TYPES.includes(entry[1].type)) .map(entry => entry[0]) } From e3d6a68ea14263bd1bcf4083798a0ea8e2f95da8 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 13 Oct 2023 19:06:53 +0100 Subject: [PATCH 11/34] Refactor inline searching to be a separate concept from normal filters, and optimise API usage across all datasources --- .../src/components/grid/stores/datasource.js | 5 +- .../grid/stores/datasources/nonPlus.js | 7 ++- .../grid/stores/datasources/table.js | 7 ++- .../grid/stores/datasources/viewV2.js | 52 +++++++++++-------- .../src/components/grid/stores/filter.js | 34 ++++++++---- .../src/components/grid/stores/rows.js | 6 +-- .../frontend-core/src/fetch/ViewV2Fetch.js | 29 +++++++++-- 7 files changed, 96 insertions(+), 44 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/datasource.js b/packages/frontend-core/src/components/grid/stores/datasource.js index 0b62194f73..1be5ae7878 100644 --- a/packages/frontend-core/src/components/grid/stores/datasource.js +++ b/packages/frontend-core/src/components/grid/stores/datasource.js @@ -1,8 +1,9 @@ -import { derived, get, writable } from "svelte/store" +import { derived, get } from "svelte/store" import { getDatasourceDefinition } from "../../../fetch" +import { memo } from "../../../utils" export const createStores = () => { - const definition = writable(null) + const definition = memo(null) return { definition, diff --git a/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js b/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js index a05e1f7d37..017c16a03c 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js @@ -66,6 +66,8 @@ export const initialise = context => { datasource, sort, filter, + inlineFilters, + allFilters, nonPlus, initialFilter, initialSortColumn, @@ -87,6 +89,7 @@ export const initialise = context => { // Wipe state filter.set(get(initialFilter)) + inlineFilters.set([]) sort.set({ column: get(initialSortColumn), order: get(initialSortOrder) || "ascending", @@ -94,14 +97,14 @@ export const initialise = context => { // Update fetch when filter changes unsubscribers.push( - filter.subscribe($filter => { + allFilters.subscribe($allFilters => { // Ensure we're updating the correct fetch const $fetch = get(fetch) if (!isSameDatasource($fetch?.options?.datasource, $datasource)) { return } $fetch.update({ - filter: $filter, + filter: $allFilters, }) }) ) diff --git a/packages/frontend-core/src/components/grid/stores/datasources/table.js b/packages/frontend-core/src/components/grid/stores/datasources/table.js index 9ced1530ba..2f49ab1d38 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/table.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/table.js @@ -71,6 +71,8 @@ export const initialise = context => { datasource, fetch, filter, + inlineFilters, + allFilters, sort, table, initialFilter, @@ -93,6 +95,7 @@ export const initialise = context => { // Wipe state filter.set(get(initialFilter)) + inlineFilters.set([]) sort.set({ column: get(initialSortColumn), order: get(initialSortOrder) || "ascending", @@ -100,14 +103,14 @@ export const initialise = context => { // Update fetch when filter changes unsubscribers.push( - filter.subscribe($filter => { + allFilters.subscribe($allFilters => { // Ensure we're updating the correct fetch const $fetch = get(fetch) if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { return } $fetch.update({ - filter: $filter, + filter: $allFilters, }) }) ) diff --git a/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js b/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js index f0572003c2..35f57a5fc4 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js @@ -73,6 +73,8 @@ export const initialise = context => { sort, rows, filter, + inlineFilters, + allFilters, subscribe, viewV2, initialFilter, @@ -97,6 +99,7 @@ export const initialise = context => { // Reset state for new view filter.set(get(initialFilter)) + inlineFilters.set([]) sort.set({ column: get(initialSortColumn), order: get(initialSortOrder) || "ascending", @@ -143,21 +146,19 @@ export const initialise = context => { order: $sort.order || "ascending", }, }) - await rows.actions.refreshData() } } - // Otherwise just update the fetch - else { - // Ensure we're updating the correct fetch - const $fetch = get(fetch) - if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { - return - } - $fetch.update({ - sortOrder: $sort.order || "ascending", - sortColumn: $sort.column, - }) + + // Also update the fetch to ensure the new sort is respected. + // Ensure we're updating the correct fetch. + const $fetch = get(fetch) + if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { + return } + $fetch.update({ + sortOrder: $sort.order, + sortColumn: $sort.column, + }) }) ) @@ -176,20 +177,25 @@ export const initialise = context => { ...$view, query: $filter, }) - await rows.actions.refreshData() } } - // Otherwise just update the fetch - else { - // Ensure we're updating the correct fetch - const $fetch = get(fetch) - if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { - return - } - $fetch.update({ - filter: $filter, - }) + }) + ) + + // Keep fetch up to date with filters. + // If we're able to save filters against the view then we only need to apply + // inline filters to the fetch, as saved filters are applied server side. + // If we can't save filters, then all filters must be applied to the fetch. + unsubscribers.push( + allFilters.subscribe($allFilters => { + // Ensure we're updating the correct fetch + const $fetch = get(fetch) + if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { + return } + $fetch.update({ + filter: $allFilters, + }) }) ) diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index 76c8c5d3ec..a16b101bbb 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -1,4 +1,4 @@ -import { writable, get } from "svelte/store" +import { writable, get, derived } from "svelte/store" import { FieldType } from "@budibase/types" export const createStores = context => { @@ -6,14 +6,31 @@ export const createStores = context => { // Initialise to default props const filter = writable(get(props).initialFilter) + const inlineFilters = writable([]) return { filter, + inlineFilters, + } +} + +export const deriveStores = context => { + const { filter, inlineFilters } = context + + const allFilters = derived( + [filter, inlineFilters], + ([$filter, $inlineFilters]) => { + return [...($filter || []), ...$inlineFilters] + } + ) + + return { + allFilters, } } export const createActions = context => { - const { filter } = context + const { filter, inlineFilters } = context const addInlineFilter = (column, value) => { const filterId = `inline-${column.name}` @@ -38,16 +55,15 @@ export const createActions = context => { } // Add this filter - filter.update($filter => { - // Remove any existing inline filter - if ($filter?.length) { - $filter = $filter?.filter(x => x.id !== filterId) - } + inlineFilters.update($inlineFilters => { + // Remove any existing inline filter for this column + $inlineFilters = $inlineFilters?.filter(x => x.id !== filterId) + // Add new one if a value exists if (value) { - $filter = [...($filter || []), inlineFilter] + $inlineFilters.push(inlineFilter) } - return $filter + return $inlineFilters }) } diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index 98e64d7acb..51c46f8263 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -68,7 +68,7 @@ export const createActions = context => { rows, rowLookupMap, definition, - filter, + allFilters, loading, sort, datasource, @@ -111,7 +111,7 @@ export const createActions = context => { // Tick to allow other reactive logic to update stores when datasource changes // before proceeding. This allows us to wipe filters etc if needed. await tick() - const $filter = get(filter) + const $allFilters = get(allFilters) const $sort = get(sort) // Determine how many rows to fetch per page @@ -123,7 +123,7 @@ export const createActions = context => { API, datasource: $datasource, options: { - filter: $filter, + filter: $allFilters, sortColumn: $sort.column, sortOrder: $sort.order, limit, diff --git a/packages/frontend-core/src/fetch/ViewV2Fetch.js b/packages/frontend-core/src/fetch/ViewV2Fetch.js index b9eaf4bdf7..464a85464e 100644 --- a/packages/frontend-core/src/fetch/ViewV2Fetch.js +++ b/packages/frontend-core/src/fetch/ViewV2Fetch.js @@ -35,9 +35,32 @@ export default class ViewV2Fetch extends DataFetch { } async getData() { - const { datasource, limit, sortColumn, sortOrder, sortType, paginate } = - this.options - const { cursor, query } = get(this.store) + const { + datasource, + limit, + sortColumn, + sortOrder, + sortType, + paginate, + filter, + } = this.options + const { cursor, query, definition } = get(this.store) + + // If sort params are not defined, update options to store the sorting + // params built in to this view. This ensures that we can accurately + // compare old and new sorting params and skip a redundant API call. + if (!sortColumn && definition.sort?.field) { + this.options.sortColumn = definition.sort.field + this.options.sortOrder = definition.sort.order + } + + // If sort params are not defined, update options to store the sorting + // params built in to this view. This ensures that we can accurately + // compare old and new sorting params and skip a redundant API call. + if (!filter?.length && definition.query?.length) { + this.options.filter = definition.query + } + try { const res = await this.API.viewV2.fetch({ viewId: datasource.id, From 27373a9648585baf8c5f812dfcd29f63c1c19aeb Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 13 Oct 2023 19:17:49 +0100 Subject: [PATCH 12/34] Ensure header cells properly update when reordered while a search value is applied --- .../src/components/grid/cells/HeaderCell.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index d4ed41efd3..a053b2a6f0 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -28,6 +28,7 @@ schema, focusedCellId, filter, + inlineFilters, } = getContext("grid") const searchableTypes = [ @@ -56,9 +57,14 @@ $: ascendingLabel = numericType ? "low-high" : "A-Z" $: descendingLabel = numericType ? "high-low" : "Z-A" $: searchable = isColumnSearchable(column) + $: resetSearchValue(column.name) $: searching = searchValue != null $: debouncedUpdateFilter(searchValue) + const resetSearchValue = name => { + searchValue = $inlineFilters?.find(x => x.id === `inline-${name}`)?.value + } + const isColumnSearchable = col => { const { type, formulaType } = col.schema return ( From a857eb266ca1a22666d5f9ae0258d50988685923 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 16 Oct 2023 17:12:25 +0100 Subject: [PATCH 13/34] 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 14/34] 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 a06451222400312d9928e67618e870cc28f2dbf7 Mon Sep 17 00:00:00 2001 From: Dean Date: Wed, 18 Oct 2023 11:14:16 +0100 Subject: [PATCH 15/34] 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 16/34] 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}