mirror of https://github.com/Budibase/budibase.git
39 changed files with 720 additions and 242 deletions
@ -1,73 +1,20 @@ |
|||
<script> |
|||
import "@spectrum-css/fieldlabel/dist/index-vars.css" |
|||
import Tooltip from "../Tooltip/Tooltip.svelte" |
|||
import Icon from "../Icon/Icon.svelte" |
|||
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte" |
|||
|
|||
export let size = "M" |
|||
export let tooltip = "" |
|||
export let showTooltip = false |
|||
</script> |
|||
|
|||
{#if tooltip} |
|||
<div class="container"> |
|||
<label |
|||
for="" |
|||
class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`} |
|||
> |
|||
<slot /> |
|||
</label> |
|||
<div class="icon-container"> |
|||
<div |
|||
class="icon" |
|||
class:icon-small={size === "M" || size === "S"} |
|||
on:mouseover={() => (showTooltip = true)} |
|||
on:mouseleave={() => (showTooltip = false)} |
|||
> |
|||
<Icon name="InfoOutline" size="S" disabled={true} /> |
|||
</div> |
|||
{#if showTooltip} |
|||
<div class="tooltip"> |
|||
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} /> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
{:else} |
|||
<TooltipWrapper {tooltip} {size}> |
|||
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}> |
|||
<slot /> |
|||
</label> |
|||
{/if} |
|||
</TooltipWrapper> |
|||
|
|||
<style> |
|||
label { |
|||
padding: 0; |
|||
white-space: nowrap; |
|||
} |
|||
.container { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
.icon-container { |
|||
position: relative; |
|||
display: flex; |
|||
justify-content: center; |
|||
margin-top: 1px; |
|||
margin-left: 5px; |
|||
margin-right: 5px; |
|||
} |
|||
.tooltip { |
|||
position: absolute; |
|||
display: flex; |
|||
justify-content: center; |
|||
top: 15px; |
|||
z-index: 1; |
|||
width: 160px; |
|||
} |
|||
.icon { |
|||
transform: scale(0.75); |
|||
} |
|||
.icon-small { |
|||
margin-top: -2px; |
|||
margin-bottom: -5px; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,60 @@ |
|||
<script> |
|||
import Tooltip from "./Tooltip.svelte" |
|||
import Icon from "../Icon/Icon.svelte" |
|||
|
|||
export let tooltip = "" |
|||
export let size = "M" |
|||
|
|||
let showTooltip = false |
|||
</script> |
|||
|
|||
<div class:container={!!tooltip}> |
|||
<slot /> |
|||
{#if tooltip} |
|||
<div class="icon-container"> |
|||
<div |
|||
class="icon" |
|||
class:icon-small={size === "M" || size === "S"} |
|||
on:mouseover={() => (showTooltip = true)} |
|||
on:mouseleave={() => (showTooltip = false)} |
|||
> |
|||
<Icon name="InfoOutline" size="S" disabled={true} /> |
|||
</div> |
|||
{#if showTooltip} |
|||
<div class="tooltip"> |
|||
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} /> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
.icon-container { |
|||
position: relative; |
|||
display: flex; |
|||
justify-content: center; |
|||
margin-top: 1px; |
|||
margin-left: 5px; |
|||
margin-right: 5px; |
|||
} |
|||
.tooltip { |
|||
position: absolute; |
|||
display: flex; |
|||
justify-content: center; |
|||
top: 15px; |
|||
z-index: 1; |
|||
width: 160px; |
|||
} |
|||
.icon { |
|||
transform: scale(0.75); |
|||
} |
|||
.icon-small { |
|||
margin-top: -2px; |
|||
margin-bottom: -5px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,156 @@ |
|||
const { getRowParams } = require("../../../db/utils") |
|||
const { |
|||
outputProcessing, |
|||
processAutoColumn, |
|||
processFormulas, |
|||
} = require("../../../utilities/rowProcessor") |
|||
const { FieldTypes, FormulaTypes } = require("../../../constants") |
|||
const { isEqual } = require("lodash") |
|||
const { cloneDeep } = require("lodash/fp") |
|||
const { getAppDB } = require("@budibase/backend-core/context") |
|||
|
|||
/** |
|||
* This function runs through a list of enriched rows, looks at the rows which |
|||
* are related and then checks if they need the state of their formulas |
|||
* updated. |
|||
* NOTE: this will only for affect static formulas. |
|||
*/ |
|||
exports.updateRelatedFormula = async (table, enrichedRows) => { |
|||
const db = getAppDB() |
|||
// no formula to update, we're done
|
|||
if (!table.relatedFormula) { |
|||
return |
|||
} |
|||
let promises = [] |
|||
for (let enrichedRow of Array.isArray(enrichedRows) |
|||
? enrichedRows |
|||
: [enrichedRows]) { |
|||
// the related rows by tableId
|
|||
let relatedRows = {} |
|||
for (let [key, field] of Object.entries(enrichedRow)) { |
|||
const columnDefinition = table.schema[key] |
|||
if (columnDefinition && columnDefinition.type === FieldTypes.LINK) { |
|||
const relatedTableId = columnDefinition.tableId |
|||
if (!relatedRows[relatedTableId]) { |
|||
relatedRows[relatedTableId] = [] |
|||
} |
|||
relatedRows[relatedTableId] = relatedRows[relatedTableId].concat(field) |
|||
} |
|||
} |
|||
for (let tableId of table.relatedFormula) { |
|||
let relatedTable |
|||
try { |
|||
// no rows to update, skip
|
|||
if (!relatedRows[tableId] || relatedRows[tableId].length === 0) { |
|||
continue |
|||
} |
|||
relatedTable = await db.get(tableId) |
|||
} catch (err) { |
|||
// no error scenario, table doesn't seem to exist anymore, ignore
|
|||
} |
|||
for (let column of Object.values(relatedTable.schema)) { |
|||
// needs updated in related rows
|
|||
if ( |
|||
column.type === FieldTypes.FORMULA && |
|||
column.formulaType === FormulaTypes.STATIC |
|||
) { |
|||
// re-enrich rows for all the related, don't update the related formula for them
|
|||
promises = promises.concat( |
|||
relatedRows[tableId].map(related => |
|||
exports.finaliseRow(relatedTable, related, { |
|||
updateFormula: false, |
|||
}) |
|||
) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
await Promise.all(promises) |
|||
} |
|||
|
|||
exports.updateAllFormulasInTable = async table => { |
|||
const db = getAppDB() |
|||
// start by getting the raw rows (which will be written back to DB after update)
|
|||
let rows = ( |
|||
await db.allDocs( |
|||
getRowParams(table._id, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
).rows.map(row => row.doc) |
|||
// now enrich the rows, note the clone so that we have the base state of the
|
|||
// rows so that we don't write any of the enriched information back
|
|||
let enrichedRows = await outputProcessing(table, cloneDeep(rows), { |
|||
squash: false, |
|||
}) |
|||
const updatedRows = [] |
|||
for (let row of rows) { |
|||
// find the enriched row, if found process the formulas
|
|||
const enrichedRow = enrichedRows.find(enriched => enriched._id === row._id) |
|||
if (enrichedRow) { |
|||
const processed = processFormulas(table, cloneDeep(row), { |
|||
dynamic: false, |
|||
contextRows: enrichedRow, |
|||
}) |
|||
// values have changed, need to add to bulk docs to update
|
|||
if (!isEqual(processed, row)) { |
|||
updatedRows.push(processed) |
|||
} |
|||
} |
|||
} |
|||
await db.bulkDocs(updatedRows) |
|||
} |
|||
|
|||
/** |
|||
* This function runs at the end of the save/patch functions of the row controller, all this |
|||
* really does is enrich the row, handle any static formula processing, then return the enriched |
|||
* row. The reason we need to return the enriched row is that the automation row created trigger |
|||
* expects the row to be totally enriched/contain all relationships. |
|||
*/ |
|||
exports.finaliseRow = async ( |
|||
table, |
|||
row, |
|||
{ oldTable, updateFormula } = { updateFormula: true } |
|||
) => { |
|||
const db = getAppDB() |
|||
row.type = "row" |
|||
// process the row before return, to include relationships
|
|||
let enrichedRow = await outputProcessing(table, cloneDeep(row), { |
|||
squash: false, |
|||
}) |
|||
// use enriched row to generate formulas for saving, specifically only use as context
|
|||
row = processFormulas(table, row, { |
|||
dynamic: false, |
|||
contextRows: enrichedRow, |
|||
}) |
|||
|
|||
// don't worry about rev, tables handle rev/lastID updates
|
|||
// if another row has been written since processing this will
|
|||
// handle the auto ID clash
|
|||
if (oldTable && !isEqual(oldTable, table)) { |
|||
try { |
|||
await db.put(table) |
|||
} catch (err) { |
|||
if (err.status === 409) { |
|||
const updatedTable = await db.get(table._id) |
|||
let response = processAutoColumn(null, updatedTable, row, { |
|||
reprocessing: true, |
|||
}) |
|||
await db.put(response.table) |
|||
row = response.row |
|||
} else { |
|||
throw err |
|||
} |
|||
} |
|||
} |
|||
const response = await db.put(row) |
|||
// for response, calculate the formulas for the enriched row
|
|||
enrichedRow._rev = response.rev |
|||
enrichedRow = await processFormulas(table, enrichedRow, { dynamic: false }) |
|||
// this updates the related formulas in other rows based on the relations to this row
|
|||
if (updateFormula) { |
|||
await exports.updateRelatedFormula(table, enrichedRow) |
|||
} |
|||
return { row: enrichedRow, table } |
|||
} |
|||
@ -0,0 +1,173 @@ |
|||
const { FieldTypes, FormulaTypes } = require("../../../constants") |
|||
const { getAllInternalTables, clearColumns } = require("./utils") |
|||
const { doesContainStrings } = require("@budibase/string-templates") |
|||
const { cloneDeep } = require("lodash/fp") |
|||
const { isEqual, uniq } = require("lodash") |
|||
const { updateAllFormulasInTable } = require("../row/staticFormula") |
|||
const { getAppDB } = require("@budibase/backend-core/context") |
|||
|
|||
function isStaticFormula(column) { |
|||
return ( |
|||
column.type === FieldTypes.FORMULA && |
|||
column.formulaType === FormulaTypes.STATIC |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* This retrieves the formula columns from a table schema that use a specified column name |
|||
* in the formula. |
|||
*/ |
|||
function getFormulaThatUseColumn(table, columnNames) { |
|||
let formula = [] |
|||
columnNames = Array.isArray(columnNames) ? columnNames : [columnNames] |
|||
for (let column of Object.values(table.schema)) { |
|||
// not a static formula, or doesn't contain a relationship
|
|||
if (!isStaticFormula(column)) { |
|||
continue |
|||
} |
|||
if (!doesContainStrings(column.formula, columnNames)) { |
|||
continue |
|||
} |
|||
formula.push(column.name) |
|||
} |
|||
return formula |
|||
} |
|||
|
|||
/** |
|||
* This functions checks for when a related table, column or related column is deleted, if any |
|||
* tables need to have the formula column removed. |
|||
*/ |
|||
async function checkIfFormulaNeedsCleared(table, { oldTable, deletion }) { |
|||
// start by retrieving all tables, remove the current table from the list
|
|||
const tables = (await getAllInternalTables()).filter( |
|||
tbl => tbl._id !== table._id |
|||
) |
|||
const schemaToUse = oldTable ? oldTable.schema : table.schema |
|||
let removedColumns = Object.values(schemaToUse).filter( |
|||
column => deletion || !table.schema[column.name] |
|||
) |
|||
// remove any formula columns that used related columns
|
|||
for (let removed of removedColumns) { |
|||
let tableToUse = table |
|||
// if relationship, get the related table
|
|||
if (removed.type === FieldTypes.LINK) { |
|||
tableToUse = tables.find(table => table._id === removed.tableId) |
|||
} |
|||
const columnsToDelete = getFormulaThatUseColumn(tableToUse, removed.name) |
|||
if (columnsToDelete.length > 0) { |
|||
await clearColumns(table, columnsToDelete) |
|||
} |
|||
// need a special case, where a column has been removed from this table, but was used
|
|||
// in a different, related tables formula
|
|||
if (!table.relatedFormula) { |
|||
continue |
|||
} |
|||
for (let relatedTableId of table.relatedFormula) { |
|||
const relatedColumns = Object.values(table.schema).filter( |
|||
column => column.tableId === relatedTableId |
|||
) |
|||
const relatedTable = tables.find(table => table._id === relatedTableId) |
|||
// look to see if the column was used in a relationship formula,
|
|||
// relationships won't be used for this
|
|||
if (relatedTable && relatedColumns && removed.type !== FieldTypes.LINK) { |
|||
let relatedFormulaToRemove = [] |
|||
for (let column of relatedColumns) { |
|||
relatedFormulaToRemove = relatedFormulaToRemove.concat( |
|||
getFormulaThatUseColumn(relatedTable, [ |
|||
column.fieldName, |
|||
removed.name, |
|||
]) |
|||
) |
|||
} |
|||
if (relatedFormulaToRemove.length > 0) { |
|||
await clearColumns(relatedTable, uniq(relatedFormulaToRemove)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* This function adds a note to related tables that they are |
|||
* used in a static formula - so that the link controller |
|||
* can manage hydrating related rows formula fields. This is |
|||
* specifically only for static formula. |
|||
*/ |
|||
async function updateRelatedFormulaLinksOnTables( |
|||
table, |
|||
{ deletion } = { deletion: false } |
|||
) { |
|||
const db = getAppDB() |
|||
// start by retrieving all tables, remove the current table from the list
|
|||
const tables = (await getAllInternalTables()).filter( |
|||
tbl => tbl._id !== table._id |
|||
) |
|||
// clone the tables, so we can compare at end
|
|||
const initialTables = cloneDeep(tables) |
|||
// first find the related column names
|
|||
const relatedColumns = Object.values(table.schema).filter( |
|||
col => col.type === FieldTypes.LINK |
|||
) |
|||
// we start by removing the formula field from all tables
|
|||
for (let otherTable of tables) { |
|||
if (!otherTable.relatedFormula) { |
|||
continue |
|||
} |
|||
const index = otherTable.relatedFormula.indexOf(table._id) |
|||
if (index !== -1) { |
|||
otherTable.relatedFormula.splice(index, 1) |
|||
} |
|||
} |
|||
// if deleting, just remove the table IDs, don't try add
|
|||
if (!deletion) { |
|||
for (let relatedCol of relatedColumns) { |
|||
let columns = getFormulaThatUseColumn(table, relatedCol.name) |
|||
if (!columns || columns.length === 0) { |
|||
continue |
|||
} |
|||
const relatedTable = tables.find( |
|||
related => related._id === relatedCol.tableId |
|||
) |
|||
// check if the table is already in the list of related formula, if it isn't, then add it
|
|||
if ( |
|||
relatedTable && |
|||
(!relatedTable.relatedFormula || |
|||
!relatedTable.relatedFormula.includes(table._id)) |
|||
) { |
|||
relatedTable.relatedFormula = relatedTable.relatedFormula |
|||
? [...relatedTable.relatedFormula, table._id] |
|||
: [table._id] |
|||
} |
|||
} |
|||
} |
|||
// now we just need to compare all the tables and see if any need saved
|
|||
for (let initial of initialTables) { |
|||
const found = tables.find(tbl => initial._id === tbl._id) |
|||
if (found && !isEqual(initial, found)) { |
|||
await db.put(found) |
|||
} |
|||
} |
|||
} |
|||
|
|||
async function checkIfFormulaUpdated(table, { oldTable }) { |
|||
// look to see if any formula values have changed
|
|||
const shouldUpdate = Object.values(table.schema).find( |
|||
column => |
|||
isStaticFormula(column) && |
|||
(!oldTable || |
|||
!oldTable.schema[column.name] || |
|||
!isEqual(oldTable.schema[column.name], column)) |
|||
) |
|||
// if a static formula column has updated, then need to run the update
|
|||
if (shouldUpdate != null) { |
|||
await updateAllFormulasInTable(table) |
|||
} |
|||
} |
|||
|
|||
exports.runStaticFormulaChecks = async (table, { oldTable, deletion }) => { |
|||
await updateRelatedFormulaLinksOnTables(table, { deletion }) |
|||
await checkIfFormulaNeedsCleared(table, { oldTable, deletion }) |
|||
if (!deletion) { |
|||
await checkIfFormulaUpdated(table, { oldTable }) |
|||
} |
|||
} |
|||
@ -1,23 +1,39 @@ |
|||
const { FieldTypes } = require("../../constants") |
|||
const { FieldTypes, FormulaTypes } = require("../../constants") |
|||
const { processStringSync } = require("@budibase/string-templates") |
|||
|
|||
/** |
|||
* Looks through the rows provided and finds formulas - which it then processes. |
|||
*/ |
|||
exports.processFormulas = (table, rows) => { |
|||
exports.processFormulas = ( |
|||
table, |
|||
rows, |
|||
{ dynamic, contextRows } = { dynamic: true } |
|||
) => { |
|||
const single = !Array.isArray(rows) |
|||
if (single) { |
|||
rows = [rows] |
|||
contextRows = contextRows ? [contextRows] : contextRows |
|||
} |
|||
for (let [column, schema] of Object.entries(table.schema)) { |
|||
if (schema.type !== FieldTypes.FORMULA) { |
|||
const isStatic = schema.formulaType === FormulaTypes.STATIC |
|||
if ( |
|||
schema.type !== FieldTypes.FORMULA || |
|||
(dynamic && isStatic) || |
|||
(!dynamic && !isStatic) |
|||
) { |
|||
continue |
|||
} |
|||
// iterate through rows and process formula
|
|||
rows = rows.map(row => ({ |
|||
...row, |
|||
[column]: processStringSync(schema.formula, row), |
|||
})) |
|||
for (let i = 0; i < rows.length; i++) { |
|||
if (schema.formula) { |
|||
let row = rows[i] |
|||
let context = contextRows ? contextRows[i] : row |
|||
rows[i] = { |
|||
...row, |
|||
[column]: processStringSync(schema.formula, context), |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return single ? rows[0] : rows |
|||
} |
|||
|
|||
Loading…
Reference in new issue