diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py index db643b7141..0feb919d51 100755 --- a/backend/src/baserow/core/apps.py +++ b/backend/src/baserow/core/apps.py @@ -49,6 +49,7 @@ def ready(self): RuntimeGreaterThan, RuntimeGreaterThanOrEqual, RuntimeHour, + RuntimeIf, RuntimeIsEven, RuntimeIsOdd, RuntimeLessThan, @@ -100,6 +101,7 @@ def ready(self): formula_runtime_function_registry.register(RuntimeRandomFloat()) formula_runtime_function_registry.register(RuntimeRandomBool()) formula_runtime_function_registry.register(RuntimeGenerateUUID()) + formula_runtime_function_registry.register(RuntimeIf()) from baserow.core.permission_manager import ( AllowIfTemplatePermissionManagerType, diff --git a/backend/src/baserow/core/formula/argument_types.py b/backend/src/baserow/core/formula/argument_types.py index ac6d40c97f..53e86736e8 100644 --- a/backend/src/baserow/core/formula/argument_types.py +++ b/backend/src/baserow/core/formula/argument_types.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError from baserow.core.formula.validator import ( + ensure_boolean, ensure_datetime, ensure_numeric, ensure_object, @@ -78,3 +79,11 @@ def test(self, value): def parse(self, value): return ensure_object(value) + + +class BooleanBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType): + def test(self, value): + return isinstance(value, bool) + + def parse(self, value): + return ensure_boolean(value) diff --git a/backend/src/baserow/core/formula/runtime_formula_types.py b/backend/src/baserow/core/formula/runtime_formula_types.py index 1dcf91b592..71e86e5b11 100644 --- a/backend/src/baserow/core/formula/runtime_formula_types.py +++ b/backend/src/baserow/core/formula/runtime_formula_types.py @@ -9,6 +9,7 @@ from baserow.core.formula.argument_types import ( AddableBaserowRuntimeFormulaArgumentType, + BooleanBaserowRuntimeFormulaArgumentType, DateTimeBaserowRuntimeFormulaArgumentType, DictBaserowRuntimeFormulaArgumentType, NumberBaserowRuntimeFormulaArgumentType, @@ -396,3 +397,17 @@ class RuntimeGenerateUUID(RuntimeFormulaFunction): def execute(self, context: FormulaContext, args: FormulaArgs): return str(uuid.uuid4()) + + +class RuntimeIf(RuntimeFormulaFunction): + type = "if" + + def validate_type_of_args(self, args) -> Optional[FormulaArg]: + arg_type = BooleanBaserowRuntimeFormulaArgumentType() + if not arg_type.test(args[0]): + return args[0] + + return None + + def execute(self, context: FormulaContext, args: FormulaArgs): + return args[1] if args[0] else args[2] diff --git a/changelog/entries/unreleased/refactor/3258_added_support_for_advanced_formulas.json b/changelog/entries/unreleased/refactor/3258_added_support_for_advanced_formulas.json new file mode 100644 index 0000000000..da13f5aeae --- /dev/null +++ b/changelog/entries/unreleased/refactor/3258_added_support_for_advanced_formulas.json @@ -0,0 +1,9 @@ +{ + "type": "refactor", + "message": "Added support for Advanced Formulas.", + "domain": "builder", + "issue_number": 3258, + "issue_origin": "github", + "bullet_points": [], + "created_at": "2025-10-30" +} diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index ef2c81d36b..9d86eb7f1c 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -671,11 +671,13 @@ "randomFloatDescription": "Returns a random float from the range specified by the arguments.", "randomBoolDescription": "Returns a random boolean of true or false.", "generateUUIDDescription": "Returns a random UUID4 string.", + "ifDescription": "If the first argument is true, returns the second argument, otherwise returns the third argument.", "formulaTypeFormula": "Formula", "formulaTypeOperator": "Operator", "categoryText": "Text", "categoryNumber": "Number", "categoryBoolean": "Boolean", - "categoryDate": "Date" + "categoryDate": "Date", + "caregoryCondition": "Condition" } } diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index 95d2a43892..020d5bb10d 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -455,7 +455,8 @@ export default { } }, emitAdvancedChange() { - if (isFormulaValid(this.advancedFormulaValue)) { + const functions = new RuntimeFunctionCollection(this.$registry) + if (isFormulaValid(this.advancedFormulaValue, functions)) { this.isFormulaInvalid = false this.$emit('input', this.advancedFormulaValue) } else { diff --git a/web-frontend/modules/core/enums.js b/web-frontend/modules/core/enums.js index 200b9f60a8..cc332a5812 100644 --- a/web-frontend/modules/core/enums.js +++ b/web-frontend/modules/core/enums.js @@ -76,4 +76,8 @@ export const FORMULA_CATEGORY = { category: 'categoryFile', iconClass: 'baserow-icon-file', }, + CONDITION: { + category: 'categoryCondition', + iconClass: 'iconoir-code-brackets-square', + }, } diff --git a/web-frontend/modules/core/formula/index.js b/web-frontend/modules/core/formula/index.js index 5dc6450a66..5575704467 100644 --- a/web-frontend/modules/core/formula/index.js +++ b/web-frontend/modules/core/formula/index.js @@ -32,13 +32,14 @@ export const resolveFormula = ( } } -export const isFormulaValid = (formula) => { +export const isFormulaValid = (formula, functions) => { if (!formula) { return true } try { - parseBaserowFormula(formula) + const tree = parseBaserowFormula(formula) + new JavascriptExecutor(functions).visit(tree) return true } catch (err) { return false diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js index a0164078f5..09d5128a65 100644 --- a/web-frontend/modules/core/plugin.js +++ b/web-frontend/modules/core/plugin.js @@ -108,7 +108,6 @@ import { RuntimeRound, RuntimeIsEven, RuntimeIsOdd, - RuntimeDateTimeFormat, RuntimeDay, RuntimeMonth, RuntimeYear, @@ -122,6 +121,7 @@ import { RuntimeRandomFloat, RuntimeRandomBool, RuntimeGenerateUUID, + RuntimeIf, } from '@baserow/modules/core/runtimeFormulaTypes' import priorityBus from '@baserow/modules/core/plugins/priorityBus' @@ -282,10 +282,6 @@ export default (context, inject) => { registry.register('runtimeFormulaFunction', new RuntimeRound(context)) registry.register('runtimeFormulaFunction', new RuntimeIsEven(context)) registry.register('runtimeFormulaFunction', new RuntimeIsOdd(context)) - registry.register( - 'runtimeFormulaFunction', - new RuntimeDateTimeFormat(context) - ) registry.register('runtimeFormulaFunction', new RuntimeDay(context)) registry.register('runtimeFormulaFunction', new RuntimeMonth(context)) registry.register('runtimeFormulaFunction', new RuntimeYear(context)) @@ -299,6 +295,7 @@ export default (context, inject) => { registry.register('runtimeFormulaFunction', new RuntimeRandomFloat(context)) registry.register('runtimeFormulaFunction', new RuntimeRandomBool(context)) registry.register('runtimeFormulaFunction', new RuntimeGenerateUUID(context)) + registry.register('runtimeFormulaFunction', new RuntimeIf(context)) registry.register('roles', new AdminRoleType(context)) registry.register('roles', new MemberRoleType(context)) diff --git a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js index 8ffa70f379..17510d45f2 100644 --- a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js +++ b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js @@ -3,6 +3,7 @@ import { ensureNumeric, ensureDateTime, ensureObject, + ensureBoolean, } from '@baserow/modules/core/utils/validator' export class BaserowRuntimeFormulaArgumentType { @@ -51,20 +52,47 @@ export class TextBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormula export class DateTimeBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { test(value) { - return value instanceof Date + if (value instanceof Date) { + return true + } + try { + ensureDateTime(value, { useStrict: false }) + return true + } catch (e) { + return false + } } parse(value) { - return ensureDateTime(value) + return ensureDateTime(value, { useStrict: false }) } } export class ObjectBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { test(value) { - return value instanceof Object + if (value instanceof Object) { + return true + } + + try { + ensureObject(value) + return true + } catch (e) { + return false + } } parse(value) { return ensureObject(value) } } + +export class BooleanBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { + test(value) { + return typeof value === 'boolean' + } + + parse(value) { + return ensureBoolean(value) + } +} diff --git a/web-frontend/modules/core/runtimeFormulaTypes.js b/web-frontend/modules/core/runtimeFormulaTypes.js index 6f9ec790bf..ab0ebefc83 100644 --- a/web-frontend/modules/core/runtimeFormulaTypes.js +++ b/web-frontend/modules/core/runtimeFormulaTypes.js @@ -4,6 +4,7 @@ import { TextBaserowRuntimeFormulaArgumentType, DateTimeBaserowRuntimeFormulaArgumentType, ObjectBaserowRuntimeFormulaArgumentType, + BooleanBaserowRuntimeFormulaArgumentType, } from '@baserow/modules/core/runtimeFormulaArgumentTypes' import { InvalidFormulaArgumentType, @@ -84,7 +85,7 @@ export class RuntimeFormulaFunction extends Registerable { * @returns {boolean} - If the number is correct. */ validateNumberOfArgs(args) { - return this.numArgs === null || args.length <= this.numArgs + return this.numArgs === null || args.length === this.numArgs } /** @@ -913,10 +914,8 @@ export class RuntimeDateTimeFormat extends RuntimeFormulaFunction { } execute(context, args) { - // Backend uses Python's datetime formatting syntax, e.g. `%Y-%m-%d %H:%M:%S` - // but I haven't yet found a way to replicate this in pure JS. Maybe - // we can rely on a 3rd party lib? - return 'TODO' + // TODO see: https://github.com/baserow/baserow/issues/4141 + throw new Error("This formula function hasn't been implemented.") } getDescription() { @@ -925,9 +924,7 @@ export class RuntimeDateTimeFormat extends RuntimeFormulaFunction { } getExamples() { - return [ - "datetime_format('2025-10-16 11:05:38.547989', '%Y-%m-%d', 'Asia/Dubai') = '2025-10-16'", - ] + return ["datetime_format(now(), '%Y-%m-%d', 'Asia/Dubai') = '2025-10-16'"] } } @@ -989,7 +986,8 @@ export class RuntimeMonth extends RuntimeFormulaFunction { } getExamples() { - return ["month('2025-10-16 11:05:38') = '10'"] + // Month is 0 indexed + return ["month('2025-10-16 11:05:38') = '9'"] } } @@ -1082,7 +1080,7 @@ export class RuntimeMinute extends RuntimeFormulaFunction { } getExamples() { - return ["minute('2025-10-16 11:05:38') = '05'"] + return ["minute('2025-10-16T11:05:38') = '05'"] } } @@ -1133,6 +1131,10 @@ export class RuntimeNow extends RuntimeFormulaFunction { execute(context, args) { return new Date() } + + getExamples() { + return ["now() = '2025-10-16 11:05:38'"] + } } export class RuntimeToday extends RuntimeFormulaFunction { @@ -1183,7 +1185,7 @@ export class RuntimeGetProperty extends RuntimeFormulaFunction { } execute(context, args) { - return new args[0][args[1]]() + return args[0][args[1]] } getDescription() { @@ -1192,7 +1194,7 @@ export class RuntimeGetProperty extends RuntimeFormulaFunction { } getExamples() { - return ['get_property(\'{"cherry": "red"}\', "fruit")'] + return ["get_property('{\"cherry\": \"red\"}', 'cherry') = 'red'"] } } @@ -1279,6 +1281,10 @@ export class RuntimeRandomBool extends RuntimeFormulaFunction { return FORMULA_CATEGORY.BOOLEAN } + validateNumberOfArgs(args) { + return args.length === 0 + } + execute(context, args) { return Math.random() < 0.5 } @@ -1306,6 +1312,10 @@ export class RuntimeGenerateUUID extends RuntimeFormulaFunction { return FORMULA_CATEGORY.TEXT } + validateNumberOfArgs(args) { + return args.length === 0 + } + execute(context, args) { return crypto.randomUUID() } @@ -1319,3 +1329,45 @@ export class RuntimeGenerateUUID extends RuntimeFormulaFunction { return ["generate_uuid() = '9b772ad6-08bc-4d19-958d-7f1c21a4f4ef'"] } } + +export class RuntimeIf extends RuntimeFormulaFunction { + static getType() { + return 'if' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.CONDITION + } + + validateNumberOfArgs(args) { + return args.length === 3 + } + + validateTypeOfArgs(args) { + const argType = new BooleanBaserowRuntimeFormulaArgumentType() + if (!argType.test(args[0])) { + return args[0] + } + return null + } + + execute(context, args) { + return args[0] ? args[1] : args[2] + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.ifDescription') + } + + getExamples() { + return [ + 'if(true, true, false)', + "if(random_bool(), 'Random bool is true', 'Random bool is false')", + ] + } +} diff --git a/web-frontend/modules/core/utils/validator.js b/web-frontend/modules/core/utils/validator.js index 135ea061d1..cc474a3151 100644 --- a/web-frontend/modules/core/utils/validator.js +++ b/web-frontend/modules/core/utils/validator.js @@ -226,7 +226,7 @@ export const ensureDate = (value, { allowEmpty = true } = {}) => { */ export const ensureDateTime = ( value, - { allowEmpty = true, format = moment.ISO_8601 } = {} + { allowEmpty = true, format = moment.ISO_8601, useStrict = true } = {} ) => { if (value === null || value === undefined || value === '') { if (!allowEmpty) { @@ -237,7 +237,7 @@ export const ensureDateTime = ( if (value instanceof Date) { return value } else { - const parsed = format ? moment(value, format, true) : moment(value) + const parsed = format ? moment(value, format, useStrict) : moment(value) if (!parsed.isValid()) { throw new TypeError( 'Value is not a valid datetime or convertible to a datetime.' @@ -254,19 +254,27 @@ export const ensureDateTime = ( * @throws {Error} if `value` is not convertable to an object. */ export const ensureObject = (value) => { - if (value !== null && typeof value !== 'object') { + // Return early if it's an object + if (value instanceof Object) { + return value + } + + if (value === null || value === undefined) { throw new TypeError( 'Value is not a valid object or convertible to an object.' ) } - if (value instanceof Object) { - return value - } else { + // If it's a string, try to parse it as JSON + if (typeof value === 'string') { try { return JSON.parse(value) } catch { throw new TypeError('Value is not a valid JSON.') } } + + throw new TypeError( + 'Value is not a valid object or convertible to an object.' + ) }