forked from tsai/budibase
committed by
GitHub
29 changed files with 709 additions and 214 deletions
@ -1,73 +1,20 @@ |
|||||
<script> |
<script> |
||||
import "@spectrum-css/fieldlabel/dist/index-vars.css" |
import "@spectrum-css/fieldlabel/dist/index-vars.css" |
||||
import Tooltip from "../Tooltip/Tooltip.svelte" |
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte" |
||||
import Icon from "../Icon/Icon.svelte" |
|
||||
|
|
||||
export let size = "M" |
export let size = "M" |
||||
export let tooltip = "" |
export let tooltip = "" |
||||
export let showTooltip = false |
|
||||
</script> |
</script> |
||||
|
|
||||
{#if tooltip} |
<TooltipWrapper {tooltip} {size}> |
||||
<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} |
|
||||
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}> |
<label for="" class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}> |
||||
<slot /> |
<slot /> |
||||
</label> |
</label> |
||||
{/if} |
</TooltipWrapper> |
||||
|
|
||||
<style> |
<style> |
||||
label { |
label { |
||||
padding: 0; |
padding: 0; |
||||
white-space: nowrap; |
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> |
</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,157 @@ |
|||||
|
const CouchDB = require("../../../db") |
||||
|
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") |
||||
|
|
||||
|
/** |
||||
|
* 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 (appId, table, enrichedRows) => { |
||||
|
const db = new CouchDB(appId) |
||||
|
// 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(appId, relatedTable, related, { |
||||
|
updateFormula: false, |
||||
|
}) |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
await Promise.all(promises) |
||||
|
} |
||||
|
|
||||
|
exports.updateAllFormulasInTable = async (appId, table) => { |
||||
|
const db = new CouchDB(appId) |
||||
|
// 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({ appId }, 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 ( |
||||
|
appId, |
||||
|
table, |
||||
|
row, |
||||
|
{ oldTable, updateFormula } = { updateFormula: true } |
||||
|
) => { |
||||
|
const db = new CouchDB(appId) |
||||
|
row.type = "row" |
||||
|
// process the row before return, to include relationships
|
||||
|
let enrichedRow = await outputProcessing({ appId }, 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(appId, table, enrichedRow) |
||||
|
} |
||||
|
return { row: enrichedRow, table } |
||||
|
} |
||||
@ -0,0 +1,183 @@ |
|||||
|
const CouchDB = require("../../../db") |
||||
|
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") |
||||
|
|
||||
|
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( |
||||
|
appId, |
||||
|
table, |
||||
|
{ oldTable, deletion } |
||||
|
) { |
||||
|
const db = new CouchDB(appId) |
||||
|
// start by retrieving all tables, remove the current table from the list
|
||||
|
const tables = (await getAllInternalTables(appId)).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(db, 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(db, 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( |
||||
|
appId, |
||||
|
table, |
||||
|
{ deletion } = { deletion: false } |
||||
|
) { |
||||
|
const db = new CouchDB(appId) |
||||
|
// start by retrieving all tables, remove the current table from the list
|
||||
|
const tables = (await getAllInternalTables(appId)).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(appId, 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(appId, table) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
exports.runStaticFormulaChecks = async ( |
||||
|
appId, |
||||
|
table, |
||||
|
{ oldTable, deletion } |
||||
|
) => { |
||||
|
await updateRelatedFormulaLinksOnTables(appId, table, { deletion }) |
||||
|
await checkIfFormulaNeedsCleared(appId, table, { oldTable, deletion }) |
||||
|
if (!deletion) { |
||||
|
await checkIfFormulaUpdated(appId, table, { oldTable }) |
||||
|
} |
||||
|
} |
||||
@ -1,23 +1,39 @@ |
|||||
const { FieldTypes } = require("../../constants") |
const { FieldTypes, FormulaTypes } = require("../../constants") |
||||
const { processStringSync } = require("@budibase/string-templates") |
const { processStringSync } = require("@budibase/string-templates") |
||||
|
|
||||
/** |
/** |
||||
* Looks through the rows provided and finds formulas - which it then processes. |
* 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) |
const single = !Array.isArray(rows) |
||||
if (single) { |
if (single) { |
||||
rows = [rows] |
rows = [rows] |
||||
|
contextRows = contextRows ? [contextRows] : contextRows |
||||
} |
} |
||||
for (let [column, schema] of Object.entries(table.schema)) { |
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 |
continue |
||||
} |
} |
||||
// iterate through rows and process formula
|
// iterate through rows and process formula
|
||||
rows = rows.map(row => ({ |
for (let i = 0; i < rows.length; i++) { |
||||
...row, |
if (schema.formula) { |
||||
[column]: processStringSync(schema.formula, row), |
let row = rows[i] |
||||
})) |
let context = contextRows ? contextRows[i] : row |
||||
|
rows[i] = { |
||||
|
...row, |
||||
|
[column]: processStringSync(schema.formula, context), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
return single ? rows[0] : rows |
return single ? rows[0] : rows |
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue