Merge branch 'develop' of github.com:Budibase/budibase into repeater-filtering

This commit is contained in:
Andrew Kingston 2021-02-18 17:45:10 +00:00
commit 4b81663664
41 changed files with 769 additions and 275 deletions

View File

@ -28,7 +28,7 @@ context("Create a View", () => {
const headers = Array.from($headers).map(header => const headers = Array.from($headers).map(header =>
header.textContent.trim() header.textContent.trim()
) )
expect(headers).to.deep.eq(["group", "age", "rating"]) expect(headers).to.deep.eq([ 'rating', 'age', 'group' ])
}) })
}) })
@ -53,27 +53,20 @@ context("Create a View", () => {
cy.wait(50) cy.wait(50)
cy.get(".menu-container").find("select").eq(1).select("age") cy.get(".menu-container").find("select").eq(1).select("age")
cy.contains("Save").click() cy.contains("Save").click()
cy.wait(100)
cy.get(".ag-center-cols-viewport").scrollTo("100%") cy.get(".ag-center-cols-viewport").scrollTo("100%")
cy.get("[data-cy=table-header]").then($headers => { cy.get("[data-cy=table-header]").then($headers => {
expect($headers).to.have.length(7) expect($headers).to.have.length(7)
const headers = Array.from($headers).map(header => const headers = Array.from($headers).map(header =>
header.textContent.trim() header.textContent.trim()
) )
expect(headers).to.deep.eq([ expect(headers).to.deep.eq([ 'avg', 'sumsqr', 'count', 'max', 'min', 'sum', 'field' ])
"field",
"sum",
"min",
"max",
"count",
"sumsqr",
"avg",
])
}) })
cy.get(".ag-cell").then($values => { cy.get(".ag-cell").then($values => {
const values = Array.from($values).map(header => let values = Array.from($values).map(header =>
header.textContent.trim() header.textContent.trim()
) )
expect(values).to.deep.eq(["age", "155", "20", "49", "5", "5347", "31"]) expect(values).to.deep.eq([ '31', '5347', '5', '49', '20', '155', 'age' ])
}) })
}) })
@ -92,15 +85,7 @@ context("Create a View", () => {
.find(".ag-cell") .find(".ag-cell")
.then($values => { .then($values => {
const values = Array.from($values).map(value => value.textContent) const values = Array.from($values).map(value => value.textContent)
expect(values).to.deep.eq([ expect(values).to.deep.eq([ 'Students', '23.333333333333332', '1650', '3', '25', '20', '70' ])
"Students",
"70",
"20",
"25",
"3",
"1650",
"23.333333333333332",
])
}) })
}) })

View File

@ -63,7 +63,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.58.5", "@budibase/bbui": "^1.58.8",
"@budibase/client": "^0.7.8", "@budibase/client": "^0.7.8",
"@budibase/colorpicker": "1.0.1", "@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.8", "@budibase/string-templates": "^0.7.8",

View File

