diff --git a/backend/src/baserow/core/formula/field.py b/backend/src/baserow/core/formula/field.py index d2119c999d..62cc4a6cb7 100644 --- a/backend/src/baserow/core/formula/field.py +++ b/backend/src/baserow/core/formula/field.py @@ -90,7 +90,10 @@ def _transform_db_value_to_dict( if context := self._deserialize_baserow_object(value): # If we have, then we can parse it and return the `BaserowFormulaObject` return BaserowFormulaObject( - mode=context["m"], version=context["v"], formula=context["f"] + mode=context["m"], + version=context["v"], + # Raw DB values can hold `"f": null`; clients expect a string. + formula=context["f"] or "", ) elif isinstance(value, str): # Otherwise, it's a raw formula string, which we can wrap in a @@ -129,7 +132,7 @@ def _transform_db_value_to_dict( ) return BaserowFormulaObject( - mode=value["m"], version=value["v"], formula=value["f"] + mode=value["m"], version=value["v"], formula=value["f"] or "" ) def contribute_to_class(self, cls, name, **kwargs): @@ -293,16 +296,21 @@ def _transform_db_property( :return: A `BaserowFormulaObject`. """ + # Legacy rows written before the formula-object migration can hold + # `null` at a formula path, and older write paths could persist a null + # `f`; clients expect `formula` to always be a string, so coerce it + # while reading. Only the formula is coerced on purpose: a missing mode + # or version is not guessed here. if not isinstance(value, dict): return BaserowFormulaObject( mode=BASEROW_FORMULA_MODE_SIMPLE, version=BASEROW_FORMULA_VERSION_INITIAL, - formula=value, + formula=value or "", ) return BaserowFormulaObject( mode=value.get("m", value.get("mode")), version=value.get("v", value.get("version")), - formula=value.get("f", value.get("formula")), + formula=value.get("f", value.get("formula")) or "", ) def _transform_db_properties( @@ -414,16 +422,18 @@ def _transform_python_property( :return: A `BaserowFormulaMinified`. """ + # Coerce `None` values so that non-serializer write paths (direct ORM + # saves, application import) can never persist a null formula. if not isinstance(value, dict): return BaserowFormulaMinified( m=BASEROW_FORMULA_MODE_SIMPLE, v=BASEROW_FORMULA_VERSION_INITIAL, - f=value, + f=value or "", ) return BaserowFormulaMinified( m=value.get("mode", BASEROW_FORMULA_MODE_SIMPLE), v=value.get("version", BASEROW_FORMULA_VERSION_INITIAL), - f=value.get("formula", ""), + f=value.get("formula") or "", ) def get_prep_value( diff --git a/backend/tests/baserow/core/formula/test_formula_field.py b/backend/tests/baserow/core/formula/test_formula_field.py index 53b991b2e3..7418f8c4dd 100644 --- a/backend/tests/baserow/core/formula/test_formula_field.py +++ b/backend/tests/baserow/core/formula/test_formula_field.py @@ -1,4 +1,9 @@ -from baserow.core.formula.field import FormulaField, JSONFormulaField +from baserow.core.formula.field import ( + BASEROW_FORMULA_VERSION_INITIAL, + FormulaField, + JSONFormulaField, +) +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE def test_json_formula_field_get_prep_value_does_not_mutate_input(): @@ -52,6 +57,89 @@ def test_json_formula_field_get_prep_value_does_not_mutate_input(): } +def test_json_formula_field_transform_db_property_coerces_null_formula(): + """ + Legacy rows written before the formula-object migration can hold `null` at + a formula path, and older write paths could persist a null `f`. These must + be read back as `formula: ""`, never `formula: None`, because clients expect + a string. A missing mode or version is deliberately not defaulted. + """ + + field = JSONFormulaField(properties=["value"]) + expected = { + "formula": "", + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + } + minified_with_null_formula = { + "f": None, + "m": BASEROW_FORMULA_MODE_SIMPLE, + "v": BASEROW_FORMULA_VERSION_INITIAL, + } + + assert field._transform_db_property(None) == expected + assert field._transform_db_property(minified_with_null_formula) == expected + + +def test_json_formula_field_transform_db_properties_coerces_legacy_null_value(): + field = JSONFormulaField(properties=["value"]) + + result = field._transform_db_properties([{"name": "id", "value": None}]) + + assert result == [ + { + "name": "id", + "value": { + "formula": "", + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + }, + } + ] + + +def test_json_formula_field_get_prep_value_coerces_null_formula(): + """ + Non-serializer write paths (direct ORM saves, application import) must not + be able to persist a null formula. + """ + + field = JSONFormulaField(properties=["value"]) + expected = [ + { + "name": "id", + "value": { + "f": "", + "m": BASEROW_FORMULA_MODE_SIMPLE, + "v": BASEROW_FORMULA_VERSION_INITIAL, + }, + } + ] + object_with_null_formula = { + "formula": None, + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + } + + assert field.get_prep_value([{"name": "id", "value": None}]) == expected + assert ( + field.get_prep_value([{"name": "id", "value": object_with_null_formula}]) + == expected + ) + + +def test_formula_field_transform_db_value_coerces_null_formula(): + field = FormulaField() + + result = field._transform_db_value_to_dict('{"m": "simple", "v": "0.1", "f": null}') + + assert result == { + "formula": "", + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + } + + def test_deserialize_baserow_object_valid(): field = FormulaField() diff --git a/changelog/entries/unreleased/bug/fixed_a_crash_when_clicking_in_a_formula_field_that_has_a_nu.json b/changelog/entries/unreleased/bug/fixed_a_crash_when_clicking_in_a_formula_field_that_has_a_nu.json new file mode 100644 index 0000000000..5e4ad88e5b --- /dev/null +++ b/changelog/entries/unreleased/bug/fixed_a_crash_when_clicking_in_a_formula_field_that_has_a_nu.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fixed a crash when clicking in a formula field that has a null value.", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-08-24" +} diff --git a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue index d23a530e37..70c76e62bb 100644 --- a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue +++ b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue @@ -81,7 +81,9 @@ const nodesHierarchy = computed(() => { * @returns {String} The formula string. */ const formulaStr = computed(() => { - return props.modelValue?.formula + // Legacy stored values can contain `formula: null`; `FormulaInputField` + // expects a string. + return props.modelValue?.formula || '' }) /** diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue index 63b2df7667..c1feb30c00 100644 --- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue +++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue @@ -115,7 +115,9 @@ const nodesHierarchy = computed(() => { * @returns {String} The expression string. */ const formulaStr = computed(() => { - return currentValue.value.formula + // Legacy stored values can contain `formula: null`; `FormulaInputField` + // expects a string. + return currentValue.value.formula || '' }) const dataSourceLoading = computed(() => { diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index 3ffaaf1a6b..f64ad0bb28 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -63,7 +63,7 @@ :node-selected="nodeSelected" :loading="loading" :mode="mode" - :has-value="value.length > 0" + :has-value="Boolean(value && value.length)" :allow-node-selection="allowNodeSelection" :nodes-hierarchy="nodesHierarchy" :enabled-modes="enabledModes" @@ -642,7 +642,9 @@ export default { // this.wrapperContent can be stale content, so get the data // directly from the editor. const editorContent = this.editor.getJSON() - const formula = this.toFormula(editorContent) + // `toFormula` returns null when the content cannot be serialized; never + // emit null upstream, parents store it and feed it back as `value`. + const formula = this.toFormula(editorContent) ?? '' if (this.validateFormula(formula)) { this.$emit('input', formula) diff --git a/web-frontend/modules/database/components/field/DatabaseFormulaInput.vue b/web-frontend/modules/database/components/field/DatabaseFormulaInput.vue index 874ddc7b49..1bb2a19350 100644 --- a/web-frontend/modules/database/components/field/DatabaseFormulaInput.vue +++ b/web-frontend/modules/database/components/field/DatabaseFormulaInput.vue @@ -58,7 +58,9 @@ export default { ) }, formulaStr() { - return this.currentValue.formula + // Legacy stored values can contain `formula: null`; `FormulaInputField` + // expects a string. + return this.currentValue.formula || '' }, applicationContext() { return this.databaseFormulaContext || {} diff --git a/web-frontend/test/unit/core/formula/FormulaInputField.spec.js b/web-frontend/test/unit/core/formula/FormulaInputField.spec.js index ae2196448a..3f54186ea6 100644 --- a/web-frontend/test/unit/core/formula/FormulaInputField.spec.js +++ b/web-frontend/test/unit/core/formula/FormulaInputField.spec.js @@ -448,3 +448,44 @@ describe('FormulaInputField example insertion', () => { expect(wrapper.emitted('input')).toBeUndefined() }) }) + +// ── Null value tolerance ──────────────────────────────────────────── +// Legacy stored data can contain `formula: null`, which parents used to pass +// straight through as the `value` prop. Focusing the field then rendered the +// explorer context, whose `:has-value="value.length > 0"` binding threw +// "Cannot read properties of null (reading 'length')". + +describe('FormulaInputField tolerates a null value', () => { + let testApp = null + + beforeEach(() => { + testApp = new TestApp() + }) + + afterEach(() => { + testApp.afterEach() + }) + + it('opens the explorer context on focus instead of crashing', async () => { + const wrapper = await testApp.mount(FormulaInputField, { + props: { value: null, mode: 'simple' }, + }) + wrapper.vm.isFocused = true + await wrapper.vm.$nextTick() + + expect( + wrapper.findComponent({ name: 'FormulaInputExplorerContext' }).exists() + ).toBe(true) + }) + + it('emits an empty string instead of null when serialization fails', async () => { + const wrapper = await testApp.mount(FormulaInputField, { + props: { value: '', mode: 'simple' }, + }) + vi.spyOn(wrapper.vm, 'toFormula').mockReturnValue(null) + + wrapper.vm.emitChange() + + expect(wrapper.emitted('input').at(-1)).toEqual(['']) + }) +})