Merge branch 'v3-ui' of github.com:Budibase/budibase into view-calculation-ui

This commit is contained in:
Andrew Kingston 2024-10-04 09:27:36 +01:00
commit 0f95c8a1c9
No known key found for this signature in database
48 changed files with 4015 additions and 3582 deletions

View File

@ -23,6 +23,7 @@ jobs:
PAYLOAD_BRANCH: ${{ github.head_ref }} PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }} PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
PAYLOAD_LICENSE_TYPE: "free" PAYLOAD_LICENSE_TYPE: "free"
PAYLOAD_DEPLOY: "true"
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys
event: featurebranch-qa-deploy event: featurebranch-qa-deploy

View File

@ -1,3 +1,3 @@
nodejs 20.10.0 nodejs 20.10.0
python 3.10.0 python 3.10.0
yarn 1.22.19 yarn 1.22.22

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.32.9", "version": "2.32.11",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -1,5 +1,5 @@
export * as utils from "./utils" export * as utils from "./utils"
export { default as Sql } from "./sql" export { default as Sql, COUNT_FIELD_NAME } from "./sql"
export { default as SqlTable } from "./sqlTable" export { default as SqlTable } from "./sqlTable"
export * as designDoc from "./designDoc" export * as designDoc from "./designDoc"

View File

