From 75c2392b601d70931971b1368d27041679dc31bb Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Thu, 8 Feb 2024 15:32:38 +0000 Subject: [PATCH 01/10] Flesh out Postgres tests, add MySQL tests. --- .../api/routes/tests/queries/mysql.spec.ts | 239 ++++++++++++++++++ .../api/routes/tests/queries/postgres.spec.ts | 73 ++++++ .../src/integrations/tests/utils/index.ts | 3 +- .../src/integrations/tests/utils/mysql.ts | 52 ++++ .../src/integrations/tests/utils/postgres.ts | 4 +- 5 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/api/routes/tests/queries/mysql.spec.ts create mode 100644 packages/server/src/integrations/tests/utils/mysql.ts diff --git a/packages/server/src/api/routes/tests/queries/mysql.spec.ts b/packages/server/src/api/routes/tests/queries/mysql.spec.ts new file mode 100644 index 0000000000..1c9c1d3865 --- /dev/null +++ b/packages/server/src/api/routes/tests/queries/mysql.spec.ts @@ -0,0 +1,239 @@ +import { Datasource, Query } from "@budibase/types" +import * as setup from "../utilities" +import { databaseTestProviders } from "../../../../integrations/tests/utils" +import mysql from "mysql2/promise" + +jest.unmock("mysql2") +jest.unmock("mysql2/promise") + +const createTableSQL = ` +CREATE TABLE test_table ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL +) +` + +const insertSQL = ` +INSERT INTO test_table (name) VALUES ('one'), ('two'), ('three'), ('four'), ('five') +` + +const dropTableSQL = ` +DROP TABLE test_table +` + +describe("/queries", () => { + let config = setup.getConfig() + let datasource: Datasource + + async function createQuery(query: Partial): Promise { + const defaultQuery: Query = { + datasourceId: datasource._id!, + name: "New Query", + parameters: [], + fields: {}, + schema: {}, + queryVerb: "read", + transformer: "return data", + readable: true, + } + return await config.api.query.create({ ...defaultQuery, ...query }) + } + + async function withConnection( + callback: (client: mysql.Connection) => Promise + ): Promise { + const ds = await databaseTestProviders.mysql.datasource() + const con = await mysql.createConnection(ds.config!) + try { + await callback(con) + } finally { + con.end() + } + } + + afterAll(async () => { + await databaseTestProviders.mysql.stop() + setup.afterAll() + }) + + beforeAll(async () => { + await config.init() + datasource = await config.api.datasource.create( + await databaseTestProviders.mysql.datasource() + ) + }) + + beforeEach(async () => { + await withConnection(async connection => { + const resp = await connection.query(createTableSQL) + await connection.query(insertSQL) + }) + }) + + afterEach(async () => { + await withConnection(async connection => { + await connection.query(dropTableSQL) + }) + }) + + it("should execute a query", async () => { + const query = await createQuery({ + fields: { + sql: "SELECT * FROM test_table ORDER BY id", + }, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + id: 1, + name: "one", + }, + { + id: 2, + name: "two", + }, + { + id: 3, + name: "three", + }, + { + id: 4, + name: "four", + }, + { + id: 5, + name: "five", + }, + ]) + }) + + it("should be able to transform a query", async () => { + const query = await createQuery({ + fields: { + sql: "SELECT * FROM test_table WHERE id = 1", + }, + transformer: ` + data[0].id = data[0].id + 1; + return data; + `, + }) + + const result = await config.api.query.execute(query._id!) + + expect(result.data).toEqual([ + { + id: 2, + name: "one", + }, + ]) + }) + + it("should be able to insert with bindings", async () => { + const query = await createQuery({ + fields: { + sql: "INSERT INTO test_table (name) VALUES ({{ foo }})", + }, + parameters: [ + { + name: "foo", + default: "bar", + }, + ], + queryVerb: "create", + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { + foo: "baz", + }, + }) + + expect(result.data).toEqual([ + { + created: true, + }, + ]) + + await withConnection(async connection => { + const [rows] = await connection.query( + "SELECT * FROM test_table WHERE name = 'baz'" + ) + expect(rows).toHaveLength(1) + }) + }) + + it("should be able to update rows", async () => { + const query = await createQuery({ + fields: { + sql: "UPDATE test_table SET name = {{ name }} WHERE id = {{ id }}", + }, + parameters: [ + { + name: "id", + default: "", + }, + { + name: "name", + default: "updated", + }, + ], + queryVerb: "update", + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { + id: "1", + name: "foo", + }, + }) + + expect(result.data).toEqual([ + { + updated: true, + }, + ]) + + await withConnection(async connection => { + const [rows] = await connection.query( + "SELECT * FROM test_table WHERE id = 1" + ) + expect(rows).toEqual([{ id: 1, name: "foo" }]) + }) + }) + + it("should be able to delete rows", async () => { + const query = await createQuery({ + fields: { + sql: "DELETE FROM test_table WHERE id = {{ id }}", + }, + parameters: [ + { + name: "id", + default: "", + }, + ], + queryVerb: "delete", + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { + id: "1", + }, + }) + + expect(result.data).toEqual([ + { + deleted: true, + }, + ]) + + await withConnection(async connection => { + const [rows] = await connection.query( + "SELECT * FROM test_table WHERE id = 1" + ) + expect(rows).toHaveLength(0) + }) + }) +}) diff --git a/packages/server/src/api/routes/tests/queries/postgres.spec.ts b/packages/server/src/api/routes/tests/queries/postgres.spec.ts index 487644e787..fd6a2b7d3c 100644 --- a/packages/server/src/api/routes/tests/queries/postgres.spec.ts +++ b/packages/server/src/api/routes/tests/queries/postgres.spec.ts @@ -167,4 +167,77 @@ describe("/queries", () => { expect(rows).toHaveLength(1) }) }) + + it("should be able to update rows", async () => { + const query = await createQuery({ + fields: { + sql: "UPDATE test_table SET name = {{ name }} WHERE id = {{ id }}", + }, + parameters: [ + { + name: "id", + default: "", + }, + { + name: "name", + default: "updated", + }, + ], + queryVerb: "update", + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { + id: "1", + name: "foo", + }, + }) + + expect(result.data).toEqual([ + { + updated: true, + }, + ]) + + await withClient(async client => { + const { rows } = await client.query( + "SELECT * FROM test_table WHERE id = 1" + ) + expect(rows).toEqual([{ id: 1, name: "foo" }]) + }) + }) + + it("should be able to delete rows", async () => { + const query = await createQuery({ + fields: { + sql: "DELETE FROM test_table WHERE id = {{ id }}", + }, + parameters: [ + { + name: "id", + default: "", + }, + ], + queryVerb: "delete", + }) + + const result = await config.api.query.execute(query._id!, { + parameters: { + id: "1", + }, + }) + + expect(result.data).toEqual([ + { + deleted: true, + }, + ]) + + await withClient(async client => { + const { rows } = await client.query( + "SELECT * FROM test_table WHERE id = 1" + ) + expect(rows).toHaveLength(0) + }) + }) }) diff --git a/packages/server/src/integrations/tests/utils/index.ts b/packages/server/src/integrations/tests/utils/index.ts index 77fb5d7128..b6e4e43e7a 100644 --- a/packages/server/src/integrations/tests/utils/index.ts +++ b/packages/server/src/integrations/tests/utils/index.ts @@ -3,6 +3,7 @@ jest.unmock("pg") import { Datasource } from "@budibase/types" import * as postgres from "./postgres" import * as mongodb from "./mongodb" +import * as mysql from "./mysql" import { StartedTestContainer } from "testcontainers" jest.setTimeout(30000) @@ -13,4 +14,4 @@ export interface DatabaseProvider { datasource(): Promise } -export const databaseTestProviders = { postgres, mongodb } +export const databaseTestProviders = { postgres, mongodb, mysql } diff --git a/packages/server/src/integrations/tests/utils/mysql.ts b/packages/server/src/integrations/tests/utils/mysql.ts new file mode 100644 index 0000000000..2c44dd1373 --- /dev/null +++ b/packages/server/src/integrations/tests/utils/mysql.ts @@ -0,0 +1,52 @@ +import { Datasource, SourceName } from "@budibase/types" +import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" + +let container: StartedTestContainer | undefined + +export async function start(): Promise { + return await new GenericContainer("mysql:8.3") + .withExposedPorts(3306) + .withEnvironment({ MYSQL_ROOT_PASSWORD: "password" }) + .withWaitStrategy( + Wait.forSuccessfulCommand( + // Because MySQL first starts itself up, runs an init script, then restarts, + // it's possible for the mysqladmin ping to succeed early and then tests to + // run against a MySQL that's mid-restart and fail. To avoid this, we run + // the ping command three times with a small delay between each. + ` + mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 0.5 && + mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 0.5 && + mysqladmin ping -h localhost -P 3306 -u root -ppassword + ` + ) + ) + .start() +} + +export async function datasource(): Promise { + if (!container) { + container = await start() + } + const host = container.getHost() + const port = container.getMappedPort(3306) + + return { + type: "datasource_plus", + source: SourceName.MYSQL, + plus: true, + config: { + host, + port, + user: "root", + password: "password", + database: "mysql", + }, + } +} + +export async function stop() { + if (container) { + await container.stop() + container = undefined + } +} diff --git a/packages/server/src/integrations/tests/utils/postgres.ts b/packages/server/src/integrations/tests/utils/postgres.ts index 82a62e3916..4bf42c7f88 100644 --- a/packages/server/src/integrations/tests/utils/postgres.ts +++ b/packages/server/src/integrations/tests/utils/postgres.ts @@ -8,9 +8,7 @@ export async function start(): Promise { .withExposedPorts(5432) .withEnvironment({ POSTGRES_PASSWORD: "password" }) .withWaitStrategy( - Wait.forSuccessfulCommand( - "pg_isready -h localhost -p 5432" - ).withStartupTimeout(10000) + Wait.forSuccessfulCommand("pg_isready -h localhost -p 5432") ) .start() } From bcabfd02a6852df8e5df28785db7cce038de3d5e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 14 Feb 2024 15:41:35 +0000 Subject: [PATCH 02/10] Add nested flag to button group settings --- packages/client/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 0fee0e7478..7faf7a02e2 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -270,6 +270,7 @@ { "type": "buttonConfiguration", "key": "buttons", + "nested": true, "defaultValue": [ { "type": "cta", From 5148cb88c6e665bb8b59c73def66a5d66a612f05 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 15 Feb 2024 09:14:43 +0000 Subject: [PATCH 03/10] Bump account portal --- packages/account-portal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account-portal b/packages/account-portal index 1ba8414bed..8c446c4ba3 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 1ba8414bed14439512153cf851086146a80560f5 +Subproject commit 8c446c4ba385592127fa31755d3b64467b291882 From ad8d1e25988c46f2c0f0d2e061b72892e3d24962 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Thu, 15 Feb 2024 09:44:01 +0000 Subject: [PATCH 04/10] Make the MySQL healthcheck stricter. --- packages/server/src/integrations/tests/utils/mysql.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/server/src/integrations/tests/utils/mysql.ts b/packages/server/src/integrations/tests/utils/mysql.ts index 2c44dd1373..474819287e 100644 --- a/packages/server/src/integrations/tests/utils/mysql.ts +++ b/packages/server/src/integrations/tests/utils/mysql.ts @@ -14,8 +14,9 @@ export async function start(): Promise { // run against a MySQL that's mid-restart and fail. To avoid this, we run // the ping command three times with a small delay between each. ` - mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 0.5 && - mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 0.5 && + mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 1 && + mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 1 && + mysqladmin ping -h localhost -P 3306 -u root -ppassword && sleep 1 && mysqladmin ping -h localhost -P 3306 -u root -ppassword ` ) From b12aa639d3098c2a79c04871d50e23d3e6503e6d Mon Sep 17 00:00:00 2001 From: Gerard Burns Date: Thu, 15 Feb 2024 10:53:58 +0000 Subject: [PATCH 05/10] Allow Collapsing Selected Components, Add Hotkeys for Collapsing Components (#12764) * wip * fix spelling * wip * linting * change order of fix version of linting * lint fix * linting --- package.json | 2 +- .../src/builderStore/store/frontend.js | 19 ++++++-- .../ComponentDropdownMenu.svelte | 34 ++++++++++++++ .../ComponentList/ComponentKeyHandler.svelte | 35 ++++++++++++++ .../ComponentList/ComponentTree.svelte | 32 ++++--------- .../stores/portal/componentTreeNodesStore.js | 36 +++++++++++++++ packages/frontend-core/src/stores/index.js | 1 + .../src/stores/sessionStorage.js | 46 +++++++++++++++++++ 8 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 packages/builder/src/stores/portal/componentTreeNodesStore.js create mode 100644 packages/frontend-core/src/stores/sessionStorage.js diff --git a/package.json b/package.json index 499952a441..4407fd33f3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "lint": "yarn run lint:eslint && yarn run lint:prettier", "lint:fix:eslint": "eslint --fix --max-warnings=0 packages qa-core", "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", - "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", + "lint:fix": "yarn run lint:fix:eslint && yarn run lint:fix:prettier", "build:specs": "lerna run --stream specs", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild", diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 55208bb97e..fd492cca0b 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -39,6 +39,7 @@ import { makePropSafe as safe } from "@budibase/string-templates" import { getComponentFieldOptions } from "helpers/formFields" import { createBuilderWebsocket } from "builderStore/websocket" import { BuilderSocketEvent } from "@budibase/shared-core" +import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" const INITIAL_FRONTEND_STATE = { initialised: false, @@ -1053,6 +1054,7 @@ export const getFrontendStore = () => { const screen = get(selectedScreen) const parent = findComponentParent(screen.props, componentId) const index = parent?._children.findIndex(x => x._id === componentId) + const componentTreeNodes = get(componentTreeNodesStore) // Check for screen and navigation component edge cases const screenComponentId = `${screen._id}-screen` @@ -1071,9 +1073,15 @@ export const getFrontendStore = () => { if (index > 0) { // If sibling before us accepts children, select a descendant const previousSibling = parent._children[index - 1] - if (previousSibling._children?.length) { + if ( + previousSibling._children?.length && + componentTreeNodes[`nodeOpen-${previousSibling._id}`] + ) { let target = previousSibling - while (target._children?.length) { + while ( + target._children?.length && + componentTreeNodes[`nodeOpen-${target._id}`] + ) { target = target._children[target._children.length - 1] } return target._id @@ -1093,6 +1101,7 @@ export const getFrontendStore = () => { const screen = get(selectedScreen) const parent = findComponentParent(screen.props, componentId) const index = parent?._children.findIndex(x => x._id === componentId) + const componentTreeNodes = get(componentTreeNodesStore) // Check for screen and navigation component edge cases const screenComponentId = `${screen._id}-screen` @@ -1102,7 +1111,11 @@ export const getFrontendStore = () => { } // If we have children, select first child - if (component._children?.length) { + if ( + component._children?.length && + (state.selectedComponentId === navComponentId || + componentTreeNodes[`nodeOpen-${component._id}`]) + ) { return component._children[0]._id } else if (!parent) { return null diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentDropdownMenu.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentDropdownMenu.svelte index 4645ee0d41..baaa561679 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentDropdownMenu.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentDropdownMenu.svelte @@ -3,6 +3,7 @@ import { ActionMenu, MenuItem, Icon } from "@budibase/bbui" export let component + export let opened $: definition = componentStore.getDefinition(component?._component) $: noPaste = !$componentStore.componentToPaste @@ -85,6 +86,39 @@ > Paste + + {#if component?._children?.length} + keyboardEvent("ArrowRight", false)} + disabled={opened} + > + Expand + + keyboardEvent("ArrowLeft", false)} + disabled={!opened} + > + Collapse + + keyboardEvent("ArrowRight", true)} + > + Expand All + + keyboardEvent("ArrowLeft", true)} + > + Collapse All + + {/if}