From 76dc299765e2e41cda8c1b41ff74dbdb88d075e3 Mon Sep 17 00:00:00 2001 From: Cezary Date: Thu, 6 Nov 2025 11:52:11 +0100 Subject: [PATCH 1/4] duration lookup filters (#4140) * duration lookup filters --- backend/src/baserow/contrib/database/apps.py | 4 +- .../django_expressions.py | 39 ++ .../database/formula/types/formula_types.py | 26 ++ .../database/views/array_view_filters.py | 19 +- .../database/view/test_view_array_filters.py | 335 ++++++++++++++++++ ...71_duration_lookup_field_type_filters.json | 8 + .../modules/database/arrayViewFilters.js | 39 +- .../modules/database/formula/formulaTypes.js | 6 +- .../database/arrayViewFiltersMatch.spec.js | 283 +++++++++++++++ 9 files changed, 740 insertions(+), 19 deletions(-) create mode 100644 changelog/entries/unreleased/feature/3471_duration_lookup_field_type_filters.json diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index 1bcf845542..de8df567ed 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -477,6 +477,7 @@ def ready(self): HasNotValueHigherThanFilterType, HasNotValueLowerOrEqualTHanFilterType, HasNotValueLowerThanFilterType, + HasValueComparableToFilter, HasValueContainsViewFilterType, HasValueContainsWordViewFilterType, HasValueEqualViewFilterType, @@ -484,7 +485,6 @@ def ready(self): HasValueLengthIsLowerThanViewFilterType, HasValueLowerOrEqualThanFilter, HasValueLowerThanFilter, - hasValueComparableToFilter, ) view_filter_type_registry.register(HasValueEqualViewFilterType()) @@ -501,7 +501,7 @@ def ready(self): view_filter_type_registry.register(HasNoneSelectOptionEqualViewFilterType()) view_filter_type_registry.register(HasValueLowerThanFilter()) view_filter_type_registry.register(HasValueLowerOrEqualThanFilter()) - view_filter_type_registry.register(hasValueComparableToFilter()) + view_filter_type_registry.register(HasValueComparableToFilter()) view_filter_type_registry.register(HasValueHigherOrEqualThanFilter()) view_filter_type_registry.register(HasNotValueHigherOrEqualTHanFilterType()) view_filter_type_registry.register(HasNotValueHigherThanFilterType()) diff --git a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py index 56f04aa4b3..be06b09917 100644 --- a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py +++ b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py @@ -269,3 +269,42 @@ def get_template_data(self, sql_value) -> dict: data = super().get_template_data(sql_value) data["comparison_op"] = self.comparison_op.value return data + + +class JSONArrayCompareIntervalValueExpr(BaserowFilterExpression): + """ + Base class for expressions that compare an interval value in a JSON array. + Together with the field_name and value, a comparison operator must be provided to be + used in the template. + """ + + def __init__( + self, + field_name: F, + value: Value, + comparison_op: ComparisonOperator, + output_field: Field, + ): + super().__init__(field_name, value, output_field) + if not isinstance(comparison_op, ComparisonOperator): + raise ValueError( + f"comparison_op must be a ComparisonOperator, not {type(comparison_op)}" + ) + self.comparison_op = comparison_op + + # fmt: off + template = ( + f""" + EXISTS( + SELECT 1 + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE (filtered_field ->> 'value')::interval %(comparison_op)s make_interval(secs=>%(value)s) + ) + """ # nosec B608 %(value)s %(comparison_op)s + ) + # fmt: on + + def get_template_data(self, sql_value) -> dict: + data = super().get_template_data(sql_value) + data["comparison_op"] = self.comparison_op.value + return data diff --git a/backend/src/baserow/contrib/database/formula/types/formula_types.py b/backend/src/baserow/contrib/database/formula/types/formula_types.py index 1b378a1fcb..d8237290c1 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_types.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_types.py @@ -60,6 +60,7 @@ ) from baserow.contrib.database.formula.expression_generator.django_expressions import ( ComparisonOperator, + JSONArrayCompareIntervalValueExpr, JSONArrayCompareNumericValueExpr, ) from baserow.contrib.database.formula.registries import formula_function_registry @@ -813,6 +814,31 @@ def get_order_by_in_array_expr(self, field, field_name, order_direction): field_name, "value", "interval", output_field=models.DurationField() ) + def get_has_numeric_value_comparable_to_filter_query( + self, + field_name: str, + value: str, + model_field: models.Field, + field: "Field", + comparison_op: ComparisonOperator, + ) -> "OptionallyAnnotatedQ": + try: + value = int(value) + except (TypeError, ValueError): + return Q() + + return get_array_json_filter_expression( + JSONArrayCompareIntervalValueExpr, + field_name, + Value(value), + comparison_op=comparison_op, + ) + + def get_in_array_is_query(self, field_name, value, model_field, field): + return self.get_has_numeric_value_comparable_to_filter_query( + field_name, value, model_field, field, ComparisonOperator.EQUAL + ) + class BaserowFormulaDateType( HasValueEmptyFilterSupport, HasValueContainsFilterSupport, BaserowFormulaValidType diff --git a/backend/src/baserow/contrib/database/views/array_view_filters.py b/backend/src/baserow/contrib/database/views/array_view_filters.py index 2796c5240d..24b199d943 100644 --- a/backend/src/baserow/contrib/database/views/array_view_filters.py +++ b/backend/src/baserow/contrib/database/views/array_view_filters.py @@ -29,6 +29,7 @@ BaserowFormulaBooleanType, BaserowFormulaCharType, BaserowFormulaDateType, + BaserowFormulaDurationType, BaserowFormulaMultipleSelectType, BaserowFormulaSingleSelectType, BaserowFormulaURLType, @@ -55,6 +56,7 @@ class HasEmptyValueViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaNumberType.type), FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type), FormulaFieldType.array_of(BaserowFormulaMultipleCollaboratorsType.type), + FormulaFieldType.array_of(BaserowFormulaDurationType.type), ), ] @@ -124,6 +126,7 @@ class HasValueEqualViewFilterType(ComparisonHasValueFilter): FormulaFieldType.array_of(BaserowFormulaNumberType.type), FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type), FormulaFieldType.array_of(BaserowFormulaMultipleCollaboratorsType.type), + FormulaFieldType.array_of(BaserowFormulaDurationType.type), ), ] @@ -287,11 +290,12 @@ class HasNoneSelectOptionEqualViewFilterType( type = "has_none_select_option_equal" -class hasValueComparableToFilter(ComparisonHasValueFilter): +class HasValueComparableToFilter(ComparisonHasValueFilter): type = "has_value_higher" compatible_field_types = [ FormulaFieldType.compatible_with_formula_types( - FormulaFieldType.array_of(BaserowFormulaNumberType.type) + FormulaFieldType.array_of(BaserowFormulaNumberType.type), + FormulaFieldType.array_of(BaserowFormulaDurationType.type), ), ] @@ -305,7 +309,7 @@ def get_filter_expression(self, field_name, value, model_field, field): class HasNotValueHigherThanFilterType( - NotViewFilterTypeMixin, hasValueComparableToFilter + NotViewFilterTypeMixin, HasValueComparableToFilter ): type = "has_not_value_higher" @@ -314,7 +318,8 @@ class HasValueHigherOrEqualThanFilter(ComparisonHasValueFilter): type = "has_value_higher_or_equal" compatible_field_types = [ FormulaFieldType.compatible_with_formula_types( - FormulaFieldType.array_of(BaserowFormulaNumberType.type) + FormulaFieldType.array_of(BaserowFormulaNumberType.type), + FormulaFieldType.array_of(BaserowFormulaDurationType.type), ), ] @@ -341,7 +346,8 @@ class HasValueLowerThanFilter(ComparisonHasValueFilter): type = "has_value_lower" compatible_field_types = [ FormulaFieldType.compatible_with_formula_types( - FormulaFieldType.array_of(BaserowFormulaNumberType.type) + FormulaFieldType.array_of(BaserowFormulaNumberType.type), + FormulaFieldType.array_of(BaserowFormulaDurationType.type), ), ] @@ -362,7 +368,8 @@ class HasValueLowerOrEqualThanFilter(ComparisonHasValueFilter): type = "has_value_lower_or_equal" compatible_field_types = [ FormulaFieldType.compatible_with_formula_types( - FormulaFieldType.array_of(BaserowFormulaNumberType.type) + FormulaFieldType.array_of(BaserowFormulaNumberType.type), + FormulaFieldType.array_of(BaserowFormulaDurationType.type), ), ] diff --git a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py index 629d3dc546..4e0eb400c3 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py +++ b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py @@ -6,6 +6,7 @@ from pytest_unordered import unordered from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.fields.utils.duration import parse_duration_value from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.table.handler import TableHandler from baserow.contrib.database.views.array_view_filters import ( @@ -66,6 +67,14 @@ from baserow.test_utils.fixtures import Fixtures +def duration_value(val: str, format: str = "d h") -> str: + """ + Parses symbolic duration value to a proper number of seconds for the test. + """ + + return str(int(parse_duration_value(val, format))) + + class BooleanLookupRow(int, Enum): """ Helper enum for boolean lookup field filters tests. @@ -2681,6 +2690,90 @@ def setup_multiple_select_rows(data_fixture): return test_setup, [row_1, row_2, row_3], [*row_A_value, *row_B_value] +def setup_duration_lookup_fields(data_fixture): + test_setup = setup_linked_table_and_lookup(data_fixture, duration_field_factory) + + # tables layout: + # referenced B: + # * row B1 [no value] + # * row B2 [1h value] + # * row B3 [1d value] + # * row B4 [1d 6h value] + # + # table A: + # * A1 - no values [] + # * A2 - empty value [B1] + # * A3 - 1h value [B2] + # * A4 - 1h, 1d 6h value [B2, B4] + # * A5 - 1h, 1d [B2, B3] + # * A6 - 1d 6h [B4] + # * A7 - 1h + empty [B2, B1] + + user = test_setup.user + user.first_name = "derp" + user.save() + table = test_setup.table + other_table = test_setup.other_table + database = test_setup.table.database + workspace = database.workspace + chandler = CoreHandler() + thandler = TableHandler() + fhandler = FieldHandler() + rhandler = RowHandler() + + pk_A = data_fixture.create_text_field( + table=table, user=user, name="pk", primary=True + ) + pk_B = data_fixture.create_text_field( + table=other_table, user=user, name="pk", primary=True + ) + pk_b_name = pk_B.db_column + pk_a_name = pk_A.db_column + dur_b_name = test_setup.target_field.db_column + lookup_name = test_setup.link_row_field.db_column + + related_table_rows = [ + {pk_b_name: "B1", dur_b_name: None}, + {pk_b_name: "B2", dur_b_name: "1h"}, + {pk_b_name: "B3", dur_b_name: "1d"}, + {pk_b_name: "B4", dur_b_name: "1d 6h"}, + ] + + related_rows = rhandler.force_create_rows( + user, + table=other_table, + rows_values=related_table_rows, + send_webhook_events=False, + send_realtime_update=False, + ) + + related_rows = {getattr(r, pk_b_name): r for r in related_rows.created_rows} + + def rel(row_name): + return related_rows[row_name].id + + table_rows = [ + {pk_a_name: "A1", lookup_name: []}, + {pk_a_name: "A2", lookup_name: [rel("B1")]}, + {pk_a_name: "A3", lookup_name: [rel("B2")]}, + {pk_a_name: "A4", lookup_name: [rel("B2"), rel("B4")]}, + {pk_a_name: "A5", lookup_name: [rel("B2"), rel("B3")]}, + {pk_a_name: "A6", lookup_name: [rel("B4")]}, + {pk_a_name: "A7", lookup_name: [rel("B2"), rel("B1")]}, + ] + created_rows = rhandler.force_create_rows( + user=user, + table=table, + rows_values=table_rows, + send_webhook_events=False, + send_realtime_update=False, + ) + + created_rows = {getattr(r, pk_a_name): r for r in created_rows.created_rows} + + return test_setup, created_rows, related_rows + + @pytest.mark.django_db @pytest.mark.field_multiple_collaborators def test_has_or_has_not_empty_value_filter_multiple_collaborators_lookup_type( @@ -2825,6 +2918,248 @@ def test_has_or_doesnt_have_value_contains_word_filter_multiple_collaborators_lo assert ids == [created_rows[row_id].id for row_id in ["A", "C", "D"]] +@pytest.mark.django_db +@pytest.mark.field_duration +def test_has_or_has_not_empty_value_filter_duration_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_duration_lookup_fields(data_fixture) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_empty_value", + value="", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert [created_rows["A2"].id, created_rows["A7"].id] == ids + + view_filter.type = "has_not_empty_value" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert ids == [created_rows["A1"].id] + [ + created_rows[f"A{row_name}"].id for row_name in range(3, 7) + ] + + +@pytest.mark.django_db +@pytest.mark.field_duration +def test_has_or_has_not_value_higher_than_filter_duration_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_duration_lookup_fields(data_fixture) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_higher", + value=duration_value("1d"), + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert [ + created_rows["A4"].id, + created_rows["A6"].id, + ] == ids + + view_filter.type = "has_not_value_higher" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + # A1,A2,A3,A5,A6,A7 + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name not in {4, 6} + ] + + +@pytest.mark.django_db +@pytest.mark.field_duration +def test_has_or_has_not_value_higher_or_equal_than_filter_duration_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_duration_lookup_fields(data_fixture) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_higher_or_equal", + value=duration_value("1d"), + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert [ + created_rows["A4"].id, + created_rows["A5"].id, + created_rows["A6"].id, + ] == ids + + view_filter.type = "has_not_value_higher_or_equal" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name not in {4, 5, 6} + ] + + +@pytest.mark.django_db +@pytest.mark.field_duration +def test_has_or_has_not_value_lower_or_equal_than_filter_duration_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_duration_lookup_fields(data_fixture) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_lower_or_equal", + value=duration_value("1d"), + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name not in {1, 2, 6} + ] + + view_filter.type = "has_not_value_lower_or_equal" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name in {1, 2, 6} + ] + + +@pytest.mark.django_db +@pytest.mark.field_duration +def test_has_or_has_not_value_lower_than_filter_duration_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_duration_lookup_fields(data_fixture) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_lower", + value=duration_value("1d"), + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name not in {1, 2, 6} + ] + + view_filter.type = "has_not_value_lower" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name in {1, 2, 6} + ] + + +@pytest.mark.django_db +@pytest.mark.field_duration +def test_has_or_has_not_value_equal_duration_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_duration_lookup_fields(data_fixture) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_equal", + value=duration_value("1d"), + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + + assert ids == [ + created_rows[f"A{row_name}"].id for row_name in range(1, 8) if row_name in {5} + ] + + view_filter.type = "has_not_value_equal" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + + assert ids == [ + created_rows[f"A{row_name}"].id + for row_name in range(1, 8) + if row_name not in {5} + ] + + @pytest.mark.django_db @pytest.mark.field_multiple_select def test_has_or_has_not_empty_value_filter_multiple_select_field_types( diff --git a/changelog/entries/unreleased/feature/3471_duration_lookup_field_type_filters.json b/changelog/entries/unreleased/feature/3471_duration_lookup_field_type_filters.json new file mode 100644 index 0000000000..08aa2750f4 --- /dev/null +++ b/changelog/entries/unreleased/feature/3471_duration_lookup_field_type_filters.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Duration lookup field type filters", + "domain": "database", + "issue_number": 3471, + "bullet_points": [], + "created_at": "2025-10-29" +} \ No newline at end of file diff --git a/web-frontend/modules/database/arrayViewFilters.js b/web-frontend/modules/database/arrayViewFilters.js index dd205e8e95..e4216bbd94 100644 --- a/web-frontend/modules/database/arrayViewFilters.js +++ b/web-frontend/modules/database/arrayViewFilters.js @@ -8,7 +8,10 @@ import { } from '@baserow/modules/database/viewFilters' import viewFilterTypeText from '@baserow/modules/database/components/view/ViewFilterTypeText.vue' import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions' -import { BaserowFormulaNumberType } from '@baserow/modules/database/formula/formulaTypes' +import { + BaserowFormulaDurationType, + BaserowFormulaNumberType, +} from '@baserow/modules/database/formula/formulaTypes' import { ComparisonOperator } from '@baserow/modules/database//utils/fieldFilters' import { mix } from '@baserow/modules/core/mixins' @@ -25,6 +28,7 @@ const HasEmptyValueViewFilterTypeMixin = { FormulaFieldType.compatibleWithFormulaTypes( 'array(multiple_collaborators)' ), + FormulaFieldType.compatibleWithFormulaTypes('array(duration)'), ] }, } @@ -86,7 +90,8 @@ const HasValueEqualViewFilterTypeMixin = { FormulaFieldType.arrayOf('number'), FormulaFieldType.arrayOf('single_select'), FormulaFieldType.arrayOf('multiple_select'), - FormulaFieldType.arrayOf('multiple_collaborators') + FormulaFieldType.arrayOf('multiple_collaborators'), + FormulaFieldType.arrayOf('duration') ), ] }, @@ -372,13 +377,15 @@ export class HasValueHigherThanViewFilterType extends ViewFilterType { } getInputComponent(field) { - return ViewFilterTypeNumber + const fieldType = this.app.$registry.get('field', field.type) + return fieldType.getFilterInputComponent(field, this) || viewFilterTypeText } getCompatibleFieldTypes() { return [ FormulaFieldType.compatibleWithFormulaTypes( - FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()) + FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()), + FormulaFieldType.arrayOf(BaserowFormulaDurationType.getType()) ), ] } @@ -432,13 +439,17 @@ export class HasValueHigherThanOrEqualViewFilterType extends ViewFilterType { } getInputComponent(field) { - return ViewFilterTypeNumber + const fieldType = this.app.$registry.get('field', field.type) + return ( + fieldType.getFilterInputComponent(field, this) || ViewFilterTypeNumber + ) } getCompatibleFieldTypes() { return [ FormulaFieldType.compatibleWithFormulaTypes( - FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()) + FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()), + FormulaFieldType.arrayOf(BaserowFormulaDurationType.getType()) ), ] } @@ -492,13 +503,17 @@ export class HasValueLowerThanViewFilterType extends ViewFilterType { } getInputComponent(field) { - return ViewFilterTypeNumber + const fieldType = this.app.$registry.get('field', field.type) + return ( + fieldType.getFilterInputComponent(field, this) || ViewFilterTypeNumber + ) } getCompatibleFieldTypes() { return [ FormulaFieldType.compatibleWithFormulaTypes( - FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()) + FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()), + FormulaFieldType.arrayOf(BaserowFormulaDurationType.getType()) ), ] } @@ -552,13 +567,17 @@ export class HasValueLowerThanOrEqualViewFilterType extends ViewFilterType { } getInputComponent(field) { - return ViewFilterTypeNumber + const fieldType = this.app.$registry.get('field', field.type) + return ( + fieldType.getFilterInputComponent(field, this) || ViewFilterTypeNumber + ) } getCompatibleFieldTypes() { return [ FormulaFieldType.compatibleWithFormulaTypes( - FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()) + FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType()), + FormulaFieldType.arrayOf(BaserowFormulaDurationType.getType()) ), ] } diff --git a/web-frontend/modules/database/formula/formulaTypes.js b/web-frontend/modules/database/formula/formulaTypes.js index 86be021713..ce1684c843 100644 --- a/web-frontend/modules/database/formula/formulaTypes.js +++ b/web-frontend/modules/database/formula/formulaTypes.js @@ -524,7 +524,11 @@ export class BaserowFormulaDateType extends mix( } } -export class BaserowFormulaDurationType extends BaserowFormulaTypeDefinition { +export class BaserowFormulaDurationType extends mix( + hasEmptyValueFilterMixin, + hasNumericValueComparableToFilterMixin, + BaserowFormulaTypeDefinition +) { static getType() { return 'duration' } diff --git a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js index 45006b791e..4f96320724 100644 --- a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js +++ b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js @@ -25,6 +25,8 @@ import { HasDateOnOrAfterViewFilterType, HasNotDateOnOrAfterViewFilterType, HasDateWithinViewFilterType, + HasValueLowerThanViewFilterType, + HasNotValueLowerThanViewFilterType, } from '@baserow/modules/database/arrayViewFilters' import { FormulaFieldType, @@ -2497,3 +2499,284 @@ describe('Duration view filters', () => { expect(result).toBe(!testValues.expected) }) }) + +describe('Duration lookup filters', () => { + let testApp = null + let fieldType = null + const field = { + type: 'lookup', + formula_type: 'array', + array_formula_type: 'duration', + } + + beforeAll(() => { + testApp = new TestApp() + fieldType = new FormulaFieldType({ app: testApp.getApp() }) + }) + + afterEach(() => { + testApp.afterEach() + }) + + const DurationLookupFilterCases = [ + { + rowValue: [], + filterValue: { + hasEmptyValue: '', + hasValueEqual: '86400', + hasValueLower: '86400', + hasValueLowerOrEqual: '86400', + hasValueHigher: '86400', + hasValueHigherOrEqual: '86400', + }, + + expected: { + hasEmptyValue: false, + hasValueEqual: false, + hasValueLower: false, + hasValueLowerOrEqual: false, + hasValueHigher: false, + hasValueHigherOrEqual: false, + }, + }, + { + rowValue: [{ id: 1, value: [] }], + filterValue: { + hasEmptyValue: '', + hasValueEqual: '86400', + hasValueLower: '86400', + hasValueLowerOrEqual: '86400', + hasValueHigher: '86400', + hasValueHigherOrEqual: '86400', + }, + + expected: { + hasEmptyValue: true, + hasValueEqual: false, + hasValueLower: false, + hasValueLowerOrEqual: false, + hasValueHigher: false, + hasValueHigherOrEqual: false, + }, + }, + + { + rowValue: [{ id: 1, value: 100 }], + filterValue: { + hasEmptyValue: '', + hasValueEqual: '200', + hasValueLower: '200', + hasValueLowerOrEqual: '200', + hasValueHigher: '200', + hasValueHigherOrEqual: '200', + }, + + expected: { + hasEmptyValue: false, + hasValueEqual: false, + hasValueLower: true, + hasValueLowerOrEqual: true, + hasValueHigher: false, + hasValueHigherOrEqual: false, + }, + }, + + { + rowValue: [ + { id: 1, value: 100 }, + { id: 2, value: 200 }, + ], + filterValue: { + hasEmptyValue: '', + hasValueEqual: '200', + hasValueLower: '200', + hasValueLowerOrEqual: '200', + hasValueHigher: '200', + hasValueHigherOrEqual: '200', + }, + + expected: { + hasEmptyValue: false, + hasValueEqual: true, + hasValueLower: true, + hasValueLowerOrEqual: true, + hasValueHigher: false, + hasValueHigherOrEqual: true, + }, + }, + + { + rowValue: [ + { id: 1, value: 100 }, + { id: 2, value: 200 }, + ], + filterValue: { + hasEmptyValue: '', + hasValueEqual: '101', + hasValueLower: '101', + hasValueLowerOrEqual: '101', + hasValueHigher: '101', + hasValueHigherOrEqual: '101', + }, + + expected: { + hasEmptyValue: false, + hasValueEqual: false, + hasValueLower: true, + hasValueLowerOrEqual: false, + hasValueHigher: true, + hasValueHigherOrEqual: true, + }, + }, + ] + + test.each(DurationLookupFilterCases)( + 'duration lookup has empty value %j.', + (values) => { + const result = new HasEmptyValueViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasEmptyValue, + field, + fieldType + ) + expect(result).toBe(values.expected.hasEmptyValue) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has not empty value %j.', + (values) => { + const result = new HasNotEmptyValueViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasEmptyValue, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasEmptyValue) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has value equal %j.', + (values) => { + const result = new HasValueEqualViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueEqual, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueEqual) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has not value equal %j.', + (values) => { + const result = new HasNotValueEqualViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueEqual, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueEqual) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has value lower %j.', + (values) => { + const result = new HasValueLowerThanViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueLower, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueLower) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has not value lower %j.', + (values) => { + const result = new HasNotValueLowerThanViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueLower, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueLower) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has value higher %j.', + (values) => { + const result = new HasValueHigherThanViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueHigher, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueHigher) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has not value higher %j.', + (values) => { + const result = new HasNotValueHigherThanViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueHigher, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueHigher) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has value higher or equal %j.', + (values) => { + const result = new HasValueHigherThanOrEqualViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueHigherOrEqual, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueHigherOrEqual) + } + ) + + test.each(DurationLookupFilterCases)( + 'duration lookup has not value higher or equal %j.', + (values) => { + const result = new HasNotValueHigherThanOrEqualViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueHigherOrEqual, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueHigherOrEqual) + } + ) +}) From 8b10d99312f5f749685a9c4908b1f7fa3167a4fd Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 6 Nov 2025 11:52:22 +0100 Subject: [PATCH 2/4] Fix critical dependency vulnerabilities (#4130) --- web-frontend/yarn.lock | 164 +++++++++++++++++++++++++++++++---------- 1 file changed, 127 insertions(+), 37 deletions(-) diff --git a/web-frontend/yarn.lock b/web-frontend/yarn.lock index 0b81a65c0f..a629dc57d1 100644 --- a/web-frontend/yarn.lock +++ b/web-frontend/yarn.lock @@ -6249,20 +6249,20 @@ source-map "0.5.6" tsconfig "^7.0.0" -"@vue2-flow/background@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@vue2-flow/background/-/background-7.0.0.tgz#f7213d93f9c56d0a9278545a7d3e71cc5fc89b87" - integrity sha512-Heco/nTpG9Cn0h7cSTeWH2CeabJ9oXAIirw/5YNXDGNC6PAEKhDzy2iZ7g8zlwNinkMbxrlXsoaK3fsdaXtcNw== +"@vue2-flow/background@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@vue2-flow/background/-/background-8.0.0.tgz#39d656281bd052067758fbe813d48fceb6b289bd" + integrity sha512-gEEz4iGKSrkuhZRVy67QsDLEiK1n+3wGnfC6+wYefs1Ylc9lAqKAWkq58IHKmxZ2bVt369bQgliiXBqLyLFUbA== -"@vue2-flow/controls@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@vue2-flow/controls/-/controls-7.0.0.tgz#eb20a32c03263eb3cda2178c117efd7d60ce937d" - integrity sha512-EBAh0m6hXQCyl+y63iT1Yu9RlIH+onvBWxVLa5gEjFrLyeJ8P7tRCAsvJ41a+cBWF7B5KSJmtBwv5wga1dNf2w== +"@vue2-flow/controls@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@vue2-flow/controls/-/controls-8.0.0.tgz#cce7bbef8a6e3f78d03b3262301679babb11188f" + integrity sha512-lGdzoh/+xalUttrvHNnILjaankGnHoGfTdZdhLegAvUN32AwIvJRxlBjE46JqimHKmNXdoOAFUd0Z0g1/YgBmA== -"@vue2-flow/core@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@vue2-flow/core/-/core-0.7.0.tgz#3ace46c38db13107e4337af192d81152fd21824b" - integrity sha512-/6oBvvTgwVBLQ3aXTQyENwvd/L1pl3IbDHIJHruzv1t/frt5C5fvuUVuANEmwzPO/1MynEAzXJ7OEqQXLHKrWg== +"@vue2-flow/core@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@vue2-flow/core/-/core-0.8.0.tgz#fdf4012482f7326e55307c3864968f99c5a368db" + integrity sha512-Rz3Jg5fz8J9M0TiKtaS8J4drylLYh36r8oWRHETfv9Mo2KwfLCwhe8muHPvlWER5ignROgej2GamIukTYhQ1JA== dependencies: "@vueuse/core" "^10.5.0" d3-drag "^3.0.0" @@ -7842,7 +7842,7 @@ caching-transform@^4.0.0: package-hash "^4.0.0" write-file-atomic "^3.0.0" -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== @@ -7870,6 +7870,24 @@ call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + call-me-maybe@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" @@ -8127,12 +8145,12 @@ ci-info@^4.2.0: integrity sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" - integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + version "1.0.5" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.5.tgz#749f80731c7821e9a5fabd51f6998b696f296686" + integrity sha512-xq7ICKB4TMHUx7Tz1L9O2SGKOhYMOTR32oir45Bq28/AQTpHogKgHcoYFSdRbMtddl+ozNXfXY9jWcgYKmde0w== dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" + inherits "^2.0.4" + safe-buffer "^5.2.1" cjs-module-lexer@^1.0.0: version "1.2.3" @@ -8652,7 +8670,7 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.5.3" -create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: +create-hash@^1.1.0, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== @@ -8663,7 +8681,17 @@ create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: ripemd160 "^2.0.1" sha.js "^2.4.0" -create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: +create-hash@~1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd" + integrity sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + ripemd160 "^2.0.0" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== @@ -10998,6 +11026,13 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -11304,7 +11339,7 @@ get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.2.6: +get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -11763,6 +11798,13 @@ has@^1.0.3: resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== +hash-base@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1" + integrity sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw== + dependencies: + inherits "^2.0.1" + hash-base@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" @@ -12734,6 +12776,13 @@ is-typed-array@^1.1.13: dependencies: which-typed-array "^1.1.14" +is-typed-array@^1.1.14: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -16222,16 +16271,17 @@ pathe@^2.0.1: resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -pbkdf2@^3.0.3: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" - integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== +pbkdf2@3.1.3, pbkdf2@^3.0.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.3.tgz#8be674d591d65658113424592a95d1517318dd4b" + integrity sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA== dependencies: - create-hash "^1.1.2" - create-hmac "^1.1.4" - ripemd160 "^2.0.1" - safe-buffer "^5.0.1" - sha.js "^2.4.8" + create-hash "~1.1.3" + create-hmac "^1.1.7" + ripemd160 "=2.0.1" + safe-buffer "^5.2.1" + sha.js "^2.4.11" + to-buffer "^1.2.0" picocolors@^0.2.1: version "0.2.1" @@ -18496,6 +18546,14 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" +ripemd160@=2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" + integrity sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w== + dependencies: + hash-base "^2.0.0" + inherits "^2.0.1" + ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" @@ -18881,7 +18939,7 @@ set-function-length@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" -set-function-length@^1.2.1: +set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -18922,13 +18980,14 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== +sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8: + version "2.4.12" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" + integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.0" shallow-clone@^3.0.0: version "3.0.1" @@ -20188,6 +20247,15 @@ to-arraybuffer@^1.0.0: resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" integrity sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA== +to-buffer@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.2.tgz#ffe59ef7522ada0a2d1cb5dfe03bb8abc3cdc133" + integrity sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw== + dependencies: + isarray "^2.0.5" + safe-buffer "^5.2.1" + typed-array-buffer "^1.0.3" + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -20453,6 +20521,15 @@ typed-array-buffer@^1.0.2: es-errors "^1.3.0" is-typed-array "^1.1.13" +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + typed-array-byte-length@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz#d787a24a995711611fb2b87a4052799517b230d0" @@ -21523,6 +21600,19 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15: gopd "^1.0.1" has-tostringtag "^1.0.2" +which-typed-array@^1.1.16: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^1.2.12, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" From c057d763676063ab66b38e77c28af5c3d7e4f1cd Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 6 Nov 2025 11:52:53 +0100 Subject: [PATCH 3/4] Show workspace settings modal if clicked on deactivated AI field (#4138) --- .../workspace_modal_deactivated_ai_field.json | 8 +++++ .../modules/baserow_premium/fieldTypes.js | 9 ++++++ .../modules/baserow_premium/locales/en.json | 2 +- .../database/components/field/FieldForm.vue | 31 +++++++++++++++++-- web-frontend/modules/database/fieldTypes.js | 15 +++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 changelog/entries/unreleased/feature/workspace_modal_deactivated_ai_field.json diff --git a/changelog/entries/unreleased/feature/workspace_modal_deactivated_ai_field.json b/changelog/entries/unreleased/feature/workspace_modal_deactivated_ai_field.json new file mode 100644 index 0000000000..3ee1906a17 --- /dev/null +++ b/changelog/entries/unreleased/feature/workspace_modal_deactivated_ai_field.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Show workspace settings modal if clicked on deactivated AI field.", + "domain": "database", + "issue_number": null, + "bullet_points": [], + "created_at": "2025-10-29" +} diff --git a/premium/web-frontend/modules/baserow_premium/fieldTypes.js b/premium/web-frontend/modules/baserow_premium/fieldTypes.js index a2eff7dc81..4df464c89a 100644 --- a/premium/web-frontend/modules/baserow_premium/fieldTypes.js +++ b/premium/web-frontend/modules/baserow_premium/fieldTypes.js @@ -13,6 +13,7 @@ import PremiumFeatures from '@baserow_premium/features' import PaidFeaturesModal from '@baserow_premium/components/PaidFeaturesModal' import { AIPaidFeature } from '@baserow_premium/paidFeatures' import _ from 'lodash' +import WorkspaceSettingsModal from '@baserow/modules/core/components/workspace/WorkspaceSettingsModal.vue' export class AIFieldType extends FieldType { static getType() { @@ -204,6 +205,14 @@ export class AIFieldType extends FieldType { ) } + getDisabledClickModal(workspace) { + return [WorkspaceSettingsModal, { workspace }] + } + + getDisabledTooltip() { + return 'Click to configure API key' + } + isDeactivated(workspaceId) { return !this.app.$hasFeature(PremiumFeatures.PREMIUM, workspaceId) } diff --git a/premium/web-frontend/modules/baserow_premium/locales/en.json b/premium/web-frontend/modules/baserow_premium/locales/en.json index f6659b5f31..b5c393e873 100644 --- a/premium/web-frontend/modules/baserow_premium/locales/en.json +++ b/premium/web-frontend/modules/baserow_premium/locales/en.json @@ -350,7 +350,7 @@ "label": "Prompt", "labelDescription": "Describe the formula you would like to generate", "generate": "Generate", - "noModels": "Your Baserow instance and workspace doesn't have any AI models configured. Click on the three dots next to your workspace, then on settings to configure them." + "noModels": "Your Baserow instance and workspace doesn't have any AI models configured. Navigate to the homepage, click on the name of your workspace, then on settings to configure them." }, "formulaFieldAI": { "generateWithAI": "Generate using AI", diff --git a/web-frontend/modules/database/components/field/FieldForm.vue b/web-frontend/modules/database/components/field/FieldForm.vue index 14a0c5cdb0..6e21db0356 100644 --- a/web-frontend/modules/database/components/field/FieldForm.vue +++ b/web-frontend/modules/database/components/field/FieldForm.vue @@ -40,6 +40,13 @@ + @@ -394,9 +419,11 @@ export default { this.$emit('keydown-enter') this.submit() }, - clickOnDeactivatedItem(event, fieldType) { + clickOnItem(event, fieldType) { if (fieldType.isDeactivated(this.workspace.id)) { this.$refs[`deactivatedClickModal-${fieldType.type}`][0].show() + } else if (!fieldType.isEnabled(this.workspace)) { + this.$refs[`disabledClickModal-${fieldType.type}`][0].show() } }, /** diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index 992e5593ed..ac5b263b45 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -964,6 +964,21 @@ export class FieldType extends Registerable { return true } + /** + * Can return a modal vue component that's opened when the user clicks in this + * field type, but `isEnabled` is false. Should return [component, props as {}] + */ + getDisabledClickModal(workspace) { + return null + } + + /** + * Can return a tooltip text that's shown on hover when the `isEnabled` is False. + */ + getDisabledTooltip(workspace) { + return null + } + /** * Indicates whether the field is visible, but in a deactivated state. */ From 14e029feb4b233fcda8b23135b85fdce6a6c42cd Mon Sep 17 00:00:00 2001 From: dimmur-brw Date: Thu, 6 Nov 2025 12:17:24 +0100 Subject: [PATCH 4/4] Fix CSV import throwing 'no fetchall attribute' error and respect primary field order (#4165) Fix CSV import throwing 'no fetchall attribute' error and respect primary field order --- .../baserow/contrib/database/rows/handler.py | 4 +- .../contrib/database/table/queryset.py | 2 + .../database/rows/test_rows_actions.py | 58 +++++++++++++++++++ ...tchall_attribute_error_and_respect_pr.json | 8 +++ 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 changelog/entries/unreleased/bug/4163_fix_csv_import_throwing_no_fetchall_attribute_error_and_respect_pr.json diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py index 7b331e8aee..128aadbc33 100644 --- a/backend/src/baserow/contrib/database/rows/handler.py +++ b/backend/src/baserow/contrib/database/rows/handler.py @@ -1808,8 +1808,8 @@ def import_rows( and not field_object["field"].read_only ] - # Sort by order then by id - fields.sort(key=lambda f: (f.order, f.id)) + # Sort by primary first (descending), then by order, then by id + fields.sort(key=lambda f: (not f.primary, f.order, f.id)) for index, row in enumerate(data): # Check row length diff --git a/backend/src/baserow/contrib/database/table/queryset.py b/backend/src/baserow/contrib/database/table/queryset.py index 723350d2fa..8ebaa4428b 100644 --- a/backend/src/baserow/contrib/database/table/queryset.py +++ b/backend/src/baserow/contrib/database/table/queryset.py @@ -22,6 +22,8 @@ def _as_sql(): def execute_sql(self, result_type): cursor = super(SQLUpdateCompiler, self).execute_sql(result_type) + if cursor is None: + return [] return [res[0] for res in cursor.fetchall()] diff --git a/backend/tests/baserow/contrib/database/rows/test_rows_actions.py b/backend/tests/baserow/contrib/database/rows/test_rows_actions.py index 79cb287319..206f2ec20c 100644 --- a/backend/tests/baserow/contrib/database/rows/test_rows_actions.py +++ b/backend/tests/baserow/contrib/database/rows/test_rows_actions.py @@ -1313,3 +1313,61 @@ def test_can_undo_redo_update_rows_interesting_field_types(data_fixture): ) ) == [multi_select_option_2.id] assert getattr(row_table_1, f"field_{formula_field.id}") == "New value" + + +@pytest.mark.django_db(transaction=True) +def test_import_rows_respects_primary_priority_sorting(data_fixture): + user = data_fixture.create_user() + database = data_fixture.create_database_application( + user=user, name="Sample database" + ) + table = data_fixture.create_database_table(database=database, name="Sample table") + + single_select = data_fixture.create_single_select_field( + table=table, name="Single select", order=4, primary=False + ) + data_fixture.create_select_option( + field=single_select, value="A", color="dark-green", order=0 + ) + data_fixture.create_select_option( + field=single_select, value="B", color="light-blue", order=1 + ) + + name_field = data_fixture.create_text_field( + table=table, name="Name", order=5, primary=True + ) + + text_field = data_fixture.create_text_field( + table=table, name="Text", order=8, primary=False + ) + + data_fixture.create_formula_field( + table=table, + name="Formula", + order=58, + formula="field('Text')", + formula_type="text", + internal_formula="field('Text')", + nullable=True, + recalculate=True, + ) + + data = [["N", "A", "text"]] + + created_rows, error_report = ImportRowsActionType.do( + user, + table, + data={"data": data, "configuration": None}, + progress=None, + ) + + assert error_report == {} + assert len(created_rows) == 1 + + row = created_rows[0] + model = table.get_model() + stored = model.objects.get(id=row.id) + + assert getattr(stored, name_field.db_column) == "N" + assert getattr(stored, text_field.db_column) == "text" + assert getattr(stored, single_select.db_column).value == "A" diff --git a/changelog/entries/unreleased/bug/4163_fix_csv_import_throwing_no_fetchall_attribute_error_and_respect_pr.json b/changelog/entries/unreleased/bug/4163_fix_csv_import_throwing_no_fetchall_attribute_error_and_respect_pr.json new file mode 100644 index 0000000000..e521d1ebbb --- /dev/null +++ b/changelog/entries/unreleased/bug/4163_fix_csv_import_throwing_no_fetchall_attribute_error_and_respect_pr.json @@ -0,0 +1,8 @@ +{ + "type": "bug", + "message": "Fix CSV import throwing 'no fetchall attribute' error and respect primary field order", + "domain": "database", + "issue_number": 4163, + "bullet_points": [], + "created_at": "2025-11-05" +} \ No newline at end of file