forked from tsai/budibase
48 changed files with 973 additions and 1761 deletions
@ -1,207 +0,0 @@ |
|||
<script> |
|||
import { TextButton, Body, DropdownMenu, ModalContent } from "@budibase/bbui" |
|||
import { AddIcon, ArrowDownIcon } from "components/common/Icons/" |
|||
import actionTypes from "./actions" |
|||
import { createEventDispatcher } from "svelte" |
|||
import { automationStore } from "builderStore" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
const eventTypeKey = "##eventHandlerType" |
|||
|
|||
export let event |
|||
|
|||
let addActionButton |
|||
let addActionDropdown |
|||
let selectedAction |
|||
|
|||
$: actions = event || [] |
|||
$: selectedActionComponent = |
|||
selectedAction && |
|||
actionTypes.find(t => t.name === selectedAction[eventTypeKey]).component |
|||
|
|||
const deleteAction = index => { |
|||
actions.splice(index, 1) |
|||
actions = actions |
|||
} |
|||
|
|||
const addAction = actionType => () => { |
|||
const newAction = { |
|||
parameters: {}, |
|||
[eventTypeKey]: actionType.name, |
|||
} |
|||
actions.push(newAction) |
|||
selectedAction = newAction |
|||
actions = actions |
|||
addActionDropdown.hide() |
|||
} |
|||
|
|||
const selectAction = action => () => { |
|||
selectedAction = action |
|||
} |
|||
|
|||
const saveEventData = async () => { |
|||
// e.g. The Trigger Automation action exposes beforeSave, so it can |
|||
// create any automations it needs to |
|||
for (let action of actions) { |
|||
if (action[eventTypeKey] === "Trigger Automation") { |
|||
await createAutomation(action.parameters) |
|||
} |
|||
} |
|||
dispatch("change", actions) |
|||
} |
|||
|
|||
// called by the parent modal when actions are saved |
|||
const createAutomation = async parameters => { |
|||
if (parameters.automationId || !parameters.newAutomationName) return |
|||
|
|||
await automationStore.actions.create({ name: parameters.newAutomationName }) |
|||
|
|||
const appActionDefinition = $automationStore.blockDefinitions.TRIGGER.APP |
|||
|
|||
const newBlock = $automationStore.selectedAutomation.constructBlock( |
|||
"TRIGGER", |
|||
"APP", |
|||
appActionDefinition |
|||
) |
|||
|
|||
newBlock.inputs = { |
|||
fields: Object.entries(parameters.fields).reduce( |
|||
(fields, [key, value]) => { |
|||
fields[key] = value.type |
|||
return fields |
|||
}, |
|||
{} |
|||
), |
|||
} |
|||
|
|||
automationStore.actions.addBlockToAutomation(newBlock) |
|||
|
|||
await automationStore.actions.save($automationStore.selectedAutomation) |
|||
|
|||
parameters.automationId = $automationStore.selectedAutomation.automation._id |
|||
delete parameters.newAutomationName |
|||
} |
|||
</script> |
|||
|
|||
<ModalContent title="Actions" confirmText="Save" onConfirm={saveEventData}> |
|||
<div slot="header"> |
|||
<div bind:this={addActionButton}> |
|||
<TextButton text small blue on:click={addActionDropdown.show}> |
|||
<div style="height: 20px; width: 20px;"> |
|||
<AddIcon /> |
|||
</div> |
|||
Add Action |
|||
</TextButton> |
|||
</div> |
|||
<DropdownMenu |
|||
bind:this={addActionDropdown} |
|||
anchor={addActionButton} |
|||
align="right"> |
|||
<div class="available-actions-container"> |
|||
{#each actionTypes as actionType} |
|||
<div class="available-action" on:click={addAction(actionType)}> |
|||
<span>{actionType.name}</span> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
</DropdownMenu> |
|||
</div> |
|||
|
|||
<div class="actions-container"> |
|||
{#if actions && actions.length > 0} |
|||
{#each actions as action, index} |
|||
<div class="action-container"> |
|||
<div class="action-header" on:click={selectAction(action)}> |
|||
<Body small lh>{index + 1}. {action[eventTypeKey]}</Body> |
|||
<div class="row-expander" class:rotate={action !== selectedAction}> |
|||
<ArrowDownIcon /> |
|||
</div> |
|||
</div> |
|||
{#if action === selectedAction} |
|||
<div class="selected-action-container"> |
|||
<svelte:component |
|||
this={selectedActionComponent} |
|||
parameters={selectedAction.parameters} /> |
|||
<div class="delete-action-button"> |
|||
<TextButton text medium on:click={() => deleteAction(index)}> |
|||
Delete |
|||
</TextButton> |
|||
</div> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
{/each} |
|||
{/if} |
|||
</div> |
|||
|
|||
<div slot="footer"> |
|||
<a href="https://docs.budibase.com">Learn more about Actions</a> |
|||
</div> |
|||
</ModalContent> |
|||
|
|||
<style> |
|||
.action-header { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
} |
|||
|
|||
.action-header > p { |
|||
flex: 1; |
|||
} |
|||
|
|||
.row-expander { |
|||
height: 30px; |
|||
width: 30px; |
|||
} |
|||
|
|||
.available-action { |
|||
padding: var(--spacing-m); |
|||
font-size: var(--font-size-m); |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.available-action:hover { |
|||
background: var(--grey-2); |
|||
} |
|||
|
|||
.actions-container { |
|||
flex: 1; |
|||
min-height: 0; |
|||
padding-top: 0; |
|||
border: var(--border-light); |
|||
border-width: 0 0 1px 0; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.action-container { |
|||
border: var(--border-light); |
|||
border-width: 1px 0 0 0; |
|||
} |
|||
|
|||
.selected-action-container { |
|||
padding-bottom: var(--spacing-s); |
|||
padding-top: var(--spacing-s); |
|||
} |
|||
|
|||
.delete-action-button { |
|||
padding-top: var(--spacing-l); |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
flex-direction: row; |
|||
} |
|||
|
|||
a { |
|||
flex: 1; |
|||
color: var(--grey-5); |
|||
font-size: var(--font-size-s); |
|||
text-decoration: none; |
|||
} |
|||
a:hover { |
|||
color: var(--blue); |
|||
} |
|||
|
|||
.rotate :global(svg) { |
|||
transform: rotate(90deg); |
|||
} |
|||
</style> |
|||
@ -1,37 +0,0 @@ |
|||
<script> |
|||
import { Select, Label } from "@budibase/bbui" |
|||
import { currentAsset, store } from "builderStore" |
|||
import { getDataProviderComponents } from "builderStore/dataBinding" |
|||
|
|||
export let parameters |
|||
|
|||
$: dataProviders = getDataProviderComponents( |
|||
$currentAsset, |
|||
$store.selectedComponentId |
|||
) |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<Label size="m" color="dark">Form</Label> |
|||
<Select secondary bind:value={parameters.componentId}> |
|||
<option value="" /> |
|||
{#if dataProviders} |
|||
{#each dataProviders as component} |
|||
<option value={component._id}>{component._instanceName}</option> |
|||
{/each} |
|||
{/if} |
|||
</Select> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: baseline; |
|||
} |
|||
|
|||
.root :global(> div) { |
|||
flex: 1; |
|||
margin-left: var(--spacing-l); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,77 @@ |
|||
<script> |
|||
import { Button, Drawer, Spacer, Body } from "@budibase/bbui" |
|||
import { createEventDispatcher } from "svelte" |
|||
import { notifier } from "builderStore/store/notifications" |
|||
import { |
|||
getDatasourceForProvider, |
|||
getSchemaForDatasource, |
|||
} from "builderStore/dataBinding" |
|||
import SaveFields from "./EventsEditor/actions/SaveFields.svelte" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
|
|||
export let value = {} |
|||
export let componentInstance |
|||
let drawer |
|||
let tempValue = value |
|||
|
|||
$: schemaFields = getSchemaFields(componentInstance) |
|||
|
|||
const getSchemaFields = component => { |
|||
const datasource = getDatasourceForProvider(component) |
|||
const { schema } = getSchemaForDatasource(datasource) |
|||
return Object.values(schema || {}) |
|||
} |
|||
|
|||
const saveFilter = async () => { |
|||
dispatch("change", tempValue) |
|||
notifier.success("Filters saved.") |
|||
drawer.hide() |
|||
} |
|||
|
|||
const onFieldsChanged = event => { |
|||
tempValue = event.detail |
|||
} |
|||
</script> |
|||
|
|||
<Button secondary wide on:click={drawer.show}>Define Filters</Button> |
|||
<Drawer bind:this={drawer} title={'Filtering'}> |
|||
<heading slot="buttons"> |
|||
<Button thin blue on:click={saveFilter}>Save</Button> |
|||
</heading> |
|||
<div slot="body"> |
|||
<div class="root"> |
|||
<Body small grey> |
|||
{#if !Object.keys(tempValue || {}).length} |
|||
Add your first filter column. |
|||
{:else} |
|||
Results are filtered to only those which match all of the following |
|||
constaints. |
|||
{/if} |
|||
</Body> |
|||
<Spacer medium /> |
|||
<div class="fields"> |
|||
<SaveFields |
|||
parameterFields={value} |
|||
{schemaFields} |
|||
valueLabel="Equals" |
|||
on:change={onFieldsChanged} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Drawer> |
|||
|
|||
<style> |
|||
.root { |
|||
padding: var(--spacing-l); |
|||
min-height: calc(40vh - 2 * var(--spacing-l)); |
|||
} |
|||
|
|||
.fields { |
|||
display: grid; |
|||
column-gap: var(--spacing-l); |
|||
row-gap: var(--spacing-s); |
|||
grid-template-columns: auto 1fr auto 1fr auto; |
|||
align-items: baseline; |
|||
} |
|||
</style> |
|||
File diff suppressed because it is too large
@ -1,285 +0,0 @@ |
|||
const CouchDB = require("../../db") |
|||
const linkRows = require("../../db/linkedRows") |
|||
const csvParser = require("../../utilities/csvParser") |
|||
const { |
|||
getRowParams, |
|||
getTableParams, |
|||
generateTableID, |
|||
generateRowID, |
|||
} = require("../../db/utils") |
|||
const { isEqual } = require("lodash/fp") |
|||
const { FieldTypes, AutoFieldSubTypes } = require("../../constants") |
|||
const { inputProcessing } = require("../../utilities/rowProcessor") |
|||
|
|||
async function checkForColumnUpdates(db, oldTable, updatedTable) { |
|||
let updatedRows |
|||
const rename = updatedTable._rename |
|||
let deletedColumns = [] |
|||
if (oldTable && oldTable.schema && updatedTable.schema) { |
|||
deletedColumns = Object.keys(oldTable.schema).filter( |
|||
colName => updatedTable.schema[colName] == null |
|||
) |
|||
} |
|||
// check for renaming of columns or deleted columns
|
|||
if (rename || deletedColumns.length !== 0) { |
|||
const rows = await db.allDocs( |
|||
getRowParams(updatedTable._id, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
updatedRows = rows.rows.map(({ doc }) => { |
|||
if (rename) { |
|||
doc[rename.updated] = doc[rename.old] |
|||
delete doc[rename.old] |
|||
} else if (deletedColumns.length !== 0) { |
|||
deletedColumns.forEach(colName => delete doc[colName]) |
|||
} |
|||
return doc |
|||
}) |
|||
delete updatedTable._rename |
|||
} |
|||
return updatedRows |
|||
} |
|||
|
|||
// makes sure the passed in table isn't going to reset the auto ID
|
|||
function makeSureTableUpToDate(table, tableToSave) { |
|||
if (!table) { |
|||
return tableToSave |
|||
} |
|||
// sure sure rev is up to date
|
|||
tableToSave._rev = table._rev |
|||
// make sure auto IDs are always updated - these are internal
|
|||
// so the client may not know they have changed
|
|||
for (let [field, column] of Object.entries(table.schema)) { |
|||
if ( |
|||
column.autocolumn && |
|||
column.subtype === AutoFieldSubTypes.AUTO_ID && |
|||
tableToSave.schema[field] |
|||
) { |
|||
tableToSave.schema[field].lastID = column.lastID |
|||
} |
|||
} |
|||
return tableToSave |
|||
} |
|||
|
|||
async function handleDataImport(user, table, dataImport) { |
|||
const db = new CouchDB(user.appId) |
|||
if (dataImport && dataImport.csvString) { |
|||
// Populate the table with rows imported from CSV in a bulk update
|
|||
const data = await csvParser.transform(dataImport) |
|||
|
|||
for (let i = 0; i < data.length; i++) { |
|||
let row = data[i] |
|||
row._id = generateRowID(table._id) |
|||
row.tableId = table._id |
|||
const processed = inputProcessing(user, table, row) |
|||
row = processed.row |
|||
// these auto-fields will never actually link anywhere (always builder)
|
|||
for (let [fieldName, schema] of Object.entries(table.schema)) { |
|||
if ( |
|||
schema.autocolumn && |
|||
(schema.subtype === AutoFieldSubTypes.CREATED_BY || |
|||
schema.subtype === AutoFieldSubTypes.UPDATED_BY) |
|||
) { |
|||
delete row[fieldName] |
|||
} |
|||
} |
|||
table = processed.table |
|||
data[i] = row |
|||
} |
|||
|
|||
await db.bulkDocs(data) |
|||
let response = await db.put(table) |
|||
table._rev = response._rev |
|||
} |
|||
return table |
|||
} |
|||
|
|||
async function handleSearchIndexes(db, table) { |
|||
// create relevant search indexes
|
|||
if (table.indexes && table.indexes.length > 0) { |
|||
const currentIndexes = await db.getIndexes() |
|||
const indexName = `search:${table._id}` |
|||
|
|||
const existingIndex = currentIndexes.indexes.find( |
|||
existing => existing.name === indexName |
|||
) |
|||
|
|||
if (existingIndex) { |
|||
const currentFields = existingIndex.def.fields.map( |
|||
field => Object.keys(field)[0] |
|||
) |
|||
|
|||
// if index fields have changed, delete the original index
|
|||
if (!isEqual(currentFields, table.indexes)) { |
|||
await db.deleteIndex(existingIndex) |
|||
// create/recreate the index with fields
|
|||
await db.createIndex({ |
|||
index: { |
|||
fields: table.indexes, |
|||
name: indexName, |
|||
ddoc: "search_ddoc", |
|||
type: "json", |
|||
}, |
|||
}) |
|||
} |
|||
} else { |
|||
// create/recreate the index with fields
|
|||
await db.createIndex({ |
|||
index: { |
|||
fields: table.indexes, |
|||
name: indexName, |
|||
ddoc: "search_ddoc", |
|||
type: "json", |
|||
}, |
|||
}) |
|||
} |
|||
} |
|||
return table |
|||
} |
|||
|
|||
exports.fetch = async function(ctx) { |
|||
const db = new CouchDB(ctx.user.appId) |
|||
const body = await db.allDocs( |
|||
getTableParams(null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
ctx.body = body.rows.map(row => row.doc) |
|||
} |
|||
|
|||
exports.find = async function(ctx) { |
|||
const db = new CouchDB(ctx.user.appId) |
|||
ctx.body = await db.get(ctx.params.id) |
|||
} |
|||
|
|||
exports.save = async function(ctx) { |
|||
const appId = ctx.user.appId |
|||
const db = new CouchDB(appId) |
|||
const { dataImport, ...rest } = ctx.request.body |
|||
let tableToSave = { |
|||
type: "table", |
|||
_id: generateTableID(), |
|||
views: {}, |
|||
...rest, |
|||
} |
|||
|
|||
// if the table obj had an _id then it will have been retrieved
|
|||
let oldTable |
|||
if (ctx.request.body && ctx.request.body._id) { |
|||
oldTable = await db.get(ctx.request.body._id) |
|||
tableToSave = makeSureTableUpToDate(oldTable, tableToSave) |
|||
} |
|||
|
|||
// make sure that types don't change of a column, have to remove
|
|||
// the column if you want to change the type
|
|||
if (oldTable && oldTable.schema) { |
|||
for (let propKey of Object.keys(tableToSave.schema)) { |
|||
let column = tableToSave.schema[propKey] |
|||
let oldColumn = oldTable.schema[propKey] |
|||
if (oldColumn && oldColumn.type !== column.type) { |
|||
ctx.throw(400, "Cannot change the type of a column") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Don't rename if the name is the same
|
|||
let { _rename } = tableToSave |
|||
if (_rename && _rename.old === _rename.updated) { |
|||
_rename = null |
|||
delete tableToSave._rename |
|||
} |
|||
|
|||
// rename row fields when table column is renamed
|
|||
if (_rename && tableToSave.schema[_rename.updated].type === FieldTypes.LINK) { |
|||
ctx.throw(400, "Cannot rename a linked column.") |
|||
} else if (_rename && tableToSave.primaryDisplay === _rename.old) { |
|||
ctx.throw(400, "Cannot rename the display column.") |
|||
} |
|||
|
|||
let updatedRows = await checkForColumnUpdates(db, oldTable, tableToSave) |
|||
|
|||
// update schema of non-statistics views when new columns are added
|
|||
for (let view in tableToSave.views) { |
|||
const tableView = tableToSave.views[view] |
|||
if (!tableView) continue |
|||
|
|||
if (tableView.schema.group || tableView.schema.field) continue |
|||
tableView.schema = tableToSave.schema |
|||
} |
|||
|
|||
// update linked rows
|
|||
const linkResp = await linkRows.updateLinks({ |
|||
appId, |
|||
eventType: oldTable |
|||
? linkRows.EventType.TABLE_UPDATED |
|||
: linkRows.EventType.TABLE_SAVE, |
|||
table: tableToSave, |
|||
oldTable: oldTable, |
|||
}) |
|||
if (linkResp != null && linkResp._rev) { |
|||
tableToSave._rev = linkResp._rev |
|||
} |
|||
|
|||
// don't perform any updates until relationships have been
|
|||
// checked by the updateLinks function
|
|||
if (updatedRows && updatedRows.length !== 0) { |
|||
await db.bulkDocs(updatedRows) |
|||
} |
|||
const result = await db.post(tableToSave) |
|||
tableToSave._rev = result.rev |
|||
|
|||
tableToSave = await handleSearchIndexes(db, tableToSave) |
|||
tableToSave = await handleDataImport(ctx.user, tableToSave, dataImport) |
|||
|
|||
ctx.eventEmitter && |
|||
ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave) |
|||
|
|||
ctx.status = 200 |
|||
ctx.message = `Table ${ctx.request.body.name} saved successfully.` |
|||
ctx.body = tableToSave |
|||
} |
|||
|
|||
exports.destroy = async function(ctx) { |
|||
const appId = ctx.user.appId |
|||
const db = new CouchDB(appId) |
|||
const tableToDelete = await db.get(ctx.params.tableId) |
|||
|
|||
// Delete all rows for that table
|
|||
const rows = await db.allDocs( |
|||
getRowParams(ctx.params.tableId, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
await db.bulkDocs(rows.rows.map(row => ({ ...row.doc, _deleted: true }))) |
|||
|
|||
// update linked rows
|
|||
await linkRows.updateLinks({ |
|||
appId, |
|||
eventType: linkRows.EventType.TABLE_DELETE, |
|||
table: tableToDelete, |
|||
}) |
|||
|
|||
// don't remove the table itself until very end
|
|||
await db.remove(tableToDelete) |
|||
|
|||
// remove table search index
|
|||
const currentIndexes = await db.getIndexes() |
|||
const existingIndex = currentIndexes.indexes.find( |
|||
existing => existing.name === `search:${ctx.params.tableId}` |
|||
) |
|||
if (existingIndex) { |
|||
await db.deleteIndex(existingIndex) |
|||
} |
|||
|
|||
ctx.eventEmitter && |
|||
ctx.eventEmitter.emitTable(`table:delete`, appId, tableToDelete) |
|||
ctx.status = 200 |
|||
ctx.message = `Table ${ctx.params.tableId} deleted.` |
|||
} |
|||
|
|||
exports.validateCSVSchema = async function(ctx) { |
|||
const { csvString, schema = {} } = ctx.request.body |
|||
const result = await csvParser.parse(csvString, schema) |
|||
ctx.body = { schema: result } |
|||
} |
|||
@ -0,0 +1,165 @@ |
|||
const CouchDB = require("../../../db") |
|||
const linkRows = require("../../../db/linkedRows") |
|||
const csvParser = require("../../../utilities/csvParser") |
|||
const { |
|||
getRowParams, |
|||
getTableParams, |
|||
generateTableID, |
|||
} = require("../../../db/utils") |
|||
const { FieldTypes } = require("../../../constants") |
|||
const { TableSaveFunctions } = require("./utils") |
|||
|
|||
exports.fetch = async function(ctx) { |
|||
const db = new CouchDB(ctx.user.appId) |
|||
const body = await db.allDocs( |
|||
getTableParams(null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
ctx.body = body.rows.map(row => row.doc) |
|||
} |
|||
|
|||
exports.find = async function(ctx) { |
|||
const db = new CouchDB(ctx.user.appId) |
|||
ctx.body = await db.get(ctx.params.id) |
|||
} |
|||
|
|||
exports.save = async function(ctx) { |
|||
const appId = ctx.user.appId |
|||
const db = new CouchDB(appId) |
|||
const { dataImport, ...rest } = ctx.request.body |
|||
let tableToSave = { |
|||
type: "table", |
|||
_id: generateTableID(), |
|||
views: {}, |
|||
...rest, |
|||
} |
|||
|
|||
// if the table obj had an _id then it will have been retrieved
|
|||
let oldTable |
|||
if (ctx.request.body && ctx.request.body._id) { |
|||
oldTable = await db.get(ctx.request.body._id) |
|||
} |
|||
|
|||
// saving a table is a complex operation, involving many different steps, this
|
|||
// has been broken out into a utility to make it more obvious/easier to manipulate
|
|||
const tableSaveFunctions = new TableSaveFunctions({ |
|||
db, |
|||
ctx, |
|||
oldTable, |
|||
dataImport, |
|||
}) |
|||
tableToSave = await tableSaveFunctions.before(tableToSave) |
|||
|
|||
// make sure that types don't change of a column, have to remove
|
|||
// the column if you want to change the type
|
|||
if (oldTable && oldTable.schema) { |
|||
for (let propKey of Object.keys(tableToSave.schema)) { |
|||
let column = tableToSave.schema[propKey] |
|||
let oldColumn = oldTable.schema[propKey] |
|||
if (oldColumn && oldColumn.type !== column.type) { |
|||
ctx.throw(400, "Cannot change the type of a column") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Don't rename if the name is the same
|
|||
let { _rename } = tableToSave |
|||
if (_rename && _rename.old === _rename.updated) { |
|||
_rename = null |
|||
delete tableToSave._rename |
|||
} |
|||
|
|||
// rename row fields when table column is renamed
|
|||
if (_rename && tableToSave.schema[_rename.updated].type === FieldTypes.LINK) { |
|||
ctx.throw(400, "Cannot rename a linked column.") |
|||
} else if (_rename && tableToSave.primaryDisplay === _rename.old) { |
|||
ctx.throw(400, "Cannot rename the display column.") |
|||
} |
|||
|
|||
tableToSave = await tableSaveFunctions.mid(tableToSave) |
|||
|
|||
// update schema of non-statistics views when new columns are added
|
|||
for (let view in tableToSave.views) { |
|||
const tableView = tableToSave.views[view] |
|||
if (!tableView) continue |
|||
|
|||
if (tableView.schema.group || tableView.schema.field) continue |
|||
tableView.schema = tableToSave.schema |
|||
} |
|||
|
|||
// update linked rows
|
|||
const linkResp = await linkRows.updateLinks({ |
|||
appId, |
|||
eventType: oldTable |
|||
? linkRows.EventType.TABLE_UPDATED |
|||
: linkRows.EventType.TABLE_SAVE, |
|||
table: tableToSave, |
|||
oldTable: oldTable, |
|||
}) |
|||
if (linkResp != null && linkResp._rev) { |
|||
tableToSave._rev = linkResp._rev |
|||
} |
|||
|
|||
// don't perform any updates until relationships have been
|
|||
// checked by the updateLinks function
|
|||
const updatedRows = tableSaveFunctions.getUpdatedRows() |
|||
if (updatedRows && updatedRows.length !== 0) { |
|||
await db.bulkDocs(updatedRows) |
|||
} |
|||
const result = await db.post(tableToSave) |
|||
tableToSave._rev = result.rev |
|||
|
|||
tableToSave = await tableSaveFunctions.after(tableToSave) |
|||
|
|||
ctx.eventEmitter && |
|||
ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave) |
|||
|
|||
ctx.status = 200 |
|||
ctx.message = `Table ${ctx.request.body.name} saved successfully.` |
|||
ctx.body = tableToSave |
|||
} |
|||
|
|||
exports.destroy = async function(ctx) { |
|||
const appId = ctx.user.appId |
|||
const db = new CouchDB(appId) |
|||
const tableToDelete = await db.get(ctx.params.tableId) |
|||
|
|||
// Delete all rows for that table
|
|||
const rows = await db.allDocs( |
|||
getRowParams(ctx.params.tableId, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
await db.bulkDocs(rows.rows.map(row => ({ ...row.doc, _deleted: true }))) |
|||
|
|||
// update linked rows
|
|||
await linkRows.updateLinks({ |
|||
appId, |
|||
eventType: linkRows.EventType.TABLE_DELETE, |
|||
table: tableToDelete, |
|||
}) |
|||
|
|||
// don't remove the table itself until very end
|
|||
await db.remove(tableToDelete) |
|||
|
|||
// remove table search index
|
|||
const currentIndexes = await db.getIndexes() |
|||
const existingIndex = currentIndexes.indexes.find( |
|||
existing => existing.name === `search:${ctx.params.tableId}` |
|||
) |
|||
if (existingIndex) { |
|||
await db.deleteIndex(existingIndex) |
|||
} |
|||
|
|||
ctx.eventEmitter && |
|||
ctx.eventEmitter.emitTable(`table:delete`, appId, tableToDelete) |
|||
ctx.status = 200 |
|||
ctx.message = `Table ${ctx.params.tableId} deleted.` |
|||
} |
|||
|
|||
exports.validateCSVSchema = async function(ctx) { |
|||
const { csvString, schema = {} } = ctx.request.body |
|||
const result = await csvParser.parse(csvString, schema) |
|||
ctx.body = { schema: result } |
|||
} |
|||
@ -0,0 +1,195 @@ |
|||
const CouchDB = require("../../../db") |
|||
const csvParser = require("../../../utilities/csvParser") |
|||
const { getRowParams, generateRowID, ViewNames } = require("../../../db/utils") |
|||
const { isEqual } = require("lodash/fp") |
|||
const { AutoFieldSubTypes } = require("../../../constants") |
|||
const { inputProcessing } = require("../../../utilities/rowProcessor") |
|||
const { USERS_TABLE_SCHEMA } = require("../../../constants") |
|||
|
|||
exports.checkForColumnUpdates = async (db, oldTable, updatedTable) => { |
|||
let updatedRows = [] |
|||
const rename = updatedTable._rename |
|||
let deletedColumns = [] |
|||
if (oldTable && oldTable.schema && updatedTable.schema) { |
|||
deletedColumns = Object.keys(oldTable.schema).filter( |
|||
colName => updatedTable.schema[colName] == null |
|||
) |
|||
} |
|||
// check for renaming of columns or deleted columns
|
|||
if (rename || deletedColumns.length !== 0) { |
|||
const rows = await db.allDocs( |
|||
getRowParams(updatedTable._id, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
updatedRows = rows.rows.map(({ doc }) => { |
|||
if (rename) { |
|||
doc[rename.updated] = doc[rename.old] |
|||
delete doc[rename.old] |
|||
} else if (deletedColumns.length !== 0) { |
|||
deletedColumns.forEach(colName => delete doc[colName]) |
|||
} |
|||
return doc |
|||
}) |
|||
delete updatedTable._rename |
|||
} |
|||
return { rows: updatedRows, table: updatedTable } |
|||
} |
|||
|
|||
// makes sure the passed in table isn't going to reset the auto ID
|
|||
exports.makeSureTableUpToDate = (table, tableToSave) => { |
|||
if (!table) { |
|||
return tableToSave |
|||
} |
|||
// sure sure rev is up to date
|
|||
tableToSave._rev = table._rev |
|||
// make sure auto IDs are always updated - these are internal
|
|||
// so the client may not know they have changed
|
|||
for (let [field, column] of Object.entries(table.schema)) { |
|||
if ( |
|||
column.autocolumn && |
|||
column.subtype === AutoFieldSubTypes.AUTO_ID && |
|||
tableToSave.schema[field] |
|||
) { |
|||
tableToSave.schema[field].lastID = column.lastID |
|||
} |
|||
} |
|||
return tableToSave |
|||
} |
|||
|
|||
exports.handleDataImport = async (user, table, dataImport) => { |
|||
const db = new CouchDB(user.appId) |
|||
if (dataImport && dataImport.csvString) { |
|||
// Populate the table with rows imported from CSV in a bulk update
|
|||
const data = await csvParser.transform(dataImport) |
|||
|
|||
for (let i = 0; i < data.length; i++) { |
|||
let row = data[i] |
|||
row._id = generateRowID(table._id) |
|||
row.tableId = table._id |
|||
const processed = inputProcessing(user, table, row) |
|||
row = processed.row |
|||
// these auto-fields will never actually link anywhere (always builder)
|
|||
for (let [fieldName, schema] of Object.entries(table.schema)) { |
|||
if ( |
|||
schema.autocolumn && |
|||
(schema.subtype === AutoFieldSubTypes.CREATED_BY || |
|||
schema.subtype === AutoFieldSubTypes.UPDATED_BY) |
|||
) { |
|||
delete row[fieldName] |
|||
} |
|||
} |
|||
table = processed.table |
|||
data[i] = row |
|||
} |
|||
|
|||
await db.bulkDocs(data) |
|||
let response = await db.put(table) |
|||
table._rev = response._rev |
|||
} |
|||
return table |
|||
} |
|||
|
|||
exports.handleSearchIndexes = async (db, table) => { |
|||
// create relevant search indexes
|
|||
if (table.indexes && table.indexes.length > 0) { |
|||
const currentIndexes = await db.getIndexes() |
|||
const indexName = `search:${table._id}` |
|||
|
|||
const existingIndex = currentIndexes.indexes.find( |
|||
existing => existing.name === indexName |
|||
) |
|||
|
|||
if (existingIndex) { |
|||
const currentFields = existingIndex.def.fields.map( |
|||
field => Object.keys(field)[0] |
|||
) |
|||
|
|||
// if index fields have changed, delete the original index
|
|||
if (!isEqual(currentFields, table.indexes)) { |
|||
await db.deleteIndex(existingIndex) |
|||
// create/recreate the index with fields
|
|||
await db.createIndex({ |
|||
index: { |
|||
fields: table.indexes, |
|||
name: indexName, |
|||
ddoc: "search_ddoc", |
|||
type: "json", |
|||
}, |
|||
}) |
|||
} |
|||
} else { |
|||
// create/recreate the index with fields
|
|||
await db.createIndex({ |
|||
index: { |
|||
fields: table.indexes, |
|||
name: indexName, |
|||
ddoc: "search_ddoc", |
|||
type: "json", |
|||
}, |
|||
}) |
|||
} |
|||
} |
|||
return table |
|||
} |
|||
|
|||
exports.checkStaticTables = table => { |
|||
// check user schema has all required elements
|
|||
if (table._id === ViewNames.USERS) { |
|||
for (let [key, schema] of Object.entries(USERS_TABLE_SCHEMA.schema)) { |
|||
// check if the schema exists on the table to be created/updated
|
|||
if (table.schema[key] == null) { |
|||
table.schema[key] = schema |
|||
} |
|||
} |
|||
} |
|||
return table |
|||
} |
|||
|
|||
class TableSaveFunctions { |
|||
constructor({ db, ctx, oldTable, dataImport }) { |
|||
this.db = db |
|||
this.ctx = ctx |
|||
this.oldTable = oldTable |
|||
this.dataImport = dataImport |
|||
// any rows that need updated
|
|||
this.rows = [] |
|||
} |
|||
|
|||
// before anything is done
|
|||
async before(table) { |
|||
if (this.oldTable) { |
|||
table = exports.makeSureTableUpToDate(this.oldTable, table) |
|||
} |
|||
table = exports.checkStaticTables(table) |
|||
return table |
|||
} |
|||
|
|||
// when confirmed valid
|
|||
async mid(table) { |
|||
let response = await exports.checkForColumnUpdates( |
|||
this.db, |
|||
this.oldTable, |
|||
table |
|||
) |
|||
this.rows = this.rows.concat(response.rows) |
|||
return table |
|||
} |
|||
|
|||
// after saving
|
|||
async after(table) { |
|||
table = await exports.handleSearchIndexes(this.db, table) |
|||
table = await exports.handleDataImport( |
|||
this.ctx.user, |
|||
table, |
|||
this.dataImport |
|||
) |
|||
return table |
|||
} |
|||
|
|||
getUpdatedRows() { |
|||
return this.rows |
|||
} |
|||
} |
|||
|
|||
exports.TableSaveFunctions = TableSaveFunctions |
|||
Loading…
Reference in new issue