From eeb9a5fbc77a7bb26dac9ec31ce913c2ef4ff91c Mon Sep 17 00:00:00 2001
From: Bram
Date: Wed, 12 Nov 2025 10:24:29 +0100
Subject: [PATCH 1/6] Date dependencies - timeline presentation #3829 (#4078)
* show date dependencies on timeline view
---------
Co-authored-by: Cezary Statkiewicz
---
.../date_dependency/constants.py | 14 +-
.../scss/components/date_dependency.scss | 116 +++
.../DateDependencyConnection.vue | 947 ++++++++++++++++++
.../dateDependency/DateDependencyMenuItem.vue | 2 +-
.../dateDependency/DateDependencyModal.vue | 5 +-
.../baserow_enterprise/dateDependency.js | 65 --
.../dateDependencyContextItemTypes.js | 12 -
.../baserow_enterprise/dateDependencyTypes.js | 332 ++++++
.../baserow_enterprise/locales/en.json | 11 +
.../modules/baserow_enterprise/plugin.js | 11 +-
.../views/timeline/timeline_grid.scss | 6 +
.../views/timeline/TimelineGrid.vue | 27 +
.../timeline/TimelineGridRowDependencies.vue | 35 +
.../timeline/TimelineGridRowFieldRules.vue | 84 ++
.../modules/baserow_premium/plugin.js | 2 +
.../baserow_premium/timelineFieldRuleType.js | 15 +
.../database/store/view/bufferedRows.js | 5 +-
web-frontend/modules/database/viewTypes.js | 20 +
18 files changed, 1622 insertions(+), 87 deletions(-)
create mode 100644 enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyConnection.vue
delete mode 100644 enterprise/web-frontend/modules/baserow_enterprise/dateDependency.js
delete mode 100644 enterprise/web-frontend/modules/baserow_enterprise/dateDependencyContextItemTypes.js
create mode 100644 enterprise/web-frontend/modules/baserow_enterprise/dateDependencyTypes.js
create mode 100644 premium/web-frontend/modules/baserow_premium/components/views/timeline/TimelineGridRowDependencies.vue
create mode 100644 premium/web-frontend/modules/baserow_premium/components/views/timeline/TimelineGridRowFieldRules.vue
create mode 100644 premium/web-frontend/modules/baserow_premium/timelineFieldRuleType.js
diff --git a/enterprise/backend/src/baserow_enterprise/date_dependency/constants.py b/enterprise/backend/src/baserow_enterprise/date_dependency/constants.py
index 89c6a5aa8d..1dede0cc13 100644
--- a/enterprise/backend/src/baserow_enterprise/date_dependency/constants.py
+++ b/enterprise/backend/src/baserow_enterprise/date_dependency/constants.py
@@ -31,6 +31,7 @@ class NoValueSentinel:
0 AS level,
ARRAY [u.updated_id] AS path
FROM updated u
+ JOIN {table_name} t ON t.id = u.updated_id AND NOT t.trashed
UNION ALL
-- Recursively find parents
SELECT ip.{to_field_name} AS parent_id
@@ -40,6 +41,7 @@ class NoValueSentinel:
FROM ancestors a
JOIN {relation_table_name} ip
ON ip.{from_field_name} = a.id
+ JOIN {table_name} t ON t.id = a.id AND NOT t.trashed
WHERE NOT (ip.{to_field_name} = ANY (a.path)) -- Prevent cycles
),
-- one starting row can have multiple roots
@@ -47,9 +49,10 @@ class NoValueSentinel:
a.id,
a.original_id
FROM ancestors a
- left outer join {relation_table_name} ip
- on ip.{from_field_name} = a.id
- where ip.id is null
+ LEFT OUTER JOIN {relation_table_name} ip
+ ON ip.{from_field_name} = a.id
+ JOIN {table_name} t ON t.id = a.id AND NOT t.trashed
+ WHERE ip.id IS NULL
ORDER BY original_id, level ASC),
-- Find all descendants starting from roots
descendants AS (
@@ -60,6 +63,7 @@ class NoValueSentinel:
0 AS level,
ARRAY [r.root_id] AS path
FROM roots r
+ JOIN {table_name} t ON t.id = r.root_id AND NOT t.trashed
UNION ALL
-- Recursively find children
SELECT d.root_id,
@@ -70,6 +74,7 @@ class NoValueSentinel:
FROM descendants d
JOIN {relation_table_name} ip
ON ip.{to_field_name} = d.id
+ JOIN {table_name} t ON t.id = d.id AND NOT t.trashed
WHERE NOT (ip.{from_field_name} = ANY (d.path)) -- Prevent cycles
),
-- Combine all nodes in the dependency trees
@@ -96,8 +101,7 @@ class NoValueSentinel:
END AS node_type
FROM complete_tree ct
-JOIN {table_name} i ON i.id = ct.id
-WHERE NOT i.trashed
+JOIN {table_name} i ON i.id = ct.id AND NOT i.trashed
ORDER BY ct.original_id, ct.level desc, ct.id;
"""
) # noqa STR100
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/date_dependency.scss b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/date_dependency.scss
index 74e34ee2c9..446d333ee4 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/date_dependency.scss
+++ b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/date_dependency.scss
@@ -1,7 +1,123 @@
+.date-dependency__circle {
+ r: 4px;
+ stroke: $palette-neutral-600;
+ stroke-width: 1px;
+ fill: $palette-neutral-100;
+ visibility: hidden;
+ cursor: grab;
+ pointer-events: all;
+
+ &:hover {
+ visibility: visible;
+ r: 6px
+ }
+
+ &--handle {
+ r: 6px;
+ fill: $palette-green-500;
+ stroke-width: 2px;
+ pointer-events: none;
+ }
+
+ &--end {
+ pointer-events: all;
+ }
+}
+
+
+.date-dependency__drop-zone {
+ pointer-events: none;
+ display: none;
+ fill:transparent;
+
+ &--droppable {
+ display: block;
+ visibility: visible;
+ pointer-events: all;
+ }
+}
+
+
.date-dependency__container {
padding-top: 16px;
}
+
.date-dependency__help-icon {
padding-right: 2px;
}
+
+
+.date-dependency__path {
+ fill: none;
+ stroke-width: 1px;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+ pointer-events: stroke;
+
+ &--creating {
+ fill: none;
+ stroke-dasharray: 4 1;
+ stroke: $palette-blue-400;
+ pointer-events: none;
+ }
+
+ &--invalid {
+ stroke: $palette-red-500;
+ stroke-dasharray: 5 4;
+ }
+
+ &:hover {
+ stroke-width: 3px;
+ }
+
+}
+
+.date-dependency__text {
+ stroke-dasharray: none;
+ stroke-width: 0;
+ visibility: hidden;
+
+ &--invalid {
+ fill: $palette-red-500;
+ }
+}
+
+.date-dependency__connection-group {
+ stroke: $palette-neutral-500;
+ fill: none;
+ pointer-events: none;
+
+ &:hover {
+ stroke-width: 4px;
+ pointer-events: all;
+
+ // When a connection group is hovered, we want to show error text too.
+ .date-dependency__text {
+ visibility: visible;
+ }
+ }
+}
+
+.date-dependency__timeline-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+
+ &--draggable {
+ pointer-events: all;
+ cursor: grabbing;
+ }
+}
+
+.date-dependency__drawable {
+ pointer-events: none;
+
+ &--draggable {
+ pointer-events: all;
+ }
+
+}
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyConnection.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyConnection.vue
new file mode 100644
index 0000000000..85afc296ab
--- /dev/null
+++ b/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyConnection.vue
@@ -0,0 +1,947 @@
+
+
+
+
+
+
+
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyMenuItem.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyMenuItem.vue
index ba9fa8024e..b563462b27 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyMenuItem.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/components/dateDependency/DateDependencyMenuItem.vue
@@ -42,7 +42,7 @@
diff --git a/premium/web-frontend/modules/baserow_premium/components/views/timeline/TimelineGridRowFieldRules.vue b/premium/web-frontend/modules/baserow_premium/components/views/timeline/TimelineGridRowFieldRules.vue
new file mode 100644
index 0000000000..d042f91d7b
--- /dev/null
+++ b/premium/web-frontend/modules/baserow_premium/components/views/timeline/TimelineGridRowFieldRules.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
diff --git a/premium/web-frontend/modules/baserow_premium/plugin.js b/premium/web-frontend/modules/baserow_premium/plugin.js
index bb2d479867..d4ed2be84e 100644
--- a/premium/web-frontend/modules/baserow_premium/plugin.js
+++ b/premium/web-frontend/modules/baserow_premium/plugin.js
@@ -358,4 +358,6 @@ export default (context) => {
new PublicLogoRemovalPaidFeature(context)
)
app.$registry.register('paidFeature', new ChartPaidFeature(context))
+
+ app.$registry.registerNamespace('timelineFieldRules')
}
diff --git a/premium/web-frontend/modules/baserow_premium/timelineFieldRuleType.js b/premium/web-frontend/modules/baserow_premium/timelineFieldRuleType.js
new file mode 100644
index 0000000000..9ceacf50b4
--- /dev/null
+++ b/premium/web-frontend/modules/baserow_premium/timelineFieldRuleType.js
@@ -0,0 +1,15 @@
+import { Registerable } from '@baserow/modules/core/registry'
+
+export default class TimelineFieldRuleType extends Registerable {
+ /**
+ * Returns field rule type name that is associated with this TimelineFieldRuleType
+ *
+ * @returns {string}
+ */
+ getType() {}
+
+ /**
+ * Returns a component that will be used to render a field rule for a single row
+ */
+ getTimelineFieldRuleComponent(rule, view, database) {}
+}
diff --git a/web-frontend/modules/database/store/view/bufferedRows.js b/web-frontend/modules/database/store/view/bufferedRows.js
index 0d175cb32a..faf66c9bb8 100644
--- a/web-frontend/modules/database/store/view/bufferedRows.js
+++ b/web-frontend/modules/database/store/view/bufferedRows.js
@@ -799,6 +799,7 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
const updateRowsData = [
Object.assign({ id: row.id }, updateRequestValues),
]
+ commit('SET_ROW_FETCHING', { row, value: true })
const { data } = await RowService(this.$client).batchUpdate(
table.id,
updateRowsData
@@ -844,6 +845,8 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
values: { ...oldValues },
})
throw error
+ } finally {
+ commit('SET_ROW_FETCHING', { row, value: false })
}
},
/**
@@ -1251,7 +1254,7 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
return state.fetching
},
getRow: (state) => (id) => {
- return state.rows.find((row) => row.id === id)
+ return state.rows.find((row) => row?.id === id)
},
getRows(state) {
return state.rows
diff --git a/web-frontend/modules/database/viewTypes.js b/web-frontend/modules/database/viewTypes.js
index 2db6ce93fb..8039665cff 100644
--- a/web-frontend/modules/database/viewTypes.js
+++ b/web-frontend/modules/database/viewTypes.js
@@ -583,6 +583,11 @@ export class GridViewType extends ViewType {
if (
this.app.$featureFlagIsEnabled(FF_DATE_DEPENDENCY) &&
!isPublic &&
+ this.app.$hasPermission(
+ 'database.table.field_rules.read_field_rules',
+ view.table,
+ database.workspace.id
+ ) &&
!store.getters['fieldRules/hasRules']({ tableId: view.table_id })
) {
await store.dispatch('fieldRules/fetchInitial', {
@@ -892,6 +897,21 @@ export const BaseBufferedRowViewTypeMixin = (Base) =>
adhocSorting,
}
)
+
+ if (
+ this.app.$featureFlagIsEnabled(FF_DATE_DEPENDENCY) &&
+ !isPublic &&
+ this.app.$hasPermission(
+ 'database.table.field_rules.read_field_rules',
+ view.table,
+ database.workspace.id
+ ) &&
+ !store.getters['fieldRules/hasRules']({ tableId: view.table_id })
+ ) {
+ await store.dispatch('fieldRules/fetchInitial', {
+ tableId: view.table_id,
+ })
+ }
}
async refresh(
From 6a1d587a79e73bd7d4353056cca6d7676b38179a Mon Sep 17 00:00:00 2001
From: Frederik Duchi <35960131+frederikduchi@users.noreply.github.com>
Date: Wed, 12 Nov 2025 10:48:03 +0100
Subject: [PATCH 2/6] added category most popular to the template (#4190)
---
backend/templates/task-management.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/templates/task-management.json b/backend/templates/task-management.json
index 006857d7fc..e515132cc4 100644
--- a/backend/templates/task-management.json
+++ b/backend/templates/task-management.json
@@ -14,7 +14,8 @@
"tasks"
],
"categories": [
- "Project Management"
+ "Project Management",
+ "🔥 Most Popular"
],
"export": [
{
From 2a6d92e1201a426d38857d6de4348e5707065ac6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?=
<571533+jrmi@users.noreply.github.com>
Date: Wed, 12 Nov 2025 12:09:00 +0100
Subject: [PATCH 3/6] Improve formula example display (#4215)
* Improve formula display
* Move 'Example' to a localised string.
---------
Co-authored-by: peter_baserow
---
.../nodeExplorer/NodeHelpTooltip.vue | 28 +-
web-frontend/modules/core/locales/en.json | 4 +
.../modules/core/runtimeFormulaTypes.js | 341 +++++++++++++++---
3 files changed, 318 insertions(+), 55 deletions(-)
diff --git a/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue b/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue
index 0f7b748cc2..3eab0b80a4 100644
--- a/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue
+++ b/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue
@@ -18,16 +18,24 @@
{{ node.description }}
-
+
+
+
diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json
index b1379119c4..ce7b130dd6 100644
--- a/web-frontend/modules/core/locales/en.json
+++ b/web-frontend/modules/core/locales/en.json
@@ -1075,6 +1075,10 @@
"notAllowedTitle": "2FA not enabled",
"notAllowedDescription": "Adding 2FA is only possible to password-based accounts."
},
+ "nodeHelpTooltip": {
+ "exampleLabel": "Example",
+ "result": "Result: {result}"
+ },
"enableWithQRCode": {
"scanQRCode": "Scan QR code",
"scanQRCodeDescription": "Scan the code with an app like Google Authenticator, Authy or Microsoft Authenticator.",
diff --git a/web-frontend/modules/core/runtimeFormulaTypes.js b/web-frontend/modules/core/runtimeFormulaTypes.js
index d203a89519..6da86934e8 100644
--- a/web-frontend/modules/core/runtimeFormulaTypes.js
+++ b/web-frontend/modules/core/runtimeFormulaTypes.js
@@ -239,8 +239,11 @@ export class RuntimeConcat extends RuntimeFormulaFunction {
getExamples() {
return [
- "concat('Hello,', ' World!') = 'Hello, world!'",
- "concat(get('data_source.1.0.field_1'), ' bar') = 'foo bar'",
+ { formula: "concat('Hello,', ' World!')", result: '"Hello, world!"' },
+ {
+ formula: "concat(get('data_source.1.0.field_1'), ' bar')",
+ result: '"foo bar"',
+ },
]
}
}
@@ -326,7 +329,12 @@ export class RuntimeGet extends RuntimeFormulaFunction {
}
getExamples() {
- return ["get('previous_node.1.body')"]
+ return [
+ {
+ formula: "get('previous_node.1.body')",
+ result: "'Hello world'",
+ },
+ ]
}
}
@@ -364,7 +372,16 @@ export class RuntimeAdd extends RuntimeFormulaFunction {
}
getExamples() {
- return ['2 + 3 = 5', '1 + 2 + 3 = 6']
+ return [
+ {
+ formula: '2 + 3',
+ result: '5',
+ },
+ {
+ formula: '1 + 2 + 3',
+ result: '6',
+ },
+ ]
}
}
@@ -402,7 +419,16 @@ export class RuntimeMinus extends RuntimeFormulaFunction {
}
getExamples() {
- return ['3 - 2 = 1', '5 - 2 - 1 = 2']
+ return [
+ {
+ formula: '3 - 2',
+ result: '1',
+ },
+ {
+ formula: '5 - 2 - 1',
+ result: '2',
+ },
+ ]
}
}
@@ -440,7 +466,16 @@ export class RuntimeMultiply extends RuntimeFormulaFunction {
}
getExamples() {
- return ['2 * 3 = 6', '2 * 3 * 3 = 18']
+ return [
+ {
+ formula: '2 * 3',
+ result: '6',
+ },
+ {
+ formula: '2 * 3 * 3',
+ result: '18',
+ },
+ ]
}
}
@@ -478,7 +513,16 @@ export class RuntimeDivide extends RuntimeFormulaFunction {
}
getExamples() {
- return ['6 / 2 = 3', '15 / 2 / 2 = 3.75']
+ return [
+ {
+ formula: '6 / 2',
+ result: '3',
+ },
+ {
+ formula: '15 / 2 / 2',
+ result: '3.75',
+ },
+ ]
}
}
@@ -516,7 +560,20 @@ export class RuntimeEqual extends RuntimeFormulaFunction {
}
getExamples() {
- return ['2 = 3 = false', '"foo" = "bar" = false', '"foo" = "foo" = true']
+ return [
+ {
+ formula: '2 = 3',
+ result: 'false',
+ },
+ {
+ formula: '"foo" = "bar"',
+ result: 'false',
+ },
+ {
+ formula: '"foo" = "foo"',
+ result: 'true',
+ },
+ ]
}
}
@@ -554,7 +611,20 @@ export class RuntimeNotEqual extends RuntimeFormulaFunction {
}
getExamples() {
- return ['2 != 3 = true', '"foo" != "foo" = false', '"foo" != "bar" = true']
+ return [
+ {
+ formula: '2 != 3',
+ result: 'true',
+ },
+ {
+ formula: '"foo" != "foo"',
+ result: 'false',
+ },
+ {
+ formula: '"foo" != "bar"',
+ result: 'true',
+ },
+ ]
}
}
@@ -592,7 +662,20 @@ export class RuntimeGreaterThan extends RuntimeFormulaFunction {
}
getExamples() {
- return ['5 > 4 = true', '"a" > "b" = false', '"Ambarella" > "fig" = false']
+ return [
+ {
+ formula: '5 > 4',
+ result: 'true',
+ },
+ {
+ formula: '"a" > "b"',
+ result: 'false',
+ },
+ {
+ formula: '"Ambarella" > "fig"',
+ result: 'false',
+ },
+ ]
}
}
@@ -630,7 +713,20 @@ export class RuntimeLessThan extends RuntimeFormulaFunction {
}
getExamples() {
- return ['2 < 3 = true', '"b" < "a" = false', '"Ambarella" < "fig" = true']
+ return [
+ {
+ formula: '2 < 3',
+ result: 'true',
+ },
+ {
+ formula: '"b" < "a"',
+ result: 'false',
+ },
+ {
+ formula: '"Ambarella" < "fig"',
+ result: 'true',
+ },
+ ]
}
}
@@ -669,9 +765,18 @@ export class RuntimeGreaterThanOrEqual extends RuntimeFormulaFunction {
getExamples() {
return [
- '3 >= 2 = false',
- '"b" >= "a" = true',
- '"Ambarella" >= "fig" = false',
+ {
+ formula: '3 >= 2',
+ result: 'false',
+ },
+ {
+ formula: '"b" >= "a"',
+ result: 'true',
+ },
+ {
+ formula: '"Ambarella" >= "fig"',
+ result: 'false',
+ },
]
}
}
@@ -711,9 +816,18 @@ export class RuntimeLessThanOrEqual extends RuntimeFormulaFunction {
getExamples() {
return [
- '3 <= 3 = true',
- '"a" <= "b" = false',
- '"fig" <= "Ambarella" = false',
+ {
+ formula: '3 <= 3',
+ result: 'true',
+ },
+ {
+ formula: '"a" <= "b"',
+ result: 'false',
+ },
+ {
+ formula: '"fig" <= "Ambarella"',
+ result: 'false',
+ },
]
}
}
@@ -745,7 +859,12 @@ export class RuntimeUpper extends RuntimeFormulaFunction {
}
getExamples() {
- return ["upper('Hello, World!') = 'HELLO, WORLD!'"]
+ return [
+ {
+ formula: "upper('Hello, World!')",
+ result: "'HELLO, WORLD!'",
+ },
+ ]
}
}
@@ -776,7 +895,12 @@ export class RuntimeLower extends RuntimeFormulaFunction {
}
getExamples() {
- return ["lower('Hello, World!') = 'hello, world!'"]
+ return [
+ {
+ formula: "lower('Hello, World!')",
+ result: "'hello, world!'",
+ },
+ ]
}
}
@@ -813,7 +937,12 @@ export class RuntimeCapitalize extends RuntimeFormulaFunction {
}
getExamples() {
- return ["capitalize('hello, world!') = 'Hello, world!'"]
+ return [
+ {
+ formula: "capitalize('hello, world!')",
+ result: "'Hello, world!'",
+ },
+ ]
}
}
@@ -858,7 +987,12 @@ export class RuntimeRound extends RuntimeFormulaFunction {
}
getExamples() {
- return ["round('12.345', 2) = '12.35'"]
+ return [
+ {
+ formula: "round('12.345', 2)",
+ result: '12.35',
+ },
+ ]
}
}
@@ -889,7 +1023,16 @@ export class RuntimeIsEven extends RuntimeFormulaFunction {
}
getExamples() {
- return ['is_even(12) = true', 'is_even(13) = false']
+ return [
+ {
+ formula: 'is_even(12)',
+ result: 'true',
+ },
+ {
+ formula: 'is_even(13)',
+ result: 'false',
+ },
+ ]
}
}
@@ -920,7 +1063,16 @@ export class RuntimeIsOdd extends RuntimeFormulaFunction {
}
getExamples() {
- return ['is_odd(11) = true', 'is_odd(12) = false']
+ return [
+ {
+ formula: 'is_odd(11)',
+ result: 'true',
+ },
+ {
+ formula: 'is_odd(12)',
+ result: 'false',
+ },
+ ]
}
}
@@ -962,9 +1114,18 @@ export class RuntimeDateTimeFormat extends RuntimeFormulaFunction {
getExamples() {
return [
- "datetime_format(now(), 'YYYY-MM-DD') = '2025-11-03'",
- "datetime_format(now(), 'YYYY-MM-DD', 'Europe/Amsterdam') = '2025-11-03'",
- "datetime_format(now(), 'DD/MM/YYYY HH:mm:ss', 'UTC') = '03/11/2025 12:12:09'",
+ {
+ formula: "datetime_format(now(), 'YYYY-MM-DD')",
+ result: "'2025-11-03'",
+ },
+ {
+ formula: "datetime_format(now(), 'YYYY-MM-DD', 'Europe/Amsterdam')",
+ result: "'2025-11-03'",
+ },
+ {
+ formula: "datetime_format(now(), 'DD/MM/YYYY HH:mm:ss', 'UTC')",
+ result: "'03/11/2025 12:12:09'",
+ },
]
}
}
@@ -996,7 +1157,12 @@ export class RuntimeDay extends RuntimeFormulaFunction {
}
getExamples() {
- return ["day('2025-10-16 11:05:38') = '16'"]
+ return [
+ {
+ formula: "day('2025-10-16 11:05:38')",
+ result: '16',
+ },
+ ]
}
}
@@ -1028,7 +1194,12 @@ export class RuntimeMonth extends RuntimeFormulaFunction {
getExamples() {
// Month is 0 indexed
- return ["month('2025-10-16 11:05:38') = '9'"]
+ return [
+ {
+ formula: "month('2025-10-16 11:05:38')",
+ result: '9',
+ },
+ ]
}
}
@@ -1059,7 +1230,12 @@ export class RuntimeYear extends RuntimeFormulaFunction {
}
getExamples() {
- return ["year('2025-10-16 11:05:38') = '2025'"]
+ return [
+ {
+ formula: "year('2025-10-16 11:05:38')",
+ result: '2025',
+ },
+ ]
}
}
@@ -1090,7 +1266,12 @@ export class RuntimeHour extends RuntimeFormulaFunction {
}
getExamples() {
- return ["hour('2025-10-16 11:05:38') = '11'"]
+ return [
+ {
+ formula: "hour('2025-10-16 11:05:38')",
+ result: '11',
+ },
+ ]
}
}
@@ -1121,7 +1302,12 @@ export class RuntimeMinute extends RuntimeFormulaFunction {
}
getExamples() {
- return ["minute('2025-10-16T11:05:38') = '5'"]
+ return [
+ {
+ formula: "minute('2025-10-16T11:05:38')",
+ result: '5',
+ },
+ ]
}
}
@@ -1152,7 +1338,12 @@ export class RuntimeSecond extends RuntimeFormulaFunction {
}
getExamples() {
- return ["second('2025-10-16 11:05:38') = '38'"]
+ return [
+ {
+ formula: "second('2025-10-16 11:05:38')",
+ result: '38',
+ },
+ ]
}
}
@@ -1178,7 +1369,12 @@ export class RuntimeNow extends RuntimeFormulaFunction {
}
getExamples() {
- return ["now() = '2025-10-16 11:05:38'"]
+ return [
+ {
+ formula: 'now()',
+ result: "'2025-10-16 11:05:38'",
+ },
+ ]
}
}
@@ -1209,7 +1405,12 @@ export class RuntimeToday extends RuntimeFormulaFunction {
}
getExamples() {
- return ["today() = '2025-10-16'"]
+ return [
+ {
+ formula: 'today()',
+ result: "'2025-10-16'",
+ },
+ ]
}
}
@@ -1243,7 +1444,12 @@ export class RuntimeGetProperty extends RuntimeFormulaFunction {
}
getExamples() {
- return ["get_property('{\"cherry\": \"red\"}', 'cherry') = 'red'"]
+ return [
+ {
+ formula: 'get_property(\'{"cherry": "red"}\', \'cherry\')',
+ result: "'red'",
+ },
+ ]
}
}
@@ -1279,7 +1485,12 @@ export class RuntimeRandomInt extends RuntimeFormulaFunction {
}
getExamples() {
- return ['random_int(10, 20) = 17']
+ return [
+ {
+ formula: 'random_int(10, 20)',
+ result: '17',
+ },
+ ]
}
}
@@ -1313,7 +1524,12 @@ export class RuntimeRandomFloat extends RuntimeFormulaFunction {
}
getExamples() {
- return ['random_float(10, 20) = 18.410550297490616']
+ return [
+ {
+ formula: 'random_float(10, 20)',
+ result: '18.410550297490616',
+ },
+ ]
}
}
@@ -1344,7 +1560,12 @@ export class RuntimeRandomBool extends RuntimeFormulaFunction {
}
getExamples() {
- return ['random_bool() = true']
+ return [
+ {
+ formula: 'random_bool()',
+ result: 'true',
+ },
+ ]
}
}
@@ -1375,7 +1596,12 @@ export class RuntimeGenerateUUID extends RuntimeFormulaFunction {
}
getExamples() {
- return ["generate_uuid() = '9b772ad6-08bc-4d19-958d-7f1c21a4f4ef'"]
+ return [
+ {
+ formula: 'generate_uuid()',
+ result: "'9b772ad6-08bc-4d19-958d-7f1c21a4f4ef'",
+ },
+ ]
}
}
@@ -1411,8 +1637,15 @@ export class RuntimeIf extends RuntimeFormulaFunction {
getExamples() {
return [
- 'if(true, true, false) = true',
- "if(random_bool(), 'Random bool is true', 'Random bool is false') = 'Random bool is false'",
+ {
+ formula: 'if(true, true, false)',
+ result: 'true',
+ },
+ {
+ formula:
+ "if(random_bool(), 'Random bool is true', 'Random bool is false')",
+ result: "'Random bool is false'",
+ },
]
}
}
@@ -1447,7 +1680,16 @@ export class RuntimeAnd extends RuntimeFormulaFunction {
}
getExamples() {
- return ['true && true = true', 'true && true && false = false']
+ return [
+ {
+ formula: 'true && true',
+ result: 'true',
+ },
+ {
+ formula: 'true && true && false',
+ result: 'false',
+ },
+ ]
}
}
@@ -1482,9 +1724,18 @@ export class RuntimeOr extends RuntimeFormulaFunction {
getExamples() {
return [
- 'true || true = true',
- 'true || true || false = true',
- 'false || false = false',
+ {
+ formula: 'true || true',
+ result: 'true',
+ },
+ {
+ formula: 'true || true || false',
+ result: 'true',
+ },
+ {
+ formula: 'false || false',
+ result: 'false',
+ },
]
}
}
From cabf111040083c132248bf757bc376f76e9d23e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?=
<571533+jrmi@users.noreply.github.com>
Date: Wed, 12 Nov 2025 12:11:41 +0100
Subject: [PATCH 4/6] Add element visibility condition (#4188)
Co-authored-by: peter_baserow
---
.../builder/api/domains/serializers.py | 6 +
.../builder/api/elements/serializers.py | 15 +
.../contrib/builder/elements/handler.py | 2 +
.../contrib/builder/elements/models.py | 4 +
.../0066_element_visibility_condition.py | 24 ++
backend/src/baserow/contrib/builder/types.py | 2 +
backend/src/baserow/core/formula/field.py | 3 +
.../api/domains/test_domain_public_views.py | 1 +
.../builder/test_builder_application_type.py | 33 ++
...visually_hidden_on_complex_conditions.json | 8 +
.../elements/AuthFormElementForm.vue | 2 +-
.../builder/elementTypes.js | 6 +-
web-frontend/jest.config.js | 5 +-
.../ApplicationBuilderFormulaInput.vue | 15 +-
.../components/elements/ElementPreview.vue | 62 +---
.../components/forms/VisibilityForm.vue | 20 +-
.../forms/general/TableElementForm.vue | 6 +-
.../builder/components/page/PageContent.vue | 8 +-
.../builder/components/page/PageElement.vue | 59 +---
.../builder/components/page/PagePreview.vue | 18 +-
.../page/sidePanels/GeneralSidePanel.vue | 6 -
.../modules/builder/elementTypeMixins.js | 43 +--
web-frontend/modules/builder/elementTypes.js | 206 ++++++------
web-frontend/modules/builder/locales/en.json | 5 +-
.../builder/mixins/collectionElement.js | 20 +-
.../modules/builder/mixins/element.js | 47 +--
.../builder/mixins/elementSidePanel.js | 2 +
.../modules/builder/mixins/resolveFormula.js | 45 +++
.../builder/mixins/useApplicationContext.js | 14 +
.../modules/builder/pageSidePanelTypes.js | 15 +-
.../modules/builder/store/elementContent.js | 148 ++++-----
.../integrations/localBaserow/serviceTypes.js | 2 +-
.../test/unit/builder/elementTypes.spec.js | 299 +++++++++++++++---
.../localBaserow/serviceTypes.spec.js | 152 +++++++++
34 files changed, 883 insertions(+), 420 deletions(-)
create mode 100644 backend/src/baserow/contrib/builder/migrations/0066_element_visibility_condition.py
create mode 100644 changelog/entries/unreleased/feature/2566_element_can_be_visually_hidden_on_complex_conditions.json
create mode 100644 web-frontend/modules/builder/mixins/resolveFormula.js
create mode 100644 web-frontend/modules/builder/mixins/useApplicationContext.js
create mode 100644 web-frontend/test/unit/integrations/localBaserow/serviceTypes.spec.js
diff --git a/backend/src/baserow/contrib/builder/api/domains/serializers.py b/backend/src/baserow/contrib/builder/api/domains/serializers.py
index 1697fb9600..ed0255b5ca 100644
--- a/backend/src/baserow/contrib/builder/api/domains/serializers.py
+++ b/backend/src/baserow/contrib/builder/api/domains/serializers.py
@@ -26,6 +26,7 @@
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.core.app_auth_providers.registries import app_auth_provider_type_registry
+from baserow.core.formula.serializers import FormulaSerializerField
from baserow.core.services.registries import service_type_registry
from baserow.core.user_sources.models import UserSource
from baserow.core.user_sources.registries import user_source_type_registry
@@ -100,6 +101,10 @@ class PublicElementSerializer(serializers.ModelSerializer):
def get_type(self, instance):
return element_type_registry.get_by_model(instance.specific_class).type
+ visibility_condition = FormulaSerializerField(
+ help_text=Element._meta.get_field("visibility_condition").help_text,
+ )
+
style_background_file = UserFileField(
allow_null=True,
help_text="The background image file",
@@ -118,6 +123,7 @@ class Meta:
"place_in_container",
"css_classes",
"visibility",
+ "visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
diff --git a/backend/src/baserow/contrib/builder/api/elements/serializers.py b/backend/src/baserow/contrib/builder/api/elements/serializers.py
index 727299da98..e148edec2a 100644
--- a/backend/src/baserow/contrib/builder/api/elements/serializers.py
+++ b/backend/src/baserow/contrib/builder/api/elements/serializers.py
@@ -50,6 +50,10 @@ class ElementSerializer(serializers.ModelSerializer):
def get_type(self, instance):
return element_type_registry.get_by_model(instance.specific_class).type
+ visibility_condition = FormulaSerializerField(
+ help_text=Element._meta.get_field("visibility_condition").help_text,
+ )
+
style_background_file = UserFileField(
allow_null=True,
help_text="The background image file",
@@ -67,6 +71,7 @@ class Meta:
"place_in_container",
"css_classes",
"visibility",
+ "visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
@@ -132,6 +137,10 @@ class CreateElementSerializer(serializers.ModelSerializer):
validators=[image_file_validation],
)
+ visibility_condition = FormulaSerializerField(
+ help_text=Element._meta.get_field("visibility_condition").help_text,
+ )
+
class Meta:
model = Element
fields = (
@@ -142,6 +151,7 @@ class Meta:
"place_in_container",
"css_classes",
"visibility",
+ "visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
@@ -181,11 +191,16 @@ class UpdateElementSerializer(serializers.ModelSerializer):
validators=[image_file_validation],
)
+ visibility_condition = FormulaSerializerField(
+ help_text=Element._meta.get_field("visibility_condition").help_text,
+ )
+
class Meta:
model = Element
fields = (
"css_classes",
"visibility",
+ "visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
diff --git a/backend/src/baserow/contrib/builder/elements/handler.py b/backend/src/baserow/contrib/builder/elements/handler.py
index 9329c8fcf3..8b5c9232cf 100644
--- a/backend/src/baserow/contrib/builder/elements/handler.py
+++ b/backend/src/baserow/contrib/builder/elements/handler.py
@@ -49,6 +49,7 @@ class ElementHandler:
"parent_element_id",
"place_in_container",
"visibility",
+ "visibility_condition",
"css_classes",
"styles",
"style_border_top_color",
@@ -82,6 +83,7 @@ class ElementHandler:
"place_in_container",
"css_classes",
"visibility",
+ "visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
diff --git a/backend/src/baserow/contrib/builder/elements/models.py b/backend/src/baserow/contrib/builder/elements/models.py
index 2803289d68..8ce073dc1e 100644
--- a/backend/src/baserow/contrib/builder/elements/models.py
+++ b/backend/src/baserow/contrib/builder/elements/models.py
@@ -160,6 +160,10 @@ class ROLE_TYPES(models.TextChoices):
db_index=True,
)
+ visibility_condition = FormulaField(
+ help_text="Change element visibility depending on a formula value"
+ )
+
styles = models.JSONField(
default=dict,
help_text="The theme overrides for this element",
diff --git a/backend/src/baserow/contrib/builder/migrations/0066_element_visibility_condition.py b/backend/src/baserow/contrib/builder/migrations/0066_element_visibility_condition.py
new file mode 100644
index 0000000000..98d1cbdc3a
--- /dev/null
+++ b/backend/src/baserow/contrib/builder/migrations/0066_element_visibility_condition.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.0.14 on 2025-11-09 10:09
+
+from django.db import migrations
+
+import baserow.core.formula.field
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("builder", "0065_aiagentworkflowaction"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="element",
+ name="visibility_condition",
+ field=baserow.core.formula.field.FormulaField(
+ blank=True,
+ default="",
+ help_text="Change element visibility depending on a formula value",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/backend/src/baserow/contrib/builder/types.py b/backend/src/baserow/contrib/builder/types.py
index e9ebd79ef1..bda50fb840 100644
--- a/backend/src/baserow/contrib/builder/types.py
+++ b/backend/src/baserow/contrib/builder/types.py
@@ -1,6 +1,7 @@
from typing import List, Optional, TypedDict
from baserow.contrib.builder.pages.types import PagePathParams, PageQueryParams
+from baserow.core.formula import BaserowFormulaObject
from baserow.core.integrations.types import IntegrationDictSubClass
from baserow.core.services.types import ServiceDictSubClass
from baserow.core.user_sources.types import UserSourceDictSubClass
@@ -15,6 +16,7 @@ class ElementDict(TypedDict):
place_in_container: str
css_classes: str
visibility: str
+ visibility_condition: BaserowFormulaObject
role_type: str
roles: list
styles: dict
diff --git a/backend/src/baserow/core/formula/field.py b/backend/src/baserow/core/formula/field.py
index 0b97ddd102..6ee50ac174 100644
--- a/backend/src/baserow/core/formula/field.py
+++ b/backend/src/baserow/core/formula/field.py
@@ -57,6 +57,9 @@ def _transform_db_value_to_dict(
# If the column type is "text", then we haven't yet migrated the schema.
if self.db_type(connection) == "text":
+ if value is None:
+ return BaserowFormulaObject.create("")
+
if isinstance(value, int):
# A small hack for our backend tests: if we
# receive an integer, we convert it to a string.
diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py
index ed13ed90e5..476def397a 100644
--- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py
+++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py
@@ -408,6 +408,7 @@ def test_get_elements_of_public_builder(api_client, data_fixture):
"place_in_container": None,
"css_classes": "",
"visibility": "all",
+ "visibility_condition": {"formula": "", "mode": "simple", "version": "0.1"},
"styles": {},
"style_border_top_color": "border",
"style_border_top_size": 0,
diff --git a/backend/tests/baserow/contrib/builder/test_builder_application_type.py b/backend/tests/baserow/contrib/builder/test_builder_application_type.py
index f51b33bf93..b8fa40fa4d 100644
--- a/backend/tests/baserow/contrib/builder/test_builder_application_type.py
+++ b/backend/tests/baserow/contrib/builder/test_builder_application_type.py
@@ -35,6 +35,9 @@
from baserow.core.action.registries import action_type_registry
from baserow.core.actions import CreateApplicationActionType
from baserow.core.db import specific_iterator
+from baserow.core.formula import BaserowFormulaObject
+from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL
+from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE
from baserow.core.registries import ImportExportConfig, application_type_registry
from baserow.core.storage import ExportZipFile
from baserow.core.trash.handler import TrashHandler
@@ -248,6 +251,11 @@ def test_builder_application_export(data_fixture):
"parent_element_id": None,
"place_in_container": None,
"visibility": "all",
+ "visibility_condition": BaserowFormulaObject(
+ formula="",
+ mode=BASEROW_FORMULA_MODE_SIMPLE,
+ version=BASEROW_FORMULA_VERSION_INITIAL,
+ ),
"css_classes": "",
"styles": {},
"style_border_top_color": "border",
@@ -296,6 +304,11 @@ def test_builder_application_export(data_fixture):
"place_in_container": None,
"css_classes": "",
"visibility": "all",
+ "visibility_condition": BaserowFormulaObject(
+ formula="",
+ mode=BASEROW_FORMULA_MODE_SIMPLE,
+ version=BASEROW_FORMULA_VERSION_INITIAL,
+ ),
"styles": {},
"style_border_top_color": "border",
"style_border_top_size": 0,
@@ -432,6 +445,11 @@ def test_builder_application_export(data_fixture):
"place_in_container": None,
"css_classes": "",
"visibility": "all",
+ "visibility_condition": BaserowFormulaObject(
+ formula="",
+ mode=BASEROW_FORMULA_MODE_SIMPLE,
+ version=BASEROW_FORMULA_VERSION_INITIAL,
+ ),
"styles": {},
"style_border_top_color": "border",
"style_border_top_size": 0,
@@ -470,6 +488,11 @@ def test_builder_application_export(data_fixture):
"place_in_container": None,
"css_classes": "",
"visibility": "all",
+ "visibility_condition": BaserowFormulaObject(
+ formula="",
+ mode=BASEROW_FORMULA_MODE_SIMPLE,
+ version=BASEROW_FORMULA_VERSION_INITIAL,
+ ),
"styles": {},
"style_border_top_color": "border",
"style_border_top_size": 0,
@@ -507,6 +530,11 @@ def test_builder_application_export(data_fixture):
"place_in_container": None,
"css_classes": "",
"visibility": "all",
+ "visibility_condition": BaserowFormulaObject(
+ formula="",
+ mode=BASEROW_FORMULA_MODE_SIMPLE,
+ version=BASEROW_FORMULA_VERSION_INITIAL,
+ ),
"styles": {},
"style_border_top_color": "border",
"style_border_top_size": 0,
@@ -546,6 +574,11 @@ def test_builder_application_export(data_fixture):
"place_in_container": "0",
"css_classes": "",
"visibility": "all",
+ "visibility_condition": BaserowFormulaObject(
+ formula="",
+ mode=BASEROW_FORMULA_MODE_SIMPLE,
+ version=BASEROW_FORMULA_VERSION_INITIAL,
+ ),
"styles": {},
"style_border_top_color": "border",
"style_border_top_size": 0,
diff --git a/changelog/entries/unreleased/feature/2566_element_can_be_visually_hidden_on_complex_conditions.json b/changelog/entries/unreleased/feature/2566_element_can_be_visually_hidden_on_complex_conditions.json
new file mode 100644
index 0000000000..1b8e809499
--- /dev/null
+++ b/changelog/entries/unreleased/feature/2566_element_can_be_visually_hidden_on_complex_conditions.json
@@ -0,0 +1,8 @@
+{
+ "type": "feature",
+ "message": "Element can be visually hidden on complex conditions",
+ "domain": "builder",
+ "issue_number": 2566,
+ "bullet_points": [],
+ "created_at": "2025-11-09"
+}
\ No newline at end of file
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue
index b7082012f8..a434769b4e 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue
@@ -58,7 +58,7 @@ export default {
allowedValues: ['user_source_id', 'styles', 'login_button_label'],
values: {
user_source_id: null,
- login_button_label: '',
+ login_button_label: {},
styles: {},
},
}
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/builder/elementTypes.js b/enterprise/web-frontend/modules/baserow_enterprise/builder/elementTypes.js
index e90cd68603..9d183d5d45 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/builder/elementTypes.js
+++ b/enterprise/web-frontend/modules/baserow_enterprise/builder/elementTypes.js
@@ -54,7 +54,9 @@ export class AuthFormElementType extends ElementType {
return [new AfterLoginEvent({ app: this.app })]
}
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
+
if (!element.user_source_id) {
return this.$t('elementType.errorUserSourceMissing')
}
@@ -74,7 +76,7 @@ export class AuthFormElementType extends ElementType {
return this.app.i18n.t('elementType.errorUserSourceHasNoLoginOption')
}
- return super.getErrorMessage({ workspace, page, element, builder })
+ return super.getErrorMessage(element, applicationContext)
}
}
diff --git a/web-frontend/jest.config.js b/web-frontend/jest.config.js
index 4ec27bf0d8..44f4ed65d3 100644
--- a/web-frontend/jest.config.js
+++ b/web-frontend/jest.config.js
@@ -4,7 +4,7 @@ const path = require('path')
module.exports = {
testEnvironment: 'jsdom',
testMatch: ['/test/unit/**/*.spec.js'],
- moduleFileExtensions: ['js', 'json', 'vue'],
+ moduleFileExtensions: ['js', 'json', 'vue', '.mjs'],
moduleNameMapper: {
'^@baserow/(.*).(scss|sass)$': '/test/helpers/scss.js',
'^@baserow/(.*)$': '/$1',
@@ -14,11 +14,12 @@ module.exports = {
'^vue$': '/node_modules/vue/dist/vue.common.js',
},
transform: {
- '^.+\\.js$': 'babel-jest',
+ '^.+\\.(mjs|js)$': 'babel-jest',
'^.+\\.vue$': '@vue/vue2-jest',
'^.+\\.(gif|ico|jpg|jpeg|png|svg)$':
'/test/helpers/stubFileTransformer.js',
},
+ transformIgnorePatterns: ['/node_modules/(?!@nuxtjs/composition-api)'],
setupFilesAfterEnv: ['/jest.setup.js'],
snapshotSerializers: ['/node_modules/jest-serializer-vue'],
cacheDirectory: '/.cache/jest',
diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue
index 0c0a92cebe..d53278686b 100644
--- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue
+++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue
@@ -25,6 +25,7 @@ import FormulaInputField from '@baserow/modules/core/components/formula/FormulaI
import { DataSourceDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
import { buildFormulaFunctionNodes } from '@baserow/modules/core/formula'
import { getDataNodesFromDataProvider } from '@baserow/modules/core/utils/dataProviders'
+import { useApplicationContext } from '@baserow/modules/builder/mixins/useApplicationContext'
const props = defineProps({
value: {
@@ -37,9 +38,17 @@ const props = defineProps({
required: false,
default: () => [],
},
+ applicationContextAdditions: {
+ type: Object,
+ required: false,
+ default: undefined,
+ },
})
-const applicationContext = inject('applicationContext')
+const applicationContext = useApplicationContext(
+ props.applicationContextAdditions
+)
+
const elementPage = inject('elementPage')
const emit = defineEmits(['input'])
@@ -60,7 +69,7 @@ watch(
const { app, store } = useContext()
const isInSidePanel = computed(() => {
- return applicationContext?.element !== undefined
+ return applicationContext.value?.element !== undefined
})
const dataProviders = computed(() => {
@@ -74,7 +83,7 @@ const nodesHierarchy = computed(() => {
const filteredDataNodes = getDataNodesFromDataProvider(
dataProviders.value,
- applicationContext
+ applicationContext.value
)
if (filteredDataNodes.length > 0) {
diff --git a/web-frontend/modules/builder/components/elements/ElementPreview.vue b/web-frontend/modules/builder/components/elements/ElementPreview.vue
index bb70a16007..ec8846fb1f 100644
--- a/web-frontend/modules/builder/components/elements/ElementPreview.vue
+++ b/web-frontend/modules/builder/components/elements/ElementPreview.vue
@@ -42,7 +42,6 @@
:element="element"
:mode="mode"
class="element--read-only"
- :application-context-additions="applicationContextAdditions"
:show-element-id="showElementId"
@move="$emit('move', $event)"
/>
@@ -70,12 +69,7 @@ import AddElementModal from '@baserow/modules/builder/components/elements/AddEle
import { notifyIf } from '@baserow/modules/core/utils/error'
import { mapActions, mapGetters } from 'vuex'
import { checkIntermediateElements } from '@baserow/modules/core/utils/dom'
-import {
- VISIBILITY_NOT_LOGGED,
- VISIBILITY_LOGGED_IN,
- ROLE_TYPE_ALLOW_EXCEPT,
- ROLE_TYPE_DISALLOW_EXCEPT,
-} from '@baserow/modules/builder/constants'
+import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
export default {
name: 'ElementPreview',
@@ -85,6 +79,7 @@ export default {
InsertElementButton,
PageElement,
},
+ mixins: [applicationContextMixin],
inject: ['workspace', 'builder', 'mode', 'currentPage', 'pageTopData'],
props: {
element: {
@@ -96,11 +91,6 @@ export default {
required: false,
default: false,
},
- applicationContextAdditions: {
- type: Object,
- required: false,
- default: null,
- },
showElementId: {
type: Boolean,
required: false,
@@ -135,40 +125,10 @@ export default {
)
},
isVisible() {
- if (
- !this.elementType.isVisible({
- element: this.element,
- currentPage: this.currentPage,
- })
- ) {
- return false
- }
-
- const isAuthenticated = this.$store.getters[
- 'userSourceUser/isAuthenticated'
- ](this.builder)
- const user = this.loggedUser(this.builder)
- const roles = this.element.roles
- const roleType = this.element.role_type
-
- const visibility = this.element.visibility
- if (visibility === VISIBILITY_LOGGED_IN) {
- if (!isAuthenticated) {
- return false
- }
-
- if (roleType === ROLE_TYPE_ALLOW_EXCEPT) {
- return !roles.includes(user.role)
- } else if (roleType === ROLE_TYPE_DISALLOW_EXCEPT) {
- return roles.includes(user.role)
- } else {
- return true
- }
- } else if (visibility === VISIBILITY_NOT_LOGGED) {
- return !isAuthenticated
- } else {
- return true
- }
+ return this.elementType.isVisible({
+ element: this.element,
+ applicationContext: this.applicationContext,
+ })
},
DIRECTIONS: () => DIRECTIONS,
directions() {
@@ -249,12 +209,10 @@ export default {
)
},
errorMessage() {
- return this.elementType.getErrorMessage({
- workspace: this.workspace,
- page: this.elementPage,
- element: this.element,
- builder: this.builder,
- })
+ return this.elementType.getErrorMessage(
+ this.element,
+ this.applicationContext
+ )
},
},
watch: {
diff --git a/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue b/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue
index b3609429e0..db79673e33 100644
--- a/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue
+++ b/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue
@@ -66,15 +66,29 @@
{{ $t('visibilityForm.notLoggedInVisitors') }}
-
+
{{ $t('visibilityForm.warningTitle') }}
+
+
+
+
diff --git a/web-frontend/modules/builder/elementTypeMixins.js b/web-frontend/modules/builder/elementTypeMixins.js
index f449db76c1..e3b5d07673 100644
--- a/web-frontend/modules/builder/elementTypeMixins.js
+++ b/web-frontend/modules/builder/elementTypeMixins.js
@@ -35,9 +35,16 @@ export const ContainerElementTypeMixin = (Base) =>
* A Container element without any child elements is invalid. Return true
* if there are no children, otherwise return false.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
+
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
+
const children = this.app.store.getters['element/getChildren'](
- page,
+ elementPage,
element
)
@@ -46,12 +53,7 @@ export const ContainerElementTypeMixin = (Base) =>
return this.app.i18n.t('elementType.errorEmptyContainer')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
}
@@ -368,10 +370,17 @@ export const CollectionElementTypeMixin = (Base) =>
/**
* Check data source errors.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { workspace, builder } = applicationContext
+
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
+
const dataSourceErrorMessage = this.getDataSourceErrorMessage({
workspace,
- page,
+ page: elementPage,
element,
builder,
})
@@ -380,12 +389,7 @@ export const CollectionElementTypeMixin = (Base) =>
return dataSourceErrorMessage
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
}
@@ -397,10 +401,13 @@ export const MultiPageElementTypeMixin = (Base) =>
return true
}
- isVisible({ element, currentPage }) {
- if (!super.isVisible({ element, currentPage })) {
+ isVisible({ element, applicationContext }) {
+ if (!super.isVisible({ element, applicationContext })) {
return false
}
+
+ const { page: currentPage } = applicationContext
+
switch (element.share_type) {
case SHARE_TYPES.ALL:
return true
diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js
index dedcc79785..2db77681f2 100644
--- a/web-frontend/modules/builder/elementTypes.js
+++ b/web-frontend/modules/builder/elementTypes.js
@@ -65,6 +65,13 @@ import {
} from '@baserow/modules/builder/elementTypeMixins'
import { isNumeric, isValidEmail } from '@baserow/modules/core/utils/string'
+import {
+ VISIBILITY_NOT_LOGGED,
+ VISIBILITY_LOGGED_IN,
+ ROLE_TYPE_ALLOW_EXCEPT,
+ ROLE_TYPE_DISALLOW_EXCEPT,
+} from '@baserow/modules/builder/constants'
+
import RatingElementForm from '@baserow/modules/builder/components/elements/components/forms/general/RatingElementForm'
import RatingElement from '@baserow/modules/builder/components/elements/components/RatingElement.vue'
import RatingInputElement from '@baserow/modules/builder/components/elements/components/RatingInputElement.vue'
@@ -266,10 +273,49 @@ export class ElementType extends Registerable {
/**
* Should return whether this element is visible.
* @param {Object} element the element to check
- * @param {Object} currentPage the current displayed page
+ * @param {Object} applicationContext the applicationContext
* @returns
*/
- isVisible({ element, currentPage }) {
+ isVisible({ element, applicationContext }) {
+ const { builder } = applicationContext
+
+ const user = this.app.store.getters['userSourceUser/getUser'](builder)
+ const isAuthenticated =
+ this.app.store.getters['userSourceUser/isAuthenticated'](builder)
+
+ const { roles, role_type: roleType, visibility } = element
+
+ if (visibility === VISIBILITY_LOGGED_IN) {
+ if (!isAuthenticated) {
+ return false
+ }
+
+ if (roleType === ROLE_TYPE_ALLOW_EXCEPT && roles.includes(user.role)) {
+ return false
+ }
+
+ if (
+ roleType === ROLE_TYPE_DISALLOW_EXCEPT &&
+ !roles.includes(user.role)
+ ) {
+ return false
+ }
+ }
+
+ if (visibility === VISIBILITY_NOT_LOGGED && isAuthenticated) {
+ return false
+ }
+
+ if (
+ element.visibility_condition?.formula &&
+ !ensureBoolean(
+ this.resolveFormula(element.visibility_condition, applicationContext),
+ { useStrict: false }
+ )
+ ) {
+ return false
+ }
+
return true
}
@@ -278,21 +324,22 @@ export class ElementType extends Registerable {
* them and ensure that they are configured properly. If they are in error,
* this will propagate into an 'element in error' state.
*/
- workflowActionsInError({ page, element, builder }) {
+ workflowActionsInError(element, applicationContext) {
+ const { builder } = applicationContext
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
const workflowActions = this.app.store.getters[
'builderWorkflowAction/getElementWorkflowActions'
- ](page, element.id)
+ ](elementPage, element.id)
return workflowActions.some((workflowAction) => {
const workflowActionType = this.app.$registry.get(
'workflowAction',
workflowAction.type
)
- return workflowActionType.isInError(workflowAction, {
- page,
- element,
- builder,
- })
+ return workflowActionType.isInError(workflowAction, applicationContext)
})
}
@@ -301,14 +348,16 @@ export class ElementType extends Registerable {
* @param {object} param An object containing the workspace, page, element, and builder
* @returns A string that represent the current error.
*/
- getErrorMessage({ workspace, builder, page, element }) {
+ getErrorMessage(element, applicationContext) {
+ const { workspace } = applicationContext
+
if (this.isDeactivatedReason({ workspace }) !== null) {
return this.isDeactivatedReason({ workspace })
}
if (
this.getEvents(element).length > 0 &&
- this.workflowActionsInError({ page, element, builder })
+ this.workflowActionsInError(element, applicationContext)
) {
return this.app.i18n.t('elementType.errorWorkflowActionInError')
}
@@ -321,8 +370,8 @@ export class ElementType extends Registerable {
* @param {object} param An object containing the page, element, and builder
* @returns true if the element is in error
*/
- isInError(params) {
- return Boolean(this.getErrorMessage(params))
+ isInError(...params) {
+ return Boolean(this.getErrorMessage(...params))
}
/**
@@ -972,21 +1021,23 @@ export class FormContainerElementType extends ContainerElementTypeMixin(
return [new SubmitEvent({ app: this.app })]
}
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
+
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
+
const workflowActions = this.app.store.getters[
'builderWorkflowAction/getElementWorkflowActions'
- ](page, element.id)
+ ](elementPage, element.id)
if (!workflowActions.length) {
return this.app.i18n.t('elementType.errorNoWorkflowAction')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
}
@@ -1183,7 +1234,9 @@ export class TableElementType extends CollectionElementTypeMixin(ElementType) {
* The table is in error if the configuration is invalid (see collection element
* mixin) or if one of the fields are in error.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
+
const hasCollectionFieldInError = element.fields.some((collectionField) => {
const collectionFieldType = this.app.$registry.get(
'collectionField',
@@ -1199,7 +1252,7 @@ export class TableElementType extends CollectionElementTypeMixin(ElementType) {
return this.app.i18n.t('elementType.errorCollectionFieldInError')
}
- return super.getErrorMessage({ workspace, page, element, builder })
+ return super.getErrorMessage(element, applicationContext)
}
}
@@ -1434,16 +1487,11 @@ export class HeadingElementType extends ElementType {
* A value is mandatory for the Heading element. Return true if the value
* is empty to indicate an error, otherwise return false.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
if (!element.value.formula) {
return this.app.i18n.t('elementType.errorValueMissing')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDisplayName(element, applicationContext) {
@@ -1487,16 +1535,11 @@ export class TextElementType extends ElementType {
* A value is mandatory for the Text element. Return true if the value
* is empty to indicate an error, otherwise return false.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
if (!element.value.formula) {
return this.app.i18n.t('elementType.errorValueMissing')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDisplayName(element, applicationContext) {
@@ -1543,7 +1586,9 @@ export class LinkElementType extends ElementType {
* When the Navigate To is a Custom URL, a Destination URL value must be
* provided.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
+
// A Link without any text isn't usable
if (!element.value.formula) {
return this.app.i18n.t('elementType.errorValueMissing')
@@ -1567,12 +1612,7 @@ export class LinkElementType extends ElementType {
) {
return this.app.i18n.t('elementType.errorNavigationUrlMissing')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDisplayName(element, applicationContext) {
@@ -1641,7 +1681,7 @@ export class ImageElementType extends ElementType {
* to indicate an error when an image source doesn't exist, otherwise
* return false.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
if (
element.image_source_type === IMAGE_SOURCE_TYPES.UPLOAD &&
!element.image_file?.url
@@ -1653,12 +1693,7 @@ export class ImageElementType extends ElementType {
) {
return this.app.i18n.t('elementType.errorImageUrlMissing')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDisplayName(element, applicationContext) {
@@ -1706,26 +1741,26 @@ export class ButtonElementType extends ElementType {
* A Button element must have a Workflow Action to be considered valid. Return
* true if there are no Workflow Actions, otherwise return false.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
// If Button without any label should be considered invalid
if (!element.value.formula) {
return this.app.i18n.t('elementType.errorValueMissing')
}
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
const workflowActions = this.app.store.getters[
'builderWorkflowAction/getElementWorkflowActions'
- ](page, element.id)
+ ](elementPage, element.id)
if (!workflowActions.length) {
return this.app.i18n.t('elementType.errorNoWorkflowAction')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDisplayName(element, applicationContext) {
@@ -1885,7 +1920,7 @@ export class ChoiceElementType extends FormElementType {
return !(element.required && !validOption)
}
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
if (element.option_type === CHOICE_OPTION_TYPES.MANUAL) {
if (element.options.length === 0) {
return this.app.i18n.t('elementType.errorOptionsMissing')
@@ -1895,12 +1930,7 @@ export class ChoiceElementType extends FormElementType {
return this.app.i18n.t('elementType.errorOptionsMissing')
}
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDataSchema(element) {
@@ -2001,7 +2031,7 @@ export class IFrameElementType extends ElementType {
* source_type. If the value doesn't exist, return true to indicate an error,
* otherwise return false.
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
if (
element.source_type === IFRAME_SOURCE_TYPES.URL &&
!element.url.formula
@@ -2013,12 +2043,7 @@ export class IFrameElementType extends ElementType {
) {
return this.app.i18n.t('elementType.errorIframeContentMissing')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDisplayName(element, applicationContext) {
@@ -2115,16 +2140,11 @@ export class RecordSelectorElementType extends CollectionElementTypeMixin(
* @param {Object} element the element to check the error
* @returns
*/
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
if (!element.data_source_id) {
return this.$t('elementType.errorDataSourceMissing')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
getDataSchema(element) {
@@ -2529,32 +2549,38 @@ export class MenuElementType extends ElementType {
.flat()
}
- getErrorMessage({ workspace, page, element, builder }) {
+ getErrorMessage(element, applicationContext) {
+ const { builder } = applicationContext
// There must be at least one menu item
if (!element.menu_items?.length) {
return this.app.i18n.t('elementType.errorNoMenuItem')
}
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
+
if (
element.menu_items.some((menuItem) => {
- return this.getItemMenuError({ builder, page, element, menuItem })
+ return this.getItemMenuError({
+ builder,
+ elementPage,
+ element,
+ menuItem,
+ })
})
) {
return this.app.i18n.t('elementType.errorMenuItemInError')
}
- return super.getErrorMessage({
- workspace,
- page,
- element,
- builder,
- })
+ return super.getErrorMessage(element, applicationContext)
}
- getItemMenuError({ builder, page, element, menuItem }) {
+ getItemMenuError({ builder, elementPage, element, menuItem }) {
const workflowActions = this.app.store.getters[
'builderWorkflowAction/getElementWorkflowActions'
- ](page, element.id)
+ ](elementPage, element.id)
if (menuItem.children?.length) {
if (
diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json
index 57fdaa1593..9f8e454958 100644
--- a/web-frontend/modules/builder/locales/en.json
+++ b/web-frontend/modules/builder/locales/en.json
@@ -959,7 +959,10 @@
"rolesAllMembersOf": "All members of {name}",
"noRole": "No role",
"errorFetchingRolesTitle": "Could not fetch User Roles",
- "errorFetchingRolesMessage": "There was a problem while fetching User Roles."
+ "errorFetchingRolesMessage": "There was a problem while fetching User Roles.",
+ "visibilityCondition": "Visibility condition",
+ "visibilityConditionHelper": "If the result of this formula is true, and the visitor choice above is true, the element will be visible. This condition only affects the element’s visibility. To exclude data from the server response instead, use the user role filtering option above.",
+ "visibilityConditionPlaceholder": "Condition..."
},
"userDataProviderType": {
"isAuthenticated": "Is authenticated",
diff --git a/web-frontend/modules/builder/mixins/collectionElement.js b/web-frontend/modules/builder/mixins/collectionElement.js
index 034386e31e..a245daf057 100644
--- a/web-frontend/modules/builder/mixins/collectionElement.js
+++ b/web-frontend/modules/builder/mixins/collectionElement.js
@@ -53,11 +53,11 @@ export default {
})
},
elementContent() {
- return (
- this.elementType.getElementCurrentContent(this.applicationContext) || []
+ const elementContent = this.elementType.getElementCurrentContent(
+ this.applicationContext
)
+ return Array.isArray(elementContent) ? elementContent : []
},
-
hasMorePage() {
return this.getHasMorePage(this.element)
},
@@ -81,24 +81,21 @@ export default {
}
},
elementIsInError() {
- return this.elementType.isInError({
- workspace: this.workspace,
- page: this.elementPage,
- element: this.element,
- builder: this.builder,
- })
+ return this.elementType.isInError(this.element, this.applicationContext)
},
},
watch: {
reset() {
this.debouncedReset()
},
- 'element.schema_property'(newValue, oldValue) {
+ async 'element.schema_property'(newValue, oldValue) {
+ await this.clearElementContent({ element: this.element })
if (newValue) {
this.debouncedReset()
}
},
- 'element.data_source_id'() {
+ async 'element.data_source_id'() {
+ await this.clearElementContent({ element: this.element })
this.debouncedReset()
},
'element.items_per_page'() {
@@ -128,6 +125,7 @@ export default {
methods: {
...mapActions({
fetchElementContent: 'elementContent/fetchElementContent',
+ clearElementContent: 'elementContent/clearElementContent',
}),
debouncedReset() {
clearTimeout(this.resetTimeout)
diff --git a/web-frontend/modules/builder/mixins/element.js b/web-frontend/modules/builder/mixins/element.js
index a75a8e722f..d1178bec9e 100644
--- a/web-frontend/modules/builder/mixins/element.js
+++ b/web-frontend/modules/builder/mixins/element.js
@@ -1,12 +1,11 @@
-import RuntimeFormulaContext from '@baserow/modules/core/runtimeFormulaContext'
-import { resolveFormula } from '@baserow/modules/core/formula'
import { resolveColor } from '@baserow/modules/core/utils/colors'
import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
+import resolveFormulaMixin from '@baserow/modules/builder/mixins/resolveFormula'
import { ThemeConfigBlockType } from '@baserow/modules/builder/themeConfigBlockTypes'
export default {
inject: ['workspace', 'builder', 'currentPage', 'elementPage', 'mode'],
- mixins: [applicationContextMixin],
+ mixins: [applicationContextMixin, resolveFormulaMixin],
props: {
element: {
type: Object,
@@ -36,36 +35,9 @@ export default {
return this.mode === 'editing'
},
elementIsInError() {
- return this.elementType.isInError({
- workspace: this.workspace,
- page: this.elementPage,
- element: this.element,
- builder: this.builder,
- })
- },
- runtimeFormulaContext() {
- /**
- * This proxy allow the RuntimeFormulaContextClass to act like a regular object.
- */
- return new Proxy(
- new RuntimeFormulaContext(
- this.$registry.getAll('builderDataProvider'),
- this.applicationContext
- ),
- {
- get(target, prop) {
- return target.get(prop)
- },
- }
- )
- },
- formulaFunctions() {
- return {
- get: (name) => {
- return this.$registry.get('runtimeFormulaFunction', name)
- },
- }
+ return this.elementType.isInError(this.element, this.applicationContext)
},
+
themeConfigBlocks() {
return this.$registry.getOrderedList('themeConfigBlock')
},
@@ -77,17 +49,6 @@ export default {
},
},
methods: {
- resolveFormula(formula, formulaContext = null, defaultIfError = '') {
- try {
- return resolveFormula(
- formula,
- this.formulaFunctions,
- formulaContext || this.runtimeFormulaContext
- )
- } catch (e) {
- return defaultIfError
- }
- },
async fireEvent(event) {
if (this.mode !== 'editing') {
if (this.workflowActionsInProgress) {
diff --git a/web-frontend/modules/builder/mixins/elementSidePanel.js b/web-frontend/modules/builder/mixins/elementSidePanel.js
index 829cfb131a..e7b0f1d7ed 100644
--- a/web-frontend/modules/builder/mixins/elementSidePanel.js
+++ b/web-frontend/modules/builder/mixins/elementSidePanel.js
@@ -3,6 +3,7 @@ import _ from 'lodash'
import { clone } from '@baserow/modules/core/utils/object'
import { notifyIf } from '@baserow/modules/core/utils/error'
+import { DATA_PROVIDERS_ALLOWED_ELEMENTS } from '@baserow/modules/builder/enums'
export default {
inject: ['workspace', 'builder', 'applicationContext'],
@@ -15,6 +16,7 @@ export default {
},
// We add the current element page
elementPage: this.elementPage,
+ dataProvidersAllowed: DATA_PROVIDERS_ALLOWED_ELEMENTS,
}
},
computed: {
diff --git a/web-frontend/modules/builder/mixins/resolveFormula.js b/web-frontend/modules/builder/mixins/resolveFormula.js
new file mode 100644
index 0000000000..e086cafc4e
--- /dev/null
+++ b/web-frontend/modules/builder/mixins/resolveFormula.js
@@ -0,0 +1,45 @@
+import RuntimeFormulaContext from '@baserow/modules/core/runtimeFormulaContext'
+import { resolveFormula } from '@baserow/modules/core/formula'
+import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
+
+export default {
+ mixins: [applicationContextMixin],
+ computed: {
+ runtimeFormulaContext() {
+ /**
+ * This proxy allow the RuntimeFormulaContextClass to act like a regular object.
+ */
+ return new Proxy(
+ new RuntimeFormulaContext(
+ this.$registry.getAll('builderDataProvider'),
+ this.applicationContext
+ ),
+ {
+ get(target, prop) {
+ return target.get(prop)
+ },
+ }
+ )
+ },
+ formulaFunctions() {
+ return {
+ get: (name) => {
+ return this.$registry.get('runtimeFormulaFunction', name)
+ },
+ }
+ },
+ },
+ methods: {
+ resolveFormula(formula, formulaContext = null, defaultIfError = '') {
+ try {
+ return resolveFormula(
+ formula,
+ this.formulaFunctions,
+ formulaContext || this.runtimeFormulaContext
+ )
+ } catch (e) {
+ return defaultIfError
+ }
+ },
+ },
+}
diff --git a/web-frontend/modules/builder/mixins/useApplicationContext.js b/web-frontend/modules/builder/mixins/useApplicationContext.js
new file mode 100644
index 0000000000..e79bdd4e6e
--- /dev/null
+++ b/web-frontend/modules/builder/mixins/useApplicationContext.js
@@ -0,0 +1,14 @@
+import { inject, provide, computed } from '@nuxtjs/composition-api'
+
+export function useApplicationContext(applicationContextAdditions) {
+ const injectedApplicationContext = inject('applicationContext')
+
+ const applicationContext = computed(() => ({
+ ...injectedApplicationContext,
+ ...(applicationContextAdditions || {}),
+ }))
+
+ provide('applicationContext', applicationContext)
+
+ return applicationContext
+}
diff --git a/web-frontend/modules/builder/pageSidePanelTypes.js b/web-frontend/modules/builder/pageSidePanelTypes.js
index 129472b139..f96be76295 100644
--- a/web-frontend/modules/builder/pageSidePanelTypes.js
+++ b/web-frontend/modules/builder/pageSidePanelTypes.js
@@ -143,25 +143,26 @@ export class EventsPageSidePanelType extends pageSidePanelType {
* @returns {string}
*/
getErrorMessage(applicationContext) {
- const { page, element, builder } = applicationContext
+ const { element, builder } = applicationContext
// If we don't have an element, then this element type
// doesn't support events, so it can't be in-error.
if (element) {
+ const elementPage = this.app.store.getters['page/getById'](
+ builder,
+ element.page_id
+ )
+
const workflowActions = this.app.store.getters[
'builderWorkflowAction/getElementWorkflowActions'
- ](page, element.id)
+ ](elementPage, element.id)
const hasActionInError = workflowActions.some((workflowAction) => {
const workflowActionType = this.app.$registry.get(
'workflowAction',
workflowAction.type
)
- return workflowActionType.isInError(workflowAction, {
- page,
- element,
- builder,
- })
+ return workflowActionType.isInError(workflowAction, applicationContext)
})
if (hasActionInError) {
diff --git a/web-frontend/modules/builder/store/elementContent.js b/web-frontend/modules/builder/store/elementContent.js
index 81fd75dbe6..8cb78550bf 100644
--- a/web-frontend/modules/builder/store/elementContent.js
+++ b/web-frontend/modules/builder/store/elementContent.js
@@ -103,107 +103,89 @@ const actions = {
const serviceType = this.app.$registry.get('service', dataSource.type)
- // We have a data source, but if it doesn't return a list,
- // it needs to have a `schema_property` to work correctly.
- if (!serviceType.returnsList && element.schema_property === null) {
- // If we previously had a list data source, we might have content,
- // so rather than leave the content *until a schema property is set*,
- // clear it.
- commit('CLEAR_CONTENT', {
- element,
- })
- commit('SET_LOADING', { element, value: false })
- return
- }
-
try {
- if (!serviceType.isInError(dataSource)) {
- let rangeToFetch = range
- if (!replace) {
- // Let's compute the range that really needs to be fetched if necessary
- const [offset, count] = range
- rangeToFetch = rangeDiff(getters.getContentRange(element), [
- offset,
- offset + count,
- ])
-
- // Everything is already loaded we can quit now
- if (!rangeToFetch || !getters.getHasMorePage(element)) {
- commit('SET_LOADING', { element, value: false })
- return
- }
- rangeToFetch = [rangeToFetch[0], rangeToFetch[1] - rangeToFetch[0]]
- }
-
- let service = DataSourceService
- if (['preview', 'public'].includes(mode)) {
- service = PublishedBuilderService
+ let rangeToFetch = range
+ if (!replace) {
+ // Let's compute the range that really needs to be fetched if necessary
+ const [offset, count] = range
+ rangeToFetch = rangeDiff(getters.getContentRange(element), [
+ offset,
+ offset + count,
+ ])
+
+ // Everything is already loaded we can quit now
+ if (!rangeToFetch || !getters.getHasMorePage(element)) {
+ commit('SET_LOADING', { element, value: false })
+ return
}
+ rangeToFetch = [rangeToFetch[0], rangeToFetch[1] - rangeToFetch[0]]
+ }
- if (!queriesInProgress[element.id]) {
- queriesInProgress[element.id] = {}
- }
+ let service = DataSourceService
+ if (['preview', 'public'].includes(mode)) {
+ service = PublishedBuilderService
+ }
- if (queriesInProgress[element.id][`${rangeToFetch}`]) {
- queriesInProgress[element.id][`${rangeToFetch}`].abort()
- }
+ if (!queriesInProgress[element.id]) {
+ queriesInProgress[element.id] = {}
+ }
- commit('SET_LOADING', { element, value: true })
+ if (queriesInProgress[element.id][`${rangeToFetch}`]) {
+ queriesInProgress[element.id][`${rangeToFetch}`].abort()
+ }
- queriesInProgress[element.id][`${rangeToFetch}`] =
- global.AbortController ? new AbortController() : null
+ commit('SET_LOADING', { element, value: true })
- const { data } = await service(this.app.$client).dispatch(
- dataSource.id,
- dispatchContext,
- { range: rangeToFetch, filters, sortings, search, searchMode },
- queriesInProgress[element.id][`${rangeToFetch}`]?.signal
- )
+ queriesInProgress[element.id][`${rangeToFetch}`] = global.AbortController
+ ? new AbortController()
+ : null
- delete queriesInProgress[element.id][`${rangeToFetch}`]
+ const { data } = await service(this.app.$client).dispatch(
+ dataSource.id,
+ dispatchContext,
+ { range: rangeToFetch, filters, sortings, search, searchMode },
+ queriesInProgress[element.id][`${rangeToFetch}`]?.signal
+ )
- // With a list-type data source, the data object will return
- // a `has_next_page` field for paging to the next set of results.
- const { has_next_page: hasNextPage = false } = data
+ delete queriesInProgress[element.id][`${rangeToFetch}`]
- if (replace) {
- commit('CLEAR_CONTENT', {
- element,
- })
- }
+ // With a list-type data source, the data object will return
+ // a `has_next_page` field for paging to the next set of results.
+ const { has_next_page: hasNextPage = false } = data
- if (serviceType.returnsList) {
- // The service type returns a list of results, we'll set the content
- // using the results key and set the range for future paging.
- commit('SET_CONTENT', {
- element,
- value: data.results.map((row) => ({
- ...row,
- __recordId__: row[serviceType.getIdProperty(service, row)],
- })),
- range,
- })
- } else {
- // The service type returns a single row of results, we'll set the
- // content using the element's schema property. Not how there's no
- // range for paging, all results are set at once. We default to an
- // empty array if the property doesn't exist, this will happen if
- // the property has been removed since the initial configuration.
- commit('SET_CONTENT', {
- element,
- value: data,
- })
- }
+ if (replace) {
+ commit('CLEAR_CONTENT', {
+ element,
+ })
+ }
- commit('SET_HAS_MORE_PAGE', {
+ if (serviceType.returnsList) {
+ // The service type returns a list of results, we'll set the content
+ // using the results key and set the range for future paging.
+ commit('SET_CONTENT', {
element,
- value: hasNextPage,
+ value: data.results.map((row) => ({
+ ...row,
+ __recordId__: row[serviceType.getIdProperty(service, row)],
+ })),
+ range,
})
} else {
- commit('CLEAR_CONTENT', {
+ // The service type returns a single row of results, we'll set the
+ // content using the element's schema property. Not how there's no
+ // range for paging, all results are set at once. We default to an
+ // empty array if the property doesn't exist, this will happen if
+ // the property has been removed since the initial configuration.
+ commit('SET_CONTENT', {
element,
+ value: data,
})
}
+
+ commit('SET_HAS_MORE_PAGE', {
+ element,
+ value: hasNextPage,
+ })
} catch (e) {
if (!axios.isCancel(e)) {
// If fetching the content failed, and we're trying to
diff --git a/web-frontend/modules/integrations/localBaserow/serviceTypes.js b/web-frontend/modules/integrations/localBaserow/serviceTypes.js
index 703a6217a0..7f3de16be6 100644
--- a/web-frontend/modules/integrations/localBaserow/serviceTypes.js
+++ b/web-frontend/modules/integrations/localBaserow/serviceTypes.js
@@ -89,7 +89,7 @@ export class LocalBaserowTableServiceType extends ServiceType {
const [field, ...rest] = path
let humanName = field
- if (schema && field && field.startsWith('field_')) {
+ if (schema && typeof field === 'string' && field.startsWith('field_')) {
if (this.returnsList) {
if (schema.items?.properties?.[field]?.title) {
humanName = schema.items.properties[field].title
diff --git a/web-frontend/test/unit/builder/elementTypes.spec.js b/web-frontend/test/unit/builder/elementTypes.spec.js
index cd0cc945cb..27c7299ef6 100644
--- a/web-frontend/test/unit/builder/elementTypes.spec.js
+++ b/web-frontend/test/unit/builder/elementTypes.spec.js
@@ -8,7 +8,14 @@ import {
RatingElementType,
} from '@baserow/modules/builder/elementTypes'
import { TestApp } from '@baserow/test/helpers/testApp'
-
+import {
+ VISIBILITY_NOT_LOGGED,
+ VISIBILITY_LOGGED_IN,
+ ROLE_TYPE_ALLOW_EXCEPT,
+ ROLE_TYPE_DISALLOW_EXCEPT,
+ VISIBILITY_ALL,
+ ROLE_TYPE_ALLOW_ALL,
+} from '@baserow/modules/builder/constants'
import {
IFRAME_SOURCE_TYPES,
IMAGE_SOURCE_TYPES,
@@ -334,7 +341,162 @@ describe('elementTypes tests', () => {
)
})
})
+ describe('elementType isVisible', () => {
+ test('HeadingElementType isVisible', () => {
+ const elementType = testApp.getRegistry().get('element', 'heading')
+
+ const element = {
+ value: { formula: "'Heading'", mode: 'simple' },
+ roles: [],
+ role_type: ROLE_TYPE_ALLOW_ALL,
+ visibility: VISIBILITY_ALL,
+ visibility_condition: { formula: 'true', mode: 'simple' },
+ }
+
+ const applicationContextNotLogged = {
+ builder: { userSourceUser: { authenticated: false } },
+ }
+ const applicationContextLoggedAdmin = {
+ builder: {
+ userSourceUser: {
+ authenticated: true,
+ user: {
+ email: 'fake@email.com',
+ id: 42,
+ username: 'Fake',
+ role: 'admin',
+ user_source_uid: '',
+ },
+ },
+ },
+ }
+ const applicationContextLoggedUser = {
+ builder: {
+ userSourceUser: {
+ authenticated: true,
+ user: {
+ email: 'fake@email.com',
+ id: 42,
+ username: 'Fake',
+ role: 'user',
+ user_source_uid: '',
+ },
+ },
+ },
+ }
+
+ // Nothing should hide it
+ expect(
+ elementType.isVisible({
+ element,
+ applicationContext: applicationContextNotLogged,
+ })
+ ).toBeTruthy()
+
+ let elementTest = {
+ ...element,
+ visibility_condition: {
+ ...element.visibility_condition,
+ formula: 'false',
+ },
+ }
+ // The visibility formula resolves to false
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextNotLogged,
+ })
+ ).toBeFalsy()
+
+ elementTest = {
+ ...element,
+ visibility: VISIBILITY_NOT_LOGGED,
+ }
+ // Not logged only
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextNotLogged,
+ })
+ ).toBeTruthy()
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextLoggedUser,
+ })
+ ).toBeFalsy()
+
+ elementTest = {
+ ...element,
+ visibility: VISIBILITY_LOGGED_IN,
+ }
+ // Logged only
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextNotLogged,
+ })
+ ).toBeFalsy()
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextLoggedUser,
+ })
+ ).toBeTruthy()
+ elementTest = {
+ ...element,
+ visibility: VISIBILITY_LOGGED_IN,
+ role_type: ROLE_TYPE_DISALLOW_EXCEPT,
+ roles: ['admin'],
+ }
+ // Logged admin only
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextNotLogged,
+ })
+ ).toBeFalsy()
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextLoggedUser,
+ })
+ ).toBeFalsy()
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextLoggedAdmin,
+ })
+ ).toBeTruthy()
+
+ elementTest = {
+ ...element,
+ visibility: VISIBILITY_LOGGED_IN,
+ role_type: ROLE_TYPE_ALLOW_EXCEPT,
+ roles: ['admin'],
+ }
+ // Logged except admin
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextNotLogged,
+ })
+ ).toBeFalsy()
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextLoggedUser,
+ })
+ ).toBeTruthy()
+ expect(
+ elementType.isVisible({
+ element: elementTest,
+ applicationContext: applicationContextLoggedAdmin,
+ })
+ ).toBeFalsy()
+ })
+ })
describe('elementType form validation tests', () => {
test('RatingInputElementType | required | no value', () => {
const elementType = new RatingInputElementType()
@@ -738,15 +900,21 @@ describe('elementTypes tests', () => {
// Heading with missing value is invalid
expect(
- elementType.isInError({ page: {}, element: { value: { formula: '' } } })
+ elementType.isInError(
+ { value: { formula: '' } },
+ { page: {}, element: { value: { formula: '' } } }
+ )
).toBe(true)
// Heading with value is valid
expect(
- elementType.isInError({
- page: {},
- element: { value: { formula: "'Foo Heading'" } },
- })
+ elementType.isInError(
+ { value: { formula: "'Foo Heading'" } },
+ {
+ page: {},
+ element: { value: { formula: "'Foo Heading'" } },
+ }
+ )
).toBe(false)
})
})
@@ -757,15 +925,21 @@ describe('elementTypes tests', () => {
// Text with missing value is invalid
expect(
- elementType.isInError({ page: {}, element: { value: { formula: '' } } })
+ elementType.isInError(
+ { value: { formula: '' } },
+ { page: {}, element: { value: { formula: '' } } }
+ )
).toBe(true)
// Text with value is valid
expect(
- elementType.isInError({
- page: {},
- element: { value: { formula: "'Foo Text'" } },
- })
+ elementType.isInError(
+ { value: { formula: "'Foo Text'" } },
+ {
+ page: {},
+ element: { value: { formula: "'Foo Text'" } },
+ }
+ )
).toBe(false)
})
})
@@ -776,7 +950,10 @@ describe('elementTypes tests', () => {
// Link with missing text is invalid
expect(
- elementType.isInError({ element: { value: { formula: '' } } })
+ elementType.isInError(
+ { value: { formula: '' } },
+ { element: { value: { formula: '' } } }
+ )
).toBe(true)
// When navigation_type is 'page' the navigate_to_page_id must be set
@@ -785,13 +962,15 @@ describe('elementTypes tests', () => {
navigate_to_page_id: '',
value: { formula: "'Foo Link'" },
}
- expect(elementType.isInError({ page: {}, element })).toBe(true)
+ expect(elementType.isInError(element, { page: {}, element })).toBe(true)
// Otherwise it is valid
const page = { id: 10, shared: false, order: 1 }
const builder = { pages: [page] }
element.navigate_to_page_id = 10
- expect(elementType.isInError({ page, builder, element })).toBe(false)
+ expect(elementType.isInError(element, { page, builder, element })).toBe(
+ false
+ )
// When navigation_type is 'custom' the navigate_to_url must be set
element = {
@@ -799,12 +978,12 @@ describe('elementTypes tests', () => {
navigate_to_url: { formula: '' },
value: { formula: "'Test'" },
}
- expect(elementType.isInError({ page, element })).toBe(true)
+ expect(elementType.isInError(element, { page, element })).toBe(true)
// Otherwise it is valid
element.navigate_to_url = { formula: 'http://localhost' }
element.value = { formula: "'Foo Link'" }
- expect(elementType.isInError({ page, element })).toBe(false)
+ expect(elementType.isInError(element, { page, element })).toBe(false)
})
})
@@ -814,20 +993,20 @@ describe('elementTypes tests', () => {
// Image with image_source_type of 'upload' must have an image_file url
const element = { image_source_type: IMAGE_SOURCE_TYPES.UPLOAD }
- expect(elementType.isInError({ element })).toBe(true)
+ expect(elementType.isInError(element, { element })).toBe(true)
// Otherwise it is valid
element.image_file = { url: 'http://localhost' }
- expect(elementType.isInError({ element })).toBe(false)
+ expect(elementType.isInError(element, { element })).toBe(false)
// Image with image_source_type of 'url' must have an image_url
delete element.image_file
element.image_source_type = IMAGE_SOURCE_TYPES.URL
- expect(elementType.isInError({ element })).toBe(true)
+ expect(elementType.isInError(element, { element })).toBe(true)
// Otherwise it is valid
element.image_url = { formula: "'http://localhost'" }
- expect(elementType.isInError({ element })).toBe(false)
+ expect(elementType.isInError(element, { element })).toBe(false)
})
})
@@ -853,7 +1032,9 @@ describe('elementTypes tests', () => {
const elementType = testApp.getRegistry().get('element', 'button')
// Button with value and invalid workflowAction is invalid
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
})
test('Returns true if Button Element has errors, false otherwise', () => {
const page = {
@@ -867,15 +1048,21 @@ describe('elementTypes tests', () => {
const elementType = testApp.getRegistry().get('element', 'button')
// Button with missing value is invalid
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
// Button with value but missing workflowActions is invalid
element.value = { formula: "'click me'" }
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
// Button with value and workflowAction is valid
page.workflowActions = [{ element_id: 50, type: 'open_page' }]
- expect(elementType.isInError({ page, element, builder })).toBe(false)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ false
+ )
})
})
@@ -889,23 +1076,23 @@ describe('elementTypes tests', () => {
url: { formula: '' },
embed: { formula: '' },
}
- expect(elementType.isInError({ element })).toBe(true)
+ expect(elementType.isInError(element, { element })).toBe(true)
// Otherwise it is valid
element.url = { formula: "'http://localhost'" }
- expect(elementType.isInError({ element })).toBe(false)
+ expect(elementType.isInError(element, { element })).toBe(false)
// IFrame with source_type of 'embed' and missing embed is invalid
element.source_type = IFRAME_SOURCE_TYPES.EMBED
- expect(elementType.isInError({ element })).toBe(true)
+ expect(elementType.isInError(element, { element })).toBe(true)
// Otherwise it is valid
element.embed = { formula: "'http://localhost'" }
- expect(elementType.isInError({ element })).toBe(false)
+ expect(elementType.isInError(element, { element })).toBe(false)
// Default is to return no errors
element.source_type = 'foo'
- expect(elementType.isInError({ element })).toBe(false)
+ expect(elementType.isInError(element, { element })).toBe(false)
})
})
@@ -939,9 +1126,11 @@ describe('elementTypes tests', () => {
const elementType = testApp.getRegistry().get('element', 'form_container')
// Form container with value and workflowAction is valid
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
})
- test('Returns true if Form Container Element has errors, false otherwise', () => {
+ test.only('Returns true if Form Container Element has errors, false otherwise', () => {
const page = {
id: 1,
shared: false,
@@ -963,11 +1152,15 @@ describe('elementTypes tests', () => {
const elementType = testApp.getRegistry().get('element', 'form_container')
// Invalid if we have no workflow actions
- expect(elementType.isInError({ page, element })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
// Invalid if we have no children
page.workflowActions = [{ element_id: 50, type: 'open_page' }]
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
// Valid as we have all required fields
const childElement = {
@@ -978,7 +1171,9 @@ describe('elementTypes tests', () => {
}
page.elementMap = { 50: element, 51: childElement }
page.orderedElements = [element, childElement]
- expect(elementType.isInError({ page, element, builder })).toBe(false)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ false
+ )
})
})
@@ -1003,7 +1198,9 @@ describe('elementTypes tests', () => {
}
// Menu element with zero Menu items is invalid.
- expect(elementType.isInError({ page: {}, element, builder })).toBe(true)
+ expect(
+ elementType.isInError(element, { page: {}, element, builder })
+ ).toBe(true)
const menuItem = {
type: 'button',
@@ -1012,40 +1209,52 @@ describe('elementTypes tests', () => {
element.menu_items = [menuItem]
// Button Menu item without workflow actions is invalid.
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
page.workflowActions = [{ element_id: 50, type: 'open_page' }]
element.menu_items[0].name = ''
// Button Menu item with empty name is invalid.
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
element.menu_items[0].type = 'link'
element.menu_items[0].name = ''
// Link Menu item with empty name is invalid.
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
element.menu_items[0].name = 'sub link'
element.menu_items[0].navigation_type = 'page'
element.menu_items[0].navigate_to_page_id = ''
// Link Menu item - sublink with Page navigation but no page ID is invalid.
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
element.menu_items[0].name = 'sub link'
element.menu_items[0].navigation_type = 'custom'
element.menu_items[0].navigate_to_url = { formula: '' }
// Link Menu item - sublink with custom navigation but no URL is invalid.
- expect(elementType.isInError({ page, element, builder })).toBe(true)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ true
+ )
// Valid Button Menu item
element.menu_items[0].type = 'button'
element.menu_items[0].name = 'foo button'
page.workflowActions = [{ element_id: 50, type: 'open_page' }]
- expect(elementType.isInError({ page, element, builder })).toBe(false)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ false
+ )
// Valid Link Menu item - page
element.menu_items[0].type = 'link'
@@ -1053,7 +1262,9 @@ describe('elementTypes tests', () => {
element.menu_items[0].navigation_type = 'page'
element.menu_items[0].navigate_to_page_id = 10
- expect(elementType.isInError({ page, element, builder })).toBe(false)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ false
+ )
// Valid Link Menu item - custom
element.menu_items[0].type = 'link'
@@ -1063,7 +1274,9 @@ describe('elementTypes tests', () => {
formula: "'https://www.baserow.io'",
}
- expect(elementType.isInError({ page, element, builder })).toBe(false)
+ expect(elementType.isInError(element, { page, element, builder })).toBe(
+ false
+ )
})
})
diff --git a/web-frontend/test/unit/integrations/localBaserow/serviceTypes.spec.js b/web-frontend/test/unit/integrations/localBaserow/serviceTypes.spec.js
new file mode 100644
index 0000000000..c50f31017e
--- /dev/null
+++ b/web-frontend/test/unit/integrations/localBaserow/serviceTypes.spec.js
@@ -0,0 +1,152 @@
+import {
+ LocalBaserowListRowsServiceType,
+ LocalBaserowGetRowServiceType,
+} from '@baserow/modules/integrations/localBaserow/serviceTypes'
+import { TestApp } from '@baserow/test/helpers/testApp'
+
+describe('Local baserow service types', () => {
+ let testApp = null
+
+ beforeAll(() => {
+ testApp = new TestApp()
+ })
+
+ afterEach(() => {
+ testApp.afterEach()
+ })
+
+ test('Get service should prepareValuePath', () => {
+ const fakeApp = {}
+ const serviceType = new LocalBaserowGetRowServiceType(fakeApp)
+
+ const service = {
+ schema: {
+ properties: { id: { title: 'Id' }, field_42: { title: 'Field 42' } },
+ },
+ }
+
+ expect(serviceType.prepareValuePath(service, [])).toEqual([])
+ expect(serviceType.prepareValuePath(service, [0])).toEqual([0])
+ expect(serviceType.prepareValuePath(service, ['id'])).toEqual(['id'])
+ expect(serviceType.prepareValuePath(service, ['field_42'])).toEqual([
+ 'Field 42',
+ ])
+ expect(
+ serviceType.prepareValuePath(service, ['field_42', 'value'])
+ ).toEqual(['Field 42', 'value'])
+ })
+
+ test('List service should prepareValuePath', () => {
+ const fakeApp = {}
+ const serviceType = new LocalBaserowListRowsServiceType(fakeApp)
+
+ const service = {
+ schema: {
+ items: {
+ properties: { id: { title: 'Id' }, field_42: { title: 'Field 42' } },
+ },
+ },
+ }
+
+ expect(serviceType.prepareValuePath(service, [])).toEqual([])
+ expect(serviceType.prepareValuePath(service, [0])).toEqual([0])
+ expect(serviceType.prepareValuePath(service, ['id'])).toEqual(['id'])
+ expect(serviceType.prepareValuePath(service, ['field_42'])).toEqual([
+ 'Field 42',
+ ])
+ expect(
+ serviceType.prepareValuePath(service, ['field_42', 'value'])
+ ).toEqual(['Field 42', 'value'])
+ })
+
+ test('List service should resolve correctly in builder data provider', () => {
+ const dataProvider = testApp
+ .getRegistry()
+ .get('builderDataProvider', 'data_source')
+
+ const service = {
+ id: 1,
+ type: 'local_baserow_list_rows',
+ schema: {
+ items: {
+ properties: { id: { title: 'Id' }, field_42: { title: 'Field 42' } },
+ },
+ },
+ }
+
+ dataProvider.getDataSourceContent = jest.fn(() => [
+ { id: 1, 'Field 42': 'Field 42 content row 1' },
+ { id: 2, 'Field 42': 'Field 42 content row 2' },
+ ])
+
+ const page = { id: 2, dataSources: [service] }
+
+ const applicationContext = {
+ builder: {
+ pages: [{ id: 1, shared: true, dataSources: [] }, page],
+ },
+ page,
+ }
+
+ expect(dataProvider.getDataChunk(applicationContext, ['1'])).toEqual([
+ { id: 1, 'Field 42': 'Field 42 content row 1' },
+ { id: 2, 'Field 42': 'Field 42 content row 2' },
+ ])
+ expect(dataProvider.getDataChunk(applicationContext, ['1', '0'])).toEqual({
+ id: 1,
+ 'Field 42': 'Field 42 content row 1',
+ })
+ expect(dataProvider.getDataChunk(applicationContext, ['1', '1'])).toEqual({
+ id: 2,
+ 'Field 42': 'Field 42 content row 2',
+ })
+ expect(
+ dataProvider.getDataChunk(applicationContext, ['1', '1', 'id'])
+ ).toEqual(2)
+ expect(
+ dataProvider.getDataChunk(applicationContext, ['1', '1', 'field_42'])
+ ).toEqual('Field 42 content row 2')
+ expect(
+ dataProvider.getDataChunk(applicationContext, ['1', '*', 'field_42'])
+ ).toEqual(['Field 42 content row 1', 'Field 42 content row 2'])
+ })
+
+ test('List service should resolve correctly in builder data provider', () => {
+ const dataProvider = testApp
+ .getRegistry()
+ .get('builderDataProvider', 'data_source')
+
+ const service = {
+ id: 1,
+ type: 'local_baserow_get_row',
+ schema: {
+ properties: { id: { title: 'Id' }, field_42: { title: 'Field 42' } },
+ },
+ }
+
+ dataProvider.getDataSourceContent = jest.fn(() => ({
+ id: 1,
+ 'Field 42': 'Field 42 content',
+ }))
+
+ const page = { id: 2, dataSources: [service] }
+
+ const applicationContext = {
+ builder: {
+ pages: [{ id: 1, shared: true, dataSources: [] }, page],
+ },
+ page,
+ }
+
+ expect(dataProvider.getDataChunk(applicationContext, ['1'])).toEqual({
+ id: 1,
+ 'Field 42': 'Field 42 content',
+ })
+ expect(dataProvider.getDataChunk(applicationContext, ['1', 'id'])).toEqual(
+ 1
+ )
+ expect(
+ dataProvider.getDataChunk(applicationContext, ['1', 'field_42'])
+ ).toEqual('Field 42 content')
+ })
+})
From 6813015c49e206f5a4746c000ee4eec2953e3fd8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Petr=20St=C5=99=C3=ADbn=C3=BD?=
Date: Wed, 12 Nov 2025 13:31:04 +0100
Subject: [PATCH 5/6] 2fa improvements (#4171)
---
.../src/baserow/api/two_factor_auth/errors.py | 7 +
.../src/baserow/api/two_factor_auth/views.py | 18 +-
backend/src/baserow/api/user/schemas.py | 25 ++-
backend/src/baserow/api/user/serializers.py | 15 +-
backend/src/baserow/api/user/views.py | 12 +-
.../core/two_factor_auth/registries.py | 7 +
.../baserow/api/users/test_token_auth.py | 19 ++
.../test_two_factor_handler.py | 152 +++++++++++++++
.../test_two_factor_registries.py | 177 ++++++++++++++++++
.../two_factor_auth/enable_with_qr_code.scss | 4 +
.../modules/core/components/auth/Login.vue | 6 +
.../core/components/auth/TOTPLogin.vue | 70 +++++--
.../settings/twoFactorAuth/AuthCodeInput.vue | 7 +-
.../twoFactorAuth/EnableWithQRCode.vue | 41 +++-
web-frontend/modules/core/locales/en.json | 10 +-
15 files changed, 539 insertions(+), 31 deletions(-)
create mode 100644 backend/tests/baserow/core/two_factor_auth/test_two_factor_handler.py
create mode 100644 backend/tests/baserow/core/two_factor_auth/test_two_factor_registries.py
diff --git a/backend/src/baserow/api/two_factor_auth/errors.py b/backend/src/baserow/api/two_factor_auth/errors.py
index 6217e6e248..f4543056ad 100644
--- a/backend/src/baserow/api/two_factor_auth/errors.py
+++ b/backend/src/baserow/api/two_factor_auth/errors.py
@@ -3,6 +3,7 @@
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
+ HTTP_429_TOO_MANY_REQUESTS,
)
ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST = (
@@ -17,6 +18,12 @@
"Two-factor authentication verification failed.",
)
+ERROR_RATE_LIMIT_EXCEEDED = (
+ "ERROR_RATE_LIMIT_EXCEEDED",
+ HTTP_429_TOO_MANY_REQUESTS,
+ "Rate limit exceeded.",
+)
+
ERROR_WRONG_PASSWORD = (
"ERROR_WRONG_PASSWORD",
HTTP_403_FORBIDDEN,
diff --git a/backend/src/baserow/api/two_factor_auth/views.py b/backend/src/baserow/api/two_factor_auth/views.py
index a3af759e22..8446a97323 100644
--- a/backend/src/baserow/api/two_factor_auth/views.py
+++ b/backend/src/baserow/api/two_factor_auth/views.py
@@ -13,6 +13,7 @@
)
from baserow.api.schemas import get_error_schema
from baserow.api.two_factor_auth.errors import (
+ ERROR_RATE_LIMIT_EXCEEDED,
ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED,
ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED,
ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED,
@@ -27,7 +28,7 @@
VerifyTOTPSerializer,
)
from baserow.api.two_factor_auth.tokens import Require2faToken
-from baserow.api.user.schemas import create_user_response_schema
+from baserow.api.user.schemas import authenticated_user_response_schema
from baserow.api.user.serializers import log_in_user
from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer
from baserow.core.models import User
@@ -48,6 +49,8 @@
TOTPAuthProviderType,
two_factor_auth_type_registry,
)
+from baserow.throttling import RateLimitExceededException, rate_limit
+from baserow.throttling_types import RateLimit
class ConfigureTwoFactorAuthView(APIView):
@@ -181,7 +184,7 @@ class VerifyTOTPAuthView(APIView):
description=("Verifies TOTP two-factor authentication"),
request=VerifyTOTPSerializer,
responses={
- 200: create_user_response_schema,
+ 200: authenticated_user_response_schema,
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
@@ -189,12 +192,14 @@ class VerifyTOTPAuthView(APIView):
),
401: get_error_schema(["ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED"]),
404: get_error_schema(["ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST"]),
+ 429: get_error_schema(["ERROR_RATE_LIMIT_EXCEEDED"]),
},
)
@map_exceptions(
{
TwoFactorAuthTypeDoesNotExist: ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST,
VerificationFailed: ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED,
+ RateLimitExceededException: ERROR_RATE_LIMIT_EXCEEDED,
}
)
@validate_body(VerifyTOTPSerializer, return_validated=True)
@@ -204,7 +209,14 @@ def post(self, request, data: dict):
Verifies TOTP two-factor authentication.
"""
- TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data)
+ def verify():
+ TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data)
+
+ rate_limit(
+ rate=RateLimit.from_string("10/m"),
+ key=f"two_fa_verify:totp:{data.get('email', '')}",
+ raise_exception=True,
+ )(verify)()
user = User.objects.filter(email=data["email"]).first()
return_data = log_in_user(request, user)
diff --git a/backend/src/baserow/api/user/schemas.py b/backend/src/baserow/api/user/schemas.py
index db095f9b69..ecf6e7c55f 100644
--- a/backend/src/baserow/api/user/schemas.py
+++ b/backend/src/baserow/api/user/schemas.py
@@ -56,7 +56,20 @@
}
}
-create_user_response_schema = build_object_type(
+two_factor_required_response_schema = build_object_type(
+ {
+ "two_factor_auth": {
+ "type": "string",
+ "description": "The type of the two factor auth that is required to perform.",
+ },
+ "token": {
+ "type": "string",
+ "description": "The temporary token for verifying authentication using 2fa.",
+ },
+ }
+)
+
+success_create_user_response_schema = build_object_type(
{
**user_response_schema,
**access_token_schema,
@@ -64,8 +77,16 @@
}
)
+authenticated_user_response_schema = {
+ "oneOf": [
+ success_create_user_response_schema,
+ two_factor_required_response_schema,
+ ],
+}
+
+
if jwt_settings.ROTATE_REFRESH_TOKENS:
- authenticate_user_schema = create_user_response_schema
+ authenticate_user_schema = authenticated_user_response_schema
else:
authenticate_user_schema = build_object_type(
{
diff --git a/backend/src/baserow/api/user/serializers.py b/backend/src/baserow/api/user/serializers.py
index d40de18f90..6accc5a664 100755
--- a/backend/src/baserow/api/user/serializers.py
+++ b/backend/src/baserow/api/user/serializers.py
@@ -307,6 +307,11 @@ def log_in_user(request, user):
return data
+class TwoFactorAuthRequiredSerializer(serializers.Serializer):
+ two_factor_auth = serializers.CharField()
+ token = serializers.CharField()
+
+
@extend_schema_serializer(deprecate_fields=["username"])
class TokenObtainPairWithUserSerializer(TokenObtainPairSerializer):
email = NormalizedEmailField(required=False)
@@ -343,10 +348,12 @@ def validate(self, attrs):
if provider_type.is_enabled(twofa_provider):
token = TwoFactorAccessToken.for_user(self.user)
token.set_exp(lifetime=timedelta(minutes=2))
- return {
- "two_factor_auth": provider_type.type,
- "token": str(token),
- }
+ return TwoFactorAuthRequiredSerializer(
+ {
+ "two_factor_auth": provider_type.type,
+ "token": str(token),
+ }
+ ).data
return log_in_user(self.context["request"], self.user)
diff --git a/backend/src/baserow/api/user/views.py b/backend/src/baserow/api/user/views.py
index 04e9518ae7..e806926757 100755
--- a/backend/src/baserow/api/user/views.py
+++ b/backend/src/baserow/api/user/views.py
@@ -102,7 +102,7 @@
from .exceptions import ClientSessionIdHeaderNotSetException
from .schemas import (
authenticate_user_schema,
- create_user_response_schema,
+ authenticated_user_response_schema,
verify_user_schema,
)
from .serializers import (
@@ -141,10 +141,12 @@ class ObtainJSONWebToken(TokenObtainPairView):
operation_id="token_auth",
description=(
"Authenticates an existing user based on their email and their password. "
- "If successful, an access token and a refresh token will be returned."
+ "If successful, an access token and a refresh token will be returned. "
+ "If the account is protected with two-factor authentication, "
+ "temporary token is returned to finish the verification."
),
responses={
- 200: create_user_response_schema,
+ 200: authenticated_user_response_schema,
401: get_error_schema(
[
"ERROR_INVALID_CREDENTIALS",
@@ -269,7 +271,7 @@ class UserView(APIView):
"account the initial workspace containing a database is created."
),
responses={
- 200: create_user_response_schema,
+ 200: authenticated_user_response_schema,
400: get_error_schema(
[
"ERROR_ALREADY_EXISTS",
@@ -556,7 +558,7 @@ class VerifyEmailAddressView(APIView):
"request is performed by unauthenticated user."
),
responses={
- 200: create_user_response_schema,
+ 200: authenticated_user_response_schema,
400: get_error_schema(
[
"ERROR_INVALID_VERIFICATION_TOKEN",
diff --git a/backend/src/baserow/core/two_factor_auth/registries.py b/backend/src/baserow/core/two_factor_auth/registries.py
index 60be1669f4..8b29eb4530 100644
--- a/backend/src/baserow/core/two_factor_auth/registries.py
+++ b/backend/src/baserow/core/two_factor_auth/registries.py
@@ -3,6 +3,7 @@
import string
from abc import ABC, abstractmethod
from base64 import b64encode
+from datetime import datetime, timedelta, timezone
from io import BytesIO
from django.conf import settings
@@ -117,6 +118,12 @@ def configure(
raise TwoFactorAuthAlreadyConfigured
if provider and kwargs.get("code"):
+ secret_valid_until = provider.created_on + timedelta(minutes=30)
+ now = datetime.now(tz=timezone.utc)
+ if now > secret_valid_until:
+ provider.delete()
+ raise VerificationFailed
+
code = kwargs.get("code")
totp = pyotp.TOTP(provider.secret)
diff --git a/backend/tests/baserow/api/users/test_token_auth.py b/backend/tests/baserow/api/users/test_token_auth.py
index b171479056..70bdd02de9 100644
--- a/backend/tests/baserow/api/users/test_token_auth.py
+++ b/backend/tests/baserow/api/users/test_token_auth.py
@@ -20,6 +20,7 @@
from baserow.core.user.handler import UserHandler
from baserow.core.user.utils import generate_session_tokens_for_user
from baserow.core.utils import generate_hash
+from baserow.test_utils.helpers import AnyStr
User = get_user_model()
@@ -278,6 +279,24 @@ def test_token_password_auth_disabled(api_client, data_fixture):
}
+@pytest.mark.django_db
+def test_token_auth_2fa_required(api_client, data_fixture):
+ data_fixture.create_password_provider(enabled=True)
+ user, token = data_fixture.create_user_and_token(
+ email="test@localhost", password="test"
+ )
+ data_fixture.configure_totp(user)
+
+ response = api_client.post(
+ reverse("api:user:token_auth"),
+ {"email": "test@localhost", "password": "test"},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_200_OK, response.json()
+ assert response.json() == {"two_factor_auth": "totp", "token": AnyStr()}
+
+
@pytest.mark.django_db
def test_token_password_auth_disabled_superadmin(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
diff --git a/backend/tests/baserow/core/two_factor_auth/test_two_factor_handler.py b/backend/tests/baserow/core/two_factor_auth/test_two_factor_handler.py
new file mode 100644
index 0000000000..1168b77637
--- /dev/null
+++ b/backend/tests/baserow/core/two_factor_auth/test_two_factor_handler.py
@@ -0,0 +1,152 @@
+from django.db import DatabaseError, connections, transaction
+
+import pyotp
+import pytest
+
+from baserow.core.two_factor_auth.exceptions import (
+ TwoFactorAuthCannotBeConfigured,
+ TwoFactorAuthNotConfigured,
+ TwoFactorAuthTypeDoesNotExist,
+ VerificationFailed,
+ WrongPassword,
+)
+from baserow.core.two_factor_auth.handler import TwoFactorAuthHandler
+from baserow.core.two_factor_auth.models import TOTPAuthProviderModel
+
+
+@pytest.mark.django_db
+def test_get_provider_doesnt_exist(data_fixture):
+ user = data_fixture.create_user()
+
+ fetched_provider = TwoFactorAuthHandler().get_provider(user=user)
+
+ assert fetched_provider is None
+
+
+@pytest.mark.django_db
+def test_get_provider(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_totp(user)
+
+ fetched_provider = TwoFactorAuthHandler().get_provider(user=user)
+
+ assert fetched_provider == provider
+ assert isinstance(fetched_provider, TOTPAuthProviderModel)
+ assert fetched_provider.is_enabled is True
+
+
+@pytest.mark.django_db
+def test_get_provider_partially_configured(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_base_totp(user)
+
+ fetched_provider = TwoFactorAuthHandler().get_provider(user=user)
+
+ assert fetched_provider == provider
+ assert isinstance(fetched_provider, TOTPAuthProviderModel)
+ assert fetched_provider.is_enabled is False
+
+
+@pytest.mark.django_db
+def test_get_provider_for_update_doesnt_exist(data_fixture):
+ user = data_fixture.create_user()
+ fetched_provider = TwoFactorAuthHandler().get_provider_for_update(user=user)
+
+ assert fetched_provider is None
+
+
+@pytest.mark.django_db(transaction=True, databases=["default", "default-copy"])
+def test_get_provider_for_update(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_totp(user)
+
+ with transaction.atomic():
+ fetched_provider = TwoFactorAuthHandler().get_provider_for_update(user=user)
+
+ with pytest.raises(DatabaseError):
+ connections["default-copy"]
+ TOTPAuthProviderModel.objects.using("default-copy").select_for_update(
+ nowait=True
+ ).get(id=fetched_provider.id)
+
+ assert fetched_provider == provider
+ assert isinstance(fetched_provider, TOTPAuthProviderModel)
+ assert fetched_provider.is_enabled is True
+
+
+@pytest.mark.django_db
+def test_configure_provider_not_allowed(data_fixture):
+ user = data_fixture.create_user()
+ user.password = ""
+ user.save()
+
+ with pytest.raises(TwoFactorAuthCannotBeConfigured):
+ TwoFactorAuthHandler().configure_provider("totp", user)
+
+
+@pytest.mark.django_db
+def test_configure_provider_type_doesnt_exist(data_fixture):
+ user = data_fixture.create_user()
+
+ with pytest.raises(TwoFactorAuthTypeDoesNotExist):
+ TwoFactorAuthHandler().configure_provider("doesnt_exist", user)
+
+
+@pytest.mark.django_db
+def test_configure_provider_totp(data_fixture):
+ user = data_fixture.create_user()
+
+ provider = TwoFactorAuthHandler().configure_provider("totp", user)
+ assert provider.user == user
+ assert provider.is_enabled is False
+ assert provider.secret != ""
+ assert provider.provisioning_url != ""
+ assert provider.provisioning_qr_code.startswith("data:image/png;base64")
+
+
+@pytest.mark.django_db
+def test_disable_wrong_password(data_fixture):
+ user = data_fixture.create_user(password="password")
+
+ with pytest.raises(WrongPassword):
+ TwoFactorAuthHandler().disable(user, "password2")
+
+
+@pytest.mark.django_db
+def test_disable_not_configured(data_fixture):
+ user = data_fixture.create_user(password="password")
+
+ with pytest.raises(TwoFactorAuthNotConfigured):
+ TwoFactorAuthHandler().disable(user, "password")
+
+
+@pytest.mark.django_db
+def test_disable(data_fixture):
+ user = data_fixture.create_user(password="password")
+ data_fixture.configure_totp(user)
+
+ TwoFactorAuthHandler().disable(user, "password")
+
+ assert TwoFactorAuthHandler().get_provider(user) is None
+
+
+@pytest.mark.django_db
+def test_verify_type_doesnt_exist(data_fixture):
+ with pytest.raises(TwoFactorAuthTypeDoesNotExist):
+ TwoFactorAuthHandler().verify("doesnt_exist")
+
+
+@pytest.mark.django_db
+def test_verify_no_provider(data_fixture):
+ with pytest.raises(VerificationFailed):
+ TwoFactorAuthHandler().verify("totp")
+
+
+@pytest.mark.django_db
+def test_verify(data_fixture):
+ user = data_fixture.create_user(password="password")
+ provider = data_fixture.configure_totp(user)
+ totp = pyotp.TOTP(provider.secret)
+ code = totp.now()
+
+ assert TwoFactorAuthHandler().verify("totp", email=user.email, code=code) is True
diff --git a/backend/tests/baserow/core/two_factor_auth/test_two_factor_registries.py b/backend/tests/baserow/core/two_factor_auth/test_two_factor_registries.py
new file mode 100644
index 0000000000..d9158b798b
--- /dev/null
+++ b/backend/tests/baserow/core/two_factor_auth/test_two_factor_registries.py
@@ -0,0 +1,177 @@
+import hashlib
+from urllib.parse import parse_qs, urlparse
+
+import pyotp
+import pytest
+from freezegun import freeze_time
+
+from baserow.core.two_factor_auth.exceptions import (
+ TwoFactorAuthAlreadyConfigured,
+ VerificationFailed,
+)
+from baserow.core.two_factor_auth.models import (
+ TOTPAuthProviderModel,
+ TwoFactorAuthRecoveryCode,
+)
+from baserow.core.two_factor_auth.registries import TOTPAuthProviderType
+
+
+@pytest.mark.django_db
+def test_totp_configure_already_configured(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_totp(user)
+
+ with pytest.raises(TwoFactorAuthAlreadyConfigured):
+ TOTPAuthProviderType().configure(user, provider)
+
+
+@pytest.mark.django_db
+def test_totp_configure_from_scratch(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_base_totp(user)
+
+ provider = TOTPAuthProviderType().configure(user, provider=provider)
+
+ # previous provider deleted
+ assert TOTPAuthProviderModel.objects.filter(user=user).count() == 0
+ assert provider.user == user
+ assert provider.enabled is False
+ assert provider.secret != ""
+ assert provider.provisioning_url != ""
+ assert provider.provisioning_qr_code.startswith("data:image/png;base64")
+
+ # generate correct TOTP code based on provisioning_url
+ provider.save()
+ parsed_url = urlparse(provider.provisioning_url)
+ params = parse_qs(parsed_url.query)
+ secret = params.get("secret", [])[0]
+ totp = pyotp.TOTP(secret)
+ valid_code = totp.now()
+
+ assert TOTPAuthProviderType().verify(code=valid_code, email=user.email)
+
+
+@pytest.mark.django_db
+def test_totp_configure_finish_configuration(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_base_totp(user)
+ totp = pyotp.TOTP(provider.secret)
+ code = totp.now()
+
+ provider = TOTPAuthProviderType().configure(user, provider, code=code)
+
+ assert provider.user == user
+ assert provider.enabled is True
+ assert provider.provisioning_url == ""
+ assert provider.provisioning_qr_code == ""
+ assert TOTPAuthProviderModel.objects.filter(user=user).count() == 1
+ assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 8
+
+
+@pytest.mark.django_db
+def test_totp_configure_finish_configuration_failed(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_base_totp(user)
+
+ with pytest.raises(VerificationFailed):
+ TOTPAuthProviderType().configure(user, provider, code="1234567")
+
+
+@pytest.mark.django_db
+def test_totp_configure_finish_configuration_secret_expired(data_fixture):
+ with freeze_time("2020-02-01 00:00"):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_base_totp(user)
+ totp = pyotp.TOTP(provider.secret)
+ code = totp.now()
+
+ with freeze_time("2020-02-01 00:31"):
+ with pytest.raises(VerificationFailed):
+ TOTPAuthProviderType().configure(user, provider, code=code)
+ assert TOTPAuthProviderModel.objects.filter(user=user).count() == 0
+
+
+@pytest.mark.django_db
+def test_store_backup_codes(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_base_totp(user)
+ assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 0
+
+ TOTPAuthProviderType().store_backup_codes(provider, ["test1", "test2"])
+
+ assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 2
+ assert TwoFactorAuthRecoveryCode.objects.filter(
+ user=user, code=hashlib.sha256("test1".encode("utf-8")).hexdigest()
+ ).exists()
+ assert TwoFactorAuthRecoveryCode.objects.filter(
+ user=user, code=hashlib.sha256("test2".encode("utf-8")).hexdigest()
+ ).exists()
+
+
+@pytest.mark.django_db
+def test_generate_backup_codes():
+ codes = TOTPAuthProviderType().generate_backup_codes()
+ assert len(codes) == 8
+ for code in codes:
+ assert len(code) == 11
+ assert "0" not in code
+ assert "o" not in code
+ assert "i" not in code
+ assert "1" not in code
+
+
+@pytest.mark.django_db
+def test_verify_with_code(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_totp(user)
+ totp = pyotp.TOTP(provider.secret)
+ code = totp.now()
+
+ assert TOTPAuthProviderType().verify(email=user.email, code=code)
+
+
+@pytest.mark.django_db
+def test_verify_with_code_fails(data_fixture):
+ user = data_fixture.create_user()
+ data_fixture.configure_totp(user)
+
+ with pytest.raises(VerificationFailed):
+ TOTPAuthProviderType().verify(email=user.email, code="1234567")
+
+
+@pytest.mark.django_db
+def test_verify_with_backup_code(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_totp(user)
+ backup_code = provider.backup_codes[0]
+
+ assert TOTPAuthProviderType().verify(email=user.email, backup_code=backup_code)
+ assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 7
+
+
+@pytest.mark.django_db
+def test_verify_with_backup_code_fails(data_fixture):
+ user = data_fixture.create_user()
+ data_fixture.configure_totp(user)
+
+ with pytest.raises(VerificationFailed):
+ TOTPAuthProviderType().verify(email=user.email, backup_code="invalid")
+
+
+@pytest.mark.django_db
+def test_verify_no_provider(data_fixture):
+ user = data_fixture.create_user()
+
+ with pytest.raises(VerificationFailed):
+ TOTPAuthProviderType().verify(email=user.email)
+
+
+@pytest.mark.django_db
+def test_totp_disable(data_fixture):
+ user = data_fixture.create_user()
+ provider = data_fixture.configure_totp(user)
+
+ TOTPAuthProviderType().disable(provider, user)
+
+ assert TOTPAuthProviderModel.objects.filter(user=user).count() == 0
+ assert TwoFactorAuthRecoveryCode.objects.filter(user=user).count() == 0
diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss
index 09bdb8caad..e18114b843 100644
--- a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss
+++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss
@@ -44,3 +44,7 @@
width: 160px;
margin-left: -16px;
}
+
+.enable-with-qr-code__copy {
+ margin-left: -2px;
+}
diff --git a/web-frontend/modules/core/components/auth/Login.vue b/web-frontend/modules/core/components/auth/Login.vue
index 2b0a26aa8e..08a343e39e 100644
--- a/web-frontend/modules/core/components/auth/Login.vue
+++ b/web-frontend/modules/core/components/auth/Login.vue
@@ -6,6 +6,7 @@
:email="twoFactorEmail"
:token="twoFaToken"
@success="success"
+ @expired="twoFactorExpired"
/>
diff --git a/web-frontend/modules/core/components/auth/TOTPLogin.vue b/web-frontend/modules/core/components/auth/TOTPLogin.vue
index c05a02a74a..9875ec0686 100644
--- a/web-frontend/modules/core/components/auth/TOTPLogin.vue
+++ b/web-frontend/modules/core/components/auth/TOTPLogin.vue
@@ -15,6 +15,10 @@
{{ $t('totpLogin.backupCodesDescription') }}
+
+ {{ errorTitle }}
+ {{ errorDescription }}
+
-
+
+
+
{{ $t('enableWithQRCode.scanQRCodeDescription') }}
+
+ {{ $t('enableWithQRCode.clickToCopy') }}
+
{{ $t('enableWithQRCode.enterCodeDescription') }}
+
+ {{ errorTitle }}
+ {{ errorDescription }}
+
import AuthCodeInput from '@baserow/modules/core/components/settings/twoFactorAuth/AuthCodeInput'
import TwoFactorAuthService from '@baserow/modules/core/services/twoFactorAuth'
+import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
export default {
name: 'EnableWithQRCode',
@@ -49,8 +62,20 @@ export default {
loading: false,
checkCodeLoading: false,
qr_code: null,
+ provisioning_url: null,
+ errorTitle: null,
+ errorDescription: null,
}
},
+ computed: {
+ secret() {
+ if (this.provisioning_url) {
+ const url = new URL(this.provisioning_url)
+ return url.searchParams.get('secret')
+ }
+ return ''
+ },
+ },
mounted() {
this.configureTOTP()
},
@@ -62,6 +87,7 @@ export default {
'totp'
)
this.qr_code = data.provisioning_qr_code
+ this.provisioning_url = data.provisioning_url
} catch (error) {
const title = this.$t('enableWithQRCode.provisioningFailed')
this.$store.dispatch('toast/error', { title })
@@ -70,6 +96,8 @@ export default {
}
},
async checkCode(code) {
+ this.errorTitle = null
+ this.errorDescription = null
this.checkCodeLoading = true
try {
const params = { code }
@@ -84,9 +112,20 @@ export default {
this.checkCodeLoading = false
this.$refs.authCodeInput.reset()
const title = this.$t('enableWithQRCode.verificationFailed')
- this.$store.dispatch('toast/error', { title })
+ const description = this.$t(
+ 'enableWithQRCode.verificationFailedDescription'
+ )
+ this.errorTitle = title
+ this.errorDescription = description
}
},
+ copy() {
+ copyToClipboard(this.secret)
+ this.$store.dispatch('toast/success', {
+ title: this.$t('enableWithQRCode.secretCopiedTitle'),
+ message: this.$t('enableWithQRCode.secretCopiedMessage'),
+ })
+ },
},
}
diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json
index ce7b130dd6..88a02ec29a 100644
--- a/web-frontend/modules/core/locales/en.json
+++ b/web-frontend/modules/core/locales/en.json
@@ -115,7 +115,11 @@
"totpDescription": "Enter the code from your authenticator app.",
"verify": "Verify",
"useBackupCode": "Use backup code",
- "verificationFailed": "Verification failed"
+ "verificationFailed": "Verification failed",
+ "verificationFailedDescription": "The entered code is not correct.",
+ "loginExpired": "Login expired",
+ "loginExpiredDescription": "Please provide your password again.",
+ "rateLimit": "Too many attempts."
},
"settingsModal": {
"title": "My settings"
@@ -1082,9 +1086,13 @@
"enableWithQRCode": {
"scanQRCode": "Scan QR code",
"scanQRCodeDescription": "Scan the code with an app like Google Authenticator, Authy or Microsoft Authenticator.",
+ "clickToCopy": "Alternatively, click here to copy the code.",
+ "secretCopiedTitle": "Secret copied",
+ "secretCopiedMessage": "TOTP secret copied to clipboard.",
"enterCode": "Enter the code shown",
"enterCodeDescription": "Enter a 6-digit code shown by the app to confirm that you have set it up correctly.",
"verificationFailed": "Verification failed",
+ "verificationFailedDescription": "The entered code is not valid.",
"provisioningFailed": "Provisioning failed",
"checkSuccess": "Successfully enabled two-factor authentication"
}
From e81049d03d3823520840baebffb23426bc2f7781 Mon Sep 17 00:00:00 2001
From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com>
Date: Wed, 12 Nov 2025 14:58:35 +0100
Subject: [PATCH 6/6] feat(AI Assistant): generates formulas for workflow nodes
(#4199)
* Kuma generates formulas for workflow nodes
* address feedback
* Set mode=BASEROW_FORMULA_MODE_ADVANCED, not type.
---------
Co-authored-by: peter_baserow
---
backend/Makefile | 2 +-
backend/src/baserow/config/settings/base.py | 2 +-
.../baserow_enterprise/assistant/prompts.py | 9 +-
.../assistant/tools/automation/prompts.py | 86 +++++
.../assistant/tools/automation/tools.py | 14 +-
.../tools/automation/types/__init__.py | 2 +
.../assistant/tools/automation/types/node.py | 239 +++++++++++--
.../assistant/tools/automation/utils.py | 316 ++++++++++++++++-
...est_assistant_automation_workflow_tools.py | 331 +++++++++++++++++-
9 files changed, 962 insertions(+), 39 deletions(-)
create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/automation/prompts.py
diff --git a/backend/Makefile b/backend/Makefile
index 9c4a982e15..310dfa2b6e 100644
--- a/backend/Makefile
+++ b/backend/Makefile
@@ -152,7 +152,7 @@ lint: .check-dev
$(VISORT) --check --skip generated $(BACKEND_SOURCE_DIRS) $(BACKEND_TESTS_DIRS)
# TODO: make baserow command reading dotenv files
DJANGO_SETTINGS_MODULE=$(DJANGO_SETTINGS_MODULE) $(VBASEROW) makemigrations --dry-run --check
- $(VBANDIT) -r --exclude src/baserow/test_utils $(BACKEND_SOURCE_DIRS)
+ $(VBANDIT) -r --exclude src/baserow/test_utils,src/baserow/config/settings/local.py $(BACKEND_SOURCE_DIRS)
lint-python: lint
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index f4772cc52a..3ff756589d 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -1049,7 +1049,7 @@ def __setitem__(self, key, value):
# The minimum amount of minutes the periodic task's "minute" interval
# supports. Self-hosters can run every minute, if they choose to.
INTEGRATIONS_PERIODIC_MINUTE_MIN = int(
- os.getenv("BASEROW_INTEGRATIONS_PERIODIC_MINUTE_MIN", 1)
+ os.getenv("BASEROW_INTEGRATIONS_PERIODIC_MINUTE_MIN") or 1
)
TOTP_ISSUER_NAME = os.getenv("BASEROW_TOTP_ISSUER_NAME", "Baserow")
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
index 2e2e4b6d65..5158931a4a 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
@@ -1,3 +1,5 @@
+from django.conf import settings
+
CORE_CONCEPTS = """
### BASEROW STRUCTURE
@@ -50,13 +52,14 @@
"""
ASSISTANT_SYSTEM_PROMPT = (
- """
+ f"""
You are Kuma, an AI expert for Baserow (open-source no-code platform).
## YOUR KNOWLEDGE
1. **Core concepts** (below)
2. **Detailed docs** - use search_docs tool to search when needed
-3. **API specs** - guide users to https://api.baserow.io/api/schema.json
+3. **API specs** - guide users to "{settings.PUBLIC_BACKEND_URL}/api/schema.json"
+4. **Official website** - "https://baserow.io"
## HOW TO HELP
• Use American English spelling and grammar
@@ -64,7 +67,7 @@
• For troubleshooting: ask for error messages or describe expected vs actual results
• **NEVER** fabricate answers or URLs. Acknowledge when you can't be sure.
• When you have the tools to help, **ALWAYS** use them instead of answering with instructions.
-* At the end, **always** ask follow-up questions to understand user needs and continue the conversation.
+• When finished, briefly suggest one or more logical next steps only if they use tools you have access to and directly builds on what was just done.
## FORMATTING (CRITICAL)
• **No HTML**: Only Markdown (bold, italics, lists, code, tables)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/prompts.py
new file mode 100644
index 0000000000..a0e54c5ab1
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/prompts.py
@@ -0,0 +1,86 @@
+GENERATE_FORMULA_PROMPT = """
+You are a formula builder. Generate formulas using these functions:
+
+**Comparison operators** (for router conditions only):
+equal, not_equal, greater_than, less_than, greater_than_equal, less_than_equal
+- Arguments: numbers, 'strings', or get() functions
+- Returns: boolean
+- Example: greater_than(get('age'), 18)
+
+**concat(...args)** - Joins arguments into a string
+- Arguments: 'string literals' or get() functions
+- Example: concat('Hello ', get('name'), '!')
+
+**get(path)** - Retrieves values from context using path notation
+- Objects: get('user.name')
+- Arrays: get('items.0'), get('orders.2.total')
+- Nested: get('users.0.address.city')
+- All: get('users.*.email') returns a list of emails from all users
+
+**if(condition, true_value, false_value)** - Conditional expression
+- Arguments: a boolean condition, value if true, value if false
+- Example: if(greater_than(get('score'), 50), 'pass', 'fail')
+
+**today()** - Returns the current date
+**now()** - Returns the current date and time
+
+**constants**:
+- A string literal enclosed in single quotes (e.g., 'hello world', '123')
+
+**Example 1 - String Fields:**
+Input:
+fields_to_resolve: {
+ "ai_prompt": "Determine the priority level based on {{ trigger.title }} and {{ trigger.due_date }}. Choices are: High, Medium, Low.",
+}
+context: {"previous_node": {"1": [{"title": "Finish report", "due_date": "2025-11-08"}]}}
+context_metadata: {
+ "1": {"id": 1, "ref": "trigger", "field_1": {"name": "title", "type": "string"}, "field_2": {"name": "due_date", "type": "date"}},
+ "today": "2025-11-07"
+}
+feedback: ""
+Output:
+generated_formula: {
+ "ai_prompt": "concat(
+ 'Determine the priority level based on ',
+ get('previous_node.1.0.title'),
+ ' and ',
+ get('previous_node.1.0.due_date'),
+ '. Choices are: High, Medium, Low.'
+ )"
+}
+**Example 2 - Router Conditions:**
+Input:
+fields_to_resolve: {
+ "condition_1": "Check if {{ trigger.amount }} is greater than 1000",
+}
+context: {"previous_node": {"1": [{"amount": 1500}]}},
+context_metadata: {
+ "1": {"id": 1, "ref": "trigger", "field_1": {"name": "amount", "type": "number"}},
+}
+feedback: ""
+Output:
+generated_formula: {
+ "condition_1": "greater_than(get('previous_node.1.0.amount'), 1000)"
+}
+
+**Task:**
+
+You are given:
+* **fields_to_resolve** — a dictionary where each key is a field name and each value contains instructions to generate a formula.
+* **context** — a dictionary containing the available data.
+* **context_metadata** — a dictionary describing the structure and types within the context.
+* **feedback** — optional information with reported formula errors from previous runs.
+
+**Goal:**
+Generate a dictionary called **generated_formula**, where:
+
+* Keys are the field names from **fields_to_resolve**.
+* Values are valid formulas that can be used in the automation node.
+
+**Rules:**
+
+1. Feel free to skip fields whose description starts with `[optional]`.
+2. Exclude any field if you cannot generate a valid formula for it.
+3. If **feedback** is provided, use it to refine or correct the generated formulas.
+4. Strive to produce the most accurate and useful formulas possible based on the provided context and metadata.
+"""
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py
index ce5e93ffb6..11cc03b38e 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py
@@ -58,7 +58,8 @@ def create_workflows(
automation_id: int, workflows: list[WorkflowCreate]
) -> dict[str, Any]:
"""
- Create one or more workflows in an automation.
+ Create one or more workflows in an automation. Always use {{ node.ref }} to
+ reference previous nodes values inside the workflow.
:param automation_id: The automation application ID
:param workflows: List of workflows to create
@@ -67,14 +68,14 @@ def create_workflows(
nonlocal user, workspace, tool_helpers
- tool_helpers.update_status(_("Creating workflows..."))
-
created = []
automation = utils.get_automation(automation_id, user, workspace)
for wf in workflows:
with transaction.atomic():
- orm_workflow = utils.create_workflow(user, automation, wf)
+ orm_workflow, node_mapping = utils.create_workflow(
+ user, automation, wf, tool_helpers
+ )
created.append(
{
"id": orm_workflow.id,
@@ -82,6 +83,11 @@ def create_workflows(
"state": orm_workflow.state,
}
)
+
+ # In separate transactions, try to update the formulas inside the workflow,
+ # so we don't block the main creation if something goes wrong here.
+ utils.update_workflow_formulas(wf, node_mapping, tool_helpers)
+
# Navigate to the last created workflow
tool_helpers.navigate_to(
WorkflowNavigationType(
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py
index 0adf1e93c6..f2c9159123 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py
@@ -2,6 +2,7 @@
AiAgentNodeCreate,
CreateRowActionCreate,
DeleteRowActionCreate,
+ HasFormulasToCreateMixin,
NodeBase,
RouterNodeCreate,
SendEmailActionCreate,
@@ -21,4 +22,5 @@
"SendEmailActionCreate",
"AiAgentNodeCreate",
"TriggerNodeCreate",
+ "HasFormulasToCreateMixin",
]
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py
index ac48fc6eb8..d70816774b 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py
@@ -1,8 +1,15 @@
+from abc import ABC, abstractmethod
from typing import Annotated, Any, Literal, Optional
from uuid import uuid4
+from django.conf import settings
+
from pydantic import Field, PrivateAttr
+from baserow.contrib.automation.nodes.models import AutomationNode
+from baserow.core.formula.types import BASEROW_FORMULA_MODE_ADVANCED
+from baserow.core.services.handler import ServiceHandler
+from baserow.core.services.models import Service
from baserow_enterprise.assistant.types import BaseModel
@@ -25,23 +32,55 @@ class Item(BaseModel):
id: str
-class PeriodicTriggerSettings(BaseModel):
- """Periodic trigger interval model."""
+class HasFormulasToCreateMixin(ABC):
+ @abstractmethod
+ def get_formulas_to_create(self, orm_node: AutomationNode) -> dict[str, str]:
+ """
+ Creates and returns a mapping between field names and formulas to be created
+ for the given ORM node. Every value needs to contain instructions or description
+ on how to generate the formula for that field.
+ Prefix optional fields with "[optional]: " in the description to indicate they
+ are not mandatory.
+ """
+
+ pass
+
+ @abstractmethod
+ def update_service_with_formulas(self, service: Service, formulas: dict[str, str]):
+ """
+ Updates the given service instance with the provided formulas mapping.
+ The names in the formulas dict correspond to the field names returned by
+ get_formulas_to_create. Once the LLM has generated the formulas, this method
+ is called to update the service with the generated formulas.
+ """
+ pass
+
+
+class PeriodicTriggerSettings(BaseModel):
interval: Literal["MINUTE", "HOUR", "DAY", "WEEK", "MONTH"] = Field(
..., description="The interval for the periodic trigger"
)
- minute: Optional[int] = Field(
- default=0, description="The number of minutes for the periodic trigger"
+ minute: int = Field(
+ default=0,
+ description=(
+ "If interval=MINUTE, the number of minutes between each trigger. "
+ f"Minimum is set to {settings.INTEGRATIONS_PERIODIC_MINUTE_MIN} minutes. "
+ "If interval=HOUR, the UTC minute for the periodic trigger. "
+ ),
)
- hour: Optional[int] = Field(
- default=0, description="The number of hours for the periodic trigger"
+ hour: int = Field(
+ default=0,
+ description=(
+ "The UTC hour for the periodic trigger. "
+ "ALWAYS remove timezone offset from the context."
+ ),
)
- day_of_week: Optional[int] = Field(
+ day_of_week: int = Field(
default=0,
description="The day of the week for the periodic trigger (0=Monday, 6=Sunday)",
)
- day_of_month: Optional[int] = Field(
+ day_of_month: int = Field(
default=1, description="The day of the month for the periodic trigger (1-31)"
)
@@ -66,7 +105,7 @@ class TriggerNodeCreate(NodeBase, RefCreate):
# periodic trigger specific
periodic_interval: Optional[PeriodicTriggerSettings] = Field(
default=None,
- description="Configuration for periodic trigger",
+ description="UTC configuration for periodic trigger. ALWAYS remove timezone offset from the context.",
)
rows_triggers_settings: Optional[RowsTriggersSettings] = Field(
default=None,
@@ -77,7 +116,13 @@ def to_orm_service_dict(self) -> dict[str, Any]:
"""Convert to ORM dict for node creation service."""
if self.type == "periodic" and self.periodic_interval:
- return self.periodic_interval.model_dump()
+ values = self.periodic_interval.model_dump()
+ if self.periodic_interval.interval == "MINUTE":
+ values["minute"] = max(
+ settings.INTEGRATIONS_PERIODIC_MINUTE_MIN,
+ values["minute"],
+ )
+ return values
if (
self.type in ["rows_created", "rows_updated", "rows_deleted"]
@@ -143,8 +188,11 @@ class RouterEdgeCreate(BaseModel):
description="The label of the router branch. Order of branches matters: first matching branch is taken.",
)
condition: str = Field(
- default="",
- description="A brief description of the condition for this branch that will be converted to a formula.",
+ description=(
+ "The condition formula to evaluate for this branch as boolean. "
+ "Use comparison operators and get(...) functions to build the formula with a boolean result. "
+ "Always mentions the field values using get(...) functions."
+ ),
)
_uid: str = PrivateAttr(default_factory=lambda: str(uuid4()))
@@ -170,12 +218,29 @@ class RouterNodeBase(NodeBase):
)
-class RouterNodeCreate(RouterNodeBase, RefCreate, EdgeCreate):
+class RouterNodeCreate(RouterNodeBase, RefCreate, EdgeCreate, HasFormulasToCreateMixin):
"""Create a router node with branches and link configuration."""
def to_orm_service_dict(self) -> dict[str, Any]:
return {"edges": [branch.to_orm_service_dict() for branch in self.edges]}
+ def get_formulas_to_create(self, orm_node: AutomationNode) -> dict[str, str]:
+ return {edge.label: edge.condition for edge in self.edges}
+
+ def update_service_with_formulas(self, service: Service, formulas: dict[str, str]):
+ orm_edges = service.specific.edges.all()
+ formulas = {k.lower(): v for k, v in formulas.items()}
+ EdgeModel = service.specific.edges.model
+ updates = []
+ for orm_edge in orm_edges:
+ label = orm_edge.label.lower()
+ if label in formulas:
+ orm_edge.condition["mode"] = BASEROW_FORMULA_MODE_ADVANCED
+ orm_edge.condition["formula"] = formulas[label]
+ updates.append(orm_edge)
+ if updates:
+ EdgeModel.objects.bulk_update(updates, ["condition"])
+
class RouterNodeItem(RouterNodeBase, Item):
"""Existing router node with ID."""
@@ -193,7 +258,9 @@ class SendEmailActionBase(NodeBase):
body_type: Literal["plain", "html"] = Field(default="plain")
-class SendEmailActionCreate(SendEmailActionBase, RefCreate, EdgeCreate):
+class SendEmailActionCreate(
+ SendEmailActionBase, RefCreate, EdgeCreate, HasFormulasToCreateMixin
+):
"""Create a send email action with edge configuration."""
def to_orm_service_dict(self) -> dict[str, Any]:
@@ -206,6 +273,54 @@ def to_orm_service_dict(self) -> dict[str, Any]:
"body_type": f"'{self.body_type}'",
}
+ def get_formulas_to_create(self, orm_node: AutomationNode) -> dict[str, str]:
+ values = {}
+ to_emails_base = (
+ "A comma separated list of email addresses to send the email to."
+ )
+ if self.to_emails:
+ values["to_emails"] = (
+ to_emails_base + f" Value to resolve: {self.to_emails}"
+ )
+ else:
+ values["to_emails"] = "[optional]: " + to_emails_base
+
+ cc_emails_base = "A comma separated list of email addresses to CC the email to."
+ if self.cc_emails:
+ values["cc_emails"] = (
+ cc_emails_base + f" Value to resolve: {self.cc_emails}"
+ )
+ else:
+ values["cc_emails"] = "[optional]: " + cc_emails_base
+
+ bcc_emails_base = (
+ "A comma separated list of email addresses to BCC the email to."
+ )
+ if self.bcc_emails:
+ values["bcc_emails"] = (
+ bcc_emails_base + f" Value to resolve: {self.bcc_emails}"
+ )
+ else:
+ values["bcc_emails"] = "[optional]: " + bcc_emails_base
+
+ values["subject"] = "The subject of the email."
+ if self.subject:
+ values["subject"] += f" Value to resolve: {self.subject}"
+
+ values["body"] = f"The {self.body_type} body content of the email."
+ if self.body:
+ values["body"] += f" Value to resolve: {self.body}"
+ return values
+
+ def update_service_with_formulas(self, service: Service, formulas: dict[str, str]):
+ save = False
+ for field_name, formula in formulas.items():
+ if hasattr(service, field_name):
+ setattr(service, field_name, formula)
+ save = True
+ if save:
+ ServiceHandler().update_service(service.get_type(), service)
+
class SendEmailActionItem(SendEmailActionBase, Item):
"""Existing send email action with ID."""
@@ -216,7 +331,9 @@ class CreateRowActionBase(NodeBase):
type: Literal["create_row"]
table_id: int
- values: dict[str, Any]
+ values: dict[int, Any] = Field(
+ ..., description="A mapping of field IDs to values or formulas to update"
+ )
class RowActionService:
@@ -226,8 +343,64 @@ def to_orm_service_dict(self) -> dict[str, Any]:
}
+class RowActionFormulaToCreate(HasFormulasToCreateMixin):
+ def get_formulas_to_create(self, orm_node: AutomationNode) -> dict[str, str]:
+ from baserow_enterprise.assistant.tools.automation.utils import (
+ _minimize_json_schema,
+ )
+
+ service = orm_node.service.specific
+ schema = service.get_type().generate_schema(service.specific)
+ values = {"row_id": "the row ID to update"}
+ for v in _minimize_json_schema(schema).values():
+ desc = v["desc"]
+ value = self.values.get(int(v["id"]))
+ if value:
+ desc += f" Value to resolve: {value}"
+ else:
+ desc = "[optional]: " + desc
+ values[int(v["id"])] = {**v, "desc": desc}
+ return values
+
+ def update_service_with_formulas(self, service: Service, formulas: dict[str, str]):
+ row_id_formula = formulas.pop("row_id", None)
+
+ field_mappings = {m.field_id: m for m in service.field_mappings.all()}
+ field_mapping_to_create = []
+ field_mapping_to_update = []
+ FieldMapping = service.field_mappings.model
+ for field_id, formula in formulas.items():
+ if field_id in field_mappings:
+ field_mappings[field_id].value = formula
+ field_mappings[field_id].enabled = True
+ field_mapping_to_update.append(field_mappings[field_id])
+ else:
+ field_mapping_to_create.append(
+ FieldMapping(
+ field_id=field_id,
+ value=formula,
+ enabled=True,
+ service_id=service.id,
+ )
+ )
+ if field_mapping_to_create:
+ service.field_mappings.bulk_create(field_mapping_to_create)
+ if field_mapping_to_update:
+ FieldMapping.objects.bulk_update(
+ field_mapping_to_update, ["value", "enabled"]
+ )
+
+ if row_id_formula:
+ service.row_id = row_id_formula
+ ServiceHandler().update_service(service.get_type(), service)
+
+
class CreateRowActionCreate(
- RowActionService, CreateRowActionBase, RefCreate, EdgeCreate
+ RowActionService,
+ CreateRowActionBase,
+ RefCreate,
+ EdgeCreate,
+ RowActionFormulaToCreate,
):
"""Create a create row action with edge configuration."""
@@ -241,12 +414,18 @@ class UpdateRowActionBase(NodeBase):
type: Literal["update_row"]
table_id: int
- row: str = Field(..., description="The row ID or a formula to identify the row")
- values: dict[str, Any]
+ row_id: str = Field(..., description="The row ID or a formula to identify the row")
+ values: dict[int, Any] = Field(
+ ..., description="A mapping of field IDs to values or formulas to update"
+ )
class UpdateRowActionCreate(
- RowActionService, UpdateRowActionBase, RefCreate, EdgeCreate
+ RowActionService,
+ UpdateRowActionBase,
+ RefCreate,
+ EdgeCreate,
+ RowActionFormulaToCreate,
):
"""Create an update row action with edge configuration."""
@@ -260,11 +439,15 @@ class DeleteRowActionBase(NodeBase):
type: Literal["delete_row"]
table_id: int
- row: str = Field(..., description="The row ID or a formula to identify the row")
+ row_id: str = Field(..., description="The row ID or a formula to identify the row")
class DeleteRowActionCreate(
- RowActionService, DeleteRowActionBase, RefCreate, EdgeCreate
+ RowActionService,
+ DeleteRowActionBase,
+ RefCreate,
+ EdgeCreate,
+ RowActionFormulaToCreate,
):
"""Create a delete row action with edge configuration."""
@@ -285,21 +468,29 @@ class AiAgentNodeBase(NodeBase):
default=None,
description="List of choices if output_type is 'choice'",
)
- temperature: float | None = Field(default=None)
prompt: str
-class AiAgentNodeCreate(AiAgentNodeBase, RefCreate, EdgeCreate):
+class AiAgentNodeCreate(
+ AiAgentNodeBase, RefCreate, EdgeCreate, HasFormulasToCreateMixin
+):
"""Create an AI Agent action with edge configuration."""
def to_orm_service_dict(self) -> dict[str, Any]:
return {
"ai_choices": (self.choices or []) if self.output_type == "choice" else [],
- "ai_temperature": self.temperature,
"ai_prompt": f"'{self.prompt}'",
"ai_output_type": self.output_type,
}
+ def get_formulas_to_create(self, orm_node: AutomationNode) -> dict[str, str]:
+ return {"ai_prompt": self.prompt}
+
+ def update_service_with_formulas(self, service: Service, formulas: dict[str, str]):
+ if "ai_prompt" in formulas:
+ service.ai_prompt = formulas["ai_prompt"]
+ ServiceHandler().update_service(service.get_type(), service)
+
class AiAgentNodeItem(AiAgentNodeBase, Item):
"""Existing AI Agent action with ID."""
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py
index 05733ad9f0..db8cca691e 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py
@@ -1,14 +1,250 @@
+from datetime import date, datetime
+from typing import TYPE_CHECKING, Any, Tuple
+
from django.contrib.auth.models import AbstractUser
+from django.db import transaction
+from django.utils.translation import gettext as _
+
+from loguru import logger
+from pydantic import ConfigDict
from baserow.contrib.automation.models import Automation
+from baserow.contrib.automation.nodes.models import AutomationNode
from baserow.contrib.automation.nodes.registries import automation_node_type_registry
from baserow.contrib.automation.nodes.service import AutomationNodeService
from baserow.contrib.automation.workflows.models import AutomationWorkflow
from baserow.contrib.automation.workflows.service import AutomationWorkflowService
+from baserow.core.formula import resolve_formula
+from baserow.core.formula.registries import formula_runtime_function_registry
+from baserow.core.formula.types import (
+ BASEROW_FORMULA_MODE_ADVANCED,
+ BaserowFormulaObject,
+ FormulaContext,
+)
from baserow.core.models import Workspace
from baserow.core.service import CoreService
+from baserow.core.utils import to_path
+
+from .prompts import GENERATE_FORMULA_PROMPT
+from .types import HasFormulasToCreateMixin, NodeBase, WorkflowCreate
+
+if TYPE_CHECKING:
+ from baserow_enterprise.assistant.assistant import ToolHelpers
+
+
+def _minimize_json_schema(schema) -> dict[str, dict[str, str]]:
+ """
+ Generate a mapping between field ids and names/types from a JSON schema.
+ Useful when generating formulas to understand the provided context.
+ """
+
+ field_type_descriptions = {
+ "link_row": "the row ID as number or the primary field value as string",
+ "single_select": "the option ID as number or the value as string",
+ "multiple_select": "a comma separated list of option IDs or values as string",
+ "date": "a date string in ISO 8601 format",
+ "date_time": "a date-time string in ISO 8601 format",
+ "boolean": "true or false",
+ }
+ field_type_extra_info = {
+ "single_select": lambda meta: {
+ "select_options": meta.get("select_options", [])
+ },
+ "multiple_select": lambda meta: {
+ "select_options": meta.get("select_options", [])
+ },
+ "multiple_collaborators": lambda meta: {
+ "available_collaborators": meta.get("available_collaborators", [])
+ },
+ }
+
+ if schema.get("type") == "array":
+ return _minimize_json_schema(schema.get("items"))
+ elif schema.get("type") != "object":
+ raise ValueError("Schema must be of type object or array of objects")
+
+ properties = schema.get("properties", {})
+ mapping = {}
+ for key, prop in properties.items():
+ metadata = prop.get("metadata")
+ if metadata:
+ field_type = metadata["type"]
+ mapping[key] = {
+ "id": metadata["id"],
+ "name": metadata["name"],
+ "type": field_type,
+ "desc": field_type_descriptions.get(field_type, ""),
+ }
+ if field_type in field_type_extra_info:
+ get_extra_info = field_type_extra_info[field_type]
+ mapping[key].update(get_extra_info(metadata))
+ return mapping
+
+
+def _create_example_from_json_schema(schema) -> Tuple[dict, dict]:
+ """
+ Generate example data from a JSON schema.
+ Useful when generating formulas to provide example context data.
+ """
+
+ examples = {
+ "string": "text",
+ "number": 1,
+ "boolean": True,
+ "null": None,
+ "object": lambda prop: _create_example_from_json_schema(prop),
+ "array": lambda prop: [_create_example_from_json_schema(prop["items"])],
+ }
+
+ if schema.get("type") == "array":
+ return [_create_example_from_json_schema(schema.get("items"))]
+ elif schema.get("type") != "object":
+ raise ValueError("Schema must be of type object or array of objects")
+
+ properties = schema.get("properties", {})
+ example = {}
+ for key, prop in properties.items():
+ value = examples[prop.get("type")]
+ if callable(value):
+ example[key] = value(prop)
+ else:
+ example[key] = value
+ return example
+
+
+class AssistantFormulaContext(FormulaContext):
+ def __init__(self):
+ self.context = {}
+ self.context_metadata = {}
+ super().__init__()
+
+ def add_node_context(
+ self,
+ node_id: int | str,
+ node_context: dict[str, any],
+ context_metadata: dict[str, dict[str, str]] | None = None,
+ ):
+ """Update the formula context with new values."""
+
+ self.context.update({str(node_id): node_context})
+ if context_metadata:
+ self.context_metadata.update({str(node_id): context_metadata})
+
+ def get_formula_context(self) -> dict[str, any]:
+ return {"previous_node": self.context}
+
+ def get_context_metadata(self) -> dict[str, any]:
+ return self.context_metadata
+
+ def __getitem__(self, key) -> any:
+ start, *key_parts = to_path(key)
+ if start != "previous_node":
+ raise KeyError(
+ f"Key '{key}' not found in context. Only 'previous_node' is supported at the root level."
+ )
+ value = self.context
+ for kp in key_parts:
+ try:
+ value = value[int(kp) if isinstance(value, list) else kp]
+ except (KeyError, TypeError, ValueError):
+ available_keys = (
+ list(value.keys())
+ if isinstance(value, dict)
+ else ", ".join(map(str, range(len(value))))
+ )
+ raise KeyError(
+ f"Key '{kp}' of '{key}' not found in {value}, Available keys: {available_keys}"
+ )
+ if not isinstance(value, (int, float, str, bool, date, datetime)):
+ raise ValueError(
+ f"Value for key '{key}' is not a valid type. "
+ f"Expected int, float, str, bool, date, or datetime. "
+ f"Got {type(value).__name__} instead. "
+ f"Make sure to only reference primitive types in the formula context."
+ )
+ return value
+
+
+def get_generate_formulas_tool():
+ import dspy
+
+ class RuntimeFormulaGenerator(dspy.Signature):
+ __doc__ = GENERATE_FORMULA_PROMPT
-from .types import WorkflowCreate
+ fields_to_resolve: dict[str, dict[str, str]] = dspy.InputField(
+ desc=(
+ "The fields that need formulas to be generated. "
+ "If prefixed with [optional], the field is not mandatory."
+ )
+ )
+ context: dict[str, Any] = dspy.InputField(
+ desc="The available context to use in formula generation composed of previous nodes results."
+ )
+ context_metadata: dict[str, Any] = dspy.InputField(
+ desc="Metadata about the context fields, with refs and names to assist in formula generation."
+ )
+ feedback: str = dspy.InputField(
+ desc="Validation errors from previous attempt. Empty if first attempt."
+ )
+ generated_formulas: dict[str, Any] = dspy.OutputField()
+
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ def check_formula(generated_formula: str, context: AssistantFormulaContext) -> str:
+ try:
+ resolve_formula(
+ BaserowFormulaObject.create(
+ formula=generated_formula, mode=BASEROW_FORMULA_MODE_ADVANCED
+ ),
+ formula_runtime_function_registry,
+ context,
+ )
+ except Exception as exc:
+ raise ValueError(f"Generated formula is invalid: {str(exc)}")
+ return "ok, the formula is valid"
+
+ def generate_node_formulas(
+ fields_to_resolve: dict,
+ context: AssistantFormulaContext,
+ max_retries: int = 3,
+ ) -> str:
+ """
+ For every non-null input field in the node's schema, generate a formula
+ that fulfills the request, using the provided context object.
+ """
+
+ predict = dspy.Predict(RuntimeFormulaGenerator)
+ feedback = ""
+ for __ in range(max_retries):
+ result = predict(
+ fields_to_resolve=fields_to_resolve,
+ context=context.get_formula_context(),
+ context_metadata=context.get_context_metadata(),
+ feedback=feedback,
+ )
+ # Ensure all the generated formulas are valid
+ valid_formulas = {}
+ generated_formulas = result.generated_formulas
+ for field_id, formula in generated_formulas.items():
+ try:
+ check_formula(formula, context)
+ valid_formulas[field_id] = formula
+ except ValueError as exc:
+ feedback += f"Error for {field_id}, formula {formula} not valid: {str(exc)}\n"
+
+ if len(valid_formulas) == len(generated_formulas):
+ return valid_formulas
+
+ # Any valid formula is better than none
+ if valid_formulas:
+ return valid_formulas
+ else:
+ raise ValueError(
+ "Failed to generate any valid formulas after "
+ f"{max_retries} attempts. Feedback:\n{feedback}"
+ )
+
+ return generate_node_formulas
def get_automation(
@@ -37,12 +273,17 @@ def get_workflow(
def create_workflow(
user: AbstractUser,
automation: Automation,
- workflow: WorkflowCreate,
-) -> AutomationWorkflow:
+ workflow: "WorkflowCreate",
+ tool_helpers: "ToolHelpers",
+) -> Tuple[AutomationWorkflow, dict[int | str, Any]]:
"""
Creates a new workflow in the given automation based on the provided definition.
"""
+ tool_helpers.update_status(
+ _("Creating workflow '%(name)s'..." % {"name": workflow.name})
+ )
+
orm_wf = AutomationWorkflowService().create_workflow(
user, automation.id, workflow.name
)
@@ -52,6 +293,9 @@ def create_workflow(
# First create the trigger node
orm_service_data = workflow.trigger.to_orm_service_dict()
node_type = automation_node_type_registry.get(workflow.trigger.type)
+ tool_helpers.update_status(
+ _("Creating trigger '%(label)s'..." % {"label": workflow.trigger.label})
+ )
orm_trigger = AutomationNodeService().create_node(
user,
node_type,
@@ -69,6 +313,9 @@ def create_workflow(
orm_service_data = node.to_orm_service_dict()
reference_node_id, output = node.to_orm_reference_node(node_mapping)
node_type = automation_node_type_registry.get(node.type)
+ tool_helpers.update_status(
+ _("Creating node '%(label)s'..." % {"label": node.label})
+ )
orm_node = AutomationNodeService().create_node(
user,
node_type,
@@ -80,4 +327,65 @@ def create_workflow(
)
node_mapping[node.ref] = node_mapping[orm_node.id] = (orm_node, node)
- return orm_wf
+ return orm_wf, node_mapping
+
+
+def update_workflow_formulas(
+ workflow: "WorkflowCreate",
+ node_mapping: dict[int | str, Any],
+ tool_helpers: "ToolHelpers",
+) -> None:
+ """
+ Loop over all nodes and verify if they have formulas to update. If so, update the
+ formulas in the ORM node service providing the available context up to that node and
+ the user request for that node.
+ """
+
+ context = AssistantFormulaContext()
+
+ def _get_service_schema(orm_node: AutomationNode):
+ return orm_node.service.get_type().generate_schema(orm_node.service.specific)
+
+ def _update_context_with_node_data(
+ orm_node: AutomationNode, node_to_create: NodeBase
+ ):
+ schema = _get_service_schema(orm_node)
+ example = _create_example_from_json_schema(schema)
+ descr = _minimize_json_schema(schema)
+ descr["node_id"] = orm_node.id
+ descr["node_ref"] = node_to_create.ref
+ if getattr(node_to_create, "previous_node_ref", None):
+ descr["previous_node_ref"] = node_to_create.previous_node_ref
+ context.add_node_context(orm_node.id, example, descr)
+
+ # Add the trigger context first
+ trigger_node = workflow.trigger
+ orm_trigger, __ = node_mapping[trigger_node.ref]
+ _update_context_with_node_data(orm_trigger, trigger_node)
+
+ generate_formula_tool = get_generate_formulas_tool()
+
+ def _generate_and_update_node_formulas(
+ node: HasFormulasToCreateMixin, orm_node: AutomationNode
+ ):
+ formulas_to_create = node.get_formulas_to_create(orm_node)
+ result = generate_formula_tool(formulas_to_create, context)
+ if result:
+ node.update_service_with_formulas(orm_node.service, result)
+
+ # Node by node, generate formulas if needed and update the context with the node
+ # data, so following nodes can use it.
+ for node in workflow.nodes:
+ orm_node, __ = node_mapping[node.ref]
+ if isinstance(node, HasFormulasToCreateMixin):
+ tool_helpers.update_status(
+ _("Generating formulas for node '%(label)s'..." % {"label": node.label})
+ )
+ with transaction.atomic():
+ try:
+ _generate_and_update_node_formulas(node, orm_node)
+ except Exception as exc:
+ logger.exception(
+ "Failed to generate formulas for node %s: %s", orm_node.id, exc
+ )
+ _update_context_with_node_data(orm_node, node)
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py
index 763719d1b0..c0138b7e2f 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py
@@ -1,6 +1,9 @@
import pytest
from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler
+from baserow.core.formula import resolve_formula
+from baserow.core.formula.registries import formula_runtime_function_registry
+from baserow.core.formula.types import BASEROW_FORMULA_MODE_ADVANCED
from baserow_enterprise.assistant.tools.automation.tools import (
get_create_workflows_tool,
get_list_workflows_tool,
@@ -8,14 +11,35 @@
from baserow_enterprise.assistant.tools.automation.types import (
CreateRowActionCreate,
DeleteRowActionCreate,
+ RouterNodeCreate,
TriggerNodeCreate,
UpdateRowActionCreate,
WorkflowCreate,
)
+from baserow_enterprise.assistant.tools.automation.types.node import RouterEdgeCreate
+from baserow_enterprise.assistant.tools.automation.utils import AssistantFormulaContext
from .utils import fake_tool_helpers
+@pytest.fixture(autouse=True)
+def mock_formula_generator(monkeypatch):
+ """
+ Mock update_workflow_formulas to avoid LM requirement in tests.
+ Simply skips formula generation entirely.
+ """
+
+ def mock_update_workflow_formulas(workflow, node_mapping, tool_helpers):
+ """Mock that does nothing - skips formula generation."""
+
+ pass
+
+ monkeypatch.setattr(
+ "baserow_enterprise.assistant.tools.automation.utils.update_workflow_formulas",
+ mock_update_workflow_formulas,
+ )
+
+
@pytest.mark.django_db
def test_list_workflows(data_fixture):
user = data_fixture.create_user()
@@ -194,7 +218,7 @@ def test_create_multiple_workflows(data_fixture):
previous_node_ref="trigger",
label="Update Row Action",
table_id=999,
- row="1",
+ row_id="1",
values={},
),
),
@@ -208,7 +232,7 @@ def test_create_multiple_workflows(data_fixture):
previous_node_ref="trigger",
label="Delete Row Action",
table_id=999,
- row="1",
+ row_id="1",
),
),
],
@@ -246,3 +270,306 @@ def test_create_workflow_with_row_triggers_and_actions(data_fixture, trigger, ac
orm_trigger = workflow.get_trigger()
assert orm_trigger is not None
assert orm_trigger.service.get_type().type == f"local_baserow_{trigger.type}"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_create_row_action_with_field_ids(data_fixture):
+ """Test CreateRowActionCreate uses field IDs in values dict, not field names."""
+
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+ text_field = data_fixture.create_text_field(table=table, name="Name")
+ number_field = data_fixture.create_number_field(table=table, name="Age")
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Test Field IDs",
+ trigger=TriggerNodeCreate(
+ ref="trigger1",
+ label="Periodic Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ CreateRowActionCreate(
+ ref="action1",
+ label="Create row with field IDs",
+ previous_node_ref="trigger1",
+ type="create_row",
+ table_id=table.id,
+ values={
+ text_field.id: "John Doe",
+ number_field.id: 25,
+ },
+ )
+ ],
+ )
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 1
+ workflow_id = result["created_workflows"][0]["id"]
+ workflow = AutomationWorkflowHandler().get_workflow(workflow_id)
+
+ # Get the action node and verify it was created with the correct table
+ action_nodes = workflow.automation_workflow_nodes.exclude(
+ id=workflow.get_trigger().id
+ )
+ assert action_nodes.count() == 1
+ action_node = action_nodes.first()
+ assert action_node.service.specific.table_id == table.id
+
+
+@pytest.mark.django_db(transaction=True)
+def test_update_row_action_with_row_id_and_field_ids(data_fixture):
+ """Test UpdateRowActionCreate uses row_id parameter and field IDs in values."""
+
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+ text_field = data_fixture.create_text_field(table=table, name="Status")
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Test Update Row",
+ trigger=TriggerNodeCreate(
+ ref="trigger1",
+ label="Periodic Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ UpdateRowActionCreate(
+ ref="action1",
+ label="Update row",
+ previous_node_ref="trigger1",
+ type="update_row",
+ table_id=table.id,
+ row_id="123",
+ values={text_field.id: "completed"},
+ )
+ ],
+ )
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 1
+ workflow_id = result["created_workflows"][0]["id"]
+ workflow = AutomationWorkflowHandler().get_workflow(workflow_id)
+
+ # Get the action node and verify it was created with the correct table
+ # Note: row_id formula generation occurs in a separate transaction and may fail
+ # if DSPy is not configured, so we only verify basic service configuration
+ action_nodes = workflow.automation_workflow_nodes.exclude(
+ id=workflow.get_trigger().id
+ )
+ assert action_nodes.count() == 1
+ action_node = action_nodes.first()
+ assert action_node.service.specific.table_id == table.id
+ # Verify the service type is correct for upsert_row (update operation)
+ assert action_node.service.get_type().type == "local_baserow_upsert_row"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_delete_row_action_with_row_id(data_fixture):
+ """Test DeleteRowActionCreate uses row_id parameter."""
+
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Test Delete Row",
+ trigger=TriggerNodeCreate(
+ ref="trigger1",
+ label="Periodic Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ DeleteRowActionCreate(
+ ref="action1",
+ label="Delete row",
+ previous_node_ref="trigger1",
+ type="delete_row",
+ table_id=table.id,
+ row_id="456",
+ )
+ ],
+ )
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 1
+ workflow_id = result["created_workflows"][0]["id"]
+ workflow = AutomationWorkflowHandler().get_workflow(workflow_id)
+
+ # Get the action node and verify it was created with the correct table
+ # Note: row_id formula generation occurs in a separate transaction and may fail
+ # if DSPy is not configured, so we only verify basic service configuration
+ action_nodes = workflow.automation_workflow_nodes.exclude(
+ id=workflow.get_trigger().id
+ )
+ assert action_nodes.count() == 1
+ action_node = action_nodes.first()
+ assert action_node.service.specific.table_id == table.id
+ # Verify the service type is correct for delete_row
+ assert action_node.service.get_type().type == "local_baserow_delete_row"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_router_node_with_required_conditions(data_fixture):
+ """Test RouterNodeCreate requires condition field for each edge."""
+
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Test Router with Conditions",
+ trigger=TriggerNodeCreate(
+ ref="trigger1",
+ label="Periodic Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ RouterNodeCreate(
+ ref="router1",
+ label="Router",
+ previous_node_ref="trigger1",
+ type="router",
+ edges=[
+ RouterEdgeCreate(
+ label="High Priority",
+ condition="Priority is high",
+ ),
+ RouterEdgeCreate(
+ label="Low Priority",
+ condition="Priority is low",
+ ),
+ ],
+ ),
+ CreateRowActionCreate(
+ ref="action1",
+ label="Create row",
+ previous_node_ref="router1",
+ type="create_row",
+ table_id=table.id,
+ values={},
+ ),
+ ],
+ )
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 1
+ workflow_id = result["created_workflows"][0]["id"]
+ workflow = AutomationWorkflowHandler().get_workflow(workflow_id)
+
+ # Get the router node and verify it was created with edges
+ router_nodes = workflow.automation_workflow_nodes.filter(
+ service__isnull=False
+ ).exclude(id=workflow.get_trigger().id)
+
+ # Find the router node (service type will be router)
+ router_node = None
+ for node in router_nodes:
+ if "router" in node.service.get_type().type:
+ router_node = node
+ break
+
+ assert router_node is not None, "Router node should be created"
+ # Verify edges were created
+ edges = router_node.service.specific.edges.all()
+ assert edges.count() == 2
+ assert {e.label for e in edges} == {"High Priority", "Low Priority"}
+
+
+def test_check_formula_with_basic_formulas():
+ """Test that check_formula validates basic formulas correctly."""
+
+ def check_formula(generated_formula: str, context: AssistantFormulaContext) -> str:
+ try:
+ resolve_formula(
+ {"formula": generated_formula, "mode": BASEROW_FORMULA_MODE_ADVANCED},
+ formula_runtime_function_registry,
+ context,
+ )
+ except Exception as exc:
+ raise ValueError(f"Generated formula is invalid: {str(exc)}")
+ return "ok, the formula is valid"
+
+ # Test basic string literal
+ context = AssistantFormulaContext()
+ result = check_formula("'a'", context)
+ assert result == "ok, the formula is valid"
+
+ # Test numeric literal
+ result = check_formula("1", context)
+ assert result == "ok, the formula is valid"
+
+ # Test simple arithmetic
+ result = check_formula("1 + 1", context)
+ assert result == "ok, the formula is valid"
+
+ # Test with context values
+ context = AssistantFormulaContext()
+ context.add_node_context(
+ node_id=1,
+ node_context=[{"name": "John", "age": 30, "active": True}],
+ )
+
+ # Test accessing context values
+ result = check_formula("get('previous_node.1[0].name')", context)
+ assert result == "ok, the formula is valid"
+
+ result = check_formula("get('previous_node.1[0].age')", context)
+ assert result == "ok, the formula is valid"
+
+ result = check_formula("get('previous_node.1[0].active')", context)
+ assert result == "ok, the formula is valid"
+
+ # Test concat with context
+ result = check_formula(
+ "concat('Hello ', get('previous_node.1[0].name'), '!')", context
+ )
+ assert result == "ok, the formula is valid"
+
+ # Test arithmetic with context
+ result = check_formula("get('previous_node.1[0].age') + 5", context)
+ assert result == "ok, the formula is valid"
+
+ # Test invalid formula should raise ValueError
+ try:
+ check_formula("invalid_function()", context)
+ assert False, "Should have raised ValueError"
+ except ValueError as e:
+ assert "Generated formula is invalid" in str(e)