Adding handlebars formulas to the system, it is now possible to set a formula at a column level which will always be applied on the way out with a relationship depth of one.

This commit is contained in:
mike12345567 2021-04-29 19:06:58 +01:00
parent e08df4110a
commit a14c80bf6c
15 changed files with 359 additions and 55 deletions

View File

@ -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])
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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}
<option value={field.type}>{field.name}</option>
{/each}
<option value={AUTO_COL}>Auto Column</option>
<option value={AUTO_TYPE}>Auto Column</option>
</Select>
{#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}
<ModalBindableInput
title="Handlebars Formula"
label="Formula"
value={field.formula}
on:change={e => (field.formula = e.detail)}
bindings={getBindings({ table })}
serverSide=true />
{:else if field.type === AUTO_TYPE}
<Select label="Auto Column Type" thin secondary bind:value={field.subtype}>
<option value="">Choose a subtype</option>
{#each Object.entries(getAutoColumnInformation()) as [subtype, info]}

View File

@ -5,6 +5,9 @@
import * as api from "../api"
import { ModalContent } from "@budibase/bbui"
import ErrorsBox from "components/common/ErrorsBox.svelte"
import { FIELDS } from "constants/backend"
const FORMULA_TYPE = FIELDS.FORMULA.type
export let row = {}
@ -44,7 +47,7 @@
onConfirm={saveRow}>
<ErrorsBox {errors} />
{#each tableSchema as [key, meta]}
{#if !meta.autocolumn}
{#if !meta.autocolumn && meta.type !== FORMULA_TYPE}
<div>
<RowFieldControl {meta} bind:value={row[key]} />
</div>

View File

@ -1,14 +1,16 @@
<script>
import { Icon, Input, Drawer, Label, Body, Button } from "@budibase/bbui"
import { Icon, Input, Drawer, Body, Button } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import BindingPanel from "components/design/PropertiesPanel/BindingPanel.svelte"
import ServerBindingPanel from "components/common/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let panel = BindingPanel
export let serverSide = false
export let panel = serverSide ? ServerBindingPanel : BindingPanel
export let value = ""
export let bindings = []
export let thin = true

View File

@ -0,0 +1,92 @@
<script>
import { Icon, Input, Modal, Body, ModalContent } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import ServerBindingPanel from "components/common/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let panel = ServerBindingPanel
export let value = ""
export let bindings = []
export let thin = true
export let title = "Bindings"
export let placeholder
export let label
let bindingModal
let validity = true
$: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
$: invalid = !validity
const saveBinding = () => {
onChange(tempValue)
bindingModal.hide()
}
const onChange = input => {
dispatch("change", readableToRuntimeBinding(bindings, input))
}
</script>
<div class="control">
<Input
{label}
{thin}
value={readableValue}
on:change={event => onChange(event.target.value)}
{placeholder} />
<div class="icon" on:click={bindingModal.show}>
<Icon name="lightning" />
</div>
</div>
<Modal bind:this={bindingModal} width="50%">
<ModalContent
{title}
onConfirm={saveBinding}
bind:disabled={invalid}>
<Body extraSmall grey>
Add the objects on the left to enrich your text.
</Body>
<svelte:component
this={panel}
serverSide
value={readableValue}
bind:validity={validity}
on:update={event => (tempValue = event.detail)}
bindableProperties={bindings} />
</ModalContent>
</Modal>
<style>
.control {
flex: 1;
position: relative;
}
.icon {
right: 2px;
bottom: 2px;
position: absolute;
align-items: center;
display: flex;
box-sizing: border-box;
padding-left: 7px;
border-left: 1px solid var(--grey-4);
background-color: var(--grey-2);
border-top-right-radius: var(--border-radius-m);
border-bottom-right-radius: var(--border-radius-m);
color: var(--grey-7);
font-size: 14px;
height: 40px;
}
.icon:hover {
color: var(--ink);
cursor: pointer;
}
</style>

View File

@ -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()
}
}
</script>
@ -53,14 +61,16 @@
{#each categories as [categoryName, bindings]}
<Heading extraSmall>{categoryName}</Heading>
<Spacer extraSmall />
{#each bindableProperties.filter(binding =>
{#each bindings.filter(binding =>
binding.label.match(searchRgx)
) as binding}
<div class="binding" on:click={() => addToText(binding)}>
<span class="binding__label">{binding.label}</span>
<span class="binding__type">{binding.type}</span>
<br />
<div class="binding__description">{binding.description || ''}</div>
{#if binding.description}
<div class="binding__description">{binding.description || ''}</div>
{/if}
</div>
{/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);

View File

@ -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 = {

View File

@ -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,
}

View File

@ -13,6 +13,7 @@ exports.FieldTypes = {
DATETIME: "datetime",
ATTACHMENT: "attachment",
LINK: "link",
FORMULA: "formula",
AUTO: "auto",
}

View File

@ -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<object>} rows The rows which are to be enriched with the linked display names/IDs.
* @returns {Promise<Array>} The enriched rows after having display names/IDs attached to the linked fields.
* @param {array<object>} 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<object>} enriched The pre-enriched rows (full docs) which are to be squashed.
* @returns {Promise<Array>} 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
}

View File

@ -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]
}

View File

@ -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,