diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 58c4d8f4b6..17de4a2207 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -1,7 +1,7 @@ import { cloneDeep } from "lodash/fp" import { get } from "svelte/store" import { findComponent, findComponentPath } from "./storeUtils" -import { store } from "builderStore" +import {currentAssetId, store} from "builderStore" import { tables as tablesStore, queries as queriesStores } from "stores/backend" import { makePropSafe } from "@budibase/string-templates" import { TableNames } from "../constants" @@ -329,6 +329,21 @@ export function removeBindings(obj) { return obj } +/** + * When converting from readable to runtime it can sometimes add too many square brackets, + * this makes sure that doesn't happen. + */ +function shouldReplaceBinding(currentValue, from, convertTo) { + if (!currentValue.includes(from)) { + return false + } + if (convertTo === "readableBinding") { + return true + } + const noSpaces = currentValue.replace(/\s+/g, "") + return !noSpaces.includes(`[${from}]`) +} + /** * utility function for the readableToRuntimeBinding and runtimeToReadableBinding. */ @@ -348,7 +363,7 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) { for (let boundValue of boundValues) { let newBoundValue = boundValue for (let from of convertFromProps) { - if (newBoundValue.includes(from)) { + if (shouldReplaceBinding(newBoundValue, from, convertTo)) { const binding = bindableProperties.find(el => el[convertFrom] === from) newBoundValue = newBoundValue.replace(from, binding[convertTo]) } diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 1e2ee46cad..ecc2a3a83c 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -6,7 +6,7 @@ import { automationStore } from "builderStore" import WebhookDisplay from "../Shared/WebhookDisplay.svelte" import DrawerBindableInput from "../../common/DrawerBindableInput.svelte" - import AutomationBindingPanel from "./AutomationBindingPanel.svelte" + import AutomationBindingPanel from "../../common/ServerBindingPanel.svelte" export let block export let webhookModal diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte index bee56045ff..4444a142da 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte @@ -2,7 +2,7 @@ import { tables } from "stores/backend" import { Select } from "@budibase/bbui" import DrawerBindableInput from "../../common/DrawerBindableInput.svelte" - import AutomationBindingPanel from "./AutomationBindingPanel.svelte" + import AutomationBindingPanel from "../../common/ServerBindingPanel.svelte" export let value export let bindings diff --git a/packages/builder/src/components/backend/DataTable/formula.js b/packages/builder/src/components/backend/DataTable/formula.js new file mode 100644 index 0000000000..3d03d577e7 --- /dev/null +++ b/packages/builder/src/components/backend/DataTable/formula.js @@ -0,0 +1,75 @@ +import { FIELDS } from "constants/backend" +import { tables } from "stores/backend" +import { get as svelteGet } from "svelte/store" + +// currently supported level of relationship depth (server side) +const MAX_DEPTH = 1 +const TYPES_TO_SKIP = [ + FIELDS.FORMULA.type, + FIELDS.LONGFORM.type, + FIELDS.ATTACHMENT.type, +] + +export function getBindings({ + table, + path = null, + category = null, + depth = 0, +}) { + let bindings = [] + if (!table) { + return bindings + } + for (let [column, schema] of Object.entries(table.schema)) { + const isRelationship = schema.type === FIELDS.LINK.type + // skip relationships after a certain depth and types which + // can't bind to + if ( + TYPES_TO_SKIP.includes(schema.type) || + (isRelationship && depth >= MAX_DEPTH) + ) { + continue + } + category = category == null ? `${table.name} Fields` : category + if (isRelationship && depth < MAX_DEPTH) { + const relatedTable = svelteGet(tables).list.find( + table => table._id === schema.tableId + ) + const relatedBindings = bindings.concat( + getBindings({ + table: relatedTable, + path: column, + category: `${column} Relationships`, + depth: depth + 1, + }) + ) + // remove the ones that have already been found + bindings = bindings.concat( + relatedBindings.filter( + related => !bindings.find(binding => binding.path === related.path) + ) + ) + } + const field = Object.values(FIELDS).find( + field => field.type === schema.type + ) + const label = path == null ? column : `${path}.0.${column}` + // only supply a description for relationship paths + const description = + path == null + ? undefined + : `Update the "0" with the index of relationships or use array helpers` + bindings.push({ + label: label, + type: field.name === FIELDS.LINK.name ? "Array" : field.name, + category, + path: label, + description, + // don't include path, it messes things up, relationship path + // will be replaced by the main array binding + readableBinding: column, + runtimeBinding: `[${column}]`, + }) + } + return bindings +} diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 639201f89b..3a17a913dd 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -8,23 +8,26 @@ Toggle, Radio, } from "@budibase/bbui" - import { cloneDeep } from "lodash/fp" - import { tables } from "stores/backend" + import {cloneDeep} from "lodash/fp" + import {tables} from "stores/backend" - import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" + import {TableNames, UNEDITABLE_USER_FIELDS} from "constants" import { FIELDS, AUTO_COLUMN_SUB_TYPES, RelationshipTypes, } from "constants/backend" - import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils" - import { notifier } from "builderStore/store/notifications" + import {getAutoColumnInformation, buildAutoColumn} from "builderStore/utils" + import {notifier} from "builderStore/store/notifications" import ValuesList from "components/common/ValuesList.svelte" import DatePicker from "components/common/DatePicker.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte" - import { truncate } from "lodash" + import {truncate} from "lodash" + import ModalBindableInput from "components/common/ModalBindableInput.svelte" + import {getBindings} from "components/backend/DataTable/formula" - const AUTO_COL = "auto" + const AUTO_TYPE = "auto" + const FORMULA_TYPE = FIELDS.FORMULA.type const LINK_TYPE = FIELDS.LINK.type let fieldDefinitions = cloneDeep(FIELDS) @@ -65,14 +68,17 @@ $: canBeSearched = field.type !== LINK_TYPE && field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY && - field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY - $: canBeDisplay = field.type !== LINK_TYPE && field.type !== AUTO_COL + field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY && + field.type !== FORMULA_TYPE + $: canBeDisplay = field.type !== LINK_TYPE && + field.type !== AUTO_TYPE && + field.type !== FORMULA_TYPE $: canBeRequired = - field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_COL + field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE $: relationshipOptions = getRelationshipOptions(field) async function saveColumn() { - if (field.type === AUTO_COL) { + if (field.type === AUTO_TYPE) { field = buildAutoColumn($tables.draft.name, field.name, field.subtype) } tables.saveField({ @@ -115,7 +121,7 @@ function onChangeRequired(e) { const req = e.target.checked - field.constraints.presence = req ? { allowEmpty: false } : false + field.constraints.presence = req ? {allowEmpty: false} : false required = req } @@ -123,7 +129,7 @@ const isPrimary = e.target.checked // primary display is always required if (isPrimary) { - field.constraints.presence = { allowEmpty: false } + field.constraints.presence = {allowEmpty: false} } } @@ -157,8 +163,8 @@ if (!linkTable) { return null } - const thisName = truncate(table.name, { length: 14 }), - linkName = truncate(linkTable.name, { length: 14 }) + const thisName = truncate(table.name, {length: 14}), + linkName = truncate(linkTable.name, {length: 14}) return [ { name: `Many ${thisName} rows → many ${linkName} rows`, @@ -192,7 +198,7 @@ {#each Object.values(fieldDefinitions) as field} {/each} - + {#if canBeRequired} @@ -285,7 +291,15 @@ label={`Column Name in Other Table`} thin bind:value={field.fieldName} /> - {:else if field.type === AUTO_COL} + {:else if field.type === FORMULA_TYPE} + (field.formula = e.detail)} + bindings={getBindings({ table })} + serverSide=true /> + {:else if field.type === AUTO_TYPE} onChange(event.target.value)} + {placeholder} /> +
+ +
+ + + + + Add the objects on the left to enrich your text. + + (tempValue = event.detail)} + bindableProperties={bindings} /> + + + + diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBindingPanel.svelte b/packages/builder/src/components/common/ServerBindingPanel.svelte similarity index 84% rename from packages/builder/src/components/automation/SetupPanel/AutomationBindingPanel.svelte rename to packages/builder/src/components/common/ServerBindingPanel.svelte index bda233f9f2..0632559bce 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBindingPanel.svelte +++ b/packages/builder/src/components/common/ServerBindingPanel.svelte @@ -4,18 +4,20 @@ import { createEventDispatcher } from "svelte" import { isValid } from "@budibase/string-templates" import { handlebarsCompletions } from "constants/completions" + import { readableToRuntimeBinding } from "../../builderStore/dataBinding" const dispatch = createEventDispatcher() - export let value = "" - export let bindingDrawer + export let bindingContainer export let bindableProperties = [] + export let validity = true + export let value = "" + let hasReadable = bindableProperties[0].readableBinding != null let originalValue = value let helpers = handlebarsCompletions() let getCaretPosition let search = "" - let validity = true $: categories = Object.entries(groupBy("category", bindableProperties)) $: value && checkValid() @@ -23,7 +25,11 @@ $: searchRgx = new RegExp(search, "ig") function checkValid() { - validity = isValid(value) + if (hasReadable) { + validity = isValid(readableToRuntimeBinding(bindableProperties, value)) + } else { + validity = isValid(value) + } } function addToText(binding) { @@ -40,7 +46,9 @@ } export function cancel() { dispatch("update", originalValue) - bindingDrawer.close() + if (bindingContainer && bindingContainer.close) { + bindingContainer.close() + } } @@ -53,14 +61,16 @@ {#each categories as [categoryName, bindings]} {categoryName} - {#each bindableProperties.filter(binding => + {#each bindings.filter(binding => binding.label.match(searchRgx) ) as binding}
addToText(binding)}> {binding.label} {binding.type}
-
{binding.description || ''}
+ {#if binding.description} +
{binding.description || ''}
+ {/if}
{/each} {/each} @@ -129,8 +139,12 @@ .binding { font-size: 12px; - padding: var(--spacing-s); - border-radius: var(--border-radius-m); + border: var(--border-light); + border-width: 1px 0 0 0; + padding: var(--spacing-m) 0; + margin: auto 0; + align-items: center; + cursor: pointer; } .binding:hover { background-color: var(--grey-2); diff --git a/packages/builder/src/constants/backend/index.js b/packages/builder/src/constants/backend/index.js index f7bb77db3b..de49883cf1 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.js @@ -80,6 +80,15 @@ export const FIELDS = { presence: false, }, }, + FORMULA: { + name: "Formula", + icon: "ri-braces-line", + type: "formula", + constraints: { + type: "string", + presence: false, + }, + }, } export const AUTO_COLUMN_SUB_TYPES = { diff --git a/packages/server/src/api/controllers/user.js b/packages/server/src/api/controllers/user.js index 1f41acc754..396428c853 100644 --- a/packages/server/src/api/controllers/user.js +++ b/packages/server/src/api/controllers/user.js @@ -88,6 +88,7 @@ exports.updateMetadata = async function(ctx) { }) const metadata = { ...globalUser, + tableId: InternalTables.USER_METADATA, _id: user._id || generateUserMetadataID(globalUser._id), _rev: user._rev, } diff --git a/packages/server/src/constants/index.js b/packages/server/src/constants/index.js index 9853676aa6..f9a72ec479 100644 --- a/packages/server/src/constants/index.js +++ b/packages/server/src/constants/index.js @@ -13,6 +13,7 @@ exports.FieldTypes = { DATETIME: "datetime", ATTACHMENT: "attachment", LINK: "link", + FORMULA: "formula", AUTO: "auto", } diff --git a/packages/server/src/db/linkedRows/index.js b/packages/server/src/db/linkedRows/index.js index 8de2093fb2..bc58636dc7 100644 --- a/packages/server/src/db/linkedRows/index.js +++ b/packages/server/src/db/linkedRows/index.js @@ -10,6 +10,7 @@ const { } = require("./linkUtils") const { flatten } = require("lodash") const CouchDB = require("../../db") +const { FieldTypes } = require("../../constants") const { getMultiIDParams } = require("../../db/utils") /** @@ -141,14 +142,14 @@ exports.attachLinkIDs = async (appId, rows) => { } /** - * Given information about the table we can extract the display name from the linked rows, this - * is what we do for showing the display name of each linked row when in a table format. + * Given a table and a list of rows this will retrieve all of the attached docs and enrich them into the row. + * This is required for formula fields, this may only be utilised internally (for now). * @param {string} appId The app in which the tables/rows/links exist. * @param {object} table The table from which the rows originated. - * @param {array} rows The rows which are to be enriched with the linked display names/IDs. - * @returns {Promise} The enriched rows after having display names/IDs attached to the linked fields. + * @param {array} rows The rows which are to be enriched. + * @return {Promise<*>} returns the rows with all of the enriched relationships on it. */ -exports.attachLinkedPrimaryDisplay = async (appId, table, rows) => { +exports.attachFullLinkedDocs = async (appId, table, rows) => { const linkedTableIds = getLinkedTableIDs(table) if (linkedTableIds.length === 0) { return rows @@ -161,7 +162,6 @@ exports.attachLinkedPrimaryDisplay = async (appId, table, rows) => { const linked = (await db.allDocs(getMultiIDParams(linkedRowIds))).rows.map( row => row.doc ) - // will populate this as we find them const linkedTables = [] for (let row of rows) { for (let link of links.filter(link => link.thisId === row._id)) { @@ -175,13 +175,44 @@ exports.attachLinkedPrimaryDisplay = async (appId, table, rows) => { if (!linkedRow || !linkedTable) { continue } - const obj = { _id: linkedRow._id } - // if we know the display column, add it - if (linkedRow[linkedTable.primaryDisplay] != null) { - obj.primaryDisplay = linkedRow[linkedTable.primaryDisplay] - } - row[link.fieldName].push(obj) + row[link.fieldName].push(linkedRow) } } return rows } + +/** + * This function will take the given enriched rows and squash the links to only contain the primary display field. + * @param {string} appId The app in which the tables/rows/links exist. + * @param {object} table The table from which the rows originated. + * @param {array} enriched The pre-enriched rows (full docs) which are to be squashed. + * @returns {Promise} The rows after having their links squashed to only contain the ID and primary display. + */ +exports.squashLinksToPrimaryDisplay = async (appId, table, enriched) => { + const db = new CouchDB(appId) + // will populate this as we find them + const linkedTables = [] + for (let [column, schema] of Object.entries(table.schema)) { + if (schema.type !== FieldTypes.LINK) { + continue + } + for (let row of enriched) { + if (!row[column] || !row[column].length) { + continue + } + const newLinks = [] + for (let link of row[column]) { + const linkTblId = link.tableId || getRelatedTableForField(table, column) + // this only fetches the table if its not already in array + const linkedTable = await getLinkedTable(db, linkTblId, linkedTables) + const obj = { _id: link._id } + if (link[linkedTable.primaryDisplay]) { + obj.primaryDisplay = link[linkedTable.primaryDisplay] + } + newLinks.push(obj) + } + row[column] = newLinks + } + } + return enriched +} diff --git a/packages/server/src/utilities/rowProcessor.js b/packages/server/src/utilities/rowProcessor.js index c0c3aff8ae..3467db3066 100644 --- a/packages/server/src/utilities/rowProcessor.js +++ b/packages/server/src/utilities/rowProcessor.js @@ -3,6 +3,7 @@ const { OBJ_STORE_DIRECTORY } = require("../constants") const linkRows = require("../db/linkedRows") const { cloneDeep } = require("lodash/fp") const { FieldTypes, AutoFieldSubTypes } = require("../constants") +const { processStringSync } = require("@budibase/string-templates") const BASE_AUTO_ID = 1 @@ -34,6 +35,11 @@ const TYPE_TRANSFORM_MAP = { [null]: "", [undefined]: undefined, }, + [FieldTypes.FORMULA]: { + "": "", + [null]: "", + [undefined]: undefined, + }, [FieldTypes.LONGFORM]: { "": "", [null]: "", @@ -118,6 +124,41 @@ function processAutoColumn(user, table, row) { return { table, row } } +/** + * Given a set of rows and the table they came from this function will sort by auto ID or a custom + * method if provided (not implemented yet). + */ +function sortRows(table, rows) { + // sort based on auto ID (if found) + let autoIDColumn = Object.entries(table.schema).find( + schema => schema[1].subtype === AutoFieldSubTypes.AUTO_ID + ) + // get the column name, this is the first element in the array (Object.entries) + autoIDColumn = autoIDColumn && autoIDColumn.length ? autoIDColumn[0] : null + if (autoIDColumn) { + // sort in ascending order + rows.sort((a, b) => a[autoIDColumn] - b[autoIDColumn]) + } + return rows +} + +/** + * Looks through the rows provided and finds formulas - which it then processes. + */ +function processFormulas(table, rows) { + for (let [column, schema] of Object.entries(table.schema)) { + if (schema.type !== FieldTypes.FORMULA) { + continue + } + // iterate through rows and process formula + rows = rows.map(row => ({ + ...row, + [column]: processStringSync(schema.formula, row), + })) + } + return rows +} + /** * This will coerce a value to the correct types based on the type transform map * @param {object} row The value to coerce @@ -173,17 +214,19 @@ exports.outputProcessing = async (appId, table, rows) => { rows = [rows] wasArray = false } + // sort by auto ID + rows = sortRows(table, rows) // attach any linked row information - const outputRows = await linkRows.attachLinkedPrimaryDisplay( - appId, - table, - rows - ) + let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows) + + // process formulas + enriched = processFormulas(table, enriched) + // update the attachments URL depending on hosting if (env.isProd() && env.SELF_HOSTED) { for (let [property, column] of Object.entries(table.schema)) { if (column.type === FieldTypes.ATTACHMENT) { - for (let row of outputRows) { + for (let row of enriched) { if (row[property] == null || row[property].length === 0) { continue } @@ -195,5 +238,6 @@ exports.outputProcessing = async (appId, table, rows) => { } } } - return wasArray ? outputRows : outputRows[0] + enriched = await linkRows.squashLinksToPrimaryDisplay(appId, table, enriched) + return wasArray ? enriched : enriched[0] } diff --git a/packages/server/src/utilities/workerRequests.js b/packages/server/src/utilities/workerRequests.js index 2de74aa155..f96d48092e 100644 --- a/packages/server/src/utilities/workerRequests.js +++ b/packages/server/src/utilities/workerRequests.js @@ -122,12 +122,15 @@ exports.saveGlobalUser = async (ctx, appId, body) => { if (json.status !== 200 && response.status !== 200) { ctx.throw(400, "Unable to save global user.") } - delete body.email delete body.password - delete body.roleId - delete body.status delete body.roles delete body.builder + // TODO: for now these have been left in as they are + // TODO: pretty important to keeping relationships working + // TODO: however if user metadata is changed this should be removed + // delete body.email + // delete body.roleId + // delete body.status return { ...body, _id: json._id,