@ -43,6 +43,8 @@ import { cloneDeep } from "lodash"
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
export const COUNT_FIELD_NAME = "__bb_total"
function getBaseLimit() { function getBaseLimit() {
const envLimit = environment.SQL_MAX_ROWS const envLimit = environment.SQL_MAX_ROWS
? parseInt(environment.SQL_MAX_ROWS) ? parseInt(environment.SQL_MAX_ROWS)
@ -71,18 +73,6 @@ function prioritisedArraySort(toSort: string[], priorities: string[]) {
}) })
} }
function getTableName(table?: Table): string | undefined {
// SQS uses the table ID rather than the table name
if (
table?.sourceType === TableSourceType.INTERNAL ||
table?.sourceId === INTERNAL_TABLE_SOURCE_ID
) {
return table?._id
} else {
return table?.name
}
}
function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] { function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
if (Array.isArray(query)) { if (Array.isArray(query)) {
return query.map((q: SqlQuery) => convertBooleans(q) as SqlQuery) return query.map((q: SqlQuery) => convertBooleans(q) as SqlQuery)
@ -99,6 +89,13 @@ function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
return query return query
} }
function isSqs(table: Table): boolean {
return (
table.sourceType === TableSourceType.INTERNAL ||
table.sourceId === INTERNAL_TABLE_SOURCE_ID
)
}
class InternalBuilder { class InternalBuilder {
private readonly client: SqlClient private readonly client: SqlClient
private readonly query: QueryJson private readonly query: QueryJson
@ -180,15 +177,13 @@ class InternalBuilder {
} }
private generateSelectStatement(): (string | Knex.Raw)[] | "*" { private generateSelectStatement(): (string | Knex.Raw)[] | "*" {
const { meta, endpoint, resource, tableAliases } = this.query const { meta, endpoint, resource } = this.query
if (!resource || !resource.fields || resource.fields.length === 0) { if (!resource || !resource.fields || resource.fields.length === 0) {
return "*" return "*"
} }
const alias = tableAliases?.[endpoint.entityId] const alias = this.getTableName(endpoint.entityId)
? tableAliases?.[endpoint.entityId]
: endpoint.entityId
const schema = meta.table.schema const schema = meta.table.schema
if (!this.isFullSelectStatementRequired()) { if (!this.isFullSelectStatementRequired()) {
return [this.knex.raw(`${this.quote(alias)}.*`)] return [this.knex.raw(`${this.quote(alias)}.*`)]
@ -813,17 +808,48 @@ class InternalBuilder {
return query return query
} }
isSqs(): boolean {
return isSqs(this.table)
}
getTableName(tableOrName?: Table | string): string {
let table: Table
if (typeof tableOrName === "string") {
const name = tableOrName
if (this.query.table?.name === name) {
table = this.query.table
} else if (this.query.meta.table?.name === name) {
table = this.query.meta.table
} else if (!this.query.meta.tables?.[name]) {
// This can legitimately happen in custom queries, where the user is
// querying against a table that may not have been imported into
// Budibase.
return name
} else {
table = this.query.meta.tables[name]
}
} else if (tableOrName) {
table = tableOrName
} else {
table = this.table
}
let name = table.name
if (isSqs(table) && table._id) {
// SQS uses the table ID rather than the table name
name = table._id
}
const aliases = this.query.tableAliases || {}
return aliases[name] ? aliases[name] : name
}
addDistinctCount(query: Knex.QueryBuilder): Knex.QueryBuilder { addDistinctCount(query: Knex.QueryBuilder): Knex.QueryBuilder {
const primary = this.table.primary if (!this.table.primary) {
const aliases = this.query.tableAliases
const aliased =
this.table.name && aliases?.[this.table.name]
? aliases[this.table.name]
: this.table.name
if (!primary) {
throw new Error("SQL counting requires primary key to be supplied") throw new Error("SQL counting requires primary key to be supplied")
} }
return query.countDistinct(`${aliased}.${primary[0]} as total`) return query.countDistinct(
`${this.getTableName()}.${this.table.primary[0]} as ${COUNT_FIELD_NAME}`
)
} }
addAggregations( addAggregations(
@ -831,12 +857,14 @@ class InternalBuilder {
aggregations: Aggregation[] aggregations: Aggregation[]
): Knex.QueryBuilder { ): Knex.QueryBuilder {
const fields = this.query.resource?.fields || [] const fields = this.query.resource?.fields || []
const tableName = this.getTableName()
if (fields.length > 0) { if (fields.length > 0) {
query = query.groupBy(fields.map(field => `${this.table.name}.${field}`)) query = query.groupBy(fields.map(field => `${tableName}.${field}`))
query = query.select(fields.map(field => `${tableName}.${field}`))
} }
for (const aggregation of aggregations) { for (const aggregation of aggregations) {
const op = aggregation.calculationType const op = aggregation.calculationType
const field = `${this.table.name}.${aggregation.field} as ${aggregation.name}` const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
switch (op) { switch (op) {
case CalculationType.COUNT: case CalculationType.COUNT:
query = query.count(field) query = query.count(field)
@ -861,10 +889,7 @@ class InternalBuilder {
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder { addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
let { sort, resource } = this.query let { sort, resource } = this.query
const primaryKey = this.table.primary const primaryKey = this.table.primary
const tableName = getTableName(this.table) const aliased = this.getTableName()
const aliases = this.query.tableAliases
const aliased =
tableName && aliases?.[tableName] ? aliases[tableName] : this.table?.name
if (!Array.isArray(primaryKey)) { if (!Array.isArray(primaryKey)) {
throw new Error("Sorting requires primary key to be specified for table") throw new Error("Sorting requires primary key to be specified for table")
} }
@ -1508,23 +1533,40 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
return results.length ? results : [{ [operation.toLowerCase()]: true }] return results.length ? results : [{ [operation.toLowerCase()]: true }]
} }
private getTableName(
table: Table,
aliases?: Record<string, string>
): string | undefined {
let name = table.name
if (
table.sourceType === TableSourceType.INTERNAL ||
table.sourceId === INTERNAL_TABLE_SOURCE_ID
) {
if (!table._id) {
return
}
// SQS uses the table ID rather than the table name
name = table._id
}
return aliases?.[name] || name
}
convertJsonStringColumns<T extends Record<string, any>>( convertJsonStringColumns<T extends Record<string, any>>(
table: Table, table: Table,
results: T[], results: T[],
aliases?: Record<string, string> aliases?: Record<string, string>
): T[] { ): T[] {
const tableName = getTableName(table) const tableName = this.getTableName(table, aliases)
for (const [name, field] of Object.entries(table.schema)) { for (const [name, field] of Object.entries(table.schema)) {
if (!this._isJsonColumn(field)) { if (!this._isJsonColumn(field)) {
continue continue
} }
const aliasedTableName = (tableName && aliases?.[tableName]) || tableName const fullName = `${tableName}.${name}` as keyof T
const fullName = `${aliasedTableName}.${name}`
for (let row of results) { for (let row of results) {
if (typeof row[fullName as keyof T] === "string") { if (typeof row[fullName] === "string") {
row[fullName as keyof T] = JSON.parse(row[fullName]) row[fullName] = JSON.parse(row[fullName])
} }
if (typeof row[name as keyof T] === "string") { if (typeof row[name] === "string") {
row[name as keyof T] = JSON.parse(row[name]) row[name as keyof T] = JSON.parse(row[name])
} }
} }

View File

@ -0,0 +1,29 @@
<script>
export let width
export let height
</script>
<svg
{width}
{height}
viewBox="0 0 13 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.4179 4.13222C9.4179 3.73121 9.26166 3.35428 8.97913 3.07175C8.41342 2.50538 7.4239 2.50408 6.85753 3.07175L5.64342 4.28586C5.6291 4.30018 5.61543 4.3158 5.60305 4.33143C5.58678 4.3438 5.5718 4.35747 5.55683 4.37244L0.491426 9.43785C0.208245 9.72103 0.052002 10.098 0.052002 10.4983C0.052002 10.8987 0.208245 11.2756 0.491426 11.5588C0.774607 11.842 1.15153 11.9982 1.5519 11.9982C1.95227 11.9982 2.32919 11.842 2.61238 11.5588L8.97848 5.1927C9.26166 4.90952 9.4179 4.53259 9.4179 4.13222ZM1.90539 10.8518C1.7166 11.0406 1.3872 11.0406 1.1984 10.8518C1.10401 10.7574 1.05193 10.6318 1.05193 10.4983C1.05193 10.3649 1.104 10.2392 1.1984 10.1448L5.99821 5.34503L6.70845 6.04875L1.90539 10.8518ZM8.2715 4.48571L7.41544 5.34178L6.7052 4.63805L7.56452 3.77873C7.7533 3.58995 8.08271 3.58929 8.2715 3.77939C8.36589 3.87313 8.41798 3.99877 8.41798 4.13223C8.41798 4.26569 8.3659 4.39132 8.2715 4.48571Z"
fill="#C8C8C8"
/>
<path
d="M11.8552 6.55146L11.0144 6.21913L10.879 5.32449C10.8356 5.03919 10.3737 4.98776 10.2686 5.255L9.93606 6.09642L9.04143 6.23085C8.89951 6.25216 8.78884 6.36658 8.77257 6.50947C8.75629 6.65253 8.83783 6.78826 8.97193 6.84148L9.81335 7.17464L9.94794 8.06862C9.9691 8.21053 10.0835 8.32121 10.2266 8.33748C10.3695 8.35375 10.5052 8.27221 10.5586 8.13811L10.8914 7.29751L11.7855 7.1621C11.9283 7.1403 12.0381 7.02637 12.0544 6.88348C12.0707 6.74058 11.9887 6.60403 11.8552 6.55146Z"
fill="#F9634C"
/>
<path
d="M8.94215 1.76145L9.78356 2.0946L9.91815 2.9885C9.93931 3.13049 10.0539 3.24117 10.1968 3.25744C10.3398 3.27371 10.4756 3.19218 10.5288 3.05807L10.8618 2.21739L11.7559 2.08207C11.8985 2.06034 12.0085 1.94633 12.0248 1.80344C12.0411 1.66054 11.959 1.524 11.8254 1.47143L10.9847 1.13909L10.8494 0.244456C10.806 -0.0409246 10.3439 -0.0922745 10.2388 0.174881L9.90643 1.0163L9.0118 1.15089C8.86972 1.17213 8.75905 1.28654 8.74278 1.42952C8.72651 1.57249 8.80804 1.70823 8.94215 1.76145Z"
fill="#8488FD"
/>
<path
d="M3.2379 2.46066L3.92063 2.73091L4.02984 3.45637C4.04709 3.57151 4.14002 3.66135 4.25606 3.67453C4.37194 3.6878 4.48212 3.62163 4.52541 3.51276L4.79557 2.83059L5.52094 2.72074C5.63682 2.70316 5.72601 2.61072 5.73936 2.49468C5.75254 2.37864 5.68597 2.26797 5.57758 2.22533L4.89533 1.95565L4.78548 1.22963C4.75016 0.998038 4.37535 0.956375 4.29007 1.17315L4.0204 1.85597L3.29437 1.96517C3.17915 1.98235 3.08931 2.07527 3.07613 2.19131C3.06294 2.30727 3.12902 2.41737 3.2379 2.46066Z"
fill="#F7D804"
/>
</svg>

View File

@ -67,6 +67,7 @@
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",
"@zerodevx/svelte-json-view": "^1.0.7", "@zerodevx/svelte-json-view": "^1.0.7",
"codemirror": "^5.65.16", "codemirror": "^5.65.16",
"cron-parser": "^4.9.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",

View File

@ -1062,7 +1062,7 @@
{:else if value.customType === "cron"} {:else if value.customType === "cron"}
<CronBuilder <CronBuilder
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} cronExpression={inputData[key]}
/> />
{:else if value.customType === "automationFields"} {:else if value.customType === "automationFields"}
<AutomationSelector <AutomationSelector

View File

@ -1,41 +1,70 @@
<script> <script>
import { Button, Select, Input, Label } from "@budibase/bbui" import {
Select,
InlineAlert,
Input,
Label,
Layout,
notifications,
} from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte" import { onMount, createEventDispatcher } from "svelte"
import { flags } from "stores/builder" import { flags } from "stores/builder"
import { licensing } from "stores/portal"
import { API } from "api"
import MagicWand from "../../../../assets/MagicWand.svelte"
import { helpers, REBOOT_CRON } from "@budibase/shared-core" import { helpers, REBOOT_CRON } from "@budibase/shared-core"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let value export let cronExpression
let error let error
let nextExecutions
// AI prompt
let aiCronPrompt = ""
let loadingAICronExpression = false
$: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: { $: {
const exists = CRON_EXPRESSIONS.some(cron => cron.value === value) if (cronExpression) {
const customIndex = CRON_EXPRESSIONS.findIndex( try {
cron => cron.label === "Custom" nextExecutions = helpers.cron
) .getNextExecutionDates(cronExpression)
.join("\n")
if (!exists && customIndex === -1) { } catch (err) {
CRON_EXPRESSIONS[0] = { label: "Custom", value: value } nextExecutions = null
} else if (exists && customIndex !== -1) { }
CRON_EXPRESSIONS.splice(customIndex, 1)
} }
} }
const onChange = e => { const onChange = e => {
if (value !== REBOOT_CRON) { if (e.detail !== REBOOT_CRON) {
error = helpers.cron.validate(e.detail).err error = helpers.cron.validate(e.detail).err
} }
if (e.detail === value || error) { if (e.detail === cronExpression || error) {
return return
} }
value = e.detail cronExpression = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
const updatePreset = e => {
aiCronPrompt = ""
onChange(e)
}
const updateCronExpression = e => {
aiCronPrompt = ""
cronExpression = null
nextExecutions = null
onChange(e)
}
let touched = false let touched = false
let presets = false
const CRON_EXPRESSIONS = [ const CRON_EXPRESSIONS = [
{ {
@ -64,45 +93,130 @@
}) })
} }
}) })
async function generateAICronExpression() {
loadingAICronExpression = true
try {
const response = await API.generateCronExpression({
prompt: aiCronPrompt,
})
cronExpression = response.message
dispatch("change", response.message)
} catch (err) {
notifications.error(err.message)
} finally {
loadingAICronExpression = false
}
}
</script> </script>
<div class="block-field"> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Layout noPadding gap="S">
<Select
on:change={updatePreset}
value={cronExpression || "Custom"}
secondary
extraThin
label="Use a Preset (Optional)"
options={CRON_EXPRESSIONS}
/>
{#if aiEnabled}
<div class="cron-ai-generator">
<Input
bind:value={aiCronPrompt}
label="Generate Cron Expression with AI"
size="S"
placeholder="Run every hour between 1pm to 4pm everyday of the week"
/>
{#if aiCronPrompt}
<div
class="icon"
class:pulsing-text={loadingAICronExpression}
on:click={generateAICronExpression}
>
<MagicWand height="17" width="17" />
</div>
{/if}
</div>
{/if}
<Input <Input
label="Cron Expression"
{error} {error}
on:change={onChange} on:change={updateCronExpression}
{value} value={cronExpression}
on:blur={() => (touched = true)} on:blur={() => (touched = true)}
updateOnChange={false} updateOnChange={false}
/> />
{#if touched && !value} {#if touched && !cronExpression}
<Label><div class="error">Please specify a CRON expression</div></Label> <Label><div class="error">Please specify a CRON expression</div></Label>
{/if} {/if}
<div class="presets"> {#if nextExecutions}
<Button on:click={() => (presets = !presets)} <InlineAlert
>{presets ? "Hide" : "Show"} Presets</Button type="info"
> header="Next Executions"
{#if presets} message={nextExecutions}
<Select />
on:change={onChange} {/if}
value={value || "Custom"} </Layout>
secondary
extraThin
label="Presets"
options={CRON_EXPRESSIONS}
/>
{/if}
</div>
</div>
<style> <style>
.presets { .cron-ai-generator {
margin-top: var(--spacing-m); flex: 1;
position: relative;
} }
.block-field { .icon {
padding-top: var(--spacing-s); right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
} }
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.error { .error {
padding-top: var(--spacing-xs); padding-top: var(--spacing-xs);
color: var(--spectrum-global-color-red-500); color: var(--spectrum-global-color-red-500);
} }
.pulsing-text {
font-size: 24px;
font-weight: bold;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0.3;
transform: scale(1);
}
}
</style> </style>

View File

@ -4,6 +4,7 @@
Button, Button,
Label, Label,
Select, Select,
Multiselect,
Toggle, Toggle,
Icon, Icon,
DatePicker, DatePicker,
@ -21,6 +22,7 @@
PROTECTED_EXTERNAL_COLUMNS, PROTECTED_EXTERNAL_COLUMNS,
canHaveDefaultColumn, canHaveDefaultColumn,
} from "@budibase/shared-core" } from "@budibase/shared-core"
import { makePropSafe } from "@budibase/string-templates"
import { createEventDispatcher, getContext, onMount } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder" import { tables, datasources } from "stores/builder"
@ -46,6 +48,7 @@
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte" import OptionsEditor from "./OptionsEditor.svelte"
import { isEnabled } from "helpers/featureFlags" import { isEnabled } from "helpers/featureFlags"
import { getUserBindings } from "dataBinding"
const AUTO_TYPE = FieldType.AUTO const AUTO_TYPE = FieldType.AUTO
const FORMULA_TYPE = FieldType.FORMULA const FORMULA_TYPE = FieldType.FORMULA
@ -135,9 +138,10 @@
} }
$: initialiseField(field, savingColumn) $: initialiseField(field, savingColumn)
$: checkConstraints(editableColumn) $: checkConstraints(editableColumn)
$: required = hasDefault $: required =
? false primaryDisplay ||
: !!editableColumn?.constraints?.presence || primaryDisplay editableColumn?.constraints?.presence === true ||
editableColumn?.constraints?.presence?.allowEmpty === false
$: uneditable = $: uneditable =
$tables.selected?._id === TableNames.USERS && $tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(editableColumn.name) UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
@ -166,8 +170,8 @@
// used to select what different options can be displayed for column type // used to select what different options can be displayed for column type
$: canBeDisplay = $: canBeDisplay =
canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
$: canHaveDefault = $: defaultValuesEnabled = isEnabled("DEFAULT_VALUES")
isEnabled("DEFAULT_VALUES") && canHaveDefaultColumn(editableColumn.type) $: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type)
$: canBeRequired = $: canBeRequired =
editableColumn?.type !== LINK_TYPE && editableColumn?.type !== LINK_TYPE &&
!uneditable && !uneditable &&
@ -186,11 +190,28 @@
(originalName && (originalName &&
SWITCHABLE_TYPES[field.type] && SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn) !editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({ $: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype), fieldId: makeFieldId(t.type, t.subtype),
...t, ...t,
})) }))
$: defaultValueBindings = [
{
type: "context",
runtimeBinding: `${makePropSafe("now")}`,
readableBinding: `Date`,
category: "Date",
icon: "Date",
display: {
name: "Server date",
},
},
...getUserBindings(),
]
$: sanitiseDefaultValue(
editableColumn.type,
editableColumn.constraints?.inclusion || [],
editableColumn.default
)
const fieldDefinitions = Object.values(FIELDS).reduce( const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id // Storing the fields by complex field id
@ -280,6 +301,22 @@
delete saveColumn.fieldName delete saveColumn.fieldName
} }
// Ensure we don't have a default value if we can't have one
if (!canHaveDefault || !defaultValuesEnabled) {
delete saveColumn.default
}
// Ensure primary display columns are always required and don't have default values
if (primaryDisplay) {
saveColumn.constraints.presence = { allowEmpty: false }
delete saveColumn.default
}
// Ensure the field is not required if we have a default value
if (saveColumn.default) {
saveColumn.constraints.presence = false
}
try { try {
await tables.saveField({ await tables.saveField({
originalName, originalName,
@ -526,6 +563,20 @@
return newError return newError
} }
const sanitiseDefaultValue = (type, options, defaultValue) => {
if (!defaultValue?.length) {
return
}
// Delete default value for options fields if the option is no longer available
if (type === FieldType.OPTIONS && !options.includes(defaultValue)) {
delete editableColumn.default
}
// Filter array default values to only valid options
if (type === FieldType.ARRAY) {
editableColumn.default = defaultValue.filter(x => options.includes(x))
}
}
onMount(() => { onMount(() => {
mounted = true mounted = true
}) })
@ -733,9 +784,9 @@
</div> </div>
</div> </div>
{:else if editableColumn.type === JSON_TYPE} {:else if editableColumn.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor} <Button primary text on:click={openJsonSchemaEditor}>
>Open schema editor</Button Open schema editor
> </Button>
{/if} {/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn} {#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select <Select
@ -764,28 +815,39 @@
</div> </div>
{/if} {/if}
{#if canHaveDefault} {#if defaultValuesEnabled}
<div> {#if editableColumn.type === FieldType.OPTIONS}
<ModalBindableInput <Select
panel={ServerBindingPanel} disabled={!canHaveDefault}
title="Default" options={editableColumn.constraints?.inclusion || []}
label="Default" label="Default value"
value={editableColumn.default} value={editableColumn.default}
on:change={e => { on:change={e => (editableColumn.default = e.detail)}
editableColumn = { placeholder="None"
...editableColumn,
default: e.detail,
}
if (e.detail) {
setRequired(false)
}
}}
bindings={getBindings({ table })}
allowJS
context={rowGoldenSample}
/> />
</div> {:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
{/if} {/if}
</Layout> </Layout>

View File

@ -2,7 +2,12 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte" import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
const { datasource } = getContext("grid") const { datasource, rows } = getContext("grid")
const onUpdate = async () => {
await datasource.actions.refreshDefinition()
await rows.actions.refreshData()
}
</script> </script>
<CreateEditColumn on:updatecolumns={datasource.actions.refreshDefinition} /> <CreateEditColumn on:updatecolumns={onUpdate} />

View File

@ -3,6 +3,7 @@
import { admin, themeStore } from "stores/portal" import { admin, themeStore } from "stores/portal"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import { notifications } from "@budibase/bbui"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte" import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte" import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte" import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
@ -34,6 +35,7 @@
text: action.name, text: action.name,
onClick: async row => { onClick: async row => {
await rowActions.trigger(id, action.id, row._id) await rowActions.trigger(id, action.id, row._id)
notifications.success("Row action triggered successfully")
}, },
})) }))
} }

View File

@ -1,5 +1,5 @@
<script> <script>
import { Banner } from "@budibase/bbui" import { Banner, notifications } from "@budibase/bbui"
import { import {
datasources, datasources,
tables, tables,
@ -67,6 +67,7 @@
text: action.name, text: action.name,
onClick: async row => { onClick: async row => {
await rowActions.trigger(id, action.id, row._id) await rowActions.trigger(id, action.id, row._id)
notifications.success("Row action triggered successfully")
}, },
})) }))
} }

