Merge pull request #1125 from Budibase/feature/autocolumns

Feature/autocolumns
This commit is contained in:
Michael Drury 2021-02-17 17:18:12 +00:00 committed by GitHub
commit 7647e346d4
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 =>
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.get(".menu-container").find("select").eq(1).select("age")
cy.contains("Save").click()
cy.wait(100)
cy.get(".ag-center-cols-viewport").scrollTo("100%")
cy.get("[data-cy=table-header]").then($headers => {
expect($headers).to.have.length(7)
const headers = Array.from($headers).map(header =>
header.textContent.trim()
)
expect(headers).to.deep.eq([
"field",
"sum",
"min",
"max",
"count",
"sumsqr",
"avg",
])
expect(headers).to.deep.eq([ 'avg', 'sumsqr', 'count', 'max', 'min', 'sum', 'field' ])
})
cy.get(".ag-cell").then($values => {
const values = Array.from($values).map(header =>
let values = Array.from($values).map(header =>
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")
.then($values => {
const values = Array.from($values).map(value => value.textContent)
expect(values).to.deep.eq([
"Students",
"70",
"20",
"25",
"3",
"1650",
"23.333333333333332",
])
expect(values).to.deep.eq([ 'Students', '23.333333333333332', '1650', '3', '25', '20', '70' ])
})
})

View File

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

View File

@ -179,6 +179,10 @@ export function makeDatasourceFormComponents(datasource) {
let fields = Object.keys(schema || {})
fields.forEach(field => {
const fieldSchema = schema[field]
// skip autocolumns
if (fieldSchema.autocolumn) {
return
}
const fieldType =
typeof fieldSchema === "object" ? fieldSchema.type : fieldSchema
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,6 +30,7 @@
{#if schemaFields.length}
<div class="schema-fields">
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn}
{#if schemaHasOptions(schema)}
<Select label={field} extraThin secondary bind:value={value[field]}>
<option value="">Choose an option</option>
@ -45,6 +46,7 @@
type="string"
{bindings} />
{/if}
{/if}
{/each}
</div>
{/if}

View File

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

View File

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

View File

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

View File

@ -7,8 +7,10 @@
import FilterButton from "./buttons/FilterButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
export let view = {}
let hideAutocolumns = true
let data = []
let loading = false
@ -48,12 +50,18 @@
}
</script>
<Table title={decodeURI(name)} schema={view.schema} {data} {loading}>
<Table
title={decodeURI(name)}
schema={view.schema}
{data}
{loading}
bind:hideAutocolumns>
<FilterButton {view} />
<CalculateButton {view} />
{#if view.calculation}
<GroupByButton {view} />
{/if}
<ManageAccessButton resourceId={decodeURI(name)} />
<HideAutocolumnButton bind:hideAutocolumns />
<ExportButton {view} />
</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 { backendUiStore } from "builderStore"
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 ValuesList from "components/common/ValuesList.svelte"
import DatePicker from "components/common/DatePicker.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
const AUTO_COL = "auto"
let fieldDefinitions = cloneDeep(FIELDS)
export let onClosed
@ -43,7 +45,23 @@
$backendUiStore.selectedTable?._id === TableNames.USERS &&
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() {
if (field.type === AUTO_COL) {
field = buildAutoColumn(
$backendUiStore.draftTable.name,
field.name,
field.subtype
)
}
backendUiStore.update(state => {
backendUiStore.actions.tables.saveField({
originalName,
@ -67,11 +85,12 @@
}
function handleFieldConstraints(event) {
const { type, constraints } = fieldDefinitions[
event.target.value.toUpperCase()
]
field.type = type
field.constraints = constraints
const definition = fieldDefinitions[event.target.value.toUpperCase()]
if (!definition) {
return
}
field.type = definition.type
field.constraints = definition.constraints
}
function onChangeRequired(e) {
@ -124,9 +143,10 @@
{#each Object.values(fieldDefinitions) as field}
<option value={field.type}>{field.name}</option>
{/each}
<option value={AUTO_COL}>Auto Column</option>
</Select>
{#if field.type !== 'link' && !uneditable}
{#if canBeRequired}
<Toggle
checked={required}
on:change={onChangeRequired}
@ -135,7 +155,7 @@
text="Required" />
{/if}
{#if field.type !== 'link'}
{#if canBeDisplay}
<Toggle
bind:checked={primaryDisplay}
on:change={onChangePrimaryDisplay}
@ -143,6 +163,8 @@
text="Use as table display column" />
<Label grey small>Search Indexes</Label>
{/if}
{#if canBeSearched}
<Toggle
checked={indexes[0] === field.name}
disabled={indexes[1] === field.name}
@ -194,9 +216,16 @@
label={`Column Name in Other Table`}
thin
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}
<footer class="create-column-options">
{#if !uneditable && originalName}
{#if !uneditable && originalName != null}
<TextButton text on:click={confirmDelete}>Delete Column</TextButton>
{/if}
<Button secondary on:click={onClosed}>Cancel</Button>

View File

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

View File

@ -30,7 +30,9 @@
Object.keys(viewTable.schema).filter(
field =>
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() {

View File

@ -2,17 +2,11 @@
import { goto } from "@sveltech/routify"
import { backendUiStore, store } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import {
Input,
Label,
ModalContent,
Button,
Spacer,
Toggle,
} from "@budibase/bbui"
import { Input, Label, ModalContent, Toggle } from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte"
import analytics from "analytics"
import screenTemplates from "builderStore/store/screenTemplates"
import { buildAutoColumn, getAutoColumnInformation } from "builderStore/utils"
import { NEW_ROW_TEMPLATE } from "builderStore/store/screenTemplates/newRowScreen"
import { ROW_DETAIL_TEMPLATE } from "builderStore/store/screenTemplates/rowDetailScreen"
import { ROW_LIST_TEMPLATE } from "builderStore/store/screenTemplates/rowListScreen"
@ -23,15 +17,28 @@
ROW_LIST_TEMPLATE,
]
$: tableNames = $backendUiStore.tables.map(table => table.name)
let modal
let name
let dataImport
let error = ""
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) {
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.`
return
}
@ -41,7 +48,7 @@
async function saveTable() {
let newTable = {
name,
schema: dataImport.schema || {},
schema: addAutoColumns(name, dataImport.schema || {}),
dataImport,
}
@ -93,6 +100,28 @@
on:input={checkValid}
bind:value={name}
{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
text="Generate screens in the design section"
bind:checked={createAutoscreens} />
@ -101,3 +130,25 @@
<TableDataImport bind:dataImport />
</div>
</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"
import { notifier } from "builderStore/store/notifications"
import api from "builderStore/api"
import { FIELDS } from "constants/backend"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.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 = {
IMAGE: ["png", "tiff", "gif", "raw", "jpg", "jpeg"],
CODE: ["js", "rs", "py", "java", "rb", "hs", "yml"],
@ -100,3 +116,10 @@ export const Roles = {
PUBLIC: "PUBLIC",
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"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.58.5":
version "1.58.7"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.7.tgz#b9ae18fbd44f830fd8b11fd1dbfaf1212243c88b"
integrity sha512-Kp6iNFXov5DXOTR9860kBJv4zgFEycBw13pdZgcPUERI++OWHtisEo6fuHIEfQGeALif3QEcYwaGr/6pu76C4w==
"@budibase/bbui@^1.58.8":
version "1.58.8"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.8.tgz#c50c54a9baa4fd6d013401586162d4c6354af1a7"
integrity sha512-4qQ6ijMk3XeUOUZwS6dwT51H0T/vfEHhHhNI5JXCQ1TzEX6CUeLNAklY0E/iIMtNteOgdipEkvXGBxh72yX6FQ==
dependencies:
markdown-it "^12.0.2"
quill "^1.3.7"

View File

@ -9,7 +9,11 @@ const {
ViewNames,
} = require("../../db/utils")
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}`
@ -54,18 +58,17 @@ async function findRow(db, appId, tableId, rowId) {
exports.patch = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)
let row = await db.get(ctx.params.rowId)
const table = await db.get(row.tableId)
let dbRow = await db.get(ctx.params.rowId)
let dbTable = await db.get(dbRow.tableId)
const patchfields = ctx.request.body
// need to build up full patch fields before coerce
for (let key of Object.keys(patchfields)) {
if (!table.schema[key]) continue
row[key] = patchfields[key]
if (!dbTable.schema[key]) continue
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({
row,
table,
@ -110,32 +113,34 @@ exports.patch = async function(ctx) {
exports.save = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)
let row = ctx.request.body
row.tableId = ctx.params.tableId
let inputs = ctx.request.body
inputs.tableId = ctx.params.tableId
// TODO: find usage of this and break out into own endpoint
if (ctx.request.body.type === "delete") {
if (inputs.type === "delete") {
await bulkDelete(ctx)
ctx.body = ctx.request.body.rows
ctx.body = inputs.rows
return
}
// if the row obj had an _id then it will have been retrieved
const existingRow = ctx.preExisting
if (existingRow) {
ctx.params.rowId = row._id
ctx.params.rowId = inputs._id
await exports.patch(ctx)
return
}
if (!row._rev && !row._id) {
row._id = generateRowID(row.tableId)
if (!inputs._rev && !inputs._id) {
inputs._id = generateRowID(inputs.tableId)
}
const table = await db.get(row.tableId)
row = coerceRowValues(row, table)
// this returns the table and row incase they have been updated
let { table, row } = await inputProcessing(
ctx.user,
await db.get(inputs.tableId),
inputs
)
const validateResult = await validate({
row,
table,
@ -204,7 +209,7 @@ exports.fetchView = async function(ctx) {
schema: {},
}
}
ctx.body = await enrichRows(appId, table, response.rows)
ctx.body = await outputProcessing(appId, table, response.rows)
}
if (calculation === CALCULATION_TYPES.STATS) {
@ -258,7 +263,7 @@ exports.search = async function(ctx) {
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) {
@ -279,7 +284,7 @@ exports.fetchTableRows = async function(ctx) {
)
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) {
@ -288,7 +293,7 @@ exports.find = async function(ctx) {
try {
const table = await db.get(ctx.params.tableId)
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) {
ctx.throw(400, err)
}
@ -373,7 +378,7 @@ exports.fetchEnrichedRow = async function(ctx) {
keys: linkVals.map(linkVal => linkVal.id),
})
// need to include the IDs in these rows for any links they may have
let linkedRows = await enrichRows(
let linkedRows = await outputProcessing(
appId,
table,
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
for (let fieldName of Object.keys(table.schema)) {
let field = table.schema[fieldName]
if (field.type === "link") {
row[fieldName] = linkedRows.filter(
linkRow => linkRow.tableId === field.tableId
if (field.type === FieldTypes.LINK) {
// find the links that pertain to this field, get their indexes
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,
} = require("../../db/utils")
const { isEqual } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../../constants")
async function checkForColumnUpdates(db, oldTable, updatedTable) {
let updatedRows
@ -39,6 +40,27 @@ async function checkForColumnUpdates(db, oldTable, updatedTable) {
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) {
const db = new CouchDB(ctx.user.appId)
const body = await db.allDocs(
@ -58,7 +80,7 @@ exports.save = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)
const { dataImport, ...rest } = ctx.request.body
const tableToSave = {
let tableToSave = {
type: "table",
_id: generateTableID(),
views: {},
@ -69,6 +91,7 @@ exports.save = async function(ctx) {
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
@ -91,7 +114,7 @@ exports.save = async function(ctx) {
}
// 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.")
} else if (_rename && tableToSave.primaryDisplay === _rename.old) {
ctx.throw(400, "Cannot rename the display column.")

View File

@ -7,9 +7,25 @@ const {
PermissionLevels,
PermissionTypes,
} = require("../../utilities/security/permissions")
const joiValidator = require("../../middleware/joi-validator")
const Joi = require("joi")
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
.get("/api/tables", authorized(BUILDER), tableController.fetch)
.get(
@ -23,6 +39,7 @@ router
// allows control over updating a table
bodyResource("_id"),
authorized(BUILDER),
generateSaveValidator(),
tableController.save
)
.post(

View File

@ -7,7 +7,7 @@ const {
createAttachmentTable,
makeBasicRow,
} = require("./couchTestUtils");
const { enrichRows } = require("../../../utilities")
const { outputProcessing } = require("../../../utilities/rowProcessor")
const env = require("../../../environment")
describe("/rows", () => {
@ -285,7 +285,7 @@ describe("/rows", () => {
link: [firstRow._id],
tableId: table._id,
})).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[0]).toBe(firstRow._id)
})
@ -304,7 +304,7 @@ describe("/rows", () => {
// the environment needs configured for this
env.CLOUD = 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`)
// remove env config
env.CLOUD = undefined

View File

@ -2,7 +2,7 @@ const CouchDB = require("../db")
const emitter = require("../events/index")
const InMemoryQueue = require("../utilities/queue/inMemoryQueue")
const { getAutomationParams } = require("../db/utils")
const { coerceValue } = require("../utilities")
const { coerce } = require("../utilities/rowProcessor")
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
const coercedFields = {}
const fields = automation.definition.trigger.inputs.fields
for (let key in fields) {
coercedFields[key] = coerceValue(params.fields[key], fields[key])
for (let key of Object.keys(fields)) {
coercedFields[key] = coerce(params.fields[key], fields[key])
}
params.fields = coercedFields
}

View File

@ -39,6 +39,26 @@ const USERS_TABLE_SCHEMA = {
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.USERS_TABLE_SCHEMA = USERS_TABLE_SCHEMA
exports.BUILDER_CONFIG_DB = "builder-config-db"

View File

@ -2,6 +2,7 @@ const CouchDB = require("../index")
const { IncludeDocs, getLinkDocuments } = require("./linkUtils")
const { generateLinkID } = require("../utils")
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
@ -24,9 +25,16 @@ function LinkDocument(
rowId2
) {
// 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
this.type = "link"
this.type = FieldTypes.LINK
this.doc1 = {
tableId: tableId1,
fieldName: fieldName1,
@ -75,7 +83,7 @@ class LinkController {
}
for (let fieldName of Object.keys(table.schema)) {
const { type } = table.schema[fieldName]
if (type === "link") {
if (type === FieldTypes.LINK) {
return true
}
}
@ -123,7 +131,7 @@ class LinkController {
// get the links this row wants to make
const rowField = row[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
const thisFieldLinkDocs = linkDocs.filter(
linkDoc =>
@ -241,7 +249,7 @@ class LinkController {
const schema = table.schema
for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName]
if (field.type === "link") {
if (field.type === FieldTypes.LINK) {
// handle this in a separate try catch, want
// the put to bubble up as an error, if can't update
// table for some reason
@ -251,14 +259,18 @@ class LinkController {
} catch (err) {
continue
}
// create the link field in the other table
linkedTable.schema[field.fieldName] = {
const linkConfig = {
name: field.fieldName,
type: "link",
type: FieldTypes.LINK,
// these are the props of the table that initiated the link
tableId: table._id,
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)
// special case for when linking back to self, make sure rev updated
if (linkedTable._id === table._id) {
@ -281,7 +293,10 @@ class LinkController {
for (let fieldName of Object.keys(oldTable.schema)) {
const field = oldTable.schema[fieldName]
// 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)
}
}
@ -302,7 +317,7 @@ class LinkController {
for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName]
try {
if (field.type === "link") {
if (field.type === FieldTypes.LINK) {
const linkedTable = await this._db.get(field.tableId)
delete linkedTable.schema[field.fieldName]
await this._db.put(linkedTable)

View File

@ -5,7 +5,7 @@ const {
createLinkView,
getUniqueByProp,
} = 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
@ -101,7 +101,7 @@ exports.attachLinkInfo = async (appId, rows) => {
}
let tableIds = [...new Set(rows.map(el => el.tableId))]
// start by getting all the link values for performance reasons
let responses = _.flatten(
let responses = flatten(
await Promise.all(
tableIds.map(tableId =>
getLinkDocuments({
@ -118,8 +118,12 @@ exports.attachLinkInfo = async (appId, rows) => {
// have to get unique as the previous table query can
// return duplicates, could be querying for both tables in a relation
const linkVals = getUniqueByProp(
responses.filter(el => el.thisId === row._id),
"id"
responses
// 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) {
// 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 view = {
map: function(doc) {
// everything in this must remain constant as its going to Pouch, no external variables
if (doc.type === "link") {
let doc1 = doc.doc1
let doc2 = doc.doc2

View File

@ -138,10 +138,22 @@ exports.generateAutomationID = () => {
* @param {string} tableId2 The ID of the linked table.
* @param {string} rowId1 The ID of the linker 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.
*/
exports.generateLinkID = (tableId1, tableId2, rowId1, rowId2) => {
return `${DocumentTypes.LINK}${SEPARATOR}${tableId1}${SEPARATOR}${tableId2}${SEPARATOR}${rowId1}${SEPARATOR}${rowId2}`
exports.generateLinkID = (
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 { DocumentTypes, SEPARATOR } = require("../db/utils")
const fs = require("fs")
const { cloneDeep } = require("lodash/fp")
const CouchDB = require("../db")
const { OBJ_STORE_DIRECTORY } = require("../constants")
const linkRows = require("../db/linkedRows")
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) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
? 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 = () => {
return "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
}
@ -219,34 +124,3 @@ exports.getAllApps = async () => {
.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

@ -1135,6 +1135,12 @@
"value": "spectrum--large"
}
]
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1185,6 +1191,12 @@
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1207,6 +1219,12 @@
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1230,6 +1248,12 @@
"label": "Placeholder",
"key": "placeholder",
"placeholder": "Choose an option"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1252,6 +1276,12 @@
"type": "text",
"label": "Text",
"key": "text"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1275,6 +1305,12 @@
"label": "Placeholder",
"key": "placeholder",
"placeholder": "Type something..."
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1303,6 +1339,12 @@
"label": "Show Time",
"key": "enableTime",
"defaultValue": true
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1320,6 +1362,12 @@
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
@ -1337,6 +1385,12 @@
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
}

View File

@ -5,6 +5,7 @@
export let field
export let label
export let disabled = false
let fieldState
let fieldApi
@ -24,11 +25,21 @@
<Field
{label}
{field}
{disabled}
type="attachment"
bind:fieldState
bind:fieldApi
defaultValue={[]}>
{#if mounted}
<div class:disabled={$fieldState.disabled}>
<Dropzone bind:files={value} />
</div>
{/if}
</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 label
export let text
export let disabled = false
let fieldState
let fieldApi
@ -17,6 +18,7 @@
<Field
{label}
{field}
{disabled}
type="boolean"
bind:fieldState
bind:fieldApi
@ -26,6 +28,7 @@
<label class="spectrum-Checkbox" class:is-invalid={!$fieldState.valid}>
<input
checked={$fieldState.value}
disabled={$fieldState.disabled}
on:change={onChange}
type="checkbox"
class="spectrum-Checkbox-input"

View File

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

View File

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

View File

@ -8,6 +8,7 @@
export let datasource
export let theme
export let size
export let disabled = false
const component = getContext("component")
const context = getContext("context")
@ -34,11 +35,23 @@
// Form API contains functions to control the form
const formApi = {
registerField: (field, defaultValue = null) => {
registerField: (field, defaultValue = null, fieldDisabled = false) => {
if (!field) {
return
}
// Auto columns are always disabled
const isAutoColumn = !!schema?.[field]?.autocolumn
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]
}
@ -47,7 +60,11 @@
const validate = createValidatorFromConstraints(constraints, field, table)
fieldMap[field] = {
fieldState: makeFieldState(field, defaultValue),
fieldState: makeFieldState(
field,
defaultValue,
disabled || fieldDisabled || isAutoColumn
),
fieldApi: makeFieldApi(field, defaultValue, validate),
fieldSchema: schema?.[field] ?? {},
}
@ -115,13 +132,14 @@
}
// Creates observable state data about a specific field
const makeFieldState = (field, defaultValue) => {
const makeFieldState = (field, defaultValue, fieldDisabled) => {
return writable({
field,
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
valid: true,
disabled: fieldDisabled,
})
}

View File

@ -6,6 +6,7 @@
export let field
export let label
export let placeholder
export let disabled = false
let fieldState
let fieldApi
@ -41,12 +42,13 @@
<Field
{label}
{field}
{disabled}
type="longform"
bind:fieldState
bind:fieldApi
defaultValue="">
{#if mounted}
<div>
<div class:disabled={$fieldState.disabled}>
<RichText bind:value {options} />
</div>
{/if}
@ -68,4 +70,12 @@
div :global(.ql-editor p) {
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>

View File

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

View File

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

View File

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

View File

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