@ -179,6 +179,10 @@ export function makeDatasourceFormComponents(datasource) {
let fields = Object.keys(schema || {}) let fields = Object.keys(schema || {})
fields.forEach(field => { fields.forEach(field => {
const fieldSchema = schema[field] const fieldSchema = schema[field]
// skip autocolumns
if (fieldSchema.autocolumn) {
return
}
const fieldType = const fieldType =
typeof fieldSchema === "object" ? fieldSchema.type : fieldSchema typeof fieldSchema === "object" ? fieldSchema.type : fieldSchema
const componentType = fieldTypeToComponentMap[fieldType] const componentType = fieldTypeToComponentMap[fieldType]

View File

@ -0,0 +1,55 @@
import { TableNames } from "../constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
isAutoColumnUserRelationship,
} from "../constants/backend"
export function getAutoColumnInformation(enabled = true) {
let info = {}
for (let [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
info[subtype] = { enabled, name: AUTO_COLUMN_DISPLAY_NAMES[key] }
}
return info
}
export function buildAutoColumn(tableName, name, subtype) {
let type, constraints
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
type = FIELDS.LINK.type
constraints = FIELDS.LINK.constraints
break
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
type = FIELDS.NUMBER.type
constraints = FIELDS.NUMBER.constraints
break
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
type = FIELDS.DATETIME.type
constraints = FIELDS.DATETIME.constraints
break
default:
type = FIELDS.STRING.type
constraints = FIELDS.STRING.constraints
break
}
if (Object.values(AUTO_COLUMN_SUB_TYPES).indexOf(subtype) === -1) {
throw "Cannot build auto column with supplied subtype"
}
const base = {
name,
type,
subtype,
icon: "ri-magic-line",
autocolumn: true,
constraints,
}
if (isAutoColumnUserRelationship(subtype)) {
base.tableId = TableNames.USERS
base.fieldName = `${tableName}-${name}`
}
return base
}

View File

@ -30,20 +30,22 @@
{#if schemaFields.length} {#if schemaFields.length}
<div class="schema-fields"> <div class="schema-fields">
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
{#if schemaHasOptions(schema)} {#if !schema.autocolumn}
<Select label={field} extraThin secondary bind:value={value[field]}> {#if schemaHasOptions(schema)}
<option value="">Choose an option</option> <Select label={field} extraThin secondary bind:value={value[field]}>
{#each schema.constraints.inclusion as option} <option value="">Choose an option</option>
<option value={option}>{option}</option> {#each schema.constraints.inclusion as option}
{/each} <option value={option}>{option}</option>
</Select> {/each}
{:else if schema.type === 'string' || schema.type === 'number'} </Select>
<BindableInput {:else if schema.type === 'string' || schema.type === 'number'}
extraThin <BindableInput
bind:value={value[field]} extraThin
label={field} bind:value={value[field]}
type="string" label={field}
{bindings} /> type="string"
{bindings} />
{/if}
{/if} {/if}
{/each} {/each}
</div> </div>

View File

@ -6,15 +6,16 @@
import ExportButton from "./buttons/ExportButton.svelte" import ExportButton from "./buttons/ExportButton.svelte"
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
import * as api from "./api" import * as api from "./api"
import Table from "./Table.svelte" import Table from "./Table.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import CreateEditUser from "./modals/CreateEditUser.svelte" import CreateEditUser from "./modals/CreateEditUser.svelte"
import CreateEditRow from "./modals/CreateEditRow.svelte" import CreateEditRow from "./modals/CreateEditRow.svelte"
let hideAutocolumns = true
let data = [] let data = []
let loading = false let loading = false
$: isUsersTable = $backendUiStore.selectedTable?._id === TableNames.USERS $: isUsersTable = $backendUiStore.selectedTable?._id === TableNames.USERS
$: title = $backendUiStore.selectedTable.name $: title = $backendUiStore.selectedTable.name
$: schema = $backendUiStore.selectedTable.schema $: schema = $backendUiStore.selectedTable.schema
@ -41,6 +42,7 @@
tableId={$backendUiStore.selectedTable?._id} tableId={$backendUiStore.selectedTable?._id}
{data} {data}
allowEditing={true} allowEditing={true}
bind:hideAutocolumns
{loading}> {loading}>
<CreateColumnButton /> <CreateColumnButton />
{#if schema && Object.keys(schema).length > 0} {#if schema && Object.keys(schema).length > 0}
@ -49,9 +51,11 @@
modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow} /> modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow} />
<CreateViewButton /> <CreateViewButton />
<ManageAccessButton resourceId={$backendUiStore.selectedTable?._id} /> <ManageAccessButton resourceId={$backendUiStore.selectedTable?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<!-- always have the export last -->
<ExportButton view={tableView} /> <ExportButton view={tableView} />
{/if} {/if}
{#if isUsersTable}
<EditRolesButton />
{/if}
</Table> </Table>

View File

@ -24,6 +24,7 @@
export let allowEditing = false export let allowEditing = false
export let loading = false export let loading = false
export let theme = "alpine" export let theme = "alpine"
export let hideAutocolumns
let columnDefs = [] let columnDefs = []
let selectedRows = [] let selectedRows = []
@ -85,8 +86,12 @@
return !(isUsersTable && ["email", "roleId"].includes(key)) return !(isUsersTable && ["email", "roleId"].includes(key))
} }
Object.entries(schema || {}).forEach(([key, value]) => { for (let [key, value] of Object.entries(schema || {})) {
result.push({ // skip autocolumns if hiding
if (hideAutocolumns && value.autocolumn) {
continue
}
let config = {
headerCheckboxSelection: false, headerCheckboxSelection: false,
headerComponent: TableHeader, headerComponent: TableHeader,
headerComponentParams: { headerComponentParams: {
@ -107,9 +112,14 @@
autoHeight: true, autoHeight: true,
resizable: true, resizable: true,
minWidth: 200, minWidth: 200,
}) }
}) // sort auto-columns to the end if they are present
if (value.autocolumn) {
result.push(config)
} else {
result.unshift(config)
}
}
columnDefs = result columnDefs = result
} }
@ -150,13 +160,15 @@
</div> </div>
</div> </div>
<div class="grid-wrapper"> <div class="grid-wrapper">
<AgGrid {#key columnDefs.length}
{theme} <AgGrid
{options} {theme}
{data} {options}
{columnDefs} {data}
{loading} {columnDefs}
on:select={({ detail }) => (selectedRows = detail)} /> {loading}
on:select={({ detail }) => (selectedRows = detail)} />
{/key}
</div> </div>
<style> <style>

View File

@ -59,8 +59,11 @@
on:mouseover={() => (hovered = true)} on:mouseover={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}> on:mouseleave={() => (hovered = false)}>
<div> <div>
<span class="column-header-name">{displayName}</span> <div class="col-icon">
<i class={`${SORT_ICON_MAP[sortDirection]} sort-icon`} /> {#if field.autocolumn}<i class="auto ri-magic-fill" />{/if}
<span class="column-header-name">{displayName}</span>
</div>
<i class={`${SORT_ICON_MAP[sortDirection]} sort-icon icon`} />
</div> </div>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent <ModalContent
@ -73,11 +76,11 @@
<section class:show={hovered || filterActive}> <section class:show={hovered || filterActive}>
{#if editable && hovered} {#if editable && hovered}
<span on:click|stopPropagation={showModal}> <span on:click|stopPropagation={showModal}>
<i class="ri-pencil-line" /> <i class="ri-pencil-line icon" />
</span> </span>
{/if} {/if}
<span on:click|stopPropagation={toggleMenu} bind:this={menuButton}> <span on:click|stopPropagation={toggleMenu} bind:this={menuButton}>
<i class="ri-filter-line" class:active={filterActive} /> <i class="ri-filter-line icon" class:active={filterActive} />
</span> </span>
</section> </section>
</header> </header>
@ -117,18 +120,29 @@
top: 2px; top: 2px;
} }
i { .icon {
transition: 0.2s all; transition: 0.2s all;
font-size: var(--font-size-m); font-size: var(--font-size-m);
font-weight: 500; font-weight: 500;
} }
i:hover { .col-icon {
display: flex;
}
.auto {
font-size: var(--font-size-xs);
transition: none;
margin-right: 6px;
margin-top: 2px;
}
.icon:hover {
color: var(--blue); color: var(--blue);
} }
i.active, .icon.active,
i:hover { .icon:hover {
color: var(--blue); color: var(--blue);
} }
</style> </style>

View File

@ -7,8 +7,10 @@
import FilterButton from "./buttons/FilterButton.svelte" import FilterButton from "./buttons/FilterButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte" import ExportButton from "./buttons/ExportButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
export let view = {} export let view = {}
let hideAutocolumns = true
let data = [] let data = []
let loading = false let loading = false
@ -48,12 +50,18 @@
} }
</script> </script>
<Table title={decodeURI(name)} schema={view.schema} {data} {loading}> <Table
title={decodeURI(name)}
schema={view.schema}
{data}
{loading}
bind:hideAutocolumns>
<FilterButton {view} /> <FilterButton {view} />
<CalculateButton {view} /> <CalculateButton {view} />
{#if view.calculation} {#if view.calculation}
<GroupByButton {view} /> <GroupByButton {view} />
{/if} {/if}
<ManageAccessButton resourceId={decodeURI(name)} /> <ManageAccessButton resourceId={decodeURI(name)} />
<HideAutocolumnButton bind:hideAutocolumns />
<ExportButton {view} /> <ExportButton {view} />
</Table> </Table>

View File

@ -0,0 +1,27 @@
<script>
import { TextButton } from "@budibase/bbui"
export let hideAutocolumns
let anchor
let dropdown
function hideOrUnhide() {
hideAutocolumns = !hideAutocolumns
}
</script>
<div bind:this={anchor}>
<TextButton text small on:click={hideOrUnhide}>
{#if hideAutocolumns}
<i class="ri-magic-line" />
Show Auto Columns
{:else}<i class="ri-magic-fill" /> Hide Auto Columns{/if}
</TextButton>
</div>
<style>
i {
margin-right: 4px;
}
</style>

View File

@ -10,12 +10,14 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
import { FIELDS } from "constants/backend" import { FIELDS, AUTO_COLUMN_SUB_TYPES } from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import ValuesList from "components/common/ValuesList.svelte" import ValuesList from "components/common/ValuesList.svelte"
import DatePicker from "components/common/DatePicker.svelte" import DatePicker from "components/common/DatePicker.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
const AUTO_COL = "auto"
let fieldDefinitions = cloneDeep(FIELDS) let fieldDefinitions = cloneDeep(FIELDS)
export let onClosed export let onClosed
@ -43,7 +45,23 @@
$backendUiStore.selectedTable?._id === TableNames.USERS && $backendUiStore.selectedTable?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(field.name) UNEDITABLE_USER_FIELDS.includes(field.name)
// used to select what different options can be displayed for column type
$: canBeSearched =
field.type !== "link" &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY
$: canBeDisplay = field.type !== "link" && field.type !== AUTO_COL
$: canBeRequired =
field.type !== "link" && !uneditable && field.type !== AUTO_COL
async function saveColumn() { async function saveColumn() {
if (field.type === AUTO_COL) {
field = buildAutoColumn(
$backendUiStore.draftTable.name,
field.name,
field.subtype
)
}
backendUiStore.update(state => { backendUiStore.update(state => {
backendUiStore.actions.tables.saveField({ backendUiStore.actions.tables.saveField({
originalName, originalName,
@ -67,11 +85,12 @@
} }
function handleFieldConstraints(event) { function handleFieldConstraints(event) {
const { type, constraints } = fieldDefinitions[ const definition = fieldDefinitions[event.target.value.toUpperCase()]
event.target.value.toUpperCase() if (!definition) {
] return
field.type = type }
field.constraints = constraints field.type = definition.type
field.constraints = definition.constraints
} }
function onChangeRequired(e) { function onChangeRequired(e) {
@ -124,9 +143,10 @@
{#each Object.values(fieldDefinitions) as field} {#each Object.values(fieldDefinitions) as field}
<option value={field.type}>{field.name}</option> <option value={field.type}>{field.name}</option>
{/each} {/each}
<option value={AUTO_COL}>Auto Column</option>
</Select> </Select>
{#if field.type !== 'link' && !uneditable} {#if canBeRequired}
<Toggle <Toggle
checked={required} checked={required}
on:change={onChangeRequired} on:change={onChangeRequired}
@ -135,7 +155,7 @@
text="Required" /> text="Required" />
{/if} {/if}
{#if field.type !== 'link'} {#if canBeDisplay}
<Toggle <Toggle
bind:checked={primaryDisplay} bind:checked={primaryDisplay}
on:change={onChangePrimaryDisplay} on:change={onChangePrimaryDisplay}
@ -143,6 +163,8 @@
text="Use as table display column" /> text="Use as table display column" />
<Label grey small>Search Indexes</Label> <Label grey small>Search Indexes</Label>
{/if}
{#if canBeSearched}
<Toggle <Toggle
checked={indexes[0] === field.name} checked={indexes[0] === field.name}
disabled={indexes[1] === field.name} disabled={indexes[1] === field.name}
@ -194,9 +216,16 @@
label={`Column Name in Other Table`} label={`Column Name in Other Table`}
thin thin
bind:value={field.fieldName} /> bind:value={field.fieldName} />
{:else if field.type === AUTO_COL}
<Select label="Auto Column Type" thin secondary bind:value={field.subtype}>
<option value="">Choose a subtype</option>
{#each Object.entries(getAutoColumnInformation()) as [subtype, info]}
<option value={subtype}>{info.name}</option>
{/each}
</Select>
{/if} {/if}
<footer class="create-column-options"> <footer class="create-column-options">
{#if !uneditable && originalName} {#if !uneditable && originalName != null}
<TextButton text on:click={confirmDelete}>Delete Column</TextButton> <TextButton text on:click={confirmDelete}>Delete Column</TextButton>
{/if} {/if}
<Button secondary on:click={onClosed}>Cancel</Button> <Button secondary on:click={onClosed}>Cancel</Button>

View File

@ -40,9 +40,11 @@
onConfirm={saveRow}> onConfirm={saveRow}>
<ErrorsBox {errors} /> <ErrorsBox {errors} />
{#each tableSchema as [key, meta]} {#each tableSchema as [key, meta]}
<div> {#if !meta.autocolumn}
<RowFieldControl {meta} bind:value={row[key]} /> <div>
</div> <RowFieldControl {meta} bind:value={row[key]} />
</div>
{/if}
{/each} {/each}
</ModalContent> </ModalContent>

View File

@ -30,7 +30,9 @@
Object.keys(viewTable.schema).filter( Object.keys(viewTable.schema).filter(
field => field =>
view.calculation === "count" || view.calculation === "count" ||
viewTable.schema[field].type === "number" // don't want to perform calculations based on auto ID
(viewTable.schema[field].type === "number" &&
!viewTable.schema[field].autocolumn)
) )
function saveView() { function saveView() {

View File

@ -2,17 +2,11 @@
import { goto } from "@sveltech/routify" import { goto } from "@sveltech/routify"
import { backendUiStore, store } from "builderStore" import { backendUiStore, store } from "builderStore"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import { import { Input, Label, ModalContent, Toggle } from "@budibase/bbui"
Input,
Label,
ModalContent,
Button,
Spacer,
Toggle,
} from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte" import TableDataImport from "../TableDataImport.svelte"
import analytics from "analytics" import analytics from "analytics"
import screenTemplates from "builderStore/store/screenTemplates" import screenTemplates from "builderStore/store/screenTemplates"
import { buildAutoColumn, getAutoColumnInformation } from "builderStore/utils"
import { NEW_ROW_TEMPLATE } from "builderStore/store/screenTemplates/newRowScreen" import { NEW_ROW_TEMPLATE } from "builderStore/store/screenTemplates/newRowScreen"
import { ROW_DETAIL_TEMPLATE } from "builderStore/store/screenTemplates/rowDetailScreen" import { ROW_DETAIL_TEMPLATE } from "builderStore/store/screenTemplates/rowDetailScreen"
import { ROW_LIST_TEMPLATE } from "builderStore/store/screenTemplates/rowListScreen" import { ROW_LIST_TEMPLATE } from "builderStore/store/screenTemplates/rowListScreen"
@ -23,15 +17,28 @@
ROW_LIST_TEMPLATE, ROW_LIST_TEMPLATE,
] ]
$: tableNames = $backendUiStore.tables.map(table => table.name)
let modal let modal
let name let name
let dataImport let dataImport
let error = "" let error = ""
let createAutoscreens = true let createAutoscreens = true
let autoColumns = getAutoColumnInformation()
function addAutoColumns(tableName, schema) {
for (let [subtype, col] of Object.entries(autoColumns)) {
if (!col.enabled) {
continue
}
schema[col.name] = buildAutoColumn(tableName, col.name, subtype)
}
return schema
}
function checkValid(evt) { function checkValid(evt) {
const tableName = evt.target.value const tableName = evt.target.value
if ($backendUiStore.models?.some(model => model.name === tableName)) { if (tableNames.includes(tableName)) {
error = `Table with name ${tableName} already exists. Please choose another name.` error = `Table with name ${tableName} already exists. Please choose another name.`
return return
} }
@ -41,7 +48,7 @@
async function saveTable() { async function saveTable() {
let newTable = { let newTable = {
name, name,
schema: dataImport.schema || {}, schema: addAutoColumns(name, dataImport.schema || {}),
dataImport, dataImport,
} }
@ -93,6 +100,28 @@
on:input={checkValid} on:input={checkValid}
bind:value={name} bind:value={name}
{error} /> {error} />
<div class="autocolumns">
<Label extraSmall grey>Auto Columns</Label>
<div class="toggles">
<div class="toggle-1">
<Toggle
text="Created by"
bind:checked={autoColumns.createdBy.enabled} />
<Toggle
text="Created at"
bind:checked={autoColumns.createdAt.enabled} />
<Toggle text="Auto ID" bind:checked={autoColumns.autoID.enabled} />
</div>
<div class="toggle-2">
<Toggle
text="Updated by"
bind:checked={autoColumns.updatedBy.enabled} />
<Toggle
text="Updated at"
bind:checked={autoColumns.updatedAt.enabled} />
</div>
</div>
</div>
<Toggle <Toggle
text="Generate screens in the design section" text="Generate screens in the design section"
bind:checked={createAutoscreens} /> bind:checked={createAutoscreens} />
@ -101,3 +130,25 @@
<TableDataImport bind:dataImport /> <TableDataImport bind:dataImport />
</div> </div>
</ModalContent> </ModalContent>
<style>
.autocolumns {
padding-bottom: 10px;
border-bottom: 3px solid var(--grey-1);
}
.toggles {
display: flex;
width: 100%;
margin-top: 6px;
}
.toggle-1 :global(> *) {
margin-bottom: 10px;
}
.toggle-2 :global(> *) {
margin-bottom: 10px;
margin-left: 20px;
}
</style>

View File

@ -13,7 +13,6 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import api from "builderStore/api" import api from "builderStore/api"
import { FIELDS } from "constants/backend"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte" import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import EditQueryParamsPopover from "components/backend/DatasourceNavigator/popovers/EditQueryParamsPopover.svelte" import EditQueryParamsPopover from "components/backend/DatasourceNavigator/popovers/EditQueryParamsPopover.svelte"

View File

@ -82,6 +82,22 @@ export const FIELDS = {
}, },
} }
export const AUTO_COLUMN_SUB_TYPES = {
AUTO_ID: "autoID",
CREATED_BY: "createdBy",
CREATED_AT: "createdAt",
UPDATED_BY: "updatedBy",
UPDATED_AT: "updatedAt",
}
export const AUTO_COLUMN_DISPLAY_NAMES = {
AUTO_ID: "Auto ID",
CREATED_BY: "Created By",
CREATED_AT: "Created At",
UPDATED_BY: "Updated By",
UPDATED_AT: "Updated At",
}
export const FILE_TYPES = { export const FILE_TYPES = {
IMAGE: ["png", "tiff", "gif", "raw", "jpg", "jpeg"], IMAGE: ["png", "tiff", "gif", "raw", "jpg", "jpeg"],
CODE: ["js", "rs", "py", "java", "rb", "hs", "yml"], CODE: ["js", "rs", "py", "java", "rb", "hs", "yml"],
@ -100,3 +116,10 @@ export const Roles = {
PUBLIC: "PUBLIC", PUBLIC: "PUBLIC",
BUILDER: "BUILDER", BUILDER: "BUILDER",
} }
export function isAutoColumnUserRelationship(subtype) {
return (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY ||
subtype === AUTO_COLUMN_SUB_TYPES.UPDATED_BY
)
}

View File

@ -821,10 +821,10 @@
lodash "^4.17.19" lodash "^4.17.19"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@budibase/bbui@^1.58.5": "@budibase/bbui@^1.58.8":
version "1.58.7" version "1.58.8"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.7.tgz#b9ae18fbd44f830fd8b11fd1dbfaf1212243c88b" resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.8.tgz#c50c54a9baa4fd6d013401586162d4c6354af1a7"
integrity sha512-Kp6iNFXov5DXOTR9860kBJv4zgFEycBw13pdZgcPUERI++OWHtisEo6fuHIEfQGeALif3QEcYwaGr/6pu76C4w== integrity sha512-4qQ6ijMk3XeUOUZwS6dwT51H0T/vfEHhHhNI5JXCQ1TzEX6CUeLNAklY0E/iIMtNteOgdipEkvXGBxh72yX6FQ==
dependencies: dependencies:
markdown-it "^12.0.2" markdown-it "^12.0.2"
quill "^1.3.7" quill "^1.3.7"

View File

@ -9,7 +9,11 @@ const {
ViewNames, ViewNames,
} = require("../../db/utils") } = require("../../db/utils")
const usersController = require("./user") const usersController = require("./user")
const { coerceRowValues, enrichRows } = require("../../utilities") const {
inputProcessing,
outputProcessing,
} = require("../../utilities/rowProcessor")
const { FieldTypes } = require("../../constants")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}` const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}`
@ -54,18 +58,17 @@ async function findRow(db, appId, tableId, rowId) {
exports.patch = async function(ctx) { exports.patch = async function(ctx) {
const appId = ctx.user.appId const appId = ctx.user.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
let row = await db.get(ctx.params.rowId) let dbRow = await db.get(ctx.params.rowId)
const table = await db.get(row.tableId) let dbTable = await db.get(dbRow.tableId)
const patchfields = ctx.request.body const patchfields = ctx.request.body
// need to build up full patch fields before coerce // need to build up full patch fields before coerce
for (let key of Object.keys(patchfields)) { for (let key of Object.keys(patchfields)) {
if (!table.schema[key]) continue if (!dbTable.schema[key]) continue
row[key] = patchfields[key] dbRow[key] = patchfields[key]
} }
row = coerceRowValues(row, table) // this returns the table and row incase they have been updated
let { table, row } = await inputProcessing(ctx.user, dbTable, dbRow)
const validateResult = await validate({ const validateResult = await validate({
row, row,
table, table,
@ -110,32 +113,34 @@ exports.patch = async function(ctx) {
exports.save = async function(ctx) { exports.save = async function(ctx) {
const appId = ctx.user.appId const appId = ctx.user.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
let row = ctx.request.body let inputs = ctx.request.body
row.tableId = ctx.params.tableId inputs.tableId = ctx.params.tableId
// TODO: find usage of this and break out into own endpoint // TODO: find usage of this and break out into own endpoint
if (ctx.request.body.type === "delete") { if (inputs.type === "delete") {
await bulkDelete(ctx) await bulkDelete(ctx)
ctx.body = ctx.request.body.rows ctx.body = inputs.rows
return return
} }
// if the row obj had an _id then it will have been retrieved // if the row obj had an _id then it will have been retrieved
const existingRow = ctx.preExisting const existingRow = ctx.preExisting
if (existingRow) { if (existingRow) {
ctx.params.rowId = row._id ctx.params.rowId = inputs._id
await exports.patch(ctx) await exports.patch(ctx)
return return
} }
if (!row._rev && !row._id) { if (!inputs._rev && !inputs._id) {
row._id = generateRowID(row.tableId) inputs._id = generateRowID(inputs.tableId)
} }
const table = await db.get(row.tableId) // this returns the table and row incase they have been updated
let { table, row } = await inputProcessing(
row = coerceRowValues(row, table) ctx.user,
await db.get(inputs.tableId),
inputs
)
const validateResult = await validate({ const validateResult = await validate({
row, row,
table, table,
@ -204,7 +209,7 @@ exports.fetchView = async function(ctx) {
schema: {}, schema: {},
} }
} }
ctx.body = await enrichRows(appId, table, response.rows) ctx.body = await outputProcessing(appId, table, response.rows)
} }
if (calculation === CALCULATION_TYPES.STATS) { if (calculation === CALCULATION_TYPES.STATS) {
@ -258,7 +263,7 @@ exports.search = async function(ctx) {
const table = await db.get(ctx.params.tableId) const table = await db.get(ctx.params.tableId)
ctx.body = await enrichRows(appId, table, rows) ctx.body = await outputProcessing(appId, table, rows)
} }
exports.fetchTableRows = async function(ctx) { exports.fetchTableRows = async function(ctx) {
@ -279,7 +284,7 @@ exports.fetchTableRows = async function(ctx) {
) )
rows = response.rows.map(row => row.doc) rows = response.rows.map(row => row.doc)
} }
ctx.body = await enrichRows(appId, table, rows) ctx.body = await outputProcessing(appId, table, rows)
} }
exports.find = async function(ctx) { exports.find = async function(ctx) {
@ -288,7 +293,7 @@ exports.find = async function(ctx) {
try { try {
const table = await db.get(ctx.params.tableId) const table = await db.get(ctx.params.tableId)
const row = await findRow(db, appId, ctx.params.tableId, ctx.params.rowId) const row = await findRow(db, appId, ctx.params.tableId, ctx.params.rowId)
ctx.body = await enrichRows(appId, table, row) ctx.body = await outputProcessing(appId, table, row)
} catch (err) { } catch (err) {
ctx.throw(400, err) ctx.throw(400, err)
} }
@ -373,7 +378,7 @@ exports.fetchEnrichedRow = async function(ctx) {
keys: linkVals.map(linkVal => linkVal.id), keys: linkVals.map(linkVal => linkVal.id),
}) })
// need to include the IDs in these rows for any links they may have // need to include the IDs in these rows for any links they may have
let linkedRows = await enrichRows( let linkedRows = await outputProcessing(
appId, appId,
table, table,
response.rows.map(row => row.doc) response.rows.map(row => row.doc)
@ -381,9 +386,14 @@ exports.fetchEnrichedRow = async function(ctx) {
// insert the link rows in the correct place throughout the main row // insert the link rows in the correct place throughout the main row
for (let fieldName of Object.keys(table.schema)) { for (let fieldName of Object.keys(table.schema)) {
let field = table.schema[fieldName] let field = table.schema[fieldName]
if (field.type === "link") { if (field.type === FieldTypes.LINK) {
row[fieldName] = linkedRows.filter( // find the links that pertain to this field, get their indexes
linkRow => linkRow.tableId === field.tableId const linkIndexes = linkVals
.filter(link => link.fieldName === fieldName)
.map(link => linkVals.indexOf(link))
// find the rows that the links state are linked to this field
row[fieldName] = linkedRows.filter((linkRow, index) =>
linkIndexes.includes(index)
) )
} }
} }

View File

@ -8,6 +8,7 @@ const {
generateRowID, generateRowID,
} = require("../../db/utils") } = require("../../db/utils")
const { isEqual } = require("lodash/fp") const { isEqual } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../../constants")
async function checkForColumnUpdates(db, oldTable, updatedTable) { async function checkForColumnUpdates(db, oldTable, updatedTable) {
let updatedRows let updatedRows
@ -39,6 +40,27 @@ async function checkForColumnUpdates(db, oldTable, updatedTable) {
return updatedRows 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
}
exports.fetch = async function(ctx) { exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.appId) const db = new CouchDB(ctx.user.appId)
const body = await db.allDocs( const body = await db.allDocs(
@ -58,7 +80,7 @@ exports.save = async function(ctx) {
const appId = ctx.user.appId const appId = ctx.user.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
const { dataImport, ...rest } = ctx.request.body const { dataImport, ...rest } = ctx.request.body
const tableToSave = { let tableToSave = {
type: "table", type: "table",
_id: generateTableID(), _id: generateTableID(),
views: {}, views: {},
@ -69,6 +91,7 @@ exports.save = async function(ctx) {
let oldTable let oldTable
if (ctx.request.body && ctx.request.body._id) { if (ctx.request.body && ctx.request.body._id) {
oldTable = await db.get(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 // make sure that types don't change of a column, have to remove
@ -91,7 +114,7 @@ exports.save = async function(ctx) {
} }
// rename row fields when table column is renamed // rename row fields when table column is renamed
if (_rename && tableToSave.schema[_rename.updated].type === "link") { if (_rename && tableToSave.schema[_rename.updated].type === FieldTypes.LINK) {
ctx.throw(400, "Cannot rename a linked column.") ctx.throw(400, "Cannot rename a linked column.")
} else if (_rename && tableToSave.primaryDisplay === _rename.old) { } else if (_rename && tableToSave.primaryDisplay === _rename.old) {
ctx.throw(400, "Cannot rename the display column.") ctx.throw(400, "Cannot rename the display column.")

View File

@ -7,9 +7,25 @@ const {
PermissionLevels, PermissionLevels,
PermissionTypes, PermissionTypes,
} = require("../../utilities/security/permissions") } = require("../../utilities/security/permissions")
const joiValidator = require("../../middleware/joi-validator")
const Joi = require("joi")
const router = Router() const router = Router()
function generateSaveValidator() {
// prettier-ignore
return joiValidator.body(Joi.object({
_id: Joi.string(),
_rev: Joi.string(),
type: Joi.string().valid("table"),
primaryDisplay: Joi.string(),
schema: Joi.object().required(),
name: Joi.string().required(),
views: Joi.object(),
dataImport: Joi.object(),
}).unknown(true))
}
router router
.get("/api/tables", authorized(BUILDER), tableController.fetch) .get("/api/tables", authorized(BUILDER), tableController.fetch)
.get( .get(
@ -23,6 +39,7 @@ router
// allows control over updating a table // allows control over updating a table
bodyResource("_id"), bodyResource("_id"),
authorized(BUILDER), authorized(BUILDER),
generateSaveValidator(),
tableController.save tableController.save
) )
.post( .post(

View File

@ -7,7 +7,7 @@ const {
createAttachmentTable, createAttachmentTable,
makeBasicRow, makeBasicRow,
} = require("./couchTestUtils"); } = require("./couchTestUtils");
const { enrichRows } = require("../../../utilities") const { outputProcessing } = require("../../../utilities/rowProcessor")
const env = require("../../../environment") const env = require("../../../environment")
describe("/rows", () => { describe("/rows", () => {
@ -285,7 +285,7 @@ describe("/rows", () => {
link: [firstRow._id], link: [firstRow._id],
tableId: table._id, tableId: table._id,
})).body })).body
const enriched = await enrichRows(appId, table, [secondRow]) const enriched = await outputProcessing(appId, table, [secondRow])
expect(enriched[0].link.length).toBe(1) expect(enriched[0].link.length).toBe(1)
expect(enriched[0].link[0]).toBe(firstRow._id) expect(enriched[0].link[0]).toBe(firstRow._id)
}) })
@ -304,7 +304,7 @@ describe("/rows", () => {
// the environment needs configured for this // the environment needs configured for this
env.CLOUD = 1 env.CLOUD = 1
env.SELF_HOSTED = 1 env.SELF_HOSTED = 1
const enriched = await enrichRows(appId, table, [row]) const enriched = await outputProcessing(appId, table, [row])
expect(enriched[0].attachment[0].url).toBe(`/app-assets/assets/${appId}/test/thing`) expect(enriched[0].attachment[0].url).toBe(`/app-assets/assets/${appId}/test/thing`)
// remove env config // remove env config
env.CLOUD = undefined env.CLOUD = undefined

View File

@ -2,7 +2,7 @@ const CouchDB = require("../db")
const emitter = require("../events/index") const emitter = require("../events/index")
const InMemoryQueue = require("../utilities/queue/inMemoryQueue") const InMemoryQueue = require("../utilities/queue/inMemoryQueue")
const { getAutomationParams } = require("../db/utils") const { getAutomationParams } = require("../db/utils")
const { coerceValue } = require("../utilities") const { coerce } = require("../utilities/rowProcessor")
let automationQueue = new InMemoryQueue("automationQueue") let automationQueue = new InMemoryQueue("automationQueue")
@ -240,8 +240,8 @@ module.exports.externalTrigger = async function(automation, params) {
// values are likely to be submitted as strings, so we shall convert to correct type // values are likely to be submitted as strings, so we shall convert to correct type
const coercedFields = {} const coercedFields = {}
const fields = automation.definition.trigger.inputs.fields const fields = automation.definition.trigger.inputs.fields
for (let key in fields) { for (let key of Object.keys(fields)) {
coercedFields[key] = coerceValue(params.fields[key], fields[key]) coercedFields[key] = coerce(params.fields[key], fields[key])
} }
params.fields = coercedFields params.fields = coercedFields
} }

View File

@ -39,6 +39,26 @@ const USERS_TABLE_SCHEMA = {
primaryDisplay: "email", primaryDisplay: "email",
} }
exports.FieldTypes = {
STRING: "string",
LONGFORM: "longform",
OPTIONS: "options",
NUMBER: "number",
BOOLEAN: "boolean",
DATETIME: "datetime",
ATTACHMENT: "attachment",
LINK: "link",
AUTO: "auto",
}
exports.AutoFieldSubTypes = {
CREATED_BY: "createdBy",
CREATED_AT: "createdAt",
UPDATED_BY: "updatedBy",
UPDATED_AT: "updatedAt",
AUTO_ID: "autoID",
}
exports.AuthTypes = AuthTypes exports.AuthTypes = AuthTypes
exports.USERS_TABLE_SCHEMA = USERS_TABLE_SCHEMA exports.USERS_TABLE_SCHEMA = USERS_TABLE_SCHEMA
exports.BUILDER_CONFIG_DB = "builder-config-db" exports.BUILDER_CONFIG_DB = "builder-config-db"

View File

@ -2,6 +2,7 @@ const CouchDB = require("../index")
const { IncludeDocs, getLinkDocuments } = require("./linkUtils") const { IncludeDocs, getLinkDocuments } = require("./linkUtils")
const { generateLinkID } = require("../utils") const { generateLinkID } = require("../utils")
const Sentry = require("@sentry/node") const Sentry = require("@sentry/node")
const { FieldTypes } = require("../../constants")
/** /**
* Creates a new link document structure which can be put to the database. It is important to * Creates a new link document structure which can be put to the database. It is important to
@ -24,9 +25,16 @@ function LinkDocument(
rowId2 rowId2
) { ) {
// build the ID out of unique references to this link document // build the ID out of unique references to this link document
this._id = generateLinkID(tableId1, tableId2, rowId1, rowId2) this._id = generateLinkID(
tableId1,
tableId2,
rowId1,
rowId2,
fieldName1,
fieldName2
)
// required for referencing in view // required for referencing in view
this.type = "link" this.type = FieldTypes.LINK
this.doc1 = { this.doc1 = {
tableId: tableId1, tableId: tableId1,
fieldName: fieldName1, fieldName: fieldName1,
@ -75,7 +83,7 @@ class LinkController {
} }
for (let fieldName of Object.keys(table.schema)) { for (let fieldName of Object.keys(table.schema)) {
const { type } = table.schema[fieldName] const { type } = table.schema[fieldName]
if (type === "link") { if (type === FieldTypes.LINK) {
return true return true
} }
} }
@ -123,7 +131,7 @@ class LinkController {
// get the links this row wants to make // get the links this row wants to make
const rowField = row[fieldName] const rowField = row[fieldName]
const field = table.schema[fieldName] const field = table.schema[fieldName]
if (field.type === "link" && rowField != null) { if (field.type === FieldTypes.LINK && rowField != null) {
// check which links actual pertain to the update in this row // check which links actual pertain to the update in this row
const thisFieldLinkDocs = linkDocs.filter( const thisFieldLinkDocs = linkDocs.filter(
linkDoc => linkDoc =>
@ -241,7 +249,7 @@ class LinkController {
const schema = table.schema const schema = table.schema
for (let fieldName of Object.keys(schema)) { for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName] const field = schema[fieldName]
if (field.type === "link") { if (field.type === FieldTypes.LINK) {
// handle this in a separate try catch, want // handle this in a separate try catch, want
// the put to bubble up as an error, if can't update // the put to bubble up as an error, if can't update
// table for some reason // table for some reason
@ -251,14 +259,18 @@ class LinkController {
} catch (err) { } catch (err) {
continue continue
} }
// create the link field in the other table const linkConfig = {
linkedTable.schema[field.fieldName] = {
name: field.fieldName, name: field.fieldName,
type: "link", type: FieldTypes.LINK,
// these are the props of the table that initiated the link // these are the props of the table that initiated the link
tableId: table._id, tableId: table._id,
fieldName: fieldName, fieldName: fieldName,
} }
if (field.autocolumn) {
linkConfig.autocolumn = field.autocolumn
}
// create the link field in the other table
linkedTable.schema[field.fieldName] = linkConfig
const response = await this._db.put(linkedTable) const response = await this._db.put(linkedTable)
// special case for when linking back to self, make sure rev updated // special case for when linking back to self, make sure rev updated
if (linkedTable._id === table._id) { if (linkedTable._id === table._id) {
@ -281,7 +293,10 @@ class LinkController {
for (let fieldName of Object.keys(oldTable.schema)) { for (let fieldName of Object.keys(oldTable.schema)) {
const field = oldTable.schema[fieldName] const field = oldTable.schema[fieldName]
// this field has been removed from the table schema // this field has been removed from the table schema
if (field.type === "link" && newTable.schema[fieldName] == null) { if (
field.type === FieldTypes.LINK &&
newTable.schema[fieldName] == null
) {
await this.removeFieldFromTable(fieldName) await this.removeFieldFromTable(fieldName)
} }
} }
@ -302,7 +317,7 @@ class LinkController {
for (let fieldName of Object.keys(schema)) { for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName] const field = schema[fieldName]
try { try {
if (field.type === "link") { if (field.type === FieldTypes.LINK) {
const linkedTable = await this._db.get(field.tableId) const linkedTable = await this._db.get(field.tableId)
delete linkedTable.schema[field.fieldName] delete linkedTable.schema[field.fieldName]
await this._db.put(linkedTable) await this._db.put(linkedTable)

View File

@ -5,7 +5,7 @@ const {
createLinkView, createLinkView,
getUniqueByProp, getUniqueByProp,
} = require("./linkUtils") } = require("./linkUtils")
const _ = require("lodash") const { flatten } = require("lodash")
/** /**
* This functionality makes sure that when rows with links are created, updated or deleted they are processed * This functionality makes sure that when rows with links are created, updated or deleted they are processed
@ -101,7 +101,7 @@ exports.attachLinkInfo = async (appId, rows) => {
} }
let tableIds = [...new Set(rows.map(el => el.tableId))] let tableIds = [...new Set(rows.map(el => el.tableId))]
// start by getting all the link values for performance reasons // start by getting all the link values for performance reasons
let responses = _.flatten( let responses = flatten(
await Promise.all( await Promise.all(
tableIds.map(tableId => tableIds.map(tableId =>
getLinkDocuments({ getLinkDocuments({
@ -118,8 +118,12 @@ exports.attachLinkInfo = async (appId, rows) => {
// have to get unique as the previous table query can // have to get unique as the previous table query can
// return duplicates, could be querying for both tables in a relation // return duplicates, could be querying for both tables in a relation
const linkVals = getUniqueByProp( const linkVals = getUniqueByProp(
responses.filter(el => el.thisId === row._id), responses
"id" // find anything that matches the row's ID we are searching for
.filter(el => el.thisId === row._id)
// create a unique ID which we can use for getting only unique ones
.map(el => ({ ...el, unique: el.id + el.fieldName })),
"unique"
) )
for (let linkVal of linkVals) { for (let linkVal of linkVals) {
// work out which link pertains to this row // work out which link pertains to this row

View File

@ -23,6 +23,7 @@ exports.createLinkView = async appId => {
const designDoc = await db.get("_design/database") const designDoc = await db.get("_design/database")
const view = { const view = {
map: function(doc) { map: function(doc) {
// everything in this must remain constant as its going to Pouch, no external variables
if (doc.type === "link") { if (doc.type === "link") {
let doc1 = doc.doc1 let doc1 = doc.doc1
let doc2 = doc.doc2 let doc2 = doc.doc2

View File

@ -138,10 +138,22 @@ exports.generateAutomationID = () => {
* @param {string} tableId2 The ID of the linked table. * @param {string} tableId2 The ID of the linked table.
* @param {string} rowId1 The ID of the linker row. * @param {string} rowId1 The ID of the linker row.
* @param {string} rowId2 The ID of the linked row. * @param {string} rowId2 The ID of the linked row.
* @param {string} fieldName1 The name of the field in the linker row.
* @param {string} fieldName2 the name of the field in the linked row.
* @returns {string} The new link doc ID which the automation doc can be stored under. * @returns {string} The new link doc ID which the automation doc can be stored under.
*/ */
exports.generateLinkID = (tableId1, tableId2, rowId1, rowId2) => { exports.generateLinkID = (
return `${DocumentTypes.LINK}${SEPARATOR}${tableId1}${SEPARATOR}${tableId2}${SEPARATOR}${rowId1}${SEPARATOR}${rowId2}` tableId1,
tableId2,
rowId1,
rowId2,
fieldName1,
fieldName2
) => {
const tables = `${SEPARATOR}${tableId1}${SEPARATOR}${tableId2}`
const rows = `${SEPARATOR}${rowId1}${SEPARATOR}${rowId2}`
const fields = `${SEPARATOR}${fieldName1}${SEPARATOR}${fieldName2}`
return `${DocumentTypes.LINK}${tables}${rows}${fields}`
} }
/** /**

View File

@ -1,68 +1,10 @@
const env = require("../environment") const env = require("../environment")
const { DocumentTypes, SEPARATOR } = require("../db/utils") const { DocumentTypes, SEPARATOR } = require("../db/utils")
const fs = require("fs") const fs = require("fs")
const { cloneDeep } = require("lodash/fp")
const CouchDB = require("../db") const CouchDB = require("../db")
const { OBJ_STORE_DIRECTORY } = require("../constants")
const linkRows = require("../db/linkedRows")
const APP_PREFIX = DocumentTypes.APP + SEPARATOR const APP_PREFIX = DocumentTypes.APP + SEPARATOR
/**
* A map of how we convert various properties in rows to each other based on the row type.
*/
const TYPE_TRANSFORM_MAP = {
link: {
"": [],
[null]: [],
[undefined]: undefined,
parse: link => {
if (typeof link === "string") {
return [link]
}
return link
},
},
options: {
"": "",
[null]: "",
[undefined]: undefined,
},
string: {
"": "",
[null]: "",
[undefined]: undefined,
},
longform: {
"": "",
[null]: "",
[undefined]: undefined,
},
number: {
"": null,
[null]: null,
[undefined]: undefined,
parse: n => parseFloat(n),
},
datetime: {
"": null,
[undefined]: undefined,
[null]: null,
},
attachment: {
"": [],
[null]: [],
[undefined]: undefined,
},
boolean: {
"": null,
[null]: null,
[undefined]: undefined,
true: true,
false: false,
},
}
function confirmAppId(possibleAppId) { function confirmAppId(possibleAppId) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX) return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
? possibleAppId ? possibleAppId
@ -165,43 +107,6 @@ exports.walkDir = (dirPath, callback) => {
} }
} }
/**
* This will coerce a value to the correct types based on the type transform map
* @param {object} row The value to coerce
* @param {object} type The type fo coerce to
* @returns {object} The coerced value
*/
exports.coerceValue = (row, type) => {
// eslint-disable-next-line no-prototype-builtins
if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(row)) {
return TYPE_TRANSFORM_MAP[type][row]
} else if (TYPE_TRANSFORM_MAP[type].parse) {
return TYPE_TRANSFORM_MAP[type].parse(row)
}
return row
}
/**
* This will coerce the values in a row to the correct types based on the type transform map and the
* table schema.
* @param {object} row The row which is to be coerced to correct values based on schema, this input
* row will not be updated.
* @param {object} table The table that has been retrieved from DB, this must contain the expected
* schema for the rows.
* @returns {object} The updated row will be returned with all values coerced.
*/
exports.coerceRowValues = (row, table) => {
const clonedRow = cloneDeep(row)
for (let [key, value] of Object.entries(clonedRow)) {
const field = table.schema[key]
if (!field) continue
clonedRow[key] = exports.coerceValue(value, field.type)
}
return clonedRow
}
exports.getLogoUrl = () => { exports.getLogoUrl = () => {
return "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" return "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
} }
@ -219,34 +124,3 @@ exports.getAllApps = async () => {
.map(({ value }) => value) .map(({ value }) => value)
} }
} }
/**
* This function "enriches" the input rows with anything they are supposed to contain, for example
* link records or attachment links.
* @param {string} appId the ID of the application for which rows are being enriched.
* @param {object} table the table from which these rows came from originally, this is used to determine
* the schema of the rows and then enrich.
* @param {object[]} rows the rows which are to be enriched.
* @returns {object[]} the enriched rows will be returned.
*/
exports.enrichRows = async (appId, table, rows) => {
// attach any linked row information
const enriched = await linkRows.attachLinkInfo(appId, rows)
// update the attachments URL depending on hosting
if (env.CLOUD && env.SELF_HOSTED) {
for (let [property, column] of Object.entries(table.schema)) {
if (column.type === "attachment") {
for (let row of enriched) {
if (row[property] == null || row[property].length === 0) {
continue
}
row[property].forEach(attachment => {
attachment.url = `${OBJ_STORE_DIRECTORY}/${appId}/${attachment.url}`
attachment.url = attachment.url.replace("//", "/")
})
}
}
}
}
return enriched
}

View File

@ -0,0 +1,188 @@
const env = require("../environment")
const { OBJ_STORE_DIRECTORY } = require("../constants")
const linkRows = require("../db/linkedRows")
const { cloneDeep } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../constants")
const CouchDB = require("../db")
const BASE_AUTO_ID = 1
/**
* A map of how we convert various properties in rows to each other based on the row type.
*/
const TYPE_TRANSFORM_MAP = {
[FieldTypes.LINK]: {
"": [],
[null]: [],
[undefined]: undefined,
parse: link => {
if (typeof link === "string") {
return [link]
}
return link
},
},
[FieldTypes.OPTIONS]: {
"": "",
[null]: "",
[undefined]: undefined,
},
[FieldTypes.STRING]: {
"": "",
[null]: "",
[undefined]: undefined,
},
[FieldTypes.LONGFORM]: {
"": "",
[null]: "",
[undefined]: undefined,
},
[FieldTypes.NUMBER]: {
"": null,
[null]: null,
[undefined]: undefined,
parse: n => parseFloat(n),
},
[FieldTypes.DATETIME]: {
"": null,
[undefined]: undefined,
[null]: null,
},
[FieldTypes.ATTACHMENT]: {
"": [],
[null]: [],
[undefined]: undefined,
},
[FieldTypes.BOOLEAN]: {
"": null,
[null]: null,
[undefined]: undefined,
true: true,
false: false,
},
[FieldTypes.AUTO]: {
parse: () => undefined,
},
}
/**
* This will update any auto columns that are found on the row/table with the correct information based on
* time now and the current logged in user making the request.
* @param {Object} user The user to be used for an appId as well as the createdBy and createdAt fields.
* @param {Object} table The table which is to be used for the schema, as well as handling auto IDs incrementing.
* @param {Object} row The row which is to be updated with information for the auto columns.
* @returns {Promise<{row: Object, table: Object}>} The updated row and table, the table may need to be updated
* for automatic ID purposes.
*/
async function processAutoColumn(user, table, row) {
let now = new Date().toISOString()
// if a row doesn't have a revision then it doesn't exist yet
const creating = !row._rev
let tableUpdated = false
for (let [key, schema] of Object.entries(table.schema)) {
if (!schema.autocolumn) {
continue
}
switch (schema.subtype) {
case AutoFieldSubTypes.CREATED_BY:
if (creating) {
row[key] = [user.userId]
}
break
case AutoFieldSubTypes.CREATED_AT:
if (creating) {
row[key] = now
}
break
case AutoFieldSubTypes.UPDATED_BY:
row[key] = [user.userId]
break
case AutoFieldSubTypes.UPDATED_AT:
row[key] = now
break
case AutoFieldSubTypes.AUTO_ID:
if (creating) {
schema.lastID = !schema.lastID ? BASE_AUTO_ID : schema.lastID + 1
row[key] = schema.lastID
tableUpdated = true
}
break
}
}
if (tableUpdated) {
const db = new CouchDB(user.appId)
const response = await db.put(table)
// update the revision
table._rev = response._rev
}
return { table, row }
}
/**
* This will coerce a value to the correct types based on the type transform map
* @param {object} row The value to coerce
* @param {object} type The type fo coerce to
* @returns {object} The coerced value
*/
exports.coerce = (row, type) => {
// eslint-disable-next-line no-prototype-builtins
if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(row)) {
return TYPE_TRANSFORM_MAP[type][row]
} else if (TYPE_TRANSFORM_MAP[type].parse) {
return TYPE_TRANSFORM_MAP[type].parse(row)
}
return row
}
/**
* Given an input route this function will apply all the necessary pre-processing to it, such as coercion
* of column values or adding auto-column values.
* @param {object} user the user which is performing the input.
* @param {object} row the row which is being created/updated.
* @param {object} table the table which the row is being saved to.
* @returns {object} the row which has been prepared to be written to the DB.
*/
exports.inputProcessing = async (user, table, row) => {
let clonedRow = cloneDeep(row)
for (let [key, value] of Object.entries(clonedRow)) {
const field = table.schema[key]
if (!field) {
continue
}
clonedRow[key] = exports.coerce(value, field.type)
}
// handle auto columns - this returns an object like {table, row}
return processAutoColumn(user, table, clonedRow)
}
/**
* This function enriches the input rows with anything they are supposed to contain, for example
* link records or attachment links.
* @param {string} appId the ID of the application for which rows are being enriched.
* @param {object} table the table from which these rows came from originally, this is used to determine
* the schema of the rows and then enrich.
* @param {object[]} rows the rows which are to be enriched.
* @returns {object[]} the enriched rows will be returned.
*/
exports.outputProcessing = async (appId, table, rows) => {
// attach any linked row information
const outputRows = await linkRows.attachLinkInfo(appId, rows)
// update the attachments URL depending on hosting
if (env.CLOUD && env.SELF_HOSTED) {
for (let [property, column] of Object.entries(table.schema)) {
if (column.type === FieldTypes.ATTACHMENT) {
for (let row of outputRows) {
if (row[property] == null || row[property].length === 0) {
continue
}
row[property].forEach(attachment => {
attachment.url = `${OBJ_STORE_DIRECTORY}/${appId}/${attachment.url}`
attachment.url = attachment.url.replace("//", "/")
})
}
}
}
}
return outputRows
}

View File

@ -1140,6 +1140,12 @@
"value": "spectrum--large" "value": "spectrum--large"
} }
] ]
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1190,6 +1196,12 @@
"type": "text", "type": "text",
"label": "Placeholder", "label": "Placeholder",
"key": "placeholder" "key": "placeholder"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1212,6 +1224,12 @@
"type": "text", "type": "text",
"label": "Placeholder", "label": "Placeholder",
"key": "placeholder" "key": "placeholder"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1235,6 +1253,12 @@
"label": "Placeholder", "label": "Placeholder",
"key": "placeholder", "key": "placeholder",
"placeholder": "Choose an option" "placeholder": "Choose an option"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1257,6 +1281,12 @@
"type": "text", "type": "text",
"label": "Text", "label": "Text",
"key": "text" "key": "text"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1280,6 +1310,12 @@
"label": "Placeholder", "label": "Placeholder",
"key": "placeholder", "key": "placeholder",
"placeholder": "Type something..." "placeholder": "Type something..."
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1308,6 +1344,12 @@
"label": "Show Time", "label": "Show Time",
"key": "enableTime", "key": "enableTime",
"defaultValue": true "defaultValue": true
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1325,6 +1367,12 @@
"type": "text", "type": "text",
"label": "Label", "label": "Label",
"key": "label" "key": "label"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
}, },
@ -1342,6 +1390,12 @@
"type": "text", "type": "text",
"label": "Label", "label": "Label",
"key": "label" "key": "label"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
} }
] ]
} }

View File

@ -5,6 +5,7 @@
export let field export let field
export let label export let label
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -24,11 +25,21 @@
<Field <Field
{label} {label}
{field} {field}
{disabled}
type="attachment" type="attachment"
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi
defaultValue={[]}> defaultValue={[]}>
{#if mounted} {#if mounted}
<Dropzone bind:files={value} /> <div class:disabled={$fieldState.disabled}>
<Dropzone bind:files={value} />
</div>
{/if} {/if}
</Field> </Field>
<style>
div.disabled :global(> *) {
background-color: var(--spectrum-global-color-gray-200) !important;
pointer-events: none !important;
}
</style>

View File

@ -5,6 +5,7 @@
export let field export let field
export let label export let label
export let text export let text
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -17,6 +18,7 @@
<Field <Field
{label} {label}
{field} {field}
{disabled}
type="boolean" type="boolean"
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi
@ -26,6 +28,7 @@
<label class="spectrum-Checkbox" class:is-invalid={!$fieldState.valid}> <label class="spectrum-Checkbox" class:is-invalid={!$fieldState.valid}>
<input <input
checked={$fieldState.value} checked={$fieldState.value}
disabled={$fieldState.disabled}
on:change={onChange} on:change={onChange}
type="checkbox" type="checkbox"
class="spectrum-Checkbox-input" class="spectrum-Checkbox-input"

View File

@ -9,6 +9,7 @@
export let label export let label
export let placeholder export let placeholder
export let enableTime export let enableTime
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -53,7 +54,7 @@
} }
</script> </script>
<Field {label} {field} type="datetime" bind:fieldState bind:fieldApi> <Field {label} {field} {disabled} type="datetime" bind:fieldState bind:fieldApi>
{#if fieldState} {#if fieldState}
<Flatpickr <Flatpickr
bind:flatpickr bind:flatpickr
@ -67,6 +68,7 @@
id={flatpickrId} id={flatpickrId}
aria-disabled="false" aria-disabled="false"
aria-invalid={!$fieldState.valid} aria-invalid={!$fieldState.valid}
class:is-disabled={$fieldState.disabled}
class:is-invalid={!$fieldState.valid} class:is-invalid={!$fieldState.valid}
class="flatpickr spectrum-InputGroup spectrum-Datepicker" class="flatpickr spectrum-InputGroup spectrum-Datepicker"
class:is-focused={open} class:is-focused={open}
@ -76,6 +78,7 @@
<div <div
on:click={flatpickr?.open} on:click={flatpickr?.open}
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={$fieldState.disabled}
class:is-invalid={!$fieldState.valid}> class:is-invalid={!$fieldState.valid}>
{#if !$fieldState.valid} {#if !$fieldState.valid}
<svg <svg
@ -88,6 +91,7 @@
<input <input
data-input data-input
type="text" type="text"
disabled={$fieldState.disabled}
class="spectrum-Textfield-input spectrum-InputGroup-input" class="spectrum-Textfield-input spectrum-InputGroup-input"
aria-invalid={!$fieldState.valid} aria-invalid={!$fieldState.valid}
{placeholder} {placeholder}
@ -98,6 +102,7 @@
type="button" type="button"
class="spectrum-Picker spectrum-InputGroup-button" class="spectrum-Picker spectrum-InputGroup-button"
tabindex="-1" tabindex="-1"
disabled={$fieldState.disabled}
class:is-invalid={!$fieldState.valid} class:is-invalid={!$fieldState.valid}
on:click={flatpickr?.open}> on:click={flatpickr?.open}>
<svg <svg
@ -120,7 +125,7 @@
.spectrum-Textfield-input { .spectrum-Textfield-input {
pointer-events: none; pointer-events: none;
} }
.spectrum-Textfield:hover { .spectrum-Textfield:not(.is-disabled):hover {
cursor: pointer; cursor: pointer;
} }
.flatpickr { .flatpickr {

View File

@ -10,6 +10,7 @@
export let fieldSchema export let fieldSchema
export let defaultValue export let defaultValue
export let type export let type
export let disabled = false
// Get contexts // Get contexts
const formContext = getContext("form") const formContext = getContext("form")
@ -20,7 +21,7 @@
// Register field with form // Register field with form
const formApi = formContext?.formApi const formApi = formContext?.formApi
const labelPosition = fieldGroupContext?.labelPosition || "above" const labelPosition = fieldGroupContext?.labelPosition || "above"
const formField = formApi?.registerField(field, defaultValue) const formField = formApi?.registerField(field, defaultValue, disabled)
// Expose field properties to parent component // Expose field properties to parent component
fieldState = formField?.fieldState fieldState = formField?.fieldState

View File

@ -8,6 +8,7 @@
export let datasource export let datasource
export let theme export let theme
export let size export let size
export let disabled = false
const component = getContext("component") const component = getContext("component")
const context = getContext("context") const context = getContext("context")
@ -34,11 +35,23 @@
// Form API contains functions to control the form // Form API contains functions to control the form
const formApi = { const formApi = {
registerField: (field, defaultValue = null) => { registerField: (field, defaultValue = null, fieldDisabled = false) => {
if (!field) { if (!field) {
return return
} }
// Auto columns are always disabled
const isAutoColumn = !!schema?.[field]?.autocolumn
if (fieldMap[field] != null) { if (fieldMap[field] != null) {
// Update disabled property just so that toggling the disabled field
// state in the builder makes updates in real time.
// We only need this because of optimisations which prevent fully
// remounting when settings change.
fieldMap[field].fieldState.update(state => {
state.disabled = disabled || fieldDisabled || isAutoColumn
return state
})
return fieldMap[field] return fieldMap[field]
} }
@ -47,7 +60,11 @@
const validate = createValidatorFromConstraints(constraints, field, table) const validate = createValidatorFromConstraints(constraints, field, table)
fieldMap[field] = { fieldMap[field] = {
fieldState: makeFieldState(field, defaultValue), fieldState: makeFieldState(
field,
defaultValue,
disabled || fieldDisabled || isAutoColumn
),
fieldApi: makeFieldApi(field, defaultValue, validate), fieldApi: makeFieldApi(field, defaultValue, validate),
fieldSchema: schema?.[field] ?? {}, fieldSchema: schema?.[field] ?? {},
} }
@ -115,13 +132,14 @@
} }
// Creates observable state data about a specific field // Creates observable state data about a specific field
const makeFieldState = (field, defaultValue) => { const makeFieldState = (field, defaultValue, fieldDisabled) => {
return writable({ return writable({
field, field,
fieldId: `id-${generateID()}`, fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue, value: initialValues[field] ?? defaultValue,
error: null, error: null,
valid: true, valid: true,
disabled: fieldDisabled,
}) })
} }

View File

@ -6,6 +6,7 @@
export let field export let field
export let label export let label
export let placeholder export let placeholder
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -41,12 +42,13 @@
<Field <Field
{label} {label}
{field} {field}
{disabled}
type="longform" type="longform"
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi
defaultValue=""> defaultValue="">
{#if mounted} {#if mounted}
<div> <div class:disabled={$fieldState.disabled}>
<RichText bind:value {options} /> <RichText bind:value {options} />
</div> </div>
{/if} {/if}
@ -68,4 +70,12 @@
div :global(.ql-editor p) { div :global(.ql-editor p) {
word-break: break-all; word-break: break-all;
} }
div.disabled {
pointer-events: none !important;
background-color: rgb(244, 244, 244);
}
div.disabled :global(.ql-container *) {
color: var(--spectrum-alias-text-color-disabled) !important;
}
</style> </style>

View File

@ -5,6 +5,7 @@
export let field export let field
export let label export let label
export let placeholder export let placeholder
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -26,6 +27,7 @@
<Field <Field
{field} {field}
{label} {label}
{disabled}
type="options" type="options"
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi

View File

@ -20,6 +20,7 @@
<button <button
id={$fieldState.fieldId} id={$fieldState.fieldId}
class="spectrum-Picker" class="spectrum-Picker"
disabled={$fieldState.disabled}
class:is-invalid={!$fieldState.valid} class:is-invalid={!$fieldState.valid}
class:is-open={open} class:is-open={open}
aria-haspopup="listbox" aria-haspopup="listbox"

View File

@ -7,6 +7,7 @@
export let field export let field
export let label export let label
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -65,6 +66,7 @@
<Field <Field
{label} {label}
{field} {field}
{disabled}
type="link" type="link"
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi

View File

@ -6,6 +6,7 @@
export let label export let label
export let placeholder export let placeholder
export let type = "text" export let type = "text"
export let disabled = false
let fieldState let fieldState
let fieldApi let fieldApi
@ -33,11 +34,15 @@
<Field <Field
{label} {label}
{field} {field}
{disabled}
type={type === 'number' ? 'number' : 'string'} type={type === 'number' ? 'number' : 'string'}
bind:fieldState bind:fieldState
bind:fieldApi> bind:fieldApi>
{#if fieldState} {#if fieldState}
<div class="spectrum-Textfield" class:is-invalid={!$fieldState.valid}> <div
class="spectrum-Textfield"
class:is-invalid={!$fieldState.valid}
class:is-disabled={$fieldState.disabled}>
{#if !$fieldState.valid} {#if !$fieldState.valid}
<svg <svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon" class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
@ -49,6 +54,7 @@
<input <input
on:keyup={updateValueOnEnter} on:keyup={updateValueOnEnter}
bind:this={input} bind:this={input}
disabled={$fieldState.disabled}
id={$fieldState.fieldId} id={$fieldState.fieldId}
value={$fieldState.value || ''} value={$fieldState.value || ''}
placeholder={placeholder || ''} placeholder={placeholder || ''}