View File

@ -1,6 +1,24 @@
import { writable, derived, get } from "svelte/store" import { writable, derived, get } from "svelte/store"
import { tables } from "./tables" import { tables } from "./tables"
import { API } from "api" import { API } from "api"
import { dataFilters } from "@budibase/shared-core"
function convertToSearchFilters(view) {
// convert from SearchFilterGroup type
if (view.query) {
view.queryUI = view.query
view.query = dataFilters.buildQuery(view.query)
}
return view
}
function convertToSearchFilterGroup(view) {
if (view.queryUI) {
view.query = view.queryUI
delete view.queryUI
}
return view
}
export function createViewsV2Store() { export function createViewsV2Store() {
const store = writable({ const store = writable({
@ -12,7 +30,7 @@ export function createViewsV2Store() {
const views = Object.values(table?.views || {}).filter(view => { const views = Object.values(table?.views || {}).filter(view => {
return view.version === 2 return view.version === 2
}) })
list = list.concat(views) list = list.concat(views.map(view => convertToSearchFilterGroup(view)))
}) })
return { return {
...$store, ...$store,
@ -34,6 +52,7 @@ export function createViewsV2Store() {
} }
const create = async view => { const create = async view => {
view = convertToSearchFilters(view)
const savedViewResponse = await API.viewV2.create(view) const savedViewResponse = await API.viewV2.create(view)
const savedView = savedViewResponse.data const savedView = savedViewResponse.data
replaceView(savedView.id, savedView) replaceView(savedView.id, savedView)
@ -41,6 +60,7 @@ export function createViewsV2Store() {
} }
const save = async view => { const save = async view => {
view = convertToSearchFilters(view)
const res = await API.viewV2.update(view) const res = await API.viewV2.update(view)
const savedView = res?.data const savedView = res?.data
replaceView(view.id, savedView) replaceView(view.id, savedView)
@ -51,6 +71,7 @@ export function createViewsV2Store() {
if (!viewId) { if (!viewId) {
return return
} }
view = convertToSearchFilterGroup(view)
const existingView = get(derivedStore).list.find(view => view.id === viewId) const existingView = get(derivedStore).list.find(view => view.id === viewId)
const tableIndex = get(tables).list.findIndex(table => { const tableIndex = get(tables).list.findIndex(table => {
return table._id === view?.tableId || table._id === existingView?.tableId return table._id === view?.tableId || table._id === existingView?.tableId

View File

@ -63,7 +63,7 @@
// Look up the component tree and find something that is provided by an // Look up the component tree and find something that is provided by an
// ancestor that matches our datasource. This is for backwards compatibility // ancestor that matches our datasource. This is for backwards compatibility
// as previously we could use the "closest" context. // as previously we could use the "closest" context.
for (let id of path.reverse().slice(1)) { for (let id of path.toReversed().slice(1)) {
// Check for matching view datasource // Check for matching view datasource
if ( if (
dataSource.type === "viewV2" && dataSource.type === "viewV2" &&

View File

@ -0,0 +1,11 @@
export const buildAIEndpoints = API => ({
/**
* Generates a cron expression from a prompt
*/
generateCronExpression: async ({ prompt }) => {
return await API.post({
url: "/api/ai/cron",
body: { prompt },
})
},
})

View File

@ -2,6 +2,7 @@ import { Helpers } from "@budibase/bbui"
import { Header } from "@budibase/shared-core" import { Header } from "@budibase/shared-core"
import { ApiVersion } from "../constants" import { ApiVersion } from "../constants"
import { buildAnalyticsEndpoints } from "./analytics" import { buildAnalyticsEndpoints } from "./analytics"
import { buildAIEndpoints } from "./ai"
import { buildAppEndpoints } from "./app" import { buildAppEndpoints } from "./app"
import { buildAttachmentEndpoints } from "./attachments" import { buildAttachmentEndpoints } from "./attachments"
import { buildAuthEndpoints } from "./auth" import { buildAuthEndpoints } from "./auth"
@ -269,6 +270,7 @@ export const createAPIClient = config => {
// Attach all endpoints // Attach all endpoints
return { return {
...API, ...API,
...buildAIEndpoints(API),
...buildAnalyticsEndpoints(API), ...buildAnalyticsEndpoints(API),
...buildAppEndpoints(API), ...buildAppEndpoints(API),
...buildAttachmentEndpoints(API), ...buildAttachmentEndpoints(API),

View File

@ -98,7 +98,6 @@
align="right" align="right"
offset={5} offset={5}
size="S" size="S"
quiet
animate={false} animate={false}
on:mouseenter={() => ($hoveredRowId = row._id)} on:mouseenter={() => ($hoveredRowId = row._id)}
/> />

View File

@ -1,6 +1,7 @@
import { derived, get } from "svelte/store" import { derived, get } from "svelte/store"
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch" import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { enrichSchemaWithRelColumns, memo } from "../../../utils" import { enrichSchemaWithRelColumns, memo } from "../../../utils"
import { cloneDeep } from "lodash"
export const createStores = () => { export const createStores = () => {
const definition = memo(null) const definition = memo(null)
@ -164,10 +165,18 @@ export const createActions = context => {
// Updates the datasources primary display column // Updates the datasources primary display column
const changePrimaryDisplay = async column => { const changePrimaryDisplay = async column => {
return await saveDefinition({ let newDefinition = cloneDeep(get(definition))
...get(definition),
primaryDisplay: column, // Update primary display
}) newDefinition.primaryDisplay = column
// Sanitise schema to ensure field is required and has no default value
if (!newDefinition.schema[column].constraints) {
newDefinition.schema[column].constraints = {}
}
newDefinition.schema[column].constraints.presence = { allowEmpty: false }
delete newDefinition.schema[column].default
return await saveDefinition(newDefinition)
} }
// Adds a schema mutation for a single field // Adds a schema mutation for a single field

View File

@ -1,4 +1,14 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { dataFilters } from "@budibase/shared-core"
function convertToSearchFilters(view) {
// convert from SearchFilterGroup type
if (view.query) {
view.queryUI = view.query
view.query = dataFilters.buildQuery(view.query)
}
return view
}
const SuppressErrors = true const SuppressErrors = true
@ -6,7 +16,7 @@ export const createActions = context => {
const { API, datasource, columns } = context const { API, datasource, columns } = context
const saveDefinition = async newDefinition => { const saveDefinition = async newDefinition => {
await API.viewV2.update(newDefinition) await API.viewV2.update(convertToSearchFilters(newDefinition))
} }
const saveRow = async row => { const saveRow = async row => {
@ -125,7 +135,7 @@ export const initialise = context => {
} }
// Only override filter state if we don't have an initial filter // Only override filter state if we don't have an initial filter
if (!get(initialFilter)) { if (!get(initialFilter)) {
filter.set($definition.query) filter.set($definition.queryUI || $definition.query)
} }
}) })
) )

@ -1 +1 @@
Subproject commit e2fe0f9cc856b4ee1a97df96d623b2d87d4e8733 Subproject commit aca9828117bb97f54f40ee359f1a3f6e259174e7

View File

@ -124,9 +124,11 @@ export async function buildSqlFieldList(
([columnName, column]) => ([columnName, column]) =>
column.type !== FieldType.LINK && column.type !== FieldType.LINK &&
column.type !== FieldType.FORMULA && column.type !== FieldType.FORMULA &&
!existing.find((field: string) => field === columnName) !existing.find(
(field: string) => field === `${table.name}.${columnName}`
)
) )
.map(column => `${table.name}.${column[0]}`) .map(([columnName]) => `${table.name}.${columnName}`)
} }
let fields: string[] = [] let fields: string[] = []

View File

@ -3,15 +3,11 @@ import {
ViewV2, ViewV2,
SearchRowResponse, SearchRowResponse,
SearchViewRowRequest, SearchViewRowRequest,
SearchFilterKey, RequiredKeys,
LogicalOperator, RowSearchParams,
SearchFilter,
} from "@budibase/types" } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { db, context, features } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { enrichSearchContext } from "./utils"
import { isExternalTableID } from "../../../integrations/utils"
export async function searchView( export async function searchView(
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse> ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
@ -26,77 +22,34 @@ export async function searchView(
ctx.throw(400, `This method only supports viewsV2`) ctx.throw(400, `This method only supports viewsV2`)
} }
const viewFields = Object.entries(view.schema || {})
.filter(([_, value]) => value.visible)
.map(([key]) => key)
const { body } = ctx.request const { body } = ctx.request
const sqsEnabled = await features.flags.isEnabled("SQS")
const supportsLogicalOperators = isExternalTableID(view.tableId) || sqsEnabled
// Enrich saved query with ephemeral query params.
// We prevent searching on any fields that are saved as part of the query, as
// that could let users find rows they should not be allowed to access.
let query = supportsLogicalOperators
? dataFilters.buildQuery(view.query)
: dataFilters.buildQueryLegacy(view.query)
delete query?.onEmptyFilter
if (body.query) {
// Delete extraneous search params that cannot be overridden
delete body.query.onEmptyFilter
if (!supportsLogicalOperators) {
// In the unlikely event that a Grouped Filter is in a non-SQS environment
// It needs to be ignored entirely
let queryFilters: SearchFilter[] = Array.isArray(view.query)
? view.query
: []
// Extract existing fields
const existingFields =
queryFilters
?.filter(filter => filter.field)
.map(filter => db.removeKeyNumbering(filter.field)) || []
// Carry over filters for unused fields
Object.keys(body.query).forEach(key => {
const operator = key as Exclude<SearchFilterKey, LogicalOperator>
Object.keys(body.query[operator] || {}).forEach(field => {
if (query && !existingFields.includes(db.removeKeyNumbering(field))) {
query[operator]![field] = body.query[operator]![field]
}
})
})
} else {
const conditions = query ? [query] : []
query = {
$and: {
conditions: [...conditions, body.query],
},
}
}
}
await context.ensureSnippetContext(true) await context.ensureSnippetContext(true)
const enrichedQuery = await enrichSearchContext(query || {}, { const searchOptions: RequiredKeys<SearchViewRowRequest> &
user: sdk.users.getUserContextBindings(ctx.user), RequiredKeys<
}) Pick<RowSearchParams, "tableId" | "viewId" | "query" | "fields">
> = {
const result = await sdk.rows.search({
viewId: view.id,
tableId: view.tableId, tableId: view.tableId,
query: enrichedQuery, viewId: view.id,
query: body.query,
fields: viewFields,
...getSortOptions(body, view), ...getSortOptions(body, view),
limit: body.limit, limit: body.limit,
bookmark: body.bookmark, bookmark: body.bookmark,
paginate: body.paginate, paginate: body.paginate,
countRows: body.countRows, countRows: body.countRows,
}) }
const result = await sdk.rows.search(searchOptions, {
user: sdk.users.getUserContextBindings(ctx.user),
})
result.rows.forEach(r => (r._viewId = view.id)) result.rows.forEach(r => (r._viewId = view.id))
ctx.body = result ctx.body = result
} }
function getSortOptions(request: SearchViewRowRequest, view: ViewV2) { function getSortOptions(request: SearchViewRowRequest, view: ViewV2) {
if (request.sort) { if (request.sort) {
return { return {

View File

@ -103,6 +103,7 @@ export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
name: view.name, name: view.name,
tableId: view.tableId, tableId: view.tableId,
query: view.query, query: view.query,
queryUI: view.queryUI,
sort: view.sort, sort: view.sort,
schema, schema,
primaryDisplay: view.primaryDisplay, primaryDisplay: view.primaryDisplay,
@ -139,6 +140,7 @@ export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
version: view.version, version: view.version,
tableId: view.tableId, tableId: view.tableId,
query: view.query, query: view.query,
queryUI: view.queryUI,
sort: view.sort, sort: view.sort,
schema, schema,
primaryDisplay: view.primaryDisplay, primaryDisplay: view.primaryDisplay,

View File

@ -33,6 +33,7 @@ import rowActionRoutes from "./rowAction"
export { default as staticRoutes } from "./static" export { default as staticRoutes } from "./static"
export { default as publicRoutes } from "./public" export { default as publicRoutes } from "./public"
const aiRoutes = pro.ai
const appBackupRoutes = pro.appBackups const appBackupRoutes = pro.appBackups
const environmentVariableRoutes = pro.environmentVariables const environmentVariableRoutes = pro.environmentVariables
@ -67,6 +68,7 @@ export const mainRoutes: Router[] = [
debugRoutes, debugRoutes,
environmentVariableRoutes, environmentVariableRoutes,
rowActionRoutes, rowActionRoutes,
aiRoutes,
// these need to be handled last as they still use /api/:tableId // these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this // this could be breaking as koa may recognise other routes as this
tableRoutes, tableRoutes,

View File

@ -695,6 +695,69 @@ describe.each([
}) })
}) })
describe("options column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
status: {
name: "status",
type: FieldType.OPTIONS,
default: "requested",
constraints: {
inclusion: ["requested", "approved"],
},
},
},
})
)
})
it("creates a new row with a default value successfully", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.status).toEqual("requested")
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
status: "approved",
})
expect(row.status).toEqual("approved")
})
})
describe("array column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
food: {
name: "food",
type: FieldType.ARRAY,
default: ["apple", "orange"],
constraints: {
type: JsonFieldSubType.ARRAY,
inclusion: ["apple", "orange", "banana"],
},
},
},
})
)
})
it("creates a new row with a default value successfully", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.food).toEqual(["apple", "orange"])
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
food: ["orange"],
})
expect(row.food).toEqual(["orange"])
})
})
describe("bindings", () => { describe("bindings", () => {
describe("string column", () => { describe("string column", () => {
beforeAll(async () => { beforeAll(async () => {

File diff suppressed because it is too large Load Diff

View File

@ -22,9 +22,10 @@ import {
RelationshipType, RelationshipType,
TableSchema, TableSchema,
RenameColumn, RenameColumn,
ViewFieldMetadata,
FeatureFlag, FeatureFlag,
BBReferenceFieldSubType, BBReferenceFieldSubType,
ViewV2Schema,
ViewCalculationFieldMetadata,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
@ -154,7 +155,7 @@ describe.each([
}) })
it("can persist views with all fields", async () => { it("can persist views with all fields", async () => {
const newView: Required<CreateViewRequest> = { const newView: Required<Omit<CreateViewRequest, "queryUI">> = {
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: "id", primaryDisplay: "id",
@ -540,6 +541,33 @@ describe.each([
status: 201, status: 201,
}) })
}) })
it("can create a view with calculation fields", async () => {
let view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "Price",
},
},
})
expect(Object.keys(view.schema!)).toHaveLength(1)
let sum = view.schema!.sum as ViewCalculationFieldMetadata
expect(sum).toBeDefined()
expect(sum.calculationType).toEqual(CalculationType.SUM)
expect(sum.field).toEqual("Price")
view = await config.api.viewV2.get(view.id)
sum = view.schema!.sum as ViewCalculationFieldMetadata
expect(sum).toBeDefined()
expect(sum.calculationType).toEqual(CalculationType.SUM)
expect(sum.field).toEqual("Price")
})
}) })
describe("update", () => { describe("update", () => {
@ -584,7 +612,7 @@ describe.each([
it("can update all fields", async () => { it("can update all fields", async () => {
const tableId = table._id! const tableId = table._id!
const updatedData: Required<UpdateViewRequest> = { const updatedData: Required<Omit<UpdateViewRequest, "queryUI">> = {
version: view.version, version: view.version,
id: view.id, id: view.id,
tableId, tableId,
@ -1152,10 +1180,7 @@ describe.each([
return table return table
} }
const createView = async ( const createView = async (tableId: string, schema: ViewV2Schema) =>
tableId: string,
schema: Record<string, ViewFieldMetadata>
) =>
await config.api.viewV2.create({ await config.api.viewV2.create({
name: generator.guid(), name: generator.guid(),
tableId, tableId,
@ -1738,6 +1763,40 @@ describe.each([
}) })
}) })
it("views filters are respected even if the column is hidden", async () => {
await config.api.row.save(table._id!, {
one: "foo",
two: "bar",
})
const two = await config.api.row.save(table._id!, {
one: "foo2",
two: "bar2",
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
query: [
{
operator: BasicOperator.EQUAL,
field: "two",
value: "bar2",
},
],
schema: {
id: { visible: true },
one: { visible: false },
two: { visible: false },
},
})
const response = await config.api.viewV2.search(view.id)
expect(response.rows).toHaveLength(1)
expect(response.rows).toEqual([
expect.objectContaining({ _id: two._id }),
])
})
it("views without data can be returned", async () => { it("views without data can be returned", async () => {
const response = await config.api.viewV2.search(view.id) const response = await config.api.viewV2.search(view.id)
expect(response.rows).toHaveLength(0) expect(response.rows).toHaveLength(0)
@ -2424,6 +2483,138 @@ describe.each([
expect("_id" in row).toBe(false) expect("_id" in row).toBe(false)
} }
}) })
it("should be able to group by a basic field", async () => {
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
quantity: {
visible: true,
field: "quantity",
},
"Total Price": {
visible: true,
calculationType: CalculationType.SUM,
field: "price",
},
},
})
const response = await config.api.viewV2.search(view.id, {
query: {},
})
const priceByQuantity: Record<number, number> = {}
for (const row of rows) {
priceByQuantity[row.quantity] ??= 0
priceByQuantity[row.quantity] += row.price
}
for (const row of response.rows) {
expect(row["Total Price"]).toEqual(priceByQuantity[row.quantity])
}
})
it.each([
CalculationType.COUNT,
CalculationType.SUM,
CalculationType.AVG,
CalculationType.MIN,
CalculationType.MAX,
])("should be able to calculate $type", async type => {
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
aggregate: {
visible: true,
calculationType: type,
field: "price",
},
},
})
const response = await config.api.viewV2.search(view.id, {
query: {},
})
function calculate(
type: CalculationType,
numbers: number[]
): number {
switch (type) {
case CalculationType.COUNT:
return numbers.length
case CalculationType.SUM:
return numbers.reduce((a, b) => a + b, 0)
case CalculationType.AVG:
return numbers.reduce((a, b) => a + b, 0) / numbers.length
case CalculationType.MIN:
return Math.min(...numbers)
case CalculationType.MAX:
return Math.max(...numbers)
}
}
const prices = rows.map(row => row.price)
const expected = calculate(type, prices)
const actual = response.rows[0].aggregate
if (type === CalculationType.AVG) {
// The average calculation can introduce floating point rounding
// errors, so we need to compare to within a small margin of
// error.
expect(actual).toBeCloseTo(expected)
} else {
expect(actual).toEqual(expected)
}
})
})
!isLucene &&
it("should not need required fields to be present", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
age: {
name: "age",
type: FieldType.NUMBER,
},
},
})
)
await Promise.all([
config.api.row.save(table._id!, { name: "Steve", age: 30 }),
config.api.row.save(table._id!, { name: "Jane", age: 31 }),
])
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "age",
},
},
})
const response = await config.api.viewV2.search(view.id, {
query: {},
})
expect(response.rows).toHaveLength(1)
expect(response.rows[0].sum).toEqual(61)
}) })
}) })

