Merge branch 'v3-ui' of github.com:Budibase/budibase into view-calculation-ui
This commit is contained in:
commit
0f95c8a1c9
|
@ -23,6 +23,7 @@ jobs:
|
|||
PAYLOAD_BRANCH: ${{ github.head_ref }}
|
||||
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PAYLOAD_LICENSE_TYPE: "free"
|
||||
PAYLOAD_DEPLOY: "true"
|
||||
with:
|
||||
repository: budibase/budibase-deploys
|
||||
event: featurebranch-qa-deploy
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
nodejs 20.10.0
|
||||
python 3.10.0
|
||||
yarn 1.22.19
|
||||
yarn 1.22.22
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "2.32.9",
|
||||
"version": "2.32.11",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 * as designDoc from "./designDoc"
|
||||
|
|
|
@ -43,6 +43,8 @@ import { cloneDeep } from "lodash"
|
|||
|
||||
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
|
||||
|
||||
export const COUNT_FIELD_NAME = "__bb_total"
|
||||
|
||||
function getBaseLimit() {
|
||||
const envLimit = 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[] {
|
||||
if (Array.isArray(query)) {
|
||||
return query.map((q: SqlQuery) => convertBooleans(q) as SqlQuery)
|
||||
|
@ -99,6 +89,13 @@ function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
|
|||
return query
|
||||
}
|
||||
|
||||
function isSqs(table: Table): boolean {
|
||||
return (
|
||||
table.sourceType === TableSourceType.INTERNAL ||
|
||||
table.sourceId === INTERNAL_TABLE_SOURCE_ID
|
||||
)
|
||||
}
|
||||
|
||||
class InternalBuilder {
|
||||
private readonly client: SqlClient
|
||||
private readonly query: QueryJson
|
||||
|
@ -180,15 +177,13 @@ class InternalBuilder {
|
|||
}
|
||||
|
||||
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) {
|
||||
return "*"
|
||||
}
|
||||
|
||||
const alias = tableAliases?.[endpoint.entityId]
|
||||
? tableAliases?.[endpoint.entityId]
|
||||
: endpoint.entityId
|
||||
const alias = this.getTableName(endpoint.entityId)
|
||||
const schema = meta.table.schema
|
||||
if (!this.isFullSelectStatementRequired()) {
|
||||
return [this.knex.raw(`${this.quote(alias)}.*`)]
|
||||
|
@ -813,17 +808,48 @@ class InternalBuilder {
|
|||
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 {
|
||||
const primary = 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) {
|
||||
if (!this.table.primary) {
|
||||
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(
|
||||
|
@ -831,12 +857,14 @@ class InternalBuilder {
|
|||
aggregations: Aggregation[]
|
||||
): Knex.QueryBuilder {
|
||||
const fields = this.query.resource?.fields || []
|
||||
const tableName = this.getTableName()
|
||||
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) {
|
||||
const op = aggregation.calculationType
|
||||
const field = `${this.table.name}.${aggregation.field} as ${aggregation.name}`
|
||||
const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
|
||||
switch (op) {
|
||||
case CalculationType.COUNT:
|
||||
query = query.count(field)
|
||||
|
@ -861,10 +889,7 @@ class InternalBuilder {
|
|||
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
|
||||
let { sort, resource } = this.query
|
||||
const primaryKey = this.table.primary
|
||||
const tableName = getTableName(this.table)
|
||||
const aliases = this.query.tableAliases
|
||||
const aliased =
|
||||
tableName && aliases?.[tableName] ? aliases[tableName] : this.table?.name
|
||||
const aliased = this.getTableName()
|
||||
if (!Array.isArray(primaryKey)) {
|
||||
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 }]
|
||||
}
|
||||
|
||||
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>>(
|
||||
table: Table,
|
||||
results: T[],
|
||||
aliases?: Record<string, string>
|
||||
): T[] {
|
||||
const tableName = getTableName(table)
|
||||
const tableName = this.getTableName(table, aliases)
|
||||
for (const [name, field] of Object.entries(table.schema)) {
|
||||
if (!this._isJsonColumn(field)) {
|
||||
continue
|
||||
}
|
||||
const aliasedTableName = (tableName && aliases?.[tableName]) || tableName
|
||||
const fullName = `${aliasedTableName}.${name}`
|
||||
const fullName = `${tableName}.${name}` as keyof T
|
||||
for (let row of results) {
|
||||
if (typeof row[fullName as keyof T] === "string") {
|
||||
row[fullName as keyof T] = JSON.parse(row[fullName])
|
||||
if (typeof row[fullName] === "string") {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -67,6 +67,7 @@
|
|||
"@spectrum-css/vars": "^3.0.1",
|
||||
"@zerodevx/svelte-json-view": "^1.0.7",
|
||||
"codemirror": "^5.65.16",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.10.8",
|
||||
"downloadjs": "1.4.7",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
|
|
|
@ -1062,7 +1062,7 @@
|
|||
{:else if value.customType === "cron"}
|
||||
<CronBuilder
|
||||
on:change={e => onChange({ [key]: e.detail })}
|
||||
value={inputData[key]}
|
||||
cronExpression={inputData[key]}
|
||||
/>
|
||||
{:else if value.customType === "automationFields"}
|
||||
<AutomationSelector
|
||||
|
|
|
@ -1,41 +1,70 @@
|
|||
<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 { 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"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let value
|
||||
export let cronExpression
|
||||
|
||||
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)
|
||||
const customIndex = CRON_EXPRESSIONS.findIndex(
|
||||
cron => cron.label === "Custom"
|
||||
)
|
||||
|
||||
if (!exists && customIndex === -1) {
|
||||
CRON_EXPRESSIONS[0] = { label: "Custom", value: value }
|
||||
} else if (exists && customIndex !== -1) {
|
||||
CRON_EXPRESSIONS.splice(customIndex, 1)
|
||||
if (cronExpression) {
|
||||
try {
|
||||
nextExecutions = helpers.cron
|
||||
.getNextExecutionDates(cronExpression)
|
||||
.join("\n")
|
||||
} catch (err) {
|
||||
nextExecutions = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onChange = e => {
|
||||
if (value !== REBOOT_CRON) {
|
||||
if (e.detail !== REBOOT_CRON) {
|
||||
error = helpers.cron.validate(e.detail).err
|
||||
}
|
||||
if (e.detail === value || error) {
|
||||
if (e.detail === cronExpression || error) {
|
||||
return
|
||||
}
|
||||
|
||||
value = e.detail
|
||||
cronExpression = 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 presets = false
|
||||
|
||||
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>
|
||||
|
||||
<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
|
||||
label="Cron Expression"
|
||||
{error}
|
||||
on:change={onChange}
|
||||
{value}
|
||||
on:change={updateCronExpression}
|
||||
value={cronExpression}
|
||||
on:blur={() => (touched = true)}
|
||||
updateOnChange={false}
|
||||
/>
|
||||
{#if touched && !value}
|
||||
{#if touched && !cronExpression}
|
||||
<Label><div class="error">Please specify a CRON expression</div></Label>
|
||||
{/if}
|
||||
<div class="presets">
|
||||
<Button on:click={() => (presets = !presets)}
|
||||
>{presets ? "Hide" : "Show"} Presets</Button
|
||||
>
|
||||
{#if presets}
|
||||
<Select
|
||||
on:change={onChange}
|
||||
value={value || "Custom"}
|
||||
secondary
|
||||
extraThin
|
||||
label="Presets"
|
||||
options={CRON_EXPRESSIONS}
|
||||
{#if nextExecutions}
|
||||
<InlineAlert
|
||||
type="info"
|
||||
header="Next Executions"
|
||||
message={nextExecutions}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.presets {
|
||||
margin-top: var(--spacing-m);
|
||||
.cron-ai-generator {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.block-field {
|
||||
padding-top: var(--spacing-s);
|
||||
.icon {
|
||||
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 {
|
||||
padding-top: var(--spacing-xs);
|
||||
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>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
Button,
|
||||
Label,
|
||||
Select,
|
||||
Multiselect,
|
||||
Toggle,
|
||||
Icon,
|
||||
DatePicker,
|
||||
|
@ -21,6 +22,7 @@
|
|||
PROTECTED_EXTERNAL_COLUMNS,
|
||||
canHaveDefaultColumn,
|
||||
} from "@budibase/shared-core"
|
||||
import { makePropSafe } from "@budibase/string-templates"
|
||||
import { createEventDispatcher, getContext, onMount } from "svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { tables, datasources } from "stores/builder"
|
||||
|
@ -46,6 +48,7 @@
|
|||
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||
import OptionsEditor from "./OptionsEditor.svelte"
|
||||
import { isEnabled } from "helpers/featureFlags"
|
||||
import { getUserBindings } from "dataBinding"
|
||||
|
||||
const AUTO_TYPE = FieldType.AUTO
|
||||
const FORMULA_TYPE = FieldType.FORMULA
|
||||
|
@ -135,9 +138,10 @@
|
|||
}
|
||||
$: initialiseField(field, savingColumn)
|
||||
$: checkConstraints(editableColumn)
|
||||
$: required = hasDefault
|
||||
? false
|
||||
: !!editableColumn?.constraints?.presence || primaryDisplay
|
||||
$: required =
|
||||
primaryDisplay ||
|
||||
editableColumn?.constraints?.presence === true ||
|
||||
editableColumn?.constraints?.presence?.allowEmpty === false
|
||||
$: uneditable =
|
||||
$tables.selected?._id === TableNames.USERS &&
|
||||
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
|
||||
|
@ -166,8 +170,8 @@
|
|||
// used to select what different options can be displayed for column type
|
||||
$: canBeDisplay =
|
||||
canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
|
||||
$: canHaveDefault =
|
||||
isEnabled("DEFAULT_VALUES") && canHaveDefaultColumn(editableColumn.type)
|
||||
$: defaultValuesEnabled = isEnabled("DEFAULT_VALUES")
|
||||
$: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type)
|
||||
$: canBeRequired =
|
||||
editableColumn?.type !== LINK_TYPE &&
|
||||
!uneditable &&
|
||||
|
@ -186,11 +190,28 @@
|
|||
(originalName &&
|
||||
SWITCHABLE_TYPES[field.type] &&
|
||||
!editableColumn?.autocolumn)
|
||||
|
||||
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
|
||||
fieldId: makeFieldId(t.type, t.subtype),
|
||||
...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(
|
||||
// Storing the fields by complex field id
|
||||
|
@ -280,6 +301,22 @@
|
|||
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 {
|
||||
await tables.saveField({
|
||||
originalName,
|
||||
|
@ -526,6 +563,20 @@
|
|||
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(() => {
|
||||
mounted = true
|
||||
})
|
||||
|
@ -733,9 +784,9 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else if editableColumn.type === JSON_TYPE}
|
||||
<Button primary text on:click={openJsonSchemaEditor}
|
||||
>Open schema editor</Button
|
||||
>
|
||||
<Button primary text on:click={openJsonSchemaEditor}>
|
||||
Open schema editor
|
||||
</Button>
|
||||
{/if}
|
||||
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
|
||||
<Select
|
||||
|
@ -764,28 +815,39 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canHaveDefault}
|
||||
<div>
|
||||
<ModalBindableInput
|
||||
panel={ServerBindingPanel}
|
||||
title="Default"
|
||||
label="Default"
|
||||
{#if defaultValuesEnabled}
|
||||
{#if editableColumn.type === FieldType.OPTIONS}
|
||||
<Select
|
||||
disabled={!canHaveDefault}
|
||||
options={editableColumn.constraints?.inclusion || []}
|
||||
label="Default value"
|
||||
value={editableColumn.default}
|
||||
on:change={e => {
|
||||
editableColumn = {
|
||||
...editableColumn,
|
||||
default: e.detail,
|
||||
}
|
||||
|
||||
if (e.detail) {
|
||||
setRequired(false)
|
||||
}
|
||||
}}
|
||||
bindings={getBindings({ table })}
|
||||
allowJS
|
||||
context={rowGoldenSample}
|
||||
on:change={e => (editableColumn.default = e.detail)}
|
||||
placeholder="None"
|
||||
/>
|
||||
</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}
|
||||
</Layout>
|
||||
|
||||
|
|
|
@ -2,7 +2,12 @@
|
|||
import { getContext } from "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>
|
||||
|
||||
<CreateEditColumn on:updatecolumns={datasource.actions.refreshDefinition} />
|
||||
<CreateEditColumn on:updatecolumns={onUpdate} />
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import { admin, themeStore } from "stores/portal"
|
||||
import { Grid } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
|
||||
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
|
||||
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
|
||||
|
@ -34,6 +35,7 @@
|
|||
text: action.name,
|
||||
onClick: async row => {
|
||||
await rowActions.trigger(id, action.id, row._id)
|
||||
notifications.success("Row action triggered successfully")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { Banner } from "@budibase/bbui"
|
||||
import { Banner, notifications } from "@budibase/bbui"
|
||||
import {
|
||||
datasources,
|
||||
tables,
|
||||
|
@ -67,6 +67,7 @@
|
|||
text: action.name,
|
||||
onClick: async row => {
|
||||
await rowActions.trigger(id, action.id, row._id)
|
||||
notifications.success("Row action triggered successfully")
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -1,6 +1,24 @@
|
|||
import { writable, derived, get } from "svelte/store"
|
||||
import { tables } from "./tables"
|
||||
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() {
|
||||
const store = writable({
|
||||
|
@ -12,7 +30,7 @@ export function createViewsV2Store() {
|
|||
const views = Object.values(table?.views || {}).filter(view => {
|
||||
return view.version === 2
|
||||
})
|
||||
list = list.concat(views)
|
||||
list = list.concat(views.map(view => convertToSearchFilterGroup(view)))
|
||||
})
|
||||
return {
|
||||
...$store,
|
||||
|
@ -34,6 +52,7 @@ export function createViewsV2Store() {
|
|||
}
|
||||
|
||||
const create = async view => {
|
||||
view = convertToSearchFilters(view)
|
||||
const savedViewResponse = await API.viewV2.create(view)
|
||||
const savedView = savedViewResponse.data
|
||||
replaceView(savedView.id, savedView)
|
||||
|
@ -41,6 +60,7 @@ export function createViewsV2Store() {
|
|||
}
|
||||
|
||||
const save = async view => {
|
||||
view = convertToSearchFilters(view)
|
||||
const res = await API.viewV2.update(view)
|
||||
const savedView = res?.data
|
||||
replaceView(view.id, savedView)
|
||||
|
@ -51,6 +71,7 @@ export function createViewsV2Store() {
|
|||
if (!viewId) {
|
||||
return
|
||||
}
|
||||
view = convertToSearchFilterGroup(view)
|
||||
const existingView = get(derivedStore).list.find(view => view.id === viewId)
|
||||
const tableIndex = get(tables).list.findIndex(table => {
|
||||
return table._id === view?.tableId || table._id === existingView?.tableId
|
||||
|
|
|
@ -63,7 +63,7 @@
|
|||
// Look up the component tree and find something that is provided by an
|
||||
// ancestor that matches our datasource. This is for backwards compatibility
|
||||
// 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
|
||||
if (
|
||||
dataSource.type === "viewV2" &&
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
},
|
||||
})
|
|
@ -2,6 +2,7 @@ import { Helpers } from "@budibase/bbui"
|
|||
import { Header } from "@budibase/shared-core"
|
||||
import { ApiVersion } from "../constants"
|
||||
import { buildAnalyticsEndpoints } from "./analytics"
|
||||
import { buildAIEndpoints } from "./ai"
|
||||
import { buildAppEndpoints } from "./app"
|
||||
import { buildAttachmentEndpoints } from "./attachments"
|
||||
import { buildAuthEndpoints } from "./auth"
|
||||
|
@ -269,6 +270,7 @@ export const createAPIClient = config => {
|
|||
// Attach all endpoints
|
||||
return {
|
||||
...API,
|
||||
...buildAIEndpoints(API),
|
||||
...buildAnalyticsEndpoints(API),
|
||||
...buildAppEndpoints(API),
|
||||
...buildAttachmentEndpoints(API),
|
||||
|
|
|
@ -98,7 +98,6 @@
|
|||
align="right"
|
||||
offset={5}
|
||||
size="S"
|
||||
quiet
|
||||
animate={false}
|
||||
on:mouseenter={() => ($hoveredRowId = row._id)}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { derived, get } from "svelte/store"
|
||||
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
|
||||
import { enrichSchemaWithRelColumns, memo } from "../../../utils"
|
||||
import { cloneDeep } from "lodash"
|
||||
|
||||
export const createStores = () => {
|
||||
const definition = memo(null)
|
||||
|
@ -164,10 +165,18 @@ export const createActions = context => {
|
|||
|
||||
// Updates the datasources primary display column
|
||||
const changePrimaryDisplay = async column => {
|
||||
return await saveDefinition({
|
||||
...get(definition),
|
||||
primaryDisplay: column,
|
||||
})
|
||||
let newDefinition = cloneDeep(get(definition))
|
||||
|
||||
// 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
|
||||
|
|
|
@ -1,4 +1,14 @@
|
|||
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
|
||||
|
||||
|
@ -6,7 +16,7 @@ export const createActions = context => {
|
|||
const { API, datasource, columns } = context
|
||||
|
||||
const saveDefinition = async newDefinition => {
|
||||
await API.viewV2.update(newDefinition)
|
||||
await API.viewV2.update(convertToSearchFilters(newDefinition))
|
||||
}
|
||||
|
||||
const saveRow = async row => {
|
||||
|
@ -125,7 +135,7 @@ export const initialise = context => {
|
|||
}
|
||||
// Only override filter state if we don't have an initial filter
|
||||
if (!get(initialFilter)) {
|
||||
filter.set($definition.query)
|
||||
filter.set($definition.queryUI || $definition.query)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit e2fe0f9cc856b4ee1a97df96d623b2d87d4e8733
|
||||
Subproject commit aca9828117bb97f54f40ee359f1a3f6e259174e7
|
|
@ -124,9 +124,11 @@ export async function buildSqlFieldList(
|
|||
([columnName, column]) =>
|
||||
column.type !== FieldType.LINK &&
|
||||
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[] = []
|
||||
|
|
|
@ -3,15 +3,11 @@ import {
|
|||
ViewV2,
|
||||
SearchRowResponse,
|
||||
SearchViewRowRequest,
|
||||
SearchFilterKey,
|
||||
LogicalOperator,
|
||||
SearchFilter,
|
||||
RequiredKeys,
|
||||
RowSearchParams,
|
||||
} from "@budibase/types"
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
import sdk from "../../../sdk"
|
||||
import { db, context, features } from "@budibase/backend-core"
|
||||
import { enrichSearchContext } from "./utils"
|
||||
import { isExternalTableID } from "../../../integrations/utils"
|
||||
import { context } from "@budibase/backend-core"
|
||||
|
||||
export async function searchView(
|
||||
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
|
||||
|
@ -26,77 +22,34 @@ export async function searchView(
|
|||
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 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)
|
||||
|
||||
const enrichedQuery = await enrichSearchContext(query || {}, {
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
|
||||
const result = await sdk.rows.search({
|
||||
viewId: view.id,
|
||||
const searchOptions: RequiredKeys<SearchViewRowRequest> &
|
||||
RequiredKeys<
|
||||
Pick<RowSearchParams, "tableId" | "viewId" | "query" | "fields">
|
||||
> = {
|
||||
tableId: view.tableId,
|
||||
query: enrichedQuery,
|
||||
viewId: view.id,
|
||||
query: body.query,
|
||||
fields: viewFields,
|
||||
...getSortOptions(body, view),
|
||||
limit: body.limit,
|
||||
bookmark: body.bookmark,
|
||||
paginate: body.paginate,
|
||||
countRows: body.countRows,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await sdk.rows.search(searchOptions, {
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
result.rows.forEach(r => (r._viewId = view.id))
|
||||
ctx.body = result
|
||||
}
|
||||
|
||||
function getSortOptions(request: SearchViewRowRequest, view: ViewV2) {
|
||||
if (request.sort) {
|
||||
return {
|
||||
|
|
|
@ -103,6 +103,7 @@ export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
|
|||
name: view.name,
|
||||
tableId: view.tableId,
|
||||
query: view.query,
|
||||
queryUI: view.queryUI,
|
||||
sort: view.sort,
|
||||
schema,
|
||||
primaryDisplay: view.primaryDisplay,
|
||||
|
@ -139,6 +140,7 @@ export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
|
|||
version: view.version,
|
||||
tableId: view.tableId,
|
||||
query: view.query,
|
||||
queryUI: view.queryUI,
|
||||
sort: view.sort,
|
||||
schema,
|
||||
primaryDisplay: view.primaryDisplay,
|
||||
|
|
|
@ -33,6 +33,7 @@ import rowActionRoutes from "./rowAction"
|
|||
export { default as staticRoutes } from "./static"
|
||||
export { default as publicRoutes } from "./public"
|
||||
|
||||
const aiRoutes = pro.ai
|
||||
const appBackupRoutes = pro.appBackups
|
||||
const environmentVariableRoutes = pro.environmentVariables
|
||||
|
||||
|
@ -67,6 +68,7 @@ export const mainRoutes: Router[] = [
|
|||
debugRoutes,
|
||||
environmentVariableRoutes,
|
||||
rowActionRoutes,
|
||||
aiRoutes,
|
||||
// these need to be handled last as they still use /api/:tableId
|
||||
// this could be breaking as koa may recognise other routes as this
|
||||
tableRoutes,
|
||||
|
|
|
@ -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("string column", () => {
|
||||
beforeAll(async () => {
|
||||
|
|
|
@ -28,11 +28,13 @@ import {
|
|||
RowSearchParams,
|
||||
SearchFilters,
|
||||
SearchResponse,
|
||||
SearchRowRequest,
|
||||
SortOrder,
|
||||
SortType,
|
||||
Table,
|
||||
TableSchema,
|
||||
User,
|
||||
ViewV2Schema,
|
||||
} from "@budibase/types"
|
||||
import _ from "lodash"
|
||||
import tk from "timekeeper"
|
||||
|
@ -64,18 +66,14 @@ describe.each([
|
|||
let envCleanup: (() => void) | undefined
|
||||
let datasource: Datasource | undefined
|
||||
let client: Knex | undefined
|
||||
let table: Table
|
||||
let tableOrViewId: string
|
||||
let rows: Row[]
|
||||
|
||||
async function basicRelationshipTables(type: RelationshipType) {
|
||||
const relatedTable = await createTable(
|
||||
{
|
||||
const relatedTable = await createTable({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
},
|
||||
generator.guid().substring(0, 10)
|
||||
)
|
||||
table = await createTable(
|
||||
{
|
||||
})
|
||||
const tableId = await createTable({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
//@ts-ignore - API accepts this structure, will build out rest of definition
|
||||
productCat: {
|
||||
|
@ -83,17 +81,15 @@ describe.each([
|
|||
relationshipType: type,
|
||||
name: "productCat",
|
||||
fieldName: "product",
|
||||
tableId: relatedTable._id!,
|
||||
tableId: relatedTable,
|
||||
constraints: {
|
||||
type: "array",
|
||||
},
|
||||
},
|
||||
},
|
||||
generator.guid().substring(0, 10)
|
||||
)
|
||||
})
|
||||
return {
|
||||
relatedTable: await config.api.table.get(relatedTable._id!),
|
||||
table,
|
||||
relatedTable: await config.api.table.get(relatedTable),
|
||||
tableId,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -136,32 +132,69 @@ describe.each([
|
|||
}
|
||||
})
|
||||
|
||||
async function createTable(schema: TableSchema, name?: string) {
|
||||
return await config.api.table.save(
|
||||
tableForDatasource(datasource, { schema, name })
|
||||
async function createTable(schema: TableSchema) {
|
||||
const table = await config.api.table.save(
|
||||
tableForDatasource(datasource, { schema })
|
||||
)
|
||||
return table._id!
|
||||
}
|
||||
|
||||
async function createView(tableId: string, schema: ViewV2Schema) {
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: tableId,
|
||||
name: generator.guid(),
|
||||
schema,
|
||||
})
|
||||
return view.id
|
||||
}
|
||||
|
||||
async function createRows(arr: Record<string, any>[]) {
|
||||
// Shuffling to avoid false positives given a fixed order
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: _.shuffle(arr),
|
||||
})
|
||||
rows = await config.api.row.fetch(table._id!)
|
||||
for (const row of _.shuffle(arr)) {
|
||||
await config.api.row.save(tableOrViewId, row)
|
||||
}
|
||||
rows = await config.api.row.fetch(tableOrViewId)
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["table", createTable],
|
||||
[
|
||||
"view",
|
||||
async (schema: TableSchema) => {
|
||||
const tableId = await createTable(schema)
|
||||
const viewId = await createView(
|
||||
tableId,
|
||||
Object.keys(schema).reduce<ViewV2Schema>((viewSchema, fieldName) => {
|
||||
const field = schema[fieldName]
|
||||
viewSchema[fieldName] = {
|
||||
visible: field.visible ?? true,
|
||||
readonly: false,
|
||||
}
|
||||
return viewSchema
|
||||
}, {})
|
||||
)
|
||||
return viewId
|
||||
},
|
||||
],
|
||||
])("from %s", (sourceType, createTableOrView) => {
|
||||
const isView = sourceType === "view"
|
||||
|
||||
if (isView && isLucene) {
|
||||
// Some tests don't have the expected result in views via lucene, and given that it is getting deprecated, we exclude them from the tests
|
||||
return
|
||||
}
|
||||
|
||||
class SearchAssertion {
|
||||
constructor(private readonly query: RowSearchParams) {}
|
||||
constructor(private readonly query: SearchRowRequest) {}
|
||||
|
||||
private async performSearch(): Promise<SearchResponse<Row>> {
|
||||
if (isInMemory) {
|
||||
return dataFilters.search(_.cloneDeep(rows), this.query)
|
||||
return dataFilters.search(_.cloneDeep(rows), {
|
||||
...this.query,
|
||||
tableId: tableOrViewId,
|
||||
})
|
||||
} else {
|
||||
const sourceId = this.query.viewId || this.query.tableId
|
||||
if (!sourceId) {
|
||||
throw new Error("No source ID provided")
|
||||
}
|
||||
return config.api.row.search(sourceId, this.query)
|
||||
return config.api.row.search(tableOrViewId, this.query)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -331,8 +364,8 @@ describe.each([
|
|||
}
|
||||
}
|
||||
|
||||
function expectSearch(query: Omit<RowSearchParams, "tableId">) {
|
||||
return new SearchAssertion({ ...query, tableId: table._id! })
|
||||
function expectSearch(query: SearchRowRequest) {
|
||||
return new SearchAssertion(query)
|
||||
}
|
||||
|
||||
function expectQuery(query: SearchFilters) {
|
||||
|
@ -341,7 +374,7 @@ describe.each([
|
|||
|
||||
describe("boolean", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
isTrue: { name: "isTrue", type: FieldType.BOOLEAN },
|
||||
})
|
||||
await createRows([{ isTrue: true }, { isTrue: false }])
|
||||
|
@ -429,38 +462,35 @@ describe.each([
|
|||
{ name: "serverDate", appointment: serverTime.toISOString() },
|
||||
{
|
||||
name: "single user, session user",
|
||||
single_user: JSON.stringify(currentUser),
|
||||
single_user: currentUser,
|
||||
},
|
||||
{
|
||||
name: "single user",
|
||||
single_user: JSON.stringify(globalUsers[0]),
|
||||
single_user: globalUsers[0],
|
||||
},
|
||||
{
|
||||
name: "deprecated single user, session user",
|
||||
deprecated_single_user: JSON.stringify([currentUser]),
|
||||
deprecated_single_user: [currentUser],
|
||||
},
|
||||
{
|
||||
name: "deprecated single user",
|
||||
deprecated_single_user: JSON.stringify([globalUsers[0]]),
|
||||
deprecated_single_user: [globalUsers[0]],
|
||||
},
|
||||
{
|
||||
name: "multi user",
|
||||
multi_user: JSON.stringify(globalUsers),
|
||||
multi_user: globalUsers,
|
||||
},
|
||||
{
|
||||
name: "multi user with session user",
|
||||
multi_user: JSON.stringify([...globalUsers, currentUser]),
|
||||
multi_user: [...globalUsers, currentUser],
|
||||
},
|
||||
{
|
||||
name: "deprecated multi user",
|
||||
deprecated_multi_user: JSON.stringify(globalUsers),
|
||||
deprecated_multi_user: globalUsers,
|
||||
},
|
||||
{
|
||||
name: "deprecated multi user with session user",
|
||||
deprecated_multi_user: JSON.stringify([
|
||||
...globalUsers,
|
||||
currentUser,
|
||||
]),
|
||||
deprecated_multi_user: [...globalUsers, currentUser],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
@ -482,7 +512,7 @@ describe.each([
|
|||
})
|
||||
)
|
||||
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
appointment: { name: "appointment", type: FieldType.DATETIME },
|
||||
single_user: {
|
||||
|
@ -632,9 +662,11 @@ describe.each([
|
|||
})
|
||||
|
||||
it("should match the session user id in a multi user field", async () => {
|
||||
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
|
||||
const allUsers = [...globalUsers, config.getUser()].map(
|
||||
(user: any) => {
|
||||
return { _id: user._id }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expectQuery({
|
||||
contains: { multi_user: ["{{ [user]._id }}"] },
|
||||
|
@ -647,9 +679,11 @@ describe.each([
|
|||
})
|
||||
|
||||
it("should match the session user id in a deprecated multi user field", async () => {
|
||||
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
|
||||
const allUsers = [...globalUsers, config.getUser()].map(
|
||||
(user: any) => {
|
||||
return { _id: user._id }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expectQuery({
|
||||
contains: { deprecated_multi_user: ["{{ [user]._id }}"] },
|
||||
|
@ -764,7 +798,7 @@ describe.each([
|
|||
|
||||
describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
})
|
||||
await createRows([{ name: "foo" }, { name: "bar" }])
|
||||
|
@ -791,6 +825,8 @@ describe.each([
|
|||
}).toContainExactly([{ name: "foo" }, { name: "bar" }])
|
||||
})
|
||||
|
||||
// onEmptyFilter cannot be sent to view searches
|
||||
!isView &&
|
||||
it("should return nothing if onEmptyFilter is RETURN_NONE", async () => {
|
||||
await expectQuery({
|
||||
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
|
||||
|
@ -891,6 +927,8 @@ describe.each([
|
|||
}).toContainExactly([{ name: "foo" }, { name: "bar" }])
|
||||
})
|
||||
|
||||
// onEmptyFilter cannot be sent to view searches
|
||||
!isView &&
|
||||
it("empty arrays returns all when onEmptyFilter is set to return 'none'", async () => {
|
||||
await expectQuery({
|
||||
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
|
||||
|
@ -1055,7 +1093,7 @@ describe.each([
|
|||
datasourceId: datasource!._id!,
|
||||
})
|
||||
|
||||
table = resp.datasource.entities![tableName]
|
||||
tableOrViewId = resp.datasource.entities![tableName]._id!
|
||||
|
||||
await createRows([{ name: "foo" }, { name: "bar" }])
|
||||
})
|
||||
|
@ -1079,7 +1117,7 @@ describe.each([
|
|||
|
||||
describe("numbers", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
age: { name: "age", type: FieldType.NUMBER },
|
||||
})
|
||||
await createRows([{ age: 1 }, { age: 10 }])
|
||||
|
@ -1087,7 +1125,9 @@ describe.each([
|
|||
|
||||
describe("equal", () => {
|
||||
it("successfully finds a row", async () => {
|
||||
await expectQuery({ equal: { age: 1 } }).toContainExactly([{ age: 1 }])
|
||||
await expectQuery({ equal: { age: 1 } }).toContainExactly([
|
||||
{ age: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it("fails to find nonexistent row", async () => {
|
||||
|
@ -1252,7 +1292,7 @@ describe.each([
|
|||
const JAN_10TH = "2020-01-10T00:00:00.000Z"
|
||||
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
dob: { name: "dob", type: FieldType.DATETIME },
|
||||
})
|
||||
|
||||
|
@ -1324,25 +1364,33 @@ describe.each([
|
|||
|
||||
it("greater than equal to", async () => {
|
||||
await expectQuery({
|
||||
range: { dob: { low: JAN_10TH, high: MAX_VALID_DATE.toISOString() } },
|
||||
range: {
|
||||
dob: { low: JAN_10TH, high: MAX_VALID_DATE.toISOString() },
|
||||
},
|
||||
}).toContainExactly([{ dob: JAN_10TH }])
|
||||
})
|
||||
|
||||
it("greater than", async () => {
|
||||
await expectQuery({
|
||||
range: { dob: { low: JAN_5TH, high: MAX_VALID_DATE.toISOString() } },
|
||||
range: {
|
||||
dob: { low: JAN_5TH, high: MAX_VALID_DATE.toISOString() },
|
||||
},
|
||||
}).toContainExactly([{ dob: JAN_10TH }])
|
||||
})
|
||||
|
||||
it("less than equal to", async () => {
|
||||
await expectQuery({
|
||||
range: { dob: { high: JAN_1ST, low: MIN_VALID_DATE.toISOString() } },
|
||||
range: {
|
||||
dob: { high: JAN_1ST, low: MIN_VALID_DATE.toISOString() },
|
||||
},
|
||||
}).toContainExactly([{ dob: JAN_1ST }])
|
||||
})
|
||||
|
||||
it("less than", async () => {
|
||||
await expectQuery({
|
||||
range: { dob: { high: JAN_5TH, low: MIN_VALID_DATE.toISOString() } },
|
||||
range: {
|
||||
dob: { high: JAN_5TH, low: MIN_VALID_DATE.toISOString() },
|
||||
},
|
||||
}).toContainExactly([{ dob: JAN_1ST }])
|
||||
})
|
||||
})
|
||||
|
@ -1399,7 +1447,7 @@ describe.each([
|
|||
const NULL_TIME__ID = `null_time__id`
|
||||
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
timeid: { name: "timeid", type: FieldType.STRING },
|
||||
time: { name: "time", type: FieldType.DATETIME, timeOnly: true },
|
||||
})
|
||||
|
@ -1560,7 +1608,7 @@ describe.each([
|
|||
|
||||
describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
numbers: {
|
||||
name: "numbers",
|
||||
type: FieldType.ARRAY,
|
||||
|
@ -1575,9 +1623,9 @@ describe.each([
|
|||
|
||||
describe("contains", () => {
|
||||
it("successfully finds a row", async () => {
|
||||
await expectQuery({ contains: { numbers: ["one"] } }).toContainExactly([
|
||||
{ numbers: ["one", "two"] },
|
||||
])
|
||||
await expectQuery({
|
||||
contains: { numbers: ["one"] },
|
||||
}).toContainExactly([{ numbers: ["one", "two"] }])
|
||||
})
|
||||
|
||||
it("fails to find nonexistent row", async () => {
|
||||
|
@ -1657,7 +1705,7 @@ describe.each([
|
|||
let BIG = "9223372036854775807"
|
||||
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
num: { name: "num", type: FieldType.BIGINT },
|
||||
})
|
||||
await createRows([{ num: SMALL }, { num: MEDIUM }, { num: BIG }])
|
||||
|
@ -1762,7 +1810,7 @@ describe.each([
|
|||
isInternal &&
|
||||
describe("auto", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
auto: {
|
||||
name: "auto",
|
||||
type: FieldType.AUTO,
|
||||
|
@ -1913,9 +1961,9 @@ describe.each([
|
|||
// to specify an order for pagination to work.
|
||||
it("is stable without a sort specified", async () => {
|
||||
let { rows: fullRowList } = await config.api.row.search(
|
||||
table._id!,
|
||||
tableOrViewId,
|
||||
{
|
||||
tableId: table._id!,
|
||||
tableId: tableOrViewId,
|
||||
query: {},
|
||||
}
|
||||
)
|
||||
|
@ -1925,8 +1973,8 @@ describe.each([
|
|||
hasNextPage: boolean | undefined = true,
|
||||
rowCount: number = 0
|
||||
do {
|
||||
const response = await config.api.row.search(table._id!, {
|
||||
tableId: table._id!,
|
||||
const response = await config.api.row.search(tableOrViewId, {
|
||||
tableId: tableOrViewId,
|
||||
limit: 1,
|
||||
paginate: true,
|
||||
query: {},
|
||||
|
@ -1949,8 +1997,8 @@ describe.each([
|
|||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const response = await config.api.row.search(table._id!, {
|
||||
tableId: table._id!,
|
||||
const response = await config.api.row.search(tableOrViewId, {
|
||||
tableId: tableOrViewId,
|
||||
limit: 3,
|
||||
query: {},
|
||||
bookmark,
|
||||
|
@ -1973,7 +2021,7 @@ describe.each([
|
|||
|
||||
describe("field name 1:name", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
"1:name": { name: "1:name", type: FieldType.STRING },
|
||||
})
|
||||
await createRows([{ "1:name": "bar" }, { "1:name": "foo" }])
|
||||
|
@ -1993,8 +2041,7 @@ describe.each([
|
|||
isSql &&
|
||||
describe("related formulas", () => {
|
||||
beforeAll(async () => {
|
||||
const arrayTable = await createTable(
|
||||
{
|
||||
const arrayTable = await createTable({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
array: {
|
||||
name: "array",
|
||||
|
@ -2004,17 +2051,14 @@ describe.each([
|
|||
inclusion: ["option 1", "option 2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"array"
|
||||
)
|
||||
table = await createTable(
|
||||
{
|
||||
})
|
||||
tableOrViewId = await createTableOrView({
|
||||
relationship: {
|
||||
type: FieldType.LINK,
|
||||
relationshipType: RelationshipType.MANY_TO_ONE,
|
||||
name: "relationship",
|
||||
fieldName: "relate",
|
||||
tableId: arrayTable._id!,
|
||||
tableId: arrayTable,
|
||||
constraints: {
|
||||
type: "array",
|
||||
},
|
||||
|
@ -2026,21 +2070,19 @@ describe.each([
|
|||
`let array = [];$("relationship").forEach(rel => array = array.concat(rel.array));return array.sort().join(",")`
|
||||
),
|
||||
},
|
||||
},
|
||||
"main"
|
||||
)
|
||||
})
|
||||
const arrayRows = await Promise.all([
|
||||
config.api.row.save(arrayTable._id!, {
|
||||
config.api.row.save(arrayTable, {
|
||||
name: "foo",
|
||||
array: ["option 1"],
|
||||
}),
|
||||
config.api.row.save(arrayTable._id!, {
|
||||
config.api.row.save(arrayTable, {
|
||||
name: "bar",
|
||||
array: ["option 2"],
|
||||
}),
|
||||
])
|
||||
await Promise.all([
|
||||
config.api.row.save(table._id!, {
|
||||
config.api.row.save(tableOrViewId, {
|
||||
relationship: [arrayRows[0]._id, arrayRows[1]._id],
|
||||
}),
|
||||
])
|
||||
|
@ -2059,7 +2101,7 @@ describe.each([
|
|||
user1 = await config.createUser({ _id: `us_${utils.newid()}` })
|
||||
user2 = await config.createUser({ _id: `us_${utils.newid()}` })
|
||||
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
user: {
|
||||
name: "user",
|
||||
type: FieldType.BB_REFERENCE_SINGLE,
|
||||
|
@ -2067,11 +2109,7 @@ describe.each([
|
|||
},
|
||||
})
|
||||
|
||||
await createRows([
|
||||
{ user: JSON.stringify(user1) },
|
||||
{ user: JSON.stringify(user2) },
|
||||
{ user: null },
|
||||
])
|
||||
await createRows([{ user: user1 }, { user: user2 }, { user: null }])
|
||||
})
|
||||
|
||||
describe("equal", () => {
|
||||
|
@ -2088,18 +2126,15 @@ describe.each([
|
|||
|
||||
describe("notEqual", () => {
|
||||
it("successfully finds a row", async () => {
|
||||
await expectQuery({ notEqual: { user: user1._id } }).toContainExactly([
|
||||
{ user: { _id: user2._id } },
|
||||
{},
|
||||
])
|
||||
await expectQuery({ notEqual: { user: user1._id } }).toContainExactly(
|
||||
[{ user: { _id: user2._id } }, {}]
|
||||
)
|
||||
})
|
||||
|
||||
it("fails to find nonexistent row", async () => {
|
||||
await expectQuery({ notEqual: { user: "us_none" } }).toContainExactly([
|
||||
{ user: { _id: user1._id } },
|
||||
{ user: { _id: user2._id } },
|
||||
{},
|
||||
])
|
||||
await expectQuery({ notEqual: { user: "us_none" } }).toContainExactly(
|
||||
[{ user: { _id: user1._id } }, { user: { _id: user2._id } }, {}]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -2139,7 +2174,7 @@ describe.each([
|
|||
user1 = await config.createUser({ _id: `us_${utils.newid()}` })
|
||||
user2 = await config.createUser({ _id: `us_${utils.newid()}` })
|
||||
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
users: {
|
||||
name: "users",
|
||||
type: FieldType.BB_REFERENCE,
|
||||
|
@ -2153,10 +2188,10 @@ describe.each([
|
|||
})
|
||||
|
||||
await createRows([
|
||||
{ number: 1, users: JSON.stringify([user1]) },
|
||||
{ number: 2, users: JSON.stringify([user2]) },
|
||||
{ number: 3, users: JSON.stringify([user1, user2]) },
|
||||
{ number: 4, users: JSON.stringify([]) },
|
||||
{ number: 1, users: [user1] },
|
||||
{ number: 2, users: [user2] },
|
||||
{ number: 3, users: [user1, user2] },
|
||||
{ number: 4, users: [] },
|
||||
])
|
||||
})
|
||||
|
||||
|
@ -2182,7 +2217,9 @@ describe.each([
|
|||
})
|
||||
|
||||
it("fails to find nonexistent row", async () => {
|
||||
await expectQuery({ contains: { users: ["us_none"] } }).toFindNothing()
|
||||
await expectQuery({
|
||||
contains: { users: ["us_none"] },
|
||||
}).toFindNothing()
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -2249,9 +2286,10 @@ describe.each([
|
|||
let productCategoryTable: Table, productCatRows: Row[]
|
||||
|
||||
beforeAll(async () => {
|
||||
const { relatedTable } = await basicRelationshipTables(
|
||||
const { relatedTable, tableId } = await basicRelationshipTables(
|
||||
RelationshipType.ONE_TO_MANY
|
||||
)
|
||||
tableOrViewId = tableId
|
||||
productCategoryTable = relatedTable
|
||||
|
||||
productCatRows = await Promise.all([
|
||||
|
@ -2260,15 +2298,15 @@ describe.each([
|
|||
])
|
||||
|
||||
await Promise.all([
|
||||
config.api.row.save(table._id!, {
|
||||
config.api.row.save(tableOrViewId, {
|
||||
name: "foo",
|
||||
productCat: [productCatRows[0]._id],
|
||||
}),
|
||||
config.api.row.save(table._id!, {
|
||||
config.api.row.save(tableOrViewId, {
|
||||
name: "bar",
|
||||
productCat: [productCatRows[1]._id],
|
||||
}),
|
||||
config.api.row.save(table._id!, {
|
||||
config.api.row.save(tableOrViewId, {
|
||||
name: "baz",
|
||||
productCat: [],
|
||||
}),
|
||||
|
@ -2301,10 +2339,11 @@ describe.each([
|
|||
isSql &&
|
||||
describe("big relations", () => {
|
||||
beforeAll(async () => {
|
||||
const { relatedTable } = await basicRelationshipTables(
|
||||
const { relatedTable, tableId } = await basicRelationshipTables(
|
||||
RelationshipType.MANY_TO_ONE
|
||||
)
|
||||
const mainRow = await config.api.row.save(table._id!, {
|
||||
tableOrViewId = tableId
|
||||
const mainRow = await config.api.row.save(tableOrViewId, {
|
||||
name: "foo",
|
||||
})
|
||||
for (let i = 0; i < 11; i++) {
|
||||
|
@ -2329,45 +2368,42 @@ describe.each([
|
|||
})
|
||||
;(isSqs || isLucene) &&
|
||||
describe("relations to same table", () => {
|
||||
let relatedTable: Table, relatedRows: Row[]
|
||||
let relatedTable: string, relatedRows: Row[]
|
||||
|
||||
beforeAll(async () => {
|
||||
relatedTable = await createTable(
|
||||
{
|
||||
relatedTable = await createTable({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
},
|
||||
"productCategory"
|
||||
)
|
||||
table = await createTable({
|
||||
})
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
related1: {
|
||||
type: FieldType.LINK,
|
||||
name: "related1",
|
||||
fieldName: "main1",
|
||||
tableId: relatedTable._id!,
|
||||
tableId: relatedTable,
|
||||
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||
},
|
||||
related2: {
|
||||
type: FieldType.LINK,
|
||||
name: "related2",
|
||||
fieldName: "main2",
|
||||
tableId: relatedTable._id!,
|
||||
tableId: relatedTable,
|
||||
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||
},
|
||||
})
|
||||
relatedRows = await Promise.all([
|
||||
config.api.row.save(relatedTable._id!, { name: "foo" }),
|
||||
config.api.row.save(relatedTable._id!, { name: "bar" }),
|
||||
config.api.row.save(relatedTable._id!, { name: "baz" }),
|
||||
config.api.row.save(relatedTable._id!, { name: "boo" }),
|
||||
config.api.row.save(relatedTable, { name: "foo" }),
|
||||
config.api.row.save(relatedTable, { name: "bar" }),
|
||||
config.api.row.save(relatedTable, { name: "baz" }),
|
||||
config.api.row.save(relatedTable, { name: "boo" }),
|
||||
])
|
||||
await Promise.all([
|
||||
config.api.row.save(table._id!, {
|
||||
config.api.row.save(tableOrViewId, {
|
||||
name: "test",
|
||||
related1: [relatedRows[0]._id!],
|
||||
related2: [relatedRows[1]._id!],
|
||||
}),
|
||||
config.api.row.save(table._id!, {
|
||||
config.api.row.save(tableOrViewId, {
|
||||
name: "test2",
|
||||
related1: [relatedRows[2]._id!],
|
||||
related2: [relatedRows[3]._id!],
|
||||
|
@ -2430,7 +2466,7 @@ describe.each([
|
|||
isInternal &&
|
||||
describe("no column error backwards compat", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2453,7 +2489,7 @@ describe.each([
|
|||
!isLucene &&
|
||||
describe("row counting", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2488,7 +2524,7 @@ describe.each([
|
|||
describe("Invalid column definitions", () => {
|
||||
beforeAll(async () => {
|
||||
// need to create an invalid table - means ignoring typescript
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
// @ts-ignore
|
||||
invalid: {
|
||||
type: FieldType.STRING,
|
||||
|
@ -2518,7 +2554,7 @@ describe.each([
|
|||
"special (%s) case",
|
||||
column => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
[column]: {
|
||||
name: column,
|
||||
type: FieldType.STRING,
|
||||
|
@ -2543,8 +2579,8 @@ describe.each([
|
|||
describe("sample data", () => {
|
||||
beforeAll(async () => {
|
||||
await config.api.application.addSampleData(config.appId!)
|
||||
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
|
||||
rows = await config.api.row.fetch(table._id!)
|
||||
tableOrViewId = DEFAULT_EMPLOYEE_TABLE_SCHEMA._id!
|
||||
rows = await config.api.row.fetch(tableOrViewId)
|
||||
})
|
||||
|
||||
it("should be able to search sample data", async () => {
|
||||
|
@ -2567,7 +2603,7 @@ describe.each([
|
|||
const earlyDate = "2024-07-03T10:00:00.000Z",
|
||||
laterDate = "2024-07-03T11:00:00.000Z"
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
date: {
|
||||
name: "date",
|
||||
type: FieldType.DATETIME,
|
||||
|
@ -2610,7 +2646,7 @@ describe.each([
|
|||
"ชื่อผู้ใช้", // Thai for "username"
|
||||
])("non-ascii column name: %s", name => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
[name]: {
|
||||
name,
|
||||
type: FieldType.STRING,
|
||||
|
@ -2637,7 +2673,7 @@ describe.each([
|
|||
isInternal &&
|
||||
describe("space at end of column name", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
"name ": {
|
||||
name: "name ",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2672,7 +2708,7 @@ describe.each([
|
|||
;(isSqs || isInMemory) &&
|
||||
describe("space at start of column name", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
" name": {
|
||||
name: " name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2703,9 +2739,10 @@ describe.each([
|
|||
})
|
||||
|
||||
isSqs &&
|
||||
!isView &&
|
||||
describe("duplicate columns", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2713,7 +2750,7 @@ describe.each([
|
|||
})
|
||||
await context.doInAppContext(config.getAppId(), async () => {
|
||||
const db = context.getAppDB()
|
||||
const tableDoc = await db.get<Table>(table._id!)
|
||||
const tableDoc = await db.get<Table>(tableOrViewId)
|
||||
tableDoc.schema.Name = {
|
||||
name: "Name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2747,7 +2784,7 @@ describe.each([
|
|||
type: FieldType.STRING,
|
||||
},
|
||||
})
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2756,15 +2793,15 @@ describe.each([
|
|||
name: "rel",
|
||||
type: FieldType.LINK,
|
||||
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||
tableId: toRelateTable._id!,
|
||||
tableId: toRelateTable,
|
||||
fieldName: "rel",
|
||||
},
|
||||
})
|
||||
const [row1, row2] = await Promise.all([
|
||||
config.api.row.save(toRelateTable._id!, { name: "tag 1" }),
|
||||
config.api.row.save(toRelateTable._id!, { name: "tag 2" }),
|
||||
config.api.row.save(toRelateTable, { name: "tag 1" }),
|
||||
config.api.row.save(toRelateTable, { name: "tag 2" }),
|
||||
])
|
||||
row = await config.api.row.save(table._id!, {
|
||||
row = await config.api.row.save(tableOrViewId, {
|
||||
name: "product 1",
|
||||
rel: [row1._id, row2._id],
|
||||
})
|
||||
|
@ -2783,7 +2820,7 @@ describe.each([
|
|||
!isInternal &&
|
||||
describe("search by composite key", () => {
|
||||
beforeAll(async () => {
|
||||
table = await config.api.table.save(
|
||||
const table = await config.api.table.save(
|
||||
tableForDatasource(datasource, {
|
||||
schema: {
|
||||
idColumn1: {
|
||||
|
@ -2798,6 +2835,7 @@ describe.each([
|
|||
primary: ["idColumn1", "idColumn2"],
|
||||
})
|
||||
)
|
||||
tableOrViewId = table._id!
|
||||
await createRows([{ idColumn1: 1, idColumn2: 2 }])
|
||||
})
|
||||
|
||||
|
@ -2819,15 +2857,13 @@ describe.each([
|
|||
isSql &&
|
||||
describe("primaryDisplay", () => {
|
||||
beforeAll(async () => {
|
||||
let toRelateTable = await createTable({
|
||||
let toRelateTableId = await createTable({
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
})
|
||||
table = await config.api.table.save(
|
||||
tableForDatasource(datasource, {
|
||||
schema: {
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
|
@ -2836,33 +2872,30 @@ describe.each([
|
|||
name: "link",
|
||||
type: FieldType.LINK,
|
||||
relationshipType: RelationshipType.MANY_TO_ONE,
|
||||
tableId: toRelateTable._id!,
|
||||
tableId: toRelateTableId,
|
||||
fieldName: "link",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
toRelateTable = await config.api.table.get(toRelateTable._id!)
|
||||
|
||||
const toRelateTable = await config.api.table.get(toRelateTableId)
|
||||
await config.api.table.save({
|
||||
...toRelateTable,
|
||||
primaryDisplay: "link",
|
||||
})
|
||||
const relatedRows = await Promise.all([
|
||||
config.api.row.save(toRelateTable._id!, { name: "test" }),
|
||||
config.api.row.save(toRelateTable._id!, { name: "related" }),
|
||||
])
|
||||
await Promise.all([
|
||||
config.api.row.save(table._id!, {
|
||||
await config.api.row.save(tableOrViewId, {
|
||||
name: "test",
|
||||
link: relatedRows.map(row => row._id),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to query, primary display on related table shouldn't be used", async () => {
|
||||
// this test makes sure that if a relationship has been specified as the primary display on a table
|
||||
// it is ignored and another column is used instead
|
||||
await expectQuery({}).toContain([
|
||||
{ name: "test", link: [{ primaryDisplay: "test" }] },
|
||||
{ name: "test", link: [{ primaryDisplay: "related" }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
@ -2870,7 +2903,7 @@ describe.each([
|
|||
!isLucene &&
|
||||
describe("$and", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
age: { name: "age", type: FieldType.NUMBER },
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
})
|
||||
|
@ -2945,7 +2978,10 @@ describe.each([
|
|||
await expect(
|
||||
expectQuery({
|
||||
$and: {
|
||||
conditions: [{ equal: { age: 10 } }, "invalidCondition" as any],
|
||||
conditions: [
|
||||
{ equal: { age: 10 } },
|
||||
"invalidCondition" as any,
|
||||
],
|
||||
},
|
||||
}).toFindNothing()
|
||||
).rejects.toThrow(
|
||||
|
@ -2973,6 +3009,8 @@ describe.each([
|
|||
)
|
||||
})
|
||||
|
||||
// onEmptyFilter cannot be sent to view searches
|
||||
!isView &&
|
||||
it("returns no rows when onEmptyFilter set to none", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
|
@ -2999,7 +3037,7 @@ describe.each([
|
|||
!isLucene &&
|
||||
describe("$or", () => {
|
||||
beforeAll(async () => {
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
age: { name: "age", type: FieldType.NUMBER },
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
})
|
||||
|
@ -3123,6 +3161,8 @@ describe.each([
|
|||
}).toContainExactly([{ age: 1, name: "Jane" }])
|
||||
})
|
||||
|
||||
// onEmptyFilter cannot be sent to view searches
|
||||
!isView &&
|
||||
it("returns no rows when onEmptyFilter set to none", async () => {
|
||||
await expectSearch({
|
||||
query: {
|
||||
|
@ -3159,20 +3199,20 @@ describe.each([
|
|||
row[name] = i
|
||||
}
|
||||
const relatedTable = await createTable(relatedSchema)
|
||||
table = await createTable({
|
||||
tableOrViewId = await createTableOrView({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
related1: {
|
||||
type: FieldType.LINK,
|
||||
name: "related1",
|
||||
fieldName: "main1",
|
||||
tableId: relatedTable._id!,
|
||||
tableId: relatedTable,
|
||||
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||
},
|
||||
})
|
||||
relatedRows = await Promise.all([
|
||||
config.api.row.save(relatedTable._id!, row),
|
||||
config.api.row.save(relatedTable, row),
|
||||
])
|
||||
await config.api.row.save(table._id!, {
|
||||
await config.api.row.save(tableOrViewId, {
|
||||
name: "foo",
|
||||
related1: [relatedRows[0]._id],
|
||||
})
|
||||
|
@ -3187,4 +3227,5 @@ describe.each([
|
|||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -22,9 +22,10 @@ import {
|
|||
RelationshipType,
|
||||
TableSchema,
|
||||
RenameColumn,
|
||||
ViewFieldMetadata,
|
||||
FeatureFlag,
|
||||
BBReferenceFieldSubType,
|
||||
ViewV2Schema,
|
||||
ViewCalculationFieldMetadata,
|
||||
} from "@budibase/types"
|
||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
|
||||
|
@ -154,7 +155,7 @@ describe.each([
|
|||
})
|
||||
|
||||
it("can persist views with all fields", async () => {
|
||||
const newView: Required<CreateViewRequest> = {
|
||||
const newView: Required<Omit<CreateViewRequest, "queryUI">> = {
|
||||
name: generator.name(),
|
||||
tableId: table._id!,
|
||||
primaryDisplay: "id",
|
||||
|
@ -540,6 +541,33 @@ describe.each([
|
|||
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", () => {
|
||||
|
@ -584,7 +612,7 @@ describe.each([
|
|||
it("can update all fields", async () => {
|
||||
const tableId = table._id!
|
||||
|
||||
const updatedData: Required<UpdateViewRequest> = {
|
||||
const updatedData: Required<Omit<UpdateViewRequest, "queryUI">> = {
|
||||
version: view.version,
|
||||
id: view.id,
|
||||
tableId,
|
||||
|
@ -1152,10 +1180,7 @@ describe.each([
|
|||
return table
|
||||
}
|
||||
|
||||
const createView = async (
|
||||
tableId: string,
|
||||
schema: Record<string, ViewFieldMetadata>
|
||||
) =>
|
||||
const createView = async (tableId: string, schema: ViewV2Schema) =>
|
||||
await config.api.viewV2.create({
|
||||
name: generator.guid(),
|
||||
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 () => {
|
||||
const response = await config.api.viewV2.search(view.id)
|
||||
expect(response.rows).toHaveLength(0)
|
||||
|
@ -2424,6 +2483,138 @@ describe.each([
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -17,44 +17,65 @@ describe("Branching automations", () => {
|
|||
afterAll(setup.afterAll)
|
||||
|
||||
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({
|
||||
name: "Test Trigger with Loop and Create Row",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
.serverLog({ text: "Starting automation" })
|
||||
.serverLog(
|
||||
{ text: "Starting automation" },
|
||||
{ stepName: "FirstLog", stepId: firstLogId }
|
||||
)
|
||||
.branch({
|
||||
topLevelBranch1: {
|
||||
steps: stepBuilder =>
|
||||
stepBuilder.serverLog({ text: "Branch 1" }).branch({
|
||||
stepBuilder
|
||||
.serverLog(
|
||||
{ text: "Branch 1" },
|
||||
{ stepId: "66666666-6666-6666-6666-666666666666" }
|
||||
)
|
||||
.branch({
|
||||
branch1: {
|
||||
steps: stepBuilder =>
|
||||
stepBuilder.serverLog({ text: "Branch 1.1" }),
|
||||
stepBuilder.serverLog(
|
||||
{ text: "Branch 1.1" },
|
||||
{ stepId: branch1LogId }
|
||||
),
|
||||
condition: {
|
||||
equal: { "{{steps.1.success}}": true },
|
||||
equal: { [`{{ steps.${firstLogId}.success }}`]: true },
|
||||
},
|
||||
},
|
||||
branch2: {
|
||||
steps: stepBuilder =>
|
||||
stepBuilder.serverLog({ text: "Branch 1.2" }),
|
||||
stepBuilder.serverLog(
|
||||
{ text: "Branch 1.2" },
|
||||
{ stepId: branch2LogId }
|
||||
),
|
||||
condition: {
|
||||
equal: { "{{steps.1.success}}": false },
|
||||
equal: { [`{{ steps.${firstLogId}.success }}`]: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
condition: {
|
||||
equal: { "{{steps.1.success}}": true },
|
||||
equal: { [`{{ steps.${firstLogId}.success }}`]: true },
|
||||
},
|
||||
},
|
||||
topLevelBranch2: {
|
||||
steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }),
|
||||
steps: stepBuilder =>
|
||||
stepBuilder.serverLog({ text: "Branch 2" }, { stepId: branch2Id }),
|
||||
condition: {
|
||||
equal: { "{{steps.1.success}}": false },
|
||||
equal: { [`{{ steps.${firstLogId}.success }}`]: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
|
||||
expect(results.steps[3].outputs.status).toContain("branch1 branch taken")
|
||||
expect(results.steps[4].outputs.message).toContain("Branch 1.1")
|
||||
})
|
||||
|
|
|
@ -64,18 +64,18 @@ class BaseStepBuilder {
|
|||
stepId: TStep,
|
||||
stepSchema: Omit<AutomationStep, "id" | "stepId" | "inputs">,
|
||||
inputs: AutomationStepInputs<TStep>,
|
||||
stepName?: string
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
const id = uuidv4()
|
||||
const id = opts?.stepId || uuidv4()
|
||||
this.steps.push({
|
||||
...stepSchema,
|
||||
inputs: inputs as any,
|
||||
id,
|
||||
stepId,
|
||||
name: stepName || stepSchema.name,
|
||||
name: opts?.stepName || stepSchema.name,
|
||||
})
|
||||
if (stepName) {
|
||||
this.stepNames[id] = stepName
|
||||
if (opts?.stepName) {
|
||||
this.stepNames[id] = opts.stepName
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -95,7 +95,6 @@ class BaseStepBuilder {
|
|||
})
|
||||
branchStepInputs.children![key] = stepBuilder.build()
|
||||
})
|
||||
|
||||
const branchStep: AutomationStep = {
|
||||
...definition,
|
||||
id: uuidv4(),
|
||||
|
@ -106,80 +105,98 @@ class BaseStepBuilder {
|
|||
}
|
||||
|
||||
// STEPS
|
||||
createRow(inputs: CreateRowStepInputs, opts?: { stepName?: string }): this {
|
||||
createRow(
|
||||
inputs: CreateRowStepInputs,
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.CREATE_ROW,
|
||||
BUILTIN_ACTION_DEFINITIONS.CREATE_ROW,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
updateRow(inputs: UpdateRowStepInputs, opts?: { stepName?: string }): this {
|
||||
updateRow(
|
||||
inputs: UpdateRowStepInputs,
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.UPDATE_ROW,
|
||||
BUILTIN_ACTION_DEFINITIONS.UPDATE_ROW,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
deleteRow(inputs: DeleteRowStepInputs, opts?: { stepName?: string }): this {
|
||||
deleteRow(
|
||||
inputs: DeleteRowStepInputs,
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.DELETE_ROW,
|
||||
BUILTIN_ACTION_DEFINITIONS.DELETE_ROW,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
sendSmtpEmail(
|
||||
inputs: SmtpEmailStepInputs,
|
||||
opts?: { stepName?: string }
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.SEND_EMAIL_SMTP,
|
||||
BUILTIN_ACTION_DEFINITIONS.SEND_EMAIL_SMTP,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
executeQuery(
|
||||
inputs: ExecuteQueryStepInputs,
|
||||
opts?: { stepName?: string }
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.EXECUTE_QUERY,
|
||||
BUILTIN_ACTION_DEFINITIONS.EXECUTE_QUERY,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
queryRows(inputs: QueryRowsStepInputs, opts?: { stepName?: string }): this {
|
||||
queryRows(
|
||||
inputs: QueryRowsStepInputs,
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.QUERY_ROWS,
|
||||
BUILTIN_ACTION_DEFINITIONS.QUERY_ROWS,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
loop(inputs: LoopStepInputs, opts?: { stepName?: string }): this {
|
||||
loop(
|
||||
inputs: LoopStepInputs,
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.LOOP,
|
||||
BUILTIN_ACTION_DEFINITIONS.LOOP,
|
||||
inputs,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
serverLog(input: ServerLogStepInputs, opts?: { stepName?: string }): this {
|
||||
serverLog(
|
||||
input: ServerLogStepInputs,
|
||||
opts?: { stepName?: string; stepId?: string }
|
||||
): this {
|
||||
return this.step(
|
||||
AutomationActionStepId.SERVER_LOG,
|
||||
BUILTIN_ACTION_DEFINITIONS.SERVER_LOG,
|
||||
input,
|
||||
opts?.stepName
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -23,8 +23,8 @@ import {
|
|||
Row,
|
||||
Table,
|
||||
TableSchema,
|
||||
ViewFieldMetadata,
|
||||
ViewV2,
|
||||
ViewV2Schema,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../../sdk"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
@ -262,7 +262,7 @@ export async function squashLinks<T = Row[] | Row>(
|
|||
FeatureFlag.ENRICHED_RELATIONSHIPS
|
||||
)
|
||||
|
||||
let viewSchema: Record<string, ViewFieldMetadata> = {}
|
||||
let viewSchema: ViewV2Schema = {}
|
||||
if (sdk.views.isView(source)) {
|
||||
if (helpers.views.isCalculationView(source)) {
|
||||
return enriched
|
||||
|
|
|
@ -15,7 +15,8 @@ export interface TriggerOutput {
|
|||
|
||||
export interface AutomationContext extends AutomationResults {
|
||||
steps: any[]
|
||||
stepsByName?: Record<string, any>
|
||||
stepsById: Record<string, any>
|
||||
stepsByName: Record<string, any>
|
||||
env?: Record<string, string>
|
||||
trigger: any
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import {
|
||||
EmptyFilterOption,
|
||||
LegacyFilter,
|
||||
LogicalOperator,
|
||||
Row,
|
||||
RowSearchParams,
|
||||
SearchFilterKey,
|
||||
SearchResponse,
|
||||
SortOrder,
|
||||
Table,
|
||||
|
@ -14,9 +17,10 @@ import { ExportRowsParams, ExportRowsResult } from "./search/types"
|
|||
import { dataFilters } from "@budibase/shared-core"
|
||||
import sdk from "../../index"
|
||||
import { searchInputMapping } from "./search/utils"
|
||||
import { features } from "@budibase/backend-core"
|
||||
import { db, features } from "@budibase/backend-core"
|
||||
import tracer from "dd-trace"
|
||||
import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
|
||||
import { enrichSearchContext } from "../../../api/controllers/row/utils"
|
||||
|
||||
export { isValidFilter } from "../../../integrations/utils"
|
||||
|
||||
|
@ -34,7 +38,8 @@ function pickApi(tableId: any) {
|
|||
}
|
||||
|
||||
export async function search(
|
||||
options: RowSearchParams
|
||||
options: RowSearchParams,
|
||||
context?: Record<string, any>
|
||||
): Promise<SearchResponse<Row>> {
|
||||
return await tracer.trace("search", async span => {
|
||||
span?.addTags({
|
||||
|
@ -51,7 +56,92 @@ export async function search(
|
|||
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)
|
||||
|
||||
span.addTags({
|
||||
|
@ -72,30 +162,6 @@ export async function search(
|
|||
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>
|
||||
if (isExternalTable) {
|
||||
span?.addTags({ searchType: "external" })
|
||||
|
|
|
@ -107,7 +107,9 @@ export function searchInputMapping(table: Table, options: RowSearchParams) {
|
|||
}
|
||||
return dataFilters.recurseLogicalOperators(filters, checkFilters)
|
||||
}
|
||||
if (options.query) {
|
||||
options.query = checkFilters(options.query)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import { Format } from "../../../api/controllers/view/exporters"
|
|||
import sdk from "../.."
|
||||
import { extractViewInfoFromID, isRelationshipColumn } from "../../../db/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"
|
||||
|
||||
const SQL_CLIENT_SOURCE_MAP: Record<SourceName, SqlClient | undefined> = {
|
||||
|
@ -57,8 +57,12 @@ export function getSQLClient(datasource: Datasource): SqlClient {
|
|||
export function processRowCountResponse(
|
||||
response: DatasourcePlusQueryResponse
|
||||
): number {
|
||||
if (response && response.length === 1 && "total" in response[0]) {
|
||||
const total = response[0].total
|
||||
if (
|
||||
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)
|
||||
} else {
|
||||
throw new Error("Unable to count rows in query - no count response")
|
||||
|
|
|
@ -255,19 +255,12 @@ export async function enrichSchema(
|
|||
view: ViewV2,
|
||||
tableSchema: TableSchema
|
||||
): Promise<ViewV2Enriched> {
|
||||
const tableCache: Record<string, Table> = {}
|
||||
|
||||
async function populateRelTableSchema(
|
||||
tableId: string,
|
||||
viewFields: Record<string, RelationSchemaField>
|
||||
) {
|
||||
if (!tableCache[tableId]) {
|
||||
tableCache[tableId] = await sdk.tables.getTable(tableId)
|
||||
}
|
||||
const relTable = tableCache[tableId]
|
||||
|
||||
const relTable = await sdk.tables.getTable(tableId)
|
||||
const result: Record<string, ViewV2ColumnEnriched> = {}
|
||||
|
||||
for (const relTableFieldName of Object.keys(relTable.schema)) {
|
||||
const relTableField = relTable.schema[relTableFieldName]
|
||||
if ([FieldType.LINK, FieldType.FORMULA].includes(relTableField.type)) {
|
||||
|
@ -296,15 +289,24 @@ export async function enrichSchema(
|
|||
|
||||
const viewSchema = view.schema || {}
|
||||
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
|
||||
const ui = viewSchema[key] || { visible: false }
|
||||
schema[key] = {
|
||||
...tableSchema[key],
|
||||
...ui,
|
||||
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order,
|
||||
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key]?.order,
|
||||
columns: undefined,
|
||||
}
|
||||
|
||||
|
@ -316,10 +318,7 @@ export async function enrichSchema(
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...view,
|
||||
schema: schema,
|
||||
}
|
||||
return { ...view, schema }
|
||||
}
|
||||
|
||||
export function syncSchema(
|
||||
|
|
|
@ -130,6 +130,26 @@ export function getUserContextBindings(user: ContextUser) {
|
|||
return {}
|
||||
}
|
||||
// Current user context for bindable search
|
||||
const { _id, _rev, firstName, lastName, email, status, roleId } = user
|
||||
return { _id, _rev, firstName, lastName, email, status, roleId }
|
||||
const {
|
||||
_id,
|
||||
_rev,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
status,
|
||||
roleId,
|
||||
globalId,
|
||||
userId,
|
||||
} = user
|
||||
return {
|
||||
_id,
|
||||
_rev,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
status,
|
||||
roleId,
|
||||
globalId,
|
||||
userId,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,11 +7,11 @@ import {
|
|||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
SearchRowResponse,
|
||||
RowSearchParams,
|
||||
DeleteRows,
|
||||
DeleteRow,
|
||||
PaginatedSearchRowResponse,
|
||||
RowExportFormat,
|
||||
SearchRowRequest,
|
||||
} from "@budibase/types"
|
||||
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,
|
||||
params?: T,
|
||||
expectations?: Expectations
|
||||
|
|
|
@ -74,7 +74,7 @@ class Orchestrator {
|
|||
private job: Job
|
||||
private loopStepOutputs: LoopStep[]
|
||||
private stopped: boolean
|
||||
private executionOutput: AutomationContext
|
||||
private executionOutput: Omit<AutomationContext, "stepsByName" | "stepsById">
|
||||
|
||||
constructor(job: AutomationJob) {
|
||||
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
|
||||
this.context = {
|
||||
steps: [{}],
|
||||
stepsById: {},
|
||||
stepsByName: {},
|
||||
trigger: triggerOutput,
|
||||
}
|
||||
|
@ -457,8 +458,9 @@ class Orchestrator {
|
|||
inputs: steps[stepToLoopIndex].inputs,
|
||||
})
|
||||
|
||||
this.context.stepsById[steps[stepToLoopIndex].id] = tempOutput
|
||||
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.filter(
|
||||
item => !item.hasOwnProperty.call(item, "currentItem")
|
||||
|
@ -517,7 +519,10 @@ class Orchestrator {
|
|||
Object.entries(filter).forEach(([_, value]) => {
|
||||
Object.entries(value).forEach(([field, _]) => {
|
||||
const updatedField = field.replace("{{", "{{ literal ")
|
||||
const fromContext = processStringSync(updatedField, this.context)
|
||||
const fromContext = processStringSync(
|
||||
updatedField,
|
||||
this.processContext(this.context)
|
||||
)
|
||||
toFilter[field] = fromContext
|
||||
})
|
||||
})
|
||||
|
@ -563,9 +568,9 @@ class Orchestrator {
|
|||
}
|
||||
|
||||
const stepFn = await this.getStepFunctionality(step.stepId)
|
||||
let inputs = await this.addContextAndProcess(
|
||||
let inputs = await processObject(
|
||||
originalStepInput,
|
||||
this.context
|
||||
this.processContext(this.context)
|
||||
)
|
||||
|
||||
inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs)
|
||||
|
@ -594,16 +599,16 @@ class Orchestrator {
|
|||
return null
|
||||
}
|
||||
|
||||
private async addContextAndProcess(inputs: any, context: any) {
|
||||
private processContext(context: AutomationContext) {
|
||||
const processContext = {
|
||||
...context,
|
||||
steps: {
|
||||
...context.steps,
|
||||
...context.stepsById,
|
||||
...context.stepsByName,
|
||||
},
|
||||
}
|
||||
|
||||
return processObject(inputs, processContext)
|
||||
return processContext
|
||||
}
|
||||
|
||||
private handleStepOutput(
|
||||
|
@ -623,6 +628,7 @@ class Orchestrator {
|
|||
} else {
|
||||
this.updateExecutionOutput(step.id, step.stepId, step.inputs, outputs)
|
||||
this.context.steps[this.context.steps.length] = outputs
|
||||
this.context.stepsById![step.id] = outputs
|
||||
const stepName = step.name || step.id
|
||||
this.context.stepsByName![stepName] = outputs
|
||||
}
|
||||
|
|
|
@ -134,8 +134,10 @@ async function processDefaultValues(table: Table, row: Row) {
|
|||
|
||||
for (const [key, schema] of Object.entries(table.schema)) {
|
||||
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 {
|
||||
row[key] = coerce(processed, schema.type)
|
||||
} 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!)) {
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
BBReferenceFieldSubType,
|
||||
FieldType,
|
||||
FormulaType,
|
||||
SearchFilter,
|
||||
LegacyFilter,
|
||||
SearchFilters,
|
||||
SearchQueryFields,
|
||||
ArrayOperator,
|
||||
|
@ -127,7 +127,7 @@ export function recurseLogicalOperators(
|
|||
fn: (f: SearchFilters) => SearchFilters
|
||||
) {
|
||||
for (const logical of LOGICAL_OPERATORS) {
|
||||
if (filters?.[logical]) {
|
||||
if (filters[logical]) {
|
||||
filters[logical]!.conditions = filters[logical]!.conditions.map(
|
||||
condition => fn(condition)
|
||||
)
|
||||
|
@ -163,9 +163,6 @@ export function recurseSearchFilters(
|
|||
* https://github.com/Budibase/budibase/issues/10118
|
||||
*/
|
||||
export const cleanupQuery = (query: SearchFilters) => {
|
||||
if (!query) {
|
||||
return query
|
||||
}
|
||||
for (let filterField of NoEmptyFilterStrings) {
|
||||
if (!query[filterField]) {
|
||||
continue
|
||||
|
@ -311,7 +308,7 @@ export class ColumnSplitter {
|
|||
* @param filter the builder filter structure
|
||||
*/
|
||||
|
||||
const buildCondition = (expression: SearchFilter) => {
|
||||
const buildCondition = (expression: LegacyFilter) => {
|
||||
// Filter body
|
||||
let query: SearchFilters = {
|
||||
string: {},
|
||||
|
@ -437,8 +434,13 @@ const buildCondition = (expression: SearchFilter) => {
|
|||
}
|
||||
|
||||
export const buildQueryLegacy = (
|
||||
filter?: SearchFilterGroup | SearchFilter[]
|
||||
filter?: LegacyFilter[] | SearchFilters
|
||||
): SearchFilters | undefined => {
|
||||
// this is of type SearchFilters or is undefined
|
||||
if (!Array.isArray(filter)) {
|
||||
return filter
|
||||
}
|
||||
|
||||
let query: SearchFilters = {
|
||||
string: {},
|
||||
fuzzy: {},
|
||||
|
@ -572,7 +574,7 @@ export const buildQueryLegacy = (
|
|||
*/
|
||||
|
||||
export const buildQuery = (
|
||||
filter?: SearchFilterGroup | SearchFilter[]
|
||||
filter?: SearchFilterGroup | LegacyFilter[]
|
||||
): SearchFilters | undefined => {
|
||||
const parsedFilter: SearchFilterGroup | undefined =
|
||||
processSearchFilters(filter)
|
||||
|
@ -594,7 +596,7 @@ export const buildQuery = (
|
|||
const globalOperator: LogicalOperator =
|
||||
operatorMap[parsedFilter.logicalOperator as FilterGroupLogicalOperator]
|
||||
|
||||
const coreRequest: SearchFilters = {
|
||||
return {
|
||||
...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}),
|
||||
[globalOperator]: {
|
||||
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
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import cronValidate from "cron-validate"
|
||||
import cronParser from "cron-parser"
|
||||
|
||||
const INPUT_CRON_START = "(Input cron: "
|
||||
const ERROR_SWAPS = {
|
||||
|
@ -30,6 +31,19 @@ function improveErrors(errors: string[]): string[] {
|
|||
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(
|
||||
cronExpression: string
|
||||
): { valid: false; err: string[] } | { valid: true } {
|
||||
|
|
|
@ -53,8 +53,9 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
|
|||
[FieldType.DATETIME]: true,
|
||||
[FieldType.LONGFORM]: true,
|
||||
[FieldType.STRING]: true,
|
||||
[FieldType.OPTIONS]: true,
|
||||
[FieldType.ARRAY]: true,
|
||||
|
||||
[FieldType.OPTIONS]: false,
|
||||
[FieldType.AUTO]: false,
|
||||
[FieldType.INTERNAL]: false,
|
||||
[FieldType.BARCODEQR]: false,
|
||||
|
@ -64,7 +65,6 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
|
|||
[FieldType.ATTACHMENTS]: false,
|
||||
[FieldType.ATTACHMENT_SINGLE]: false,
|
||||
[FieldType.SIGNATURE_SINGLE]: false,
|
||||
[FieldType.ARRAY]: false,
|
||||
[FieldType.LINK]: false,
|
||||
[FieldType.BB_REFERENCE]: false,
|
||||
[FieldType.BB_REFERENCE_SINGLE]: false,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
SearchFilter,
|
||||
LegacyFilter,
|
||||
SearchFilterGroup,
|
||||
FilterGroupLogicalOperator,
|
||||
SearchFilters,
|
||||
|
@ -9,6 +9,10 @@ import {
|
|||
import * as Constants from "./constants"
|
||||
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(
|
||||
value: never,
|
||||
message = `No such case in exhaustive switch: ${value}`
|
||||
|
@ -87,106 +91,6 @@ export function trimOtherProps(object: any, allowedProps: string[]) {
|
|||
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) {
|
||||
const allowed = [
|
||||
{ op: BasicOperator.STRING, key: "email" },
|
||||
|
@ -212,3 +116,98 @@ export function isSupportedUserSearch(query: SearchFilters) {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
SearchFilters,
|
||||
} from "../../sdk"
|
||||
|
||||
export type SearchFilter = {
|
||||
export type LegacyFilter = {
|
||||
operator: keyof SearchFilters | "rangeLow" | "rangeHigh"
|
||||
onEmptyFilter?: EmptyFilterOption
|
||||
field: string
|
||||
|
@ -14,9 +14,10 @@ export type SearchFilter = {
|
|||
externalType?: string
|
||||
}
|
||||
|
||||
// this is a type purely used by the UI
|
||||
export type SearchFilterGroup = {
|
||||
logicalOperator: FilterGroupLogicalOperator
|
||||
onEmptyFilter?: EmptyFilterOption
|
||||
groups?: SearchFilterGroup[]
|
||||
filters?: SearchFilter[]
|
||||
filters?: LegacyFilter[]
|
||||
}
|
||||
|
|
|
@ -161,6 +161,7 @@ export interface OptionsFieldMetadata extends BaseFieldSchema {
|
|||
constraints: FieldConstraints & {
|
||||
inclusion: string[]
|
||||
}
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface ArrayFieldMetadata extends BaseFieldSchema {
|
||||
|
@ -169,6 +170,7 @@ export interface ArrayFieldMetadata extends BaseFieldSchema {
|
|||
type: JsonFieldSubType.ARRAY
|
||||
inclusion: string[]
|
||||
}
|
||||
default?: string[]
|
||||
}
|
||||
|
||||
interface BaseFieldSchema extends UIFieldMetadata {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { SearchFilter, SearchFilterGroup, SortOrder, SortType } from "../../api"
|
||||
import { LegacyFilter, SearchFilterGroup, SortOrder, SortType } from "../../api"
|
||||
import { UIFieldMetadata } from "./table"
|
||||
import { Document } from "../document"
|
||||
import { DBView } from "../../sdk"
|
||||
import { DBView, SearchFilters } from "../../sdk"
|
||||
|
||||
export type ViewTemplateOpts = {
|
||||
field: string
|
||||
|
@ -65,16 +65,20 @@ export interface ViewV2 {
|
|||
name: string
|
||||
primaryDisplay?: string
|
||||
tableId: string
|
||||
query?: SearchFilter[] | SearchFilterGroup
|
||||
query?: LegacyFilter[] | SearchFilters
|
||||
// duplicate to store UI information about filters
|
||||
queryUI?: SearchFilterGroup
|
||||
sort?: {
|
||||
field: string
|
||||
order?: SortOrder
|
||||
type?: SortType
|
||||
}
|
||||
schema?: Record<string, ViewFieldMetadata>
|
||||
schema?: ViewV2Schema
|
||||
uiMetadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export type ViewV2Schema = Record<string, ViewFieldMetadata>
|
||||
|
||||
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
|
||||
|
||||
export interface ViewCountOrSumSchema {
|
||||
|
|
Loading…
Reference in New Issue