diff --git a/lerna.json b/lerna.json index 54e106cd5a..e1a469adf1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.20.10", + "version": "2.20.12", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/objectStore/buckets/plugins.ts b/packages/backend-core/src/objectStore/buckets/plugins.ts index 6f1b7116ae..02be9345ab 100644 --- a/packages/backend-core/src/objectStore/buckets/plugins.ts +++ b/packages/backend-core/src/objectStore/buckets/plugins.ts @@ -6,7 +6,7 @@ import { Plugin } from "@budibase/types" // URLS -export function enrichPluginURLs(plugins: Plugin[]) { +export function enrichPluginURLs(plugins?: Plugin[]): Plugin[] { if (!plugins || !plugins.length) { return [] } diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte index f6c8479b4e..096341783d 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte @@ -1,9 +1,9 @@ @@ -67,13 +95,30 @@ options={componentOptions} on:change={() => (parameters.columns = [])} /> + - { + const columns = e.detail + parameters.columns = columns + parameters.customHeaders = columns.reduce((headerMap, column) => { + return { + [column.name]: column.displayName, + ...headerMap, + } + }, {}) + }} /> @@ -97,8 +142,8 @@ .params { display: grid; column-gap: var(--spacing-xs); - row-gap: var(--spacing-s); - grid-template-columns: 90px 1fr; + row-gap: var(--spacing-m); + grid-template-columns: 90px 1fr 90px; align-items: center; } diff --git a/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte b/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte index 2b9fa573c2..742ab785a1 100644 --- a/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte +++ b/packages/builder/src/components/design/settings/controls/ColumnEditor/ColumnEditor.svelte @@ -29,6 +29,12 @@ allowLinks: true, }) + $: { + value = (value || []).filter( + column => (schema || {})[column.name || column] !== undefined + ) + } + const getText = value => { if (!value?.length) { return "All columns" diff --git a/packages/builder/src/helpers/data/utils.js b/packages/builder/src/helpers/data/utils.js index a29ce8db6d..a592b57a26 100644 --- a/packages/builder/src/helpers/data/utils.js +++ b/packages/builder/src/helpers/data/utils.js @@ -17,6 +17,10 @@ export function breakQueryString(qs) { return paramObj } +function isEncoded(str) { + return typeof str == "string" && decodeURIComponent(str) !== str +} + export function buildQueryString(obj) { let str = "" if (obj) { @@ -35,7 +39,7 @@ export function buildQueryString(obj) { value = value.replace(binding, marker) bindingMarkers[marker] = binding }) - let encoded = encodeURIComponent(value || "") + let encoded = isEncoded(value) ? value : encodeURIComponent(value || "") Object.entries(bindingMarkers).forEach(([marker, binding]) => { encoded = encoded.replace(marker, binding) }) diff --git a/packages/builder/src/helpers/tests/dataUtils.test.js b/packages/builder/src/helpers/tests/dataUtils.test.js index 8fc2d706d7..bd207ea339 100644 --- a/packages/builder/src/helpers/tests/dataUtils.test.js +++ b/packages/builder/src/helpers/tests/dataUtils.test.js @@ -39,4 +39,11 @@ describe("check query string utils", () => { expect(broken.key1).toBe(obj2.key1) expect(broken.key2).toBe(obj2.key2) }) + + it("should not encode a URL more than once when building the query string", () => { + const queryString = buildQueryString({ + values: "a%2Cb%2Cc", + }) + expect(queryString).toBe("values=a%2Cb%2Cc") + }) }) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte index fa126bbc99..4bd62c0049 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte @@ -12,17 +12,11 @@ hoverStore, } from "stores/builder" import ConfirmDialog from "components/common/ConfirmDialog.svelte" - import { - ProgressCircle, - Layout, - Heading, - Body, - Icon, - notifications, - } from "@budibase/bbui" + import { Layout, Heading, Body, Icon, notifications } from "@budibase/bbui" import ErrorSVG from "@budibase/frontend-core/assets/error.svg?raw" import { findComponent, findComponentPath } from "helpers/components" import { isActive, goto } from "@roxi/routify" + import { ClientAppSkeleton } from "@budibase/frontend-core" let iframe let layout @@ -240,8 +234,16 @@
{#if loading} -
- +
+
{:else if error}
@@ -258,8 +260,6 @@ bind:this={iframe} src="/app/preview" class:hidden={loading || error} - class:tablet={$previewStore.previewDevice === "tablet"} - class:mobile={$previewStore.previewDevice === "mobile"} />
diff --git a/packages/frontend-core/src/components/index.js b/packages/frontend-core/src/components/index.js index f724e1e4d9..f71420b12b 100644 --- a/packages/frontend-core/src/components/index.js +++ b/packages/frontend-core/src/components/index.js @@ -5,3 +5,4 @@ export { default as UserAvatar } from "./UserAvatar.svelte" export { default as UserAvatars } from "./UserAvatars.svelte" export { default as Updating } from "./Updating.svelte" export { Grid } from "./grid" +export { default as ClientAppSkeleton } from "./ClientAppSkeleton.svelte" diff --git a/packages/frontend-core/src/themes/midnight.css b/packages/frontend-core/src/themes/midnight.css index e311452262..cf6a4fbd13 100644 --- a/packages/frontend-core/src/themes/midnight.css +++ b/packages/frontend-core/src/themes/midnight.css @@ -17,5 +17,8 @@ --modal-background: var(--spectrum-global-color-gray-50); --drop-shadow: rgba(0, 0, 0, 0.25) !important; --spectrum-global-color-blue-100: rgba(35, 40, 50) !important; + + --spectrum-alias-background-color-secondary: var(--spectrum-global-color-gray-75); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-100); } diff --git a/packages/frontend-core/src/themes/nord.css b/packages/frontend-core/src/themes/nord.css index d47dbe8aa8..bc142db0fd 100644 --- a/packages/frontend-core/src/themes/nord.css +++ b/packages/frontend-core/src/themes/nord.css @@ -50,4 +50,7 @@ --modal-background: var(--spectrum-global-color-gray-50); --drop-shadow: rgba(0, 0, 0, 0.15) !important; --spectrum-global-color-blue-100: rgb(56, 65, 84) !important; + + --spectrum-alias-background-color-secondary: var(--spectrum-global-color-gray-75); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-100); } diff --git a/packages/server/package.json b/packages/server/package.json index 771f73ac13..02db1138b5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -52,6 +52,7 @@ "@budibase/pro": "0.0.0", "@budibase/shared-core": "0.0.0", "@budibase/string-templates": "0.0.0", + "@budibase/frontend-core": "0.0.0", "@budibase/types": "0.0.0", "@bull-board/api": "5.10.2", "@bull-board/koa": "5.10.2", diff --git a/packages/server/scripts/integrations/postgres/reset.sh b/packages/server/scripts/integrations/postgres/reset.sh index 32778bd11f..8deb01cdf8 100755 --- a/packages/server/scripts/integrations/postgres/reset.sh +++ b/packages/server/scripts/integrations/postgres/reset.sh @@ -1,3 +1,3 @@ #!/bin/bash -docker-compose down +docker-compose down -v docker volume prune -f diff --git a/packages/server/scripts/test.sh b/packages/server/scripts/test.sh index 9efef05526..3ecf8bb794 100644 --- a/packages/server/scripts/test.sh +++ b/packages/server/scripts/test.sh @@ -3,12 +3,12 @@ set -e if [[ -n $CI ]] then - export NODE_OPTIONS="--max-old-space-size=4096 --no-node-snapshot" + export NODE_OPTIONS="--max-old-space-size=4096 --no-node-snapshot $NODE_OPTIONS" echo "jest --coverage --maxWorkers=2 --forceExit --workerIdleMemoryLimit=2000MB --bail $@" jest --coverage --maxWorkers=2 --forceExit --workerIdleMemoryLimit=2000MB --bail $@ else # --maxWorkers performs better in development - export NODE_OPTIONS="--no-node-snapshot" + export NODE_OPTIONS="--no-node-snapshot $NODE_OPTIONS" echo "jest --coverage --maxWorkers=2 --forceExit $@" jest --coverage --maxWorkers=2 --forceExit $@ fi \ No newline at end of file diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 33582cf656..0bc93888ae 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -47,6 +47,9 @@ import { PlanType, Screen, UserCtx, + CreateAppRequest, + FetchAppDefinitionResponse, + FetchAppPackageResponse, } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import sdk from "../../sdk" @@ -58,23 +61,23 @@ import * as appMigrations from "../../appMigrations" async function getLayouts() { const db = context.getAppDB() return ( - await db.allDocs( + await db.allDocs( getLayoutParams(null, { include_docs: true, }) ) - ).rows.map((row: any) => row.doc) + ).rows.map(row => row.doc!) } async function getScreens() { const db = context.getAppDB() return ( - await db.allDocs( + await db.allDocs( getScreenParams(null, { include_docs: true, }) ) - ).rows.map((row: any) => row.doc) + ).rows.map(row => row.doc!) } function getUserRoleId(ctx: UserCtx) { @@ -116,8 +119,8 @@ function checkAppName( } interface AppTemplate { - templateString: string - useTemplate: string + templateString?: string + useTemplate?: string file?: { type: string path: string @@ -174,14 +177,16 @@ export const addSampleData = async (ctx: UserCtx) => { ctx.status = 200 } -export async function fetch(ctx: UserCtx) { +export async function fetch(ctx: UserCtx) { ctx.body = await sdk.applications.fetch( ctx.query.status as AppStatus, ctx.user ) } -export async function fetchAppDefinition(ctx: UserCtx) { +export async function fetchAppDefinition( + ctx: UserCtx +) { const layouts = await getLayouts() const userRoleId = getUserRoleId(ctx) const accessController = new roles.AccessController() @@ -196,10 +201,12 @@ export async function fetchAppDefinition(ctx: UserCtx) { } } -export async function fetchAppPackage(ctx: UserCtx) { +export async function fetchAppPackage( + ctx: UserCtx +) { const db = context.getAppDB() const appId = context.getAppId() - let application = await db.get(DocumentType.APP_METADATA) + let application = await db.get(DocumentType.APP_METADATA) const layouts = await getLayouts() let screens = await getScreens() const license = await licensing.cache.getCachedLicense() @@ -231,17 +238,21 @@ export async function fetchAppPackage(ctx: UserCtx) { } } -async function performAppCreate(ctx: UserCtx) { +async function performAppCreate(ctx: UserCtx) { const apps = (await dbCore.getAllApps({ dev: true })) as App[] - const name = ctx.request.body.name, - possibleUrl = ctx.request.body.url, - encryptionPassword = ctx.request.body.encryptionPassword + const { + name, + url, + encryptionPassword, + useTemplate, + templateKey, + templateString, + } = ctx.request.body checkAppName(ctx, apps, name) - const url = sdk.applications.getAppUrl({ name, url: possibleUrl }) - checkAppUrl(ctx, apps, url) + const appUrl = sdk.applications.getAppUrl({ name, url }) + checkAppUrl(ctx, apps, appUrl) - const { useTemplate, templateKey, templateString } = ctx.request.body const instanceConfig: AppTemplate = { useTemplate, key: templateKey, @@ -268,7 +279,7 @@ async function performAppCreate(ctx: UserCtx) { version: envCore.VERSION, componentLibraries: ["@budibase/standard-components"], name: name, - url: url, + url: appUrl, template: templateKey, instance, tenantId: tenancy.getTenantId(), @@ -420,7 +431,9 @@ export async function create(ctx: UserCtx) { // This endpoint currently operates as a PATCH rather than a PUT // Thus name and url fields are handled only if present -export async function update(ctx: UserCtx) { +export async function update( + ctx: UserCtx<{ name?: string; url?: string }, App> +) { const apps = (await dbCore.getAllApps({ dev: true })) as App[] // validation const name = ctx.request.body.name, @@ -493,7 +506,7 @@ export async function revertClient(ctx: UserCtx) { const revertedToVersion = application.revertableVersion const appPackageUpdates = { version: revertedToVersion, - revertableVersion: null, + revertableVersion: undefined, } const app = await updateAppPackage(appPackageUpdates, ctx.params.appId) await events.app.versionReverted(app, currentVersion, revertedToVersion) @@ -613,12 +626,15 @@ export async function importToApp(ctx: UserCtx) { ctx.body = { message: "app updated" } } -export async function updateAppPackage(appPackage: any, appId: any) { +export async function updateAppPackage( + appPackage: Partial, + appId: string +) { return context.doInAppContext(appId, async () => { const db = context.getAppDB() const application = await db.get(DocumentType.APP_METADATA) - const newAppPackage = { ...application, ...appPackage } + const newAppPackage: App = { ...application, ...appPackage } if (appPackage._rev !== application._rev) { newAppPackage._rev = application._rev } diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index 6bb886f0e5..b83132702b 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -11,12 +11,12 @@ import { PaginationJson, RelationshipFieldMetadata, RelationshipsJson, - RelationshipType, Row, SearchFilters, SortJson, SortType, Table, + isManyToOne, } from "@budibase/types" import { breakExternalTableId, @@ -24,6 +24,7 @@ import { convertRowId, isRowId, isSQL, + generateRowIdField, } from "../../../integrations/utils" import { buildExternalRelationships, @@ -33,13 +34,16 @@ import { updateRelationshipColumns, fixArrayTypes, isManyToMany, + processRelationshipFields, } from "./utils" import { getDatasourceAndQuery } from "../../../sdk/app/rows/utils" import { processObjectSync } from "@budibase/string-templates" import { cloneDeep } from "lodash/fp" import { db as dbCore } from "@budibase/backend-core" -import { processDates, processFormulas } from "../../../utilities/rowProcessor" +import { processDates } from "../../../utilities/rowProcessor" +import AliasTables from "./alias" import sdk from "../../../sdk" +import env from "../../../environment" export interface ManyRelationship { tableId?: string @@ -108,6 +112,39 @@ function buildFilters( } } +async function removeManyToManyRelationships( + rowId: string, + table: Table, + colName: string +) { + const tableId = table._id! + const filters = buildFilters(rowId, {}, table) + // safety check, if there are no filters on deletion bad things happen + if (Object.keys(filters).length !== 0) { + return getDatasourceAndQuery({ + endpoint: getEndpoint(tableId, Operation.DELETE), + body: { [colName]: null }, + filters, + }) + } else { + return [] + } +} + +async function removeOneToManyRelationships(rowId: string, table: Table) { + const tableId = table._id! + const filters = buildFilters(rowId, {}, table) + // safety check, if there are no filters on deletion bad things happen + if (Object.keys(filters).length !== 0) { + return getDatasourceAndQuery({ + endpoint: getEndpoint(tableId, Operation.UPDATE), + filters, + }) + } else { + return [] + } +} + /** * This function checks the incoming parameters to make sure all the inputs are * valid based on on the table schema. The main thing this is looking for is when a @@ -158,13 +195,13 @@ function cleanupConfig(config: RunConfig, table: Table): RunConfig { function getEndpoint(tableId: string | undefined, operation: string) { if (!tableId) { - return {} + throw new Error("Cannot get endpoint information - no table ID specified") } const { datasourceId, tableName } = breakExternalTableId(tableId) return { - datasourceId, - entityId: tableName, - operation, + datasourceId: datasourceId!, + entityId: tableName!, + operation: operation as Operation, } } @@ -264,6 +301,18 @@ export class ExternalRequest { } } + async getRow(table: Table, rowId: string): Promise { + const response = await getDatasourceAndQuery({ + endpoint: getEndpoint(table._id!, Operation.READ), + filters: buildFilters(rowId, {}, table), + }) + if (Array.isArray(response) && response.length > 0) { + return response[0] + } else { + throw new Error(`Cannot fetch row by ID "${rowId}"`) + } + } + inputProcessing(row: Row | undefined, table: Table) { if (!row) { return { row, manyRelationships: [] } @@ -348,33 +397,6 @@ export class ExternalRequest { return { row: newRow, manyRelationships } } - processRelationshipFields( - table: Table, - row: Row, - relationships: RelationshipsJson[] - ): Row { - for (let relationship of relationships) { - const linkedTable = this.tables[relationship.tableName] - if (!linkedTable || !row[relationship.column]) { - continue - } - for (let key of Object.keys(row[relationship.column])) { - let relatedRow: Row = row[relationship.column][key] - // add this row as context for the relationship - for (let col of Object.values(linkedTable.schema)) { - if (col.type === FieldType.LINK && col.tableId === table._id) { - relatedRow[col.name] = [row] - } - } - // process additional types - relatedRow = processDates(table, relatedRow) - relatedRow = processFormulas(linkedTable, relatedRow) - row[relationship.column][key] = relatedRow - } - } - return row - } - outputProcessing( rows: Row[] = [], table: Table, @@ -419,7 +441,7 @@ export class ExternalRequest { // make sure all related rows are correct let finalRowArray = Object.values(finalRows).map(row => - this.processRelationshipFields(table, row, relationships) + processRelationshipFields(table, this.tables, row, relationships) ) // process some additional types @@ -432,7 +454,9 @@ export class ExternalRequest { * information. */ async lookupRelations(tableId: string, row: Row) { - const related: { [key: string]: any } = {} + const related: { + [key: string]: { rows: Row[]; isMany: boolean; tableId: string } + } = {} const { tableName } = breakExternalTableId(tableId) if (!tableName) { return related @@ -450,14 +474,26 @@ export class ExternalRequest { ) { continue } - const isMany = field.relationshipType === RelationshipType.MANY_TO_MANY - const tableId = isMany ? field.through : field.tableId + let tableId: string | undefined, + lookupField: string | undefined, + fieldName: string | undefined + if (isManyToMany(field)) { + tableId = field.through + lookupField = primaryKey + fieldName = field.throughTo || primaryKey + } else if (isManyToOne(field)) { + tableId = field.tableId + lookupField = field.foreignKey + fieldName = field.fieldName + } + if (!tableId || !lookupField || !fieldName) { + throw new Error( + "Unable to lookup relationships - undefined column properties." + ) + } const { tableName: relatedTableName } = breakExternalTableId(tableId) // @ts-ignore const linkPrimaryKey = this.tables[relatedTableName].primary[0] - - const lookupField = isMany ? primaryKey : field.foreignKey - const fieldName = isMany ? field.throughTo || primaryKey : field.fieldName if (!lookupField || !row[lookupField]) { continue } @@ -470,9 +506,12 @@ export class ExternalRequest { }, }) // this is the response from knex if no rows found - const rows = !response[0].read ? response : [] - const storeTo = isMany ? field.throughFrom || linkPrimaryKey : fieldName - related[storeTo] = { rows, isMany, tableId } + const rows: Row[] = + !Array.isArray(response) || response?.[0].read ? [] : response + const storeTo = isManyToMany(field) + ? field.throughFrom || linkPrimaryKey + : fieldName + related[storeTo] = { rows, isMany: isManyToMany(field), tableId } } return related } @@ -558,24 +597,43 @@ export class ExternalRequest { continue } for (let row of rows) { - const filters = buildFilters(generateIdForRow(row, table), {}, table) - // safety check, if there are no filters on deletion bad things happen - if (Object.keys(filters).length !== 0) { - const op = isMany ? Operation.DELETE : Operation.UPDATE - const body = isMany ? null : { [colName]: null } - promises.push( - getDatasourceAndQuery({ - endpoint: getEndpoint(tableId, op), - body, - filters, - }) - ) + const rowId = generateIdForRow(row, table) + const promise: Promise = isMany + ? removeManyToManyRelationships(rowId, table, colName) + : removeOneToManyRelationships(rowId, table) + if (promise) { + promises.push(promise) } } } await Promise.all(promises) } + async removeRelationshipsToRow(table: Table, rowId: string) { + const row = await this.getRow(table, rowId) + const related = await this.lookupRelations(table._id!, row) + for (let column of Object.values(table.schema)) { + const relationshipColumn = column as RelationshipFieldMetadata + if (!isManyToOne(relationshipColumn)) { + continue + } + const { rows, isMany, tableId } = related[relationshipColumn.fieldName] + const table = this.getTable(tableId)! + await Promise.all( + rows.map(row => { + const rowId = generateIdForRow(row, table) + return isMany + ? removeManyToManyRelationships( + rowId, + table, + relationshipColumn.fieldName + ) + : removeOneToManyRelationships(rowId, table) + }) + ) + } + } + async run(config: RunConfig): Promise> { const { operation, tableId } = this let { datasourceId, tableName } = breakExternalTableId(tableId) @@ -632,7 +690,7 @@ export class ExternalRequest { } let json = { endpoint: { - datasourceId, + datasourceId: datasourceId!, entityId: tableName, operation, }, @@ -658,13 +716,26 @@ export class ExternalRequest { }, } - // can't really use response right now - const response = await getDatasourceAndQuery(json) - // handle many to many relationships now if we know the ID (could be auto increment) + // remove any relationships that could block deletion + if (operation === Operation.DELETE && id) { + await this.removeRelationshipsToRow(table, generateRowIdField(id)) + } + + // aliasing can be disabled fully if desired + let response + if (env.SQL_ALIASING_DISABLE) { + response = await getDatasourceAndQuery(json) + } else { + const aliasing = new AliasTables(Object.keys(this.tables)) + response = await aliasing.queryWithAliasing(json) + } + + const responseRows = Array.isArray(response) ? response : [] + // handle many-to-many relationships now if we know the ID (could be auto increment) if (operation !== Operation.READ) { await this.handleManyRelationships( table._id || "", - response[0], + responseRows[0], processed.manyRelationships ) } diff --git a/packages/server/src/api/controllers/row/alias.ts b/packages/server/src/api/controllers/row/alias.ts new file mode 100644 index 0000000000..7c5db51792 --- /dev/null +++ b/packages/server/src/api/controllers/row/alias.ts @@ -0,0 +1,168 @@ +import { + QueryJson, + SearchFilters, + Table, + Row, + DatasourcePlusQueryResponse, +} from "@budibase/types" +import { getDatasourceAndQuery } from "../../../sdk/app/rows/utils" +import { cloneDeep } from "lodash" + +class CharSequence { + static alphabet = "abcdefghijklmnopqrstuvwxyz" + counters: number[] + + constructor() { + this.counters = [0] + } + + getCharacter(): string { + const char = this.counters.map(i => CharSequence.alphabet[i]).join("") + for (let i = this.counters.length - 1; i >= 0; i--) { + if (this.counters[i] < CharSequence.alphabet.length - 1) { + this.counters[i]++ + return char + } + this.counters[i] = 0 + } + this.counters.unshift(0) + return char + } +} + +export default class AliasTables { + aliases: Record + tableAliases: Record + tableNames: string[] + charSeq: CharSequence + + constructor(tableNames: string[]) { + this.tableNames = tableNames + this.aliases = {} + this.tableAliases = {} + this.charSeq = new CharSequence() + } + + getAlias(tableName: string) { + if (this.aliases[tableName]) { + return this.aliases[tableName] + } + const char = this.charSeq.getCharacter() + this.aliases[tableName] = char + this.tableAliases[char] = tableName + return char + } + + aliasField(field: string) { + const tableNames = this.tableNames + if (field.includes(".")) { + const [tableName, column] = field.split(".") + const foundTableName = tableNames.find(name => { + const idx = tableName.indexOf(name) + if (idx === -1 || idx > 1) { + return + } + return Math.abs(tableName.length - name.length) <= 2 + }) + if (foundTableName) { + const aliasedTableName = tableName.replace( + foundTableName, + this.getAlias(foundTableName) + ) + field = `${aliasedTableName}.${column}` + } + } + return field + } + + reverse(rows: T): T { + const process = (row: Row) => { + const final: Row = {} + for (let [key, value] of Object.entries(row)) { + if (!key.includes(".")) { + final[key] = value + } else { + const [alias, column] = key.split(".") + const tableName = this.tableAliases[alias] || alias + final[`${tableName}.${column}`] = value + } + } + return final + } + if (Array.isArray(rows)) { + return rows.map(row => process(row)) as T + } else { + return process(rows) as T + } + } + + aliasMap(tableNames: (string | undefined)[]) { + const map: Record = {} + for (let tableName of tableNames) { + if (tableName) { + map[tableName] = this.getAlias(tableName) + } + } + return map + } + + async queryWithAliasing( + json: QueryJson + ): Promise { + json = cloneDeep(json) + const aliasTable = (table: Table) => ({ + ...table, + name: this.getAlias(table.name), + }) + // run through the query json to update anywhere a table may be used + if (json.resource?.fields) { + json.resource.fields = json.resource.fields.map(field => + this.aliasField(field) + ) + } + if (json.filters) { + for (let [filterKey, filter] of Object.entries(json.filters)) { + if (typeof filter !== "object") { + continue + } + const aliasedFilters: typeof filter = {} + for (let key of Object.keys(filter)) { + aliasedFilters[this.aliasField(key)] = filter[key] + } + json.filters[filterKey as keyof SearchFilters] = aliasedFilters + } + } + if (json.relationships) { + json.relationships = json.relationships.map(relationship => ({ + ...relationship, + aliases: this.aliasMap([ + relationship.through, + relationship.tableName, + json.endpoint.entityId, + ]), + })) + } + if (json.meta?.table) { + json.meta.table = aliasTable(json.meta.table) + } + if (json.meta?.tables) { + const aliasedTables: Record = {} + for (let [tableName, table] of Object.entries(json.meta.tables)) { + aliasedTables[this.getAlias(tableName)] = aliasTable(table) + } + json.meta.tables = aliasedTables + } + // invert and return + const invertedTableAliases: Record = {} + for (let [key, value] of Object.entries(this.tableAliases)) { + invertedTableAliases[value] = key + } + json.tableAliases = invertedTableAliases + const response = await getDatasourceAndQuery(json) + if (Array.isArray(response)) { + return this.reverse(response) + } else { + return response + } + } +} diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 1ad8a2a695..ec56919d12 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -223,7 +223,8 @@ export const exportRows = async ( const format = ctx.query.format - const { rows, columns, query, sort, sortOrder } = ctx.request.body + const { rows, columns, query, sort, sortOrder, delimiter, customHeaders } = + ctx.request.body if (typeof format !== "string" || !exporters.isFormat(format)) { ctx.throw( 400, @@ -241,6 +242,8 @@ export const exportRows = async ( query, sort, sortOrder, + delimiter, + customHeaders, }) ctx.attachment(fileName) ctx.body = apiFileReturn(content) diff --git a/packages/server/src/api/controllers/row/utils/utils.ts b/packages/server/src/api/controllers/row/utils/utils.ts index eb87dc8c93..8197fba0f6 100644 --- a/packages/server/src/api/controllers/row/utils/utils.ts +++ b/packages/server/src/api/controllers/row/utils/utils.ts @@ -1,7 +1,15 @@ import { InternalTables } from "../../../../db/utils" import * as userController from "../../user" import { context } from "@budibase/backend-core" -import { Ctx, RelationshipsJson, Row, Table, UserCtx } from "@budibase/types" +import { + Ctx, + DatasourcePlusQueryResponse, + FieldType, + RelationshipsJson, + Row, + Table, + UserCtx, +} from "@budibase/types" import { processDates, processFormulas, @@ -25,6 +33,34 @@ validateJs.extend(validateJs.validators.datetime, { }, }) +export function processRelationshipFields( + table: Table, + tables: Record, + row: Row, + relationships: RelationshipsJson[] +): Row { + for (let relationship of relationships) { + const linkedTable = tables[relationship.tableName] + if (!linkedTable || !row[relationship.column]) { + continue + } + for (let key of Object.keys(row[relationship.column])) { + let relatedRow: Row = row[relationship.column][key] + // add this row as context for the relationship + for (let col of Object.values(linkedTable.schema)) { + if (col.type === FieldType.LINK && col.tableId === table._id) { + relatedRow[col.name] = [row] + } + } + // process additional types + relatedRow = processDates(table, relatedRow) + relatedRow = processFormulas(linkedTable, relatedRow) + row[relationship.column][key] = relatedRow + } + } + return row +} + export async function findRow(ctx: UserCtx, tableId: string, rowId: string) { const db = context.getAppDB() let row: Row @@ -80,17 +116,17 @@ export async function validate( } export function sqlOutputProcessing( - rows: Row[] = [], + rows: DatasourcePlusQueryResponse, table: Table, tables: Record, relationships: RelationshipsJson[], opts?: { internal?: boolean } ) { - if (!rows || rows.length === 0 || rows[0].read === true) { + if (!Array.isArray(rows) || rows.length === 0 || rows[0].read === true) { return [] } let finalRows: { [key: string]: Row } = {} - for (let row of rows) { + for (let row of rows as Row[]) { let rowId = row._id if (!rowId) { rowId = generateIdForRow(row, table) @@ -103,7 +139,8 @@ export function sqlOutputProcessing( tables, row, finalRows, - relationships + relationships, + opts ) continue } @@ -126,19 +163,18 @@ export function sqlOutputProcessing( tables, row, finalRows, - relationships, - opts + relationships ) } - // Process some additional data types - let finalRowArray = Object.values(finalRows) - finalRowArray = processDates(table, finalRowArray) - finalRowArray = processFormulas(table, finalRowArray) as Row[] - - return finalRowArray.map((row: Row) => - squashRelationshipColumns(table, tables, row, relationships) + // make sure all related rows are correct + let finalRowArray = Object.values(finalRows).map(row => + processRelationshipFields(table, tables, row, relationships) ) + + // process some additional types + finalRowArray = processDates(table, finalRowArray) + return finalRowArray } export function isUserMetadataTable(tableId: string) { diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts index bf93893c8d..06c731f1a6 100644 --- a/packages/server/src/api/controllers/static/index.ts +++ b/packages/server/src/api/controllers/static/index.ts @@ -1,7 +1,5 @@ import { InvalidFileExtensions } from "@budibase/shared-core" - import AppComponent from "./templates/BudibaseApp.svelte" - import { join } from "../../../utilities/centralPath" import * as uuid from "uuid" import { ObjectStoreBuckets } from "../../../constants" @@ -24,10 +22,12 @@ import fs from "fs" import sdk from "../../../sdk" import * as pro from "@budibase/pro" import { + DocumentType, + UserCtx, App, Ctx, - DocumentType, ProcessAttachmentResponse, + Feature, } from "@budibase/types" import { getAppMigrationVersion, @@ -36,6 +36,61 @@ import { import send from "koa-send" +const getThemeVariables = (theme: string) => { + if (theme === "spectrum--lightest") { + return ` + --spectrum-global-color-gray-50: rgb(255, 255, 255); + --spectrum-global-color-gray-200: rgb(244, 244, 244); + --spectrum-global-color-gray-300: rgb(234, 234, 234); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-50); + ` + } + if (theme === "spectrum--light") { + return ` + --spectrum-global-color-gray-50: rgb(255, 255, 255); + --spectrum-global-color-gray-200: rgb(234, 234, 234); + --spectrum-global-color-gray-300: rgb(225, 225, 225); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-50); + + ` + } + if (theme === "spectrum--dark") { + return ` + --spectrum-global-color-gray-100: rgb(50, 50, 50); + --spectrum-global-color-gray-200: rgb(62, 62, 62); + --spectrum-global-color-gray-300: rgb(74, 74, 74); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-100); + ` + } + if (theme === "spectrum--darkest") { + return ` + --spectrum-global-color-gray-100: rgb(30, 30, 30); + --spectrum-global-color-gray-200: rgb(44, 44, 44); + --spectrum-global-color-gray-300: rgb(57, 57, 57); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-100); + ` + } + if (theme === "spectrum--nord") { + return ` + --spectrum-global-color-gray-100: #3b4252; + + --spectrum-global-color-gray-200: #424a5c; + --spectrum-global-color-gray-300: #4c566a; + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-100); + ` + } + if (theme === "spectrum--midnight") { + return ` + --hue: 220; + --sat: 10%; + --spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%); + --spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%); + --spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%); + --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-100); + ` + } +} + export const toggleBetaUiFeature = async function (ctx: Ctx) { const cookieName = `beta:${ctx.params.feature}` @@ -150,7 +205,7 @@ const requiresMigration = async (ctx: Ctx) => { return requiresMigrations } -export const serveApp = async function (ctx: Ctx) { +export const serveApp = async function (ctx: UserCtx) { const needMigrations = await requiresMigration(ctx) const bbHeaderEmbed = @@ -171,9 +226,19 @@ export const serveApp = async function (ctx: Ctx) { const appInfo = await db.get(DocumentType.APP_METADATA) let appId = context.getAppId() + const hideDevTools = !!ctx.params.appUrl + const sideNav = appInfo.navigation.navigation === "Left" + const hideFooter = + ctx?.user?.license?.features?.includes(Feature.BRANDING) || false + const themeVariables = getThemeVariables(appInfo?.theme) + if (!env.isJest()) { const plugins = objectStore.enrichPluginURLs(appInfo.usedPlugins) + const { head, html, css } = AppComponent.render({ + hideDevTools, + sideNav, + hideFooter, metaImage: branding?.metaImageUrl || "https://res.cloudinary.com/daog6scxm/image/upload/v1698759482/meta-images/plain-branded-meta-image-coral_ocxmgu.png", @@ -198,7 +263,7 @@ export const serveApp = async function (ctx: Ctx) { ctx.body = await processString(appHbs, { head, body: html, - style: css.code, + css: `:root{${themeVariables}} ${css.code}`, appId, embedded: bbHeaderEmbed, }) diff --git a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte index 7819368fc0..63b293b4ca 100644 --- a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte +++ b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte @@ -1,4 +1,6 @@ @@ -96,6 +102,7 @@ +
{#if clientLibPath}

There was an error loading your app

diff --git a/packages/server/src/api/controllers/static/templates/app.hbs b/packages/server/src/api/controllers/static/templates/app.hbs index 8c445158a0..b01b723c3e 100644 --- a/packages/server/src/api/controllers/static/templates/app.hbs +++ b/packages/server/src/api/controllers/static/templates/app.hbs @@ -1,8 +1,12 @@ - + {{{head}}} - +