View File

@ -17,44 +17,65 @@ describe("Branching automations", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
it("should run a multiple nested branching automation", async () => { it("should run a multiple nested branching automation", async () => {
const firstLogId = "11111111-1111-1111-1111-111111111111"
const branch1LogId = "22222222-2222-2222-2222-222222222222"
const branch2LogId = "33333333-3333-3333-3333-333333333333"
const branch2Id = "44444444-4444-4444-4444-444444444444"
const builder = createAutomationBuilder({ const builder = createAutomationBuilder({
name: "Test Trigger with Loop and Create Row", name: "Test Trigger with Loop and Create Row",
}) })
const results = await builder const results = await builder
.appAction({ fields: {} }) .appAction({ fields: {} })
.serverLog({ text: "Starting automation" }) .serverLog(
{ text: "Starting automation" },
{ stepName: "FirstLog", stepId: firstLogId }
)
.branch({ .branch({
topLevelBranch1: { topLevelBranch1: {
steps: stepBuilder => steps: stepBuilder =>
stepBuilder.serverLog({ text: "Branch 1" }).branch({ stepBuilder
branch1: { .serverLog(
steps: stepBuilder => { text: "Branch 1" },
stepBuilder.serverLog({ text: "Branch 1.1" }), { stepId: "66666666-6666-6666-6666-666666666666" }
condition: { )
equal: { "{{steps.1.success}}": true }, .branch({
branch1: {
steps: stepBuilder =>
stepBuilder.serverLog(
{ text: "Branch 1.1" },
{ stepId: branch1LogId }
),
condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: true },
},
}, },
}, branch2: {
branch2: { steps: stepBuilder =>
steps: stepBuilder => stepBuilder.serverLog(
stepBuilder.serverLog({ text: "Branch 1.2" }), { text: "Branch 1.2" },
condition: { { stepId: branch2LogId }
equal: { "{{steps.1.success}}": false }, ),
condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: false },
},
}, },
}, }),
}),
condition: { condition: {
equal: { "{{steps.1.success}}": true }, equal: { [`{{ steps.${firstLogId}.success }}`]: true },
}, },
}, },
topLevelBranch2: { topLevelBranch2: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), steps: stepBuilder =>
stepBuilder.serverLog({ text: "Branch 2" }, { stepId: branch2Id }),
condition: { condition: {
equal: { "{{steps.1.success}}": false }, equal: { [`{{ steps.${firstLogId}.success }}`]: false },
}, },
}, },
}) })
.run() .run()
expect(results.steps[3].outputs.status).toContain("branch1 branch taken") expect(results.steps[3].outputs.status).toContain("branch1 branch taken")
expect(results.steps[4].outputs.message).toContain("Branch 1.1") expect(results.steps[4].outputs.message).toContain("Branch 1.1")
}) })

View File

@ -64,18 +64,18 @@ class BaseStepBuilder {
stepId: TStep, stepId: TStep,
stepSchema: Omit<AutomationStep, "id" | "stepId" | "inputs">, stepSchema: Omit<AutomationStep, "id" | "stepId" | "inputs">,
inputs: AutomationStepInputs<TStep>, inputs: AutomationStepInputs<TStep>,
stepName?: string opts?: { stepName?: string; stepId?: string }
): this { ): this {
const id = uuidv4() const id = opts?.stepId || uuidv4()
this.steps.push({ this.steps.push({
...stepSchema, ...stepSchema,
inputs: inputs as any, inputs: inputs as any,
id, id,
stepId, stepId,
name: stepName || stepSchema.name, name: opts?.stepName || stepSchema.name,
}) })
if (stepName) { if (opts?.stepName) {
this.stepNames[id] = stepName this.stepNames[id] = opts.stepName
} }
return this return this
} }
@ -95,7 +95,6 @@ class BaseStepBuilder {
}) })
branchStepInputs.children![key] = stepBuilder.build() branchStepInputs.children![key] = stepBuilder.build()
}) })
const branchStep: AutomationStep = { const branchStep: AutomationStep = {
...definition, ...definition,
id: uuidv4(), id: uuidv4(),
@ -106,80 +105,98 @@ class BaseStepBuilder {
} }
// STEPS // STEPS
createRow(inputs: CreateRowStepInputs, opts?: { stepName?: string }): this { createRow(
inputs: CreateRowStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step( return this.step(
AutomationActionStepId.CREATE_ROW, AutomationActionStepId.CREATE_ROW,
BUILTIN_ACTION_DEFINITIONS.CREATE_ROW, BUILTIN_ACTION_DEFINITIONS.CREATE_ROW,
inputs, inputs,
opts?.stepName opts
) )
} }
updateRow(inputs: UpdateRowStepInputs, opts?: { stepName?: string }): this { updateRow(
inputs: UpdateRowStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step( return this.step(
AutomationActionStepId.UPDATE_ROW, AutomationActionStepId.UPDATE_ROW,
BUILTIN_ACTION_DEFINITIONS.UPDATE_ROW, BUILTIN_ACTION_DEFINITIONS.UPDATE_ROW,
inputs, inputs,
opts?.stepName opts
) )
} }
deleteRow(inputs: DeleteRowStepInputs, opts?: { stepName?: string }): this { deleteRow(
inputs: DeleteRowStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step( return this.step(
AutomationActionStepId.DELETE_ROW, AutomationActionStepId.DELETE_ROW,
BUILTIN_ACTION_DEFINITIONS.DELETE_ROW, BUILTIN_ACTION_DEFINITIONS.DELETE_ROW,
inputs, inputs,
opts?.stepName opts
) )
} }
sendSmtpEmail( sendSmtpEmail(
inputs: SmtpEmailStepInputs, inputs: SmtpEmailStepInputs,
opts?: { stepName?: string } opts?: { stepName?: string; stepId?: string }
): this { ): this {
return this.step( return this.step(
AutomationActionStepId.SEND_EMAIL_SMTP, AutomationActionStepId.SEND_EMAIL_SMTP,
BUILTIN_ACTION_DEFINITIONS.SEND_EMAIL_SMTP, BUILTIN_ACTION_DEFINITIONS.SEND_EMAIL_SMTP,
inputs, inputs,
opts?.stepName opts
) )
} }
executeQuery( executeQuery(
inputs: ExecuteQueryStepInputs, inputs: ExecuteQueryStepInputs,
opts?: { stepName?: string } opts?: { stepName?: string; stepId?: string }
): this { ): this {
return this.step( return this.step(
AutomationActionStepId.EXECUTE_QUERY, AutomationActionStepId.EXECUTE_QUERY,
BUILTIN_ACTION_DEFINITIONS.EXECUTE_QUERY, BUILTIN_ACTION_DEFINITIONS.EXECUTE_QUERY,
inputs, inputs,
opts?.stepName opts
) )
} }
queryRows(inputs: QueryRowsStepInputs, opts?: { stepName?: string }): this { queryRows(
inputs: QueryRowsStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step( return this.step(
AutomationActionStepId.QUERY_ROWS, AutomationActionStepId.QUERY_ROWS,
BUILTIN_ACTION_DEFINITIONS.QUERY_ROWS, BUILTIN_ACTION_DEFINITIONS.QUERY_ROWS,
inputs, inputs,
opts?.stepName opts
) )
} }
loop(inputs: LoopStepInputs, opts?: { stepName?: string }): this { loop(
inputs: LoopStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step( return this.step(
AutomationActionStepId.LOOP, AutomationActionStepId.LOOP,
BUILTIN_ACTION_DEFINITIONS.LOOP, BUILTIN_ACTION_DEFINITIONS.LOOP,
inputs, inputs,
opts?.stepName opts
) )
} }
serverLog(input: ServerLogStepInputs, opts?: { stepName?: string }): this { serverLog(
input: ServerLogStepInputs,
opts?: { stepName?: string; stepId?: string }
): this {
return this.step( return this.step(
AutomationActionStepId.SERVER_LOG, AutomationActionStepId.SERVER_LOG,
BUILTIN_ACTION_DEFINITIONS.SERVER_LOG, BUILTIN_ACTION_DEFINITIONS.SERVER_LOG,
input, input,
opts?.stepName opts
) )
} }

View File

@ -23,8 +23,8 @@ import {
Row, Row,
Table, Table,
TableSchema, TableSchema,
ViewFieldMetadata,
ViewV2, ViewV2,
ViewV2Schema,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
@ -262,7 +262,7 @@ export async function squashLinks<T = Row[] | Row>(
FeatureFlag.ENRICHED_RELATIONSHIPS FeatureFlag.ENRICHED_RELATIONSHIPS
) )
let viewSchema: Record<string, ViewFieldMetadata> = {} let viewSchema: ViewV2Schema = {}
if (sdk.views.isView(source)) { if (sdk.views.isView(source)) {
if (helpers.views.isCalculationView(source)) { if (helpers.views.isCalculationView(source)) {
return enriched return enriched

View File

@ -15,7 +15,8 @@ export interface TriggerOutput {
export interface AutomationContext extends AutomationResults { export interface AutomationContext extends AutomationResults {
steps: any[] steps: any[]
stepsByName?: Record<string, any> stepsById: Record<string, any>
stepsByName: Record<string, any>
env?: Record<string, string> env?: Record<string, string>
trigger: any trigger: any
} }

View File

@ -1,7 +1,10 @@
import { import {
EmptyFilterOption, EmptyFilterOption,
LegacyFilter,
LogicalOperator,
Row, Row,
RowSearchParams, RowSearchParams,
SearchFilterKey,
SearchResponse, SearchResponse,
SortOrder, SortOrder,
Table, Table,
@ -14,9 +17,10 @@ import { ExportRowsParams, ExportRowsResult } from "./search/types"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import sdk from "../../index" import sdk from "../../index"
import { searchInputMapping } from "./search/utils" import { searchInputMapping } from "./search/utils"
import { features } from "@budibase/backend-core" import { db, features } from "@budibase/backend-core"
import tracer from "dd-trace" import tracer from "dd-trace"
import { getQueryableFields, removeInvalidFilters } from "./queryUtils" import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
import { enrichSearchContext } from "../../../api/controllers/row/utils"
export { isValidFilter } from "../../../integrations/utils" export { isValidFilter } from "../../../integrations/utils"
@ -34,7 +38,8 @@ function pickApi(tableId: any) {
} }
export async function search( export async function search(
options: RowSearchParams options: RowSearchParams,
context?: Record<string, any>
): Promise<SearchResponse<Row>> { ): Promise<SearchResponse<Row>> {
return await tracer.trace("search", async span => { return await tracer.trace("search", async span => {
span?.addTags({ span?.addTags({
@ -51,7 +56,92 @@ export async function search(
countRows: options.countRows, countRows: options.countRows,
}) })
options.query = dataFilters.cleanupQuery(options.query || {}) let source: Table | ViewV2
let table: Table
if (options.viewId) {
source = await sdk.views.get(options.viewId)
table = await sdk.views.getTable(source)
} else if (options.tableId) {
source = await sdk.tables.getTable(options.tableId)
table = source
} else {
throw new Error(`Must supply either a view ID or a table ID`)
}
const isExternalTable = isExternalTableID(table._id!)
if (options.query) {
const visibleFields = (
options.fields || Object.keys(table.schema)
).filter(field => table.schema[field]?.visible !== false)
const queryableFields = await getQueryableFields(table, visibleFields)
options.query = removeInvalidFilters(options.query, queryableFields)
} else {
options.query = {}
}
// need to make sure filters in correct shape before checking for view
options = searchInputMapping(table, options)
if (options.viewId) {
// Delete extraneous search params that cannot be overridden
delete options.query.onEmptyFilter
const view = source as ViewV2
// Enrich saved query with ephemeral query params.
// We prevent searching on any fields that are saved as part of the query, as
// that could let users find rows they should not be allowed to access.
let viewQuery = dataFilters.buildQueryLegacy(view.query) || {}
delete viewQuery?.onEmptyFilter
const sqsEnabled = await features.flags.isEnabled("SQS")
const supportsLogicalOperators =
isExternalTableID(view.tableId) || sqsEnabled
if (!supportsLogicalOperators) {
// In the unlikely event that a Grouped Filter is in a non-SQS environment
// It needs to be ignored entirely
let queryFilters: LegacyFilter[] = Array.isArray(view.query)
? view.query
: []
delete options.query.onEmptyFilter
// Extract existing fields
const existingFields =
queryFilters
?.filter(filter => filter.field)
.map(filter => db.removeKeyNumbering(filter.field)) || []
viewQuery ??= {}
// Carry over filters for unused fields
Object.keys(options.query).forEach(key => {
const operator = key as Exclude<SearchFilterKey, LogicalOperator>
Object.keys(options.query[operator] || {}).forEach(field => {
if (!existingFields.includes(db.removeKeyNumbering(field))) {
viewQuery![operator]![field] = options.query[operator]![field]
}
})
})
options.query = viewQuery
} else {
const conditions = viewQuery ? [viewQuery] : []
options.query = {
$and: {
conditions: [...conditions, options.query],
},
}
if (viewQuery.onEmptyFilter) {
options.query.onEmptyFilter = viewQuery.onEmptyFilter
}
}
}
if (context) {
options.query = await enrichSearchContext(options.query, context)
}
options.query = dataFilters.cleanupQuery(options.query)
options.query = dataFilters.fixupFilterArrays(options.query) options.query = dataFilters.fixupFilterArrays(options.query)
span.addTags({ span.addTags({
@ -72,30 +162,6 @@ export async function search(
options.sortOrder = options.sortOrder.toLowerCase() as SortOrder options.sortOrder = options.sortOrder.toLowerCase() as SortOrder
} }
let source: Table | ViewV2
let table: Table
if (options.viewId) {
source = await sdk.views.get(options.viewId)
table = await sdk.views.getTable(source)
options = searchInputMapping(table, options)
} else if (options.tableId) {
source = await sdk.tables.getTable(options.tableId)
table = source
options = searchInputMapping(table, options)
} else {
throw new Error(`Must supply either a view ID or a table ID`)
}
if (options.query) {
const visibleFields = (
options.fields || Object.keys(table.schema)
).filter(field => table.schema[field].visible !== false)
const queryableFields = await getQueryableFields(table, visibleFields)
options.query = removeInvalidFilters(options.query, queryableFields)
}
const isExternalTable = isExternalTableID(table._id!)
let result: SearchResponse<Row> let result: SearchResponse<Row>
if (isExternalTable) { if (isExternalTable) {
span?.addTags({ searchType: "external" }) span?.addTags({ searchType: "external" })

View File

@ -107,7 +107,9 @@ export function searchInputMapping(table: Table, options: RowSearchParams) {
} }
return dataFilters.recurseLogicalOperators(filters, checkFilters) return dataFilters.recurseLogicalOperators(filters, checkFilters)
} }
options.query = checkFilters(options.query) if (options.query) {
options.query = checkFilters(options.query)
}
return options return options
} }

View File

@ -20,7 +20,7 @@ import { Format } from "../../../api/controllers/view/exporters"
import sdk from "../.." import sdk from "../.."
import { extractViewInfoFromID, isRelationshipColumn } from "../../../db/utils" import { extractViewInfoFromID, isRelationshipColumn } from "../../../db/utils"
import { isSQL } from "../../../integrations/utils" import { isSQL } from "../../../integrations/utils"
import { docIds } from "@budibase/backend-core" import { docIds, sql } from "@budibase/backend-core"
import { getTableFromSource } from "../../../api/controllers/row/utils" import { getTableFromSource } from "../../../api/controllers/row/utils"
const SQL_CLIENT_SOURCE_MAP: Record<SourceName, SqlClient | undefined> = { const SQL_CLIENT_SOURCE_MAP: Record<SourceName, SqlClient | undefined> = {
@ -57,8 +57,12 @@ export function getSQLClient(datasource: Datasource): SqlClient {
export function processRowCountResponse( export function processRowCountResponse(
response: DatasourcePlusQueryResponse response: DatasourcePlusQueryResponse
): number { ): number {
if (response && response.length === 1 && "total" in response[0]) { if (
const total = response[0].total response &&
response.length === 1 &&
sql.COUNT_FIELD_NAME in response[0]
) {
const total = response[0][sql.COUNT_FIELD_NAME]
return typeof total === "number" ? total : parseInt(total) return typeof total === "number" ? total : parseInt(total)
} else { } else {
throw new Error("Unable to count rows in query - no count response") throw new Error("Unable to count rows in query - no count response")

View File

@ -255,19 +255,12 @@ export async function enrichSchema(
view: ViewV2, view: ViewV2,
tableSchema: TableSchema tableSchema: TableSchema
): Promise<ViewV2Enriched> { ): Promise<ViewV2Enriched> {
const tableCache: Record<string, Table> = {}
async function populateRelTableSchema( async function populateRelTableSchema(
tableId: string, tableId: string,
viewFields: Record<string, RelationSchemaField> viewFields: Record<string, RelationSchemaField>
) { ) {
if (!tableCache[tableId]) { const relTable = await sdk.tables.getTable(tableId)
tableCache[tableId] = await sdk.tables.getTable(tableId)
}
const relTable = tableCache[tableId]
const result: Record<string, ViewV2ColumnEnriched> = {} const result: Record<string, ViewV2ColumnEnriched> = {}
for (const relTableFieldName of Object.keys(relTable.schema)) { for (const relTableFieldName of Object.keys(relTable.schema)) {
const relTableField = relTable.schema[relTableFieldName] const relTableField = relTable.schema[relTableFieldName]
if ([FieldType.LINK, FieldType.FORMULA].includes(relTableField.type)) { if ([FieldType.LINK, FieldType.FORMULA].includes(relTableField.type)) {
@ -296,15 +289,24 @@ export async function enrichSchema(
const viewSchema = view.schema || {} const viewSchema = view.schema || {}
const anyViewOrder = Object.values(viewSchema).some(ui => ui.order != null) const anyViewOrder = Object.values(viewSchema).some(ui => ui.order != null)
for (const key of Object.keys(tableSchema).filter(
k => tableSchema[k].visible !== false const visibleSchemaFields = Object.keys(viewSchema).filter(key => {
)) { if (helpers.views.isCalculationField(viewSchema[key])) {
return viewSchema[key].visible !== false
}
return key in tableSchema && tableSchema[key].visible !== false
})
const visibleTableFields = Object.keys(tableSchema).filter(
key => tableSchema[key].visible !== false
)
const visibleFields = new Set([...visibleSchemaFields, ...visibleTableFields])
for (const key of visibleFields) {
// if nothing specified in view, then it is not visible // if nothing specified in view, then it is not visible
const ui = viewSchema[key] || { visible: false } const ui = viewSchema[key] || { visible: false }
schema[key] = { schema[key] = {
...tableSchema[key], ...tableSchema[key],
...ui, ...ui,
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order, order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key]?.order,
columns: undefined, columns: undefined,
} }
@ -316,10 +318,7 @@ export async function enrichSchema(
} }
} }
return { return { ...view, schema }
...view,
schema: schema,
}
} }
export function syncSchema( export function syncSchema(

View File

@ -130,6 +130,26 @@ export function getUserContextBindings(user: ContextUser) {
return {} return {}
} }
// Current user context for bindable search // Current user context for bindable search
const { _id, _rev, firstName, lastName, email, status, roleId } = user const {
return { _id, _rev, firstName, lastName, email, status, roleId } _id,
_rev,
firstName,
lastName,
email,
status,
roleId,
globalId,
userId,
} = user
return {
_id,
_rev,
firstName,
lastName,
email,
status,
roleId,
globalId,
userId,
}
} }

View File

@ -7,11 +7,11 @@ import {
BulkImportRequest, BulkImportRequest,
BulkImportResponse, BulkImportResponse,
SearchRowResponse, SearchRowResponse,
RowSearchParams,
DeleteRows, DeleteRows,
DeleteRow, DeleteRow,
PaginatedSearchRowResponse, PaginatedSearchRowResponse,
RowExportFormat, RowExportFormat,
SearchRowRequest,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -136,7 +136,7 @@ export class RowAPI extends TestAPI {
) )
} }
search = async <T extends RowSearchParams>( search = async <T extends SearchRowRequest>(
sourceId: string, sourceId: string,
params?: T, params?: T,
expectations?: Expectations expectations?: Expectations

View File

@ -74,7 +74,7 @@ class Orchestrator {
private job: Job private job: Job
private loopStepOutputs: LoopStep[] private loopStepOutputs: LoopStep[]
private stopped: boolean private stopped: boolean
private executionOutput: AutomationContext private executionOutput: Omit<AutomationContext, "stepsByName" | "stepsById">
constructor(job: AutomationJob) { constructor(job: AutomationJob) {
let automation = job.data.automation let automation = job.data.automation
@ -91,6 +91,7 @@ class Orchestrator {
// step zero is never used as the template string is zero indexed for customer facing // step zero is never used as the template string is zero indexed for customer facing
this.context = { this.context = {
steps: [{}], steps: [{}],
stepsById: {},
stepsByName: {}, stepsByName: {},
trigger: triggerOutput, trigger: triggerOutput,
} }
@ -457,8 +458,9 @@ class Orchestrator {
inputs: steps[stepToLoopIndex].inputs, inputs: steps[stepToLoopIndex].inputs,
}) })
this.context.stepsById[steps[stepToLoopIndex].id] = tempOutput
const stepName = steps[stepToLoopIndex].name || steps[stepToLoopIndex].id const stepName = steps[stepToLoopIndex].name || steps[stepToLoopIndex].id
this.context.stepsByName![stepName] = tempOutput this.context.stepsByName[stepName] = tempOutput
this.context.steps[this.context.steps.length] = tempOutput this.context.steps[this.context.steps.length] = tempOutput
this.context.steps = this.context.steps.filter( this.context.steps = this.context.steps.filter(
item => !item.hasOwnProperty.call(item, "currentItem") item => !item.hasOwnProperty.call(item, "currentItem")
@ -517,7 +519,10 @@ class Orchestrator {
Object.entries(filter).forEach(([_, value]) => { Object.entries(filter).forEach(([_, value]) => {
Object.entries(value).forEach(([field, _]) => { Object.entries(value).forEach(([field, _]) => {
const updatedField = field.replace("{{", "{{ literal ") const updatedField = field.replace("{{", "{{ literal ")
const fromContext = processStringSync(updatedField, this.context) const fromContext = processStringSync(
updatedField,
this.processContext(this.context)
)
toFilter[field] = fromContext toFilter[field] = fromContext
}) })
}) })
@ -563,9 +568,9 @@ class Orchestrator {
} }
const stepFn = await this.getStepFunctionality(step.stepId) const stepFn = await this.getStepFunctionality(step.stepId)
let inputs = await this.addContextAndProcess( let inputs = await processObject(
originalStepInput, originalStepInput,
this.context this.processContext(this.context)
) )
inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs) inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs)
@ -594,16 +599,16 @@ class Orchestrator {
return null return null
} }
private async addContextAndProcess(inputs: any, context: any) { private processContext(context: AutomationContext) {
const processContext = { const processContext = {
...context, ...context,
steps: { steps: {
...context.steps, ...context.steps,
...context.stepsById,
...context.stepsByName, ...context.stepsByName,
}, },
} }
return processContext
return processObject(inputs, processContext)
} }
private handleStepOutput( private handleStepOutput(
@ -623,6 +628,7 @@ class Orchestrator {
} else { } else {
this.updateExecutionOutput(step.id, step.stepId, step.inputs, outputs) this.updateExecutionOutput(step.id, step.stepId, step.inputs, outputs)
this.context.steps[this.context.steps.length] = outputs this.context.steps[this.context.steps.length] = outputs
this.context.stepsById![step.id] = outputs
const stepName = step.name || step.id const stepName = step.name || step.id
this.context.stepsByName![stepName] = outputs this.context.stepsByName![stepName] = outputs
} }

View File

@ -134,8 +134,10 @@ async function processDefaultValues(table: Table, row: Row) {
for (const [key, schema] of Object.entries(table.schema)) { for (const [key, schema] of Object.entries(table.schema)) {
if ("default" in schema && schema.default != null && row[key] == null) { if ("default" in schema && schema.default != null && row[key] == null) {
const processed = await processString(schema.default, ctx) const processed =
typeof schema.default === "string"
? await processString(schema.default, ctx)
: schema.default
try { try {
row[key] = coerce(processed, schema.type) row[key] = coerce(processed, schema.type)
} catch (err: any) { } catch (err: any) {
@ -426,6 +428,25 @@ export async function coreOutputProcessing(
} }
} }
} }
if (sdk.views.isView(source)) {
const calculationFields = Object.keys(
helpers.views.calculationFields(source)
)
// We ensure all calculation fields are returned as numbers. During the
// testing of this feature it was discovered that the COUNT operation
// returns a string for MySQL, MariaDB, and Postgres. But given that all
// calculation fields should be numbers, we blanket make sure of that
// here.
for (const key of calculationFields) {
for (const row of rows) {
if (typeof row[key] === "string") {
row[key] = parseFloat(row[key])
}
}
}
}
} }
if (!isUserMetadataTable(table._id!)) { if (!isUserMetadataTable(table._id!)) {

View File

@ -3,7 +3,7 @@ import {
BBReferenceFieldSubType, BBReferenceFieldSubType,
FieldType, FieldType,
FormulaType, FormulaType,
SearchFilter, LegacyFilter,
SearchFilters, SearchFilters,
SearchQueryFields, SearchQueryFields,
ArrayOperator, ArrayOperator,
@ -127,7 +127,7 @@ export function recurseLogicalOperators(
fn: (f: SearchFilters) => SearchFilters fn: (f: SearchFilters) => SearchFilters
) { ) {
for (const logical of LOGICAL_OPERATORS) { for (const logical of LOGICAL_OPERATORS) {
if (filters?.[logical]) { if (filters[logical]) {
filters[logical]!.conditions = filters[logical]!.conditions.map( filters[logical]!.conditions = filters[logical]!.conditions.map(
condition => fn(condition) condition => fn(condition)
) )
@ -163,9 +163,6 @@ export function recurseSearchFilters(
* https://github.com/Budibase/budibase/issues/10118 * https://github.com/Budibase/budibase/issues/10118
*/ */
export const cleanupQuery = (query: SearchFilters) => { export const cleanupQuery = (query: SearchFilters) => {
if (!query) {
return query
}
for (let filterField of NoEmptyFilterStrings) { for (let filterField of NoEmptyFilterStrings) {
if (!query[filterField]) { if (!query[filterField]) {
continue continue
@ -311,7 +308,7 @@ export class ColumnSplitter {
* @param filter the builder filter structure * @param filter the builder filter structure
*/ */
const buildCondition = (expression: SearchFilter) => { const buildCondition = (expression: LegacyFilter) => {
// Filter body // Filter body
let query: SearchFilters = { let query: SearchFilters = {
string: {}, string: {},
@ -437,8 +434,13 @@ const buildCondition = (expression: SearchFilter) => {
} }
export const buildQueryLegacy = ( export const buildQueryLegacy = (
filter?: SearchFilterGroup | SearchFilter[] filter?: LegacyFilter[] | SearchFilters
): SearchFilters | undefined => { ): SearchFilters | undefined => {
// this is of type SearchFilters or is undefined
if (!Array.isArray(filter)) {
return filter
}
let query: SearchFilters = { let query: SearchFilters = {
string: {}, string: {},
fuzzy: {}, fuzzy: {},
@ -572,7 +574,7 @@ export const buildQueryLegacy = (
*/ */
export const buildQuery = ( export const buildQuery = (
filter?: SearchFilterGroup | SearchFilter[] filter?: SearchFilterGroup | LegacyFilter[]
): SearchFilters | undefined => { ): SearchFilters | undefined => {
const parsedFilter: SearchFilterGroup | undefined = const parsedFilter: SearchFilterGroup | undefined =
processSearchFilters(filter) processSearchFilters(filter)
@ -594,7 +596,7 @@ export const buildQuery = (
const globalOperator: LogicalOperator = const globalOperator: LogicalOperator =
operatorMap[parsedFilter.logicalOperator as FilterGroupLogicalOperator] operatorMap[parsedFilter.logicalOperator as FilterGroupLogicalOperator]
const coreRequest: SearchFilters = { return {
...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}), ...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}),
[globalOperator]: { [globalOperator]: {
conditions: parsedFilter.groups?.map((group: SearchFilterGroup) => { conditions: parsedFilter.groups?.map((group: SearchFilterGroup) => {
@ -608,7 +610,6 @@ export const buildQuery = (
}), }),
}, },
} }
return coreRequest
} }
// The frontend can send single values for array fields sometimes, so to handle // The frontend can send single values for array fields sometimes, so to handle

View File

@ -1,4 +1,5 @@
import cronValidate from "cron-validate" import cronValidate from "cron-validate"
import cronParser from "cron-parser"
const INPUT_CRON_START = "(Input cron: " const INPUT_CRON_START = "(Input cron: "
const ERROR_SWAPS = { const ERROR_SWAPS = {
@ -30,6 +31,19 @@ function improveErrors(errors: string[]): string[] {
return finalErrors return finalErrors
} }
export function getNextExecutionDates(
cronExpression: string,
limit: number = 4
): string[] {
const parsed = cronParser.parseExpression(cronExpression)
const nextRuns = []
for (let i = 0; i < limit; i++) {
nextRuns.push(parsed.next().toString())
}
return nextRuns
}
export function validate( export function validate(
cronExpression: string cronExpression: string
): { valid: false; err: string[] } | { valid: true } { ): { valid: false; err: string[] } | { valid: true } {

View File

@ -53,8 +53,9 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
[FieldType.DATETIME]: true, [FieldType.DATETIME]: true,
[FieldType.LONGFORM]: true, [FieldType.LONGFORM]: true,
[FieldType.STRING]: true, [FieldType.STRING]: true,
[FieldType.OPTIONS]: true,
[FieldType.ARRAY]: true,
[FieldType.OPTIONS]: false,
[FieldType.AUTO]: false, [FieldType.AUTO]: false,
[FieldType.INTERNAL]: false, [FieldType.INTERNAL]: false,
[FieldType.BARCODEQR]: false, [FieldType.BARCODEQR]: false,
@ -64,7 +65,6 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
[FieldType.ATTACHMENTS]: false, [FieldType.ATTACHMENTS]: false,
[FieldType.ATTACHMENT_SINGLE]: false, [FieldType.ATTACHMENT_SINGLE]: false,
[FieldType.SIGNATURE_SINGLE]: false, [FieldType.SIGNATURE_SINGLE]: false,
[FieldType.ARRAY]: false,
[FieldType.LINK]: false, [FieldType.LINK]: false,
[FieldType.BB_REFERENCE]: false, [FieldType.BB_REFERENCE]: false,
[FieldType.BB_REFERENCE_SINGLE]: false, [FieldType.BB_REFERENCE_SINGLE]: false,

View File

@ -1,5 +1,5 @@
import { import {
SearchFilter, LegacyFilter,
SearchFilterGroup, SearchFilterGroup,
FilterGroupLogicalOperator, FilterGroupLogicalOperator,
SearchFilters, SearchFilters,
@ -9,6 +9,10 @@ import {
import * as Constants from "./constants" import * as Constants from "./constants"
import { removeKeyNumbering } from "./filters" import { removeKeyNumbering } from "./filters"
// an array of keys from filter type to properties that are in the type
// this can then be converted using .fromEntries to an object
type AllowedFilters = [keyof LegacyFilter, LegacyFilter[keyof LegacyFilter]][]
export function unreachable( export function unreachable(
value: never, value: never,
message = `No such case in exhaustive switch: ${value}` message = `No such case in exhaustive switch: ${value}`
@ -87,106 +91,6 @@ export function trimOtherProps(object: any, allowedProps: string[]) {
return result return result
} }
/**
* Processes the filter config. Filters are migrated from
* SearchFilter[] to SearchFilterGroup
*
* If filters is not an array, the migration is skipped
*
* @param {SearchFilter[] | SearchFilterGroup} filters
*/
export const processSearchFilters = (
filters: SearchFilter[] | SearchFilterGroup | undefined
): SearchFilterGroup | undefined => {
if (!filters) {
return
}
// Base search config.
const defaultCfg: SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator.ALL,
groups: [],
}
const filterWhitelistKeys = [
"field",
"operator",
"value",
"type",
"externalType",
"valueType",
"noValue",
"formulaType",
]
if (Array.isArray(filters)) {
let baseGroup: SearchFilterGroup = {
filters: [],
logicalOperator: FilterGroupLogicalOperator.ALL,
}
const migratedSetting: SearchFilterGroup = filters.reduce(
(acc: SearchFilterGroup, filter: SearchFilter) => {
// Sort the properties for easier debugging
const filterEntries = Object.entries(filter)
.sort((a, b) => {
return a[0].localeCompare(b[0])
})
.filter(x => x[1] ?? false)
if (filterEntries.length == 1) {
const [key, value] = filterEntries[0]
// Global
if (key === "onEmptyFilter") {
// unset otherwise
acc.onEmptyFilter = value
} else if (key === "operator" && value === "allOr") {
// Group 1 logical operator
baseGroup.logicalOperator = FilterGroupLogicalOperator.ANY
}
return acc
}
const whiteListedFilterSettings: [string, any][] = filterEntries.reduce(
(acc: [string, any][], entry: [string, any]) => {
const [key, value] = entry
if (filterWhitelistKeys.includes(key)) {
if (key === "field") {
acc.push([key, removeKeyNumbering(value)])
} else {
acc.push([key, value])
}
}
return acc
},
[]
)
const migratedFilter: SearchFilter = Object.fromEntries(
whiteListedFilterSettings
) as SearchFilter
baseGroup.filters!.push(migratedFilter)
if (!acc.groups || !acc.groups.length) {
// init the base group
acc.groups = [baseGroup]
}
return acc
},
defaultCfg
)
return migratedSetting
} else if (!filters?.groups) {
return
}
return filters
}
export function isSupportedUserSearch(query: SearchFilters) { export function isSupportedUserSearch(query: SearchFilters) {
const allowed = [ const allowed = [
{ op: BasicOperator.STRING, key: "email" }, { op: BasicOperator.STRING, key: "email" },
@ -212,3 +116,98 @@ export function isSupportedUserSearch(query: SearchFilters) {
} }
return true return true
} }
/**
* Processes the filter config. Filters are migrated from
* SearchFilter[] to SearchFilterGroup
*
* If filters is not an array, the migration is skipped
*
* @param {LegacyFilter[] | SearchFilterGroup} filters
*/
export const processSearchFilters = (
filters: LegacyFilter[] | SearchFilterGroup | undefined
): SearchFilterGroup | undefined => {
if (!filters) {
return
}
// Base search config.
const defaultCfg: SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator.ALL,
groups: [],
}
const filterAllowedKeys = [
"field",
"operator",
"value",
"type",
"externalType",
"valueType",
"noValue",
"formulaType",
]
if (Array.isArray(filters)) {
let baseGroup: SearchFilterGroup = {
filters: [],
logicalOperator: FilterGroupLogicalOperator.ALL,
}
return filters.reduce((acc: SearchFilterGroup, filter: LegacyFilter) => {
// Sort the properties for easier debugging
const filterPropertyKeys = (Object.keys(filter) as (keyof LegacyFilter)[])
.sort((a, b) => {
return a.localeCompare(b)
})
.filter(key => key in filter)
if (filterPropertyKeys.length == 1) {
const key = filterPropertyKeys[0],
value = filter[key]
// Global
if (key === "onEmptyFilter") {
// unset otherwise
acc.onEmptyFilter = value
} else if (key === "operator" && value === "allOr") {
// Group 1 logical operator
baseGroup.logicalOperator = FilterGroupLogicalOperator.ANY
}
return acc
}
const allowedFilterSettings: AllowedFilters = filterPropertyKeys.reduce(
(acc: AllowedFilters, key) => {
const value = filter[key]
if (filterAllowedKeys.includes(key)) {
if (key === "field") {
acc.push([key, removeKeyNumbering(value)])
} else {
acc.push([key, value])
}
}
return acc
},
[]
)
const migratedFilter: LegacyFilter = Object.fromEntries(
allowedFilterSettings
) as LegacyFilter
baseGroup.filters!.push(migratedFilter)
if (!acc.groups || !acc.groups.length) {
// init the base group
acc.groups = [baseGroup]
}
return acc
}, defaultCfg)
} else if (!filters?.groups) {
return
}
return filters
}

View File

@ -5,7 +5,7 @@ import {
SearchFilters, SearchFilters,
} from "../../sdk" } from "../../sdk"
export type SearchFilter = { export type LegacyFilter = {
operator: keyof SearchFilters | "rangeLow" | "rangeHigh" operator: keyof SearchFilters | "rangeLow" | "rangeHigh"
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
field: string field: string
@ -14,9 +14,10 @@ export type SearchFilter = {
externalType?: string externalType?: string
} }
// this is a type purely used by the UI
export type SearchFilterGroup = { export type SearchFilterGroup = {
logicalOperator: FilterGroupLogicalOperator logicalOperator: FilterGroupLogicalOperator
onEmptyFilter?: EmptyFilterOption onEmptyFilter?: EmptyFilterOption
groups?: SearchFilterGroup[] groups?: SearchFilterGroup[]
filters?: SearchFilter[] filters?: LegacyFilter[]
} }

View File

@ -161,6 +161,7 @@ export interface OptionsFieldMetadata extends BaseFieldSchema {
constraints: FieldConstraints & { constraints: FieldConstraints & {
inclusion: string[] inclusion: string[]
} }
default?: string
} }
export interface ArrayFieldMetadata extends BaseFieldSchema { export interface ArrayFieldMetadata extends BaseFieldSchema {
@ -169,6 +170,7 @@ export interface ArrayFieldMetadata extends BaseFieldSchema {
type: JsonFieldSubType.ARRAY type: JsonFieldSubType.ARRAY
inclusion: string[] inclusion: string[]
} }
default?: string[]
} }
interface BaseFieldSchema extends UIFieldMetadata { interface BaseFieldSchema extends UIFieldMetadata {

View File

@ -1,7 +1,7 @@
import { SearchFilter, SearchFilterGroup, SortOrder, SortType } from "../../api" import { LegacyFilter, SearchFilterGroup, SortOrder, SortType } from "../../api"
import { UIFieldMetadata } from "./table" import { UIFieldMetadata } from "./table"
import { Document } from "../document" import { Document } from "../document"
import { DBView } from "../../sdk" import { DBView, SearchFilters } from "../../sdk"
export type ViewTemplateOpts = { export type ViewTemplateOpts = {
field: string field: string
@ -65,16 +65,20 @@ export interface ViewV2 {
name: string name: string
primaryDisplay?: string primaryDisplay?: string
tableId: string tableId: string
query?: SearchFilter[] | SearchFilterGroup query?: LegacyFilter[] | SearchFilters
// duplicate to store UI information about filters
queryUI?: SearchFilterGroup
sort?: { sort?: {
field: string field: string
order?: SortOrder order?: SortOrder
type?: SortType type?: SortType
} }
schema?: Record<string, ViewFieldMetadata> schema?: ViewV2Schema
uiMetadata?: Record<string, any> uiMetadata?: Record<string, any>
} }
export type ViewV2Schema = Record<string, ViewFieldMetadata>
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
export interface ViewCountOrSumSchema { export interface ViewCountOrSumSchema {

584
yarn.lock

File diff suppressed because it is too large Load Diff