diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index ff65e9cf0e..c412c61a66 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -401,6 +401,7 @@ def ready(self): SingleSelectIsAnyOfViewFilterType, SingleSelectIsNoneOfViewFilterType, SingleSelectNotEqualViewFilterType, + StartsWithViewFilterType, UserIsNotViewFilterType, UserIsViewFilterType, ) @@ -414,6 +415,7 @@ def ready(self): view_filter_type_registry.register(ContainsNotViewFilterType()) view_filter_type_registry.register(ContainsWordViewFilterType()) view_filter_type_registry.register(DoesntContainWordViewFilterType()) + view_filter_type_registry.register(StartsWithViewFilterType()) view_filter_type_registry.register(LengthIsLowerThanViewFilterType()) view_filter_type_registry.register(HigherThanViewFilterType()) view_filter_type_registry.register(HigherThanOrEqualViewFilterType()) diff --git a/backend/src/baserow/contrib/database/fields/field_filters.py b/backend/src/baserow/contrib/database/fields/field_filters.py index 4da51f10df..393e3fe7fc 100644 --- a/backend/src/baserow/contrib/database/fields/field_filters.py +++ b/backend/src/baserow/contrib/database/fields/field_filters.py @@ -207,6 +207,18 @@ def contains_filter( return Q(**{f"{field_name}__icontains": value}) +def starts_with_filter( + field_name, value, model_field, _, validate=True +) -> OptionallyAnnotatedQ: + value = value.strip() + # If an empty value has been provided we do not want to filter at all. + if value == "": + return Q() + if validate: + model_field.get_prep_value(value) + return Q(**{f"{field_name}__istartswith": value}) + + def contains_word_filter(field_name, value, model_field, _) -> OptionallyAnnotatedQ: value = value.strip() # If an empty value has been provided we do not want to filter at all. diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 3c81b3c9d2..621992fdeb 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -202,6 +202,7 @@ contains_word_filter, filename_contains_filter, parse_ids_from_csv_string, + starts_with_filter, ) from .field_helpers import prepare_files_for_export from .field_sortings import OptionallyAnnotatedOrderBy @@ -378,6 +379,9 @@ def get_alter_column_prepare_new_value(self, connection, from_field, to_field): def contains_query(self, *args): return contains_filter(*args) + def starts_with_query(self, *args): + return starts_with_filter(*args) + def contains_word_query(self, *args): return contains_word_filter(*args) @@ -474,6 +478,9 @@ def random_value(self, instance, fake, cache): def contains_query(self, *args): return contains_filter(*args) + def starts_with_query(self, *args): + return starts_with_filter(*args) + def contains_word_query(self, *args): return contains_word_filter(*args) @@ -544,6 +551,9 @@ def random_value(self, instance, fake, cache): def contains_query(self, *args): return contains_filter(*args) + def starts_with_query(self, *args): + return starts_with_filter(*args) + def contains_word_query(self, *args): return contains_word_filter(*args) @@ -810,6 +820,9 @@ def force_same_type_alter_column(self, from_field, to_field): def contains_query(self, *args): return contains_filter(*args) + def starts_with_query(self, *args): + return starts_with_filter(*args) + def get_export_serialized_value(self, row, field_name, cache, files_zip, storage): value = self.get_internal_value_from_db(row, field_name) return value if value is None else str(value) @@ -4776,6 +4789,13 @@ def contains_query(self, field_name, value, model_field, field): return Q() return Q(**{f"{field_name}__value__icontains": value}) + def starts_with_query(self, field_name, value, model_field, field): + value = value.strip() + # If an empty value has been provided we do not want to filter at all. + if value == "": + return Q() + return Q(**{f"{field_name}__value__istartswith": value}) + def contains_word_query(self, field_name, value, model_field, field): value = value.strip() # If an empty value has been provided we do not want to filter at all. @@ -5700,6 +5720,15 @@ def contains_query(self, field_name, value, model_field, field: FormulaField): ) = self.get_field_instance_and_type_from_formula_field(field) return field_type.contains_query(field_name, value, model_field, field_instance) + def starts_with_query(self, field_name, value, model_field, field: FormulaField): + ( + field_instance, + field_type, + ) = self.get_field_instance_and_type_from_formula_field(field) + return field_type.starts_with_query( + field_name, value, model_field, field_instance + ) + def contains_word_query(self, field_name, value, model_field, field): ( field_instance, @@ -7523,6 +7552,9 @@ def prepare_value_for_db(self, instance: Field, value): def contains_query(self, *args): return contains_filter(*args) + def starts_with_query(self, *args): + return starts_with_filter(*args) + def update_rows_with_field_sequence( self, field: Field, view: Optional["View"] = None ): diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py index eb7e4cf452..48bfc1c65e 100644 --- a/backend/src/baserow/contrib/database/fields/registries.py +++ b/backend/src/baserow/contrib/database/fields/registries.py @@ -510,6 +510,25 @@ def contains_query(self, field_name, value, model_field, field): return Q() + def starts_with_query(self, field_name, value, model_field, field): + """ + Returns a Q or AnnotatedQ filter which performs a starts with filter over the + provided field for this specific type of field. + + :param field_name: The name of the field. + :type field_name: str + :param value: The value to check if this field starts with or not. + :type value: str + :param model_field: The field's actual django field model instance. + :type model_field: models.Field + :param field: The related field's instance. + :type field: Field + :return: A Q or AnnotatedQ filter for the given value. + :rtype: OptionallyAnnotatedQ + """ + + return Q() + def contains_word_query(self, field_name, value, model_field, field): """ Returns a Q or AnnotatedQ filter which performs a regex filter over the diff --git a/backend/src/baserow/contrib/database/views/view_filters.py b/backend/src/baserow/contrib/database/views/view_filters.py index e958e3b651..dd416071ff 100644 --- a/backend/src/baserow/contrib/database/views/view_filters.py +++ b/backend/src/baserow/contrib/database/views/view_filters.py @@ -307,6 +307,42 @@ class ContainsNotViewFilterType(NotViewFilterTypeMixin, ContainsViewFilterType): type = "contains_not" +class StartsWithViewFilterType(ViewFilterType): + """ + The starts with filter checks if the field value starts with the provided filter + value. + """ + + type = "starts_with" + # Field types that resolve to one of these via `get_compatible_filter_field_type` + # also become compatible, so they must implement `starts_with_query`. Otherwise + # the base implementation returns an empty `Q()` and the filter matches every row. + compatible_field_types = [ + TextFieldType.type, + LongTextFieldType.type, + URLFieldType.type, + EmailFieldType.type, + PhoneNumberFieldType.type, + NumberFieldType.type, + AutonumberFieldType.type, + SingleSelectFieldType.type, + FormulaFieldType.compatible_with_formula_types( + BaserowFormulaTextType.type, + BaserowFormulaCharType.type, + BaserowFormulaURLType.type, + BaserowFormulaNumberType.type, + BaserowFormulaSingleSelectType.type, + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + try: + field_type = field_type_registry.get_by_model(field) + return field_type.starts_with_query(field_name, value, model_field, field) + except Exception: + return self.default_filter_on_exception() + + class LengthIsLowerThanViewFilterType(ViewFilterType): """ The length is lower than filter checks if the fields character diff --git a/backend/src/baserow/core/formula/field.py b/backend/src/baserow/core/formula/field.py index 96302ff909..d2119c999d 100644 --- a/backend/src/baserow/core/formula/field.py +++ b/backend/src/baserow/core/formula/field.py @@ -1,5 +1,6 @@ import json import logging +from copy import deepcopy from typing import Any, Dict, List, Optional, Union from django.db import connection, models @@ -451,6 +452,15 @@ def get_prep_value( if not isinstance(value, (dict, list)): return value + # Work on a copy: the minification below mutates `value` in place, but + # `value` is the model instance's in-memory attribute. Mutating it as a + # side effect of preparing the DB value is unsafe when `get_prep_value` + # runs more than once for a single save (e.g. django-silk compiles each + # query twice for logging) — the second run would re-minify the + # already-minified value and blank the formula. Copying keeps the + # in-memory value untouched (full form) so re-runs are idempotent. + value = deepcopy(value) + # Iterate over the properties bound to this field. # Each property represents a path to a formula field # we need to convert from full to minified. diff --git a/backend/tests/baserow/contrib/database/view/test_view_filters.py b/backend/tests/baserow/contrib/database/view/test_view_filters.py index dee4670fda..8ac0001ca9 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_filters.py +++ b/backend/tests/baserow/contrib/database/view/test_view_filters.py @@ -1003,6 +1003,164 @@ def test_contains_word_filter_type(data_fixture): assert len(ids) == 0 +@pytest.mark.django_db +def test_starts_with_filter_type(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + grid_view = data_fixture.create_grid_view(table=table) + text_field = data_fixture.create_text_field(table=table) + long_text_field = data_fixture.create_long_text_field(table=table) + url_field = data_fixture.create_url_field(table=table) + email_field = data_fixture.create_email_field(table=table) + phone_number_field = data_fixture.create_phone_number_field(table=table) + number_field = data_fixture.create_number_field(table=table) + autonumber_field = data_fixture.create_autonumber_field(table=table) + single_select_field = data_fixture.create_single_select_field(table=table) + option_a = data_fixture.create_select_option( + field=single_select_field, value="Active", color="blue" + ) + option_b = data_fixture.create_select_option( + field=single_select_field, value="Done", color="red" + ) + formula_field = data_fixture.create_formula_field( + table=table, + name="formula", + formula=f"field('{text_field.name}')", + formula_type="text", + ) + number_formula_field = data_fixture.create_formula_field( + table=table, + name="number formula", + formula=f"field('{number_field.name}')", + ) + + handler = ViewHandler() + model = table.get_model() + + # The autonumber field is read only and is assigned sequentially, so the + # first created row gets 1 and the second gets 2. + row, row_2 = ( + RowHandler() + .create_rows( + user, + table, + rows_values=[ + { + f"field_{text_field.id}": "hello world", + f"field_{long_text_field.id}": "hello long world", + f"field_{url_field.id}": "https://www.example.com", + f"field_{email_field.id}": "john.doe@example.com", + f"field_{phone_number_field.id}": "+1-541-754-3010", + f"field_{number_field.id}": 12345, + f"field_{single_select_field.id}": option_a.id, + }, + { + f"field_{text_field.id}": "doesnt match", + f"field_{long_text_field.id}": "another value", + f"field_{url_field.id}": "http://baserow.io", + f"field_{email_field.id}": "jane.doe@baserow.io", + f"field_{phone_number_field.id}": "+44-20-7946-0000", + f"field_{number_field.id}": 90000, + f"field_{single_select_field.id}": option_b.id, + }, + ], + model=model, + ) + .created_rows + ) + + view_filter = data_fixture.create_view_filter( + view=grid_view, field=text_field, type="starts_with", value="hello" + ) + # Only the row starting with the value is kept. + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + # The match is case insensitive. + view_filter.value = "HELLO" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + # A value that is contained but not at the start does not match. + view_filter.value = "world" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 0 + + # An empty value does not filter at all. + view_filter.value = "" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 2 + + view_filter.field = long_text_field + view_filter.value = "hello" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = url_field + view_filter.value = "https://" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = email_field + view_filter.value = "john" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = phone_number_field + view_filter.value = "+1" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = number_field + view_filter.value = "123" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = autonumber_field + view_filter.value = "1" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = single_select_field + view_filter.value = "act" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + view_filter.field = formula_field + view_filter.value = "hello" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + # A non-text formula delegates to the underlying field type's query. + view_filter.field = number_formula_field + view_filter.value = "123" + view_filter.save() + ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()] + assert len(ids) == 1 + assert row.id in ids + + @pytest.mark.django_db def test_doesnt_contain_word_filter_type(data_fixture): user = data_fixture.create_user() diff --git a/backend/tests/baserow/core/formula/test_formula_field.py b/backend/tests/baserow/core/formula/test_formula_field.py index 399f150661..53b991b2e3 100644 --- a/backend/tests/baserow/core/formula/test_formula_field.py +++ b/backend/tests/baserow/core/formula/test_formula_field.py @@ -1,4 +1,55 @@ -from baserow.core.formula.field import FormulaField +from baserow.core.formula.field import FormulaField, JSONFormulaField + + +def test_json_formula_field_get_prep_value_does_not_mutate_input(): + """ + Regression: `get_prep_value` minifies the formula paths in a config, but it + must NOT mutate the value it is given — that value is the model instance's + in-memory `config` attribute, and mutating it as a side effect of preparing + the DB value is unsafe. + + Why it matters (the silk-driven bug): django-silk (enabled in the dev env) + wraps `SQLCompiler.execute_sql` and compiles each UPDATE twice — once to log + the query, once to actually run it — so `get_prep_value` runs twice on the + *same* config object within a single save. Before the deepcopy fix the first + call minified the config in place (`{"formula": ...}` -> `{"f": ...}`) and the + second call re-minified the already-minified form; `_transform_python_property` + reads the "formula" key (absent after minify), so it wrote back `{"f": ""}` and + the formula was silently blanked. This is what made table-element duplication + lose every field formula when silk was on. Keeping `get_prep_value` free of + side effects makes it idempotent regardless of how many times it runs. + """ + + field = JSONFormulaField(properties=["value"]) + config = { + "value": { + "formula": "get('current_record.field_5')", + "mode": "simple", + "version": "0.1", + } + } + + prepped = field.get_prep_value(config) + + # 1. The input (the model's in-memory config) must be untouched — still full. + assert config == { + "value": { + "formula": "get('current_record.field_5')", + "mode": "simple", + "version": "0.1", + } + } + # 2. It returns the correct minified DB representation. + assert prepped == { + "value": {"f": "get('current_record.field_5')", "m": "simple", "v": "0.1"} + } + + # 3. Running it again on the SAME object (silk's second compile) must still + # produce the correct minified value, not a blanked one. + prepped_again = field.get_prep_value(config) + assert prepped_again == { + "value": {"f": "get('current_record.field_5')", "m": "simple", "v": "0.1"} + } def test_deserialize_baserow_object_valid(): diff --git a/changelog/entries/unreleased/feature/adds_a_starts_with_filter_for_text_fields.json b/changelog/entries/unreleased/feature/adds_a_starts_with_filter_for_text_fields.json new file mode 100644 index 0000000000..7d24b393e0 --- /dev/null +++ b/changelog/entries/unreleased/feature/adds_a_starts_with_filter_for_text_fields.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Adds a \"starts with\" filter for text fields", + "issue_origin": "github", + "issue_number": 1943, + "domain": "database", + "bullet_points": [], + "created_at": "2026-07-01" +} diff --git a/premium/backend/src/baserow_premium/fields/field_types.py b/premium/backend/src/baserow_premium/fields/field_types.py index b991196e9a..fe83ccf0e1 100644 --- a/premium/backend/src/baserow_premium/fields/field_types.py +++ b/premium/backend/src/baserow_premium/fields/field_types.py @@ -187,6 +187,12 @@ def contains_query(self, field_name, value, model_field, field): baserow_field_type = self.get_baserow_field_type(field) return baserow_field_type.contains_query(field_name, value, model_field, field) + def starts_with_query(self, field_name, value, model_field, field): + baserow_field_type = self.get_baserow_field_type(field) + return baserow_field_type.starts_with_query( + field_name, value, model_field, field + ) + def parse_filter_value(self, field, model_field, value): baserow_field_type = self.get_baserow_field_type(field) return baserow_field_type.parse_filter_value(field, model_field, value) diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_filters.py b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_filters.py index a2e724fa06..ffbb98da36 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_filters.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_filters.py @@ -260,6 +260,58 @@ def test_ai_field_text_output_supports_contains_word_filter(premium_data_fixture assert row_2 not in queryset # "worldwide" doesn't contain "world" as whole word +@pytest.mark.django_db +@pytest.mark.field_ai +def test_ai_field_text_output_supports_starts_with_filter(premium_data_fixture): + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + grid_view = premium_data_fixture.create_grid_view(table=table) + premium_data_fixture.register_fake_generate_ai_type() + + ai_field = premium_data_fixture.create_ai_field( + table=table, + order=1, + name="AI Text", + ai_generative_ai_type="test_generative_ai", + ai_generative_ai_model="test_1", + ai_output_type="text", + ai_prompt="'test'", + ) + + handler = RowHandler() + + row_1 = handler.create_row( + user=user, table=table, values={f"field_{ai_field.id}": "Hello world"} + ) + row_2 = handler.create_row( + user=user, table=table, values={f"field_{ai_field.id}": "Goodbye world"} + ) + row_3 = handler.create_row( + user=user, table=table, values={f"field_{ai_field.id}": "Hello there"} + ) + + # Create a starts_with filter + view_handler = ViewHandler() + view_handler.create_filter( + user=user, + view=grid_view, + field=ai_field, + type_name="starts_with", + value="Hello", + ) + + # Apply the filter. Without the AIFieldType.starts_with_query delegation the + # base implementation returns Q() and every row would match, so guard against + # that regression here. + queryset = view_handler.get_queryset(None, grid_view) + + # Should return only rows starting with "Hello" + assert queryset.count() == 2 + assert row_1 in queryset + assert row_3 in queryset + assert row_2 not in queryset + + @pytest.mark.django_db @pytest.mark.field_ai def test_ai_field_text_output_supports_equal_filter(premium_data_fixture): @@ -481,6 +533,7 @@ def test_ai_field_is_compatible_with_text_filters(premium_data_fixture): "contains_not", "contains_word", "doesnt_contain_word", + "starts_with", "equal", "not_equal", "length_is_lower_than", diff --git a/premium/web-frontend/modules/baserow_premium/fieldTypes.js b/premium/web-frontend/modules/baserow_premium/fieldTypes.js index aa2d12d8a0..7d43c38cc7 100644 --- a/premium/web-frontend/modules/baserow_premium/fieldTypes.js +++ b/premium/web-frontend/modules/baserow_premium/fieldTypes.js @@ -119,6 +119,10 @@ export class AIFieldType extends FieldType { return this.getBaserowFieldType(field).getContainsWordFilterFunction(field) } + getStartsWithFilterFunction(field) { + return this.getBaserowFieldType(field).getStartsWithFilterFunction(field) + } + toHumanReadableString(field, value) { return this.getBaserowFieldType(field).toHumanReadableString(field, value) } diff --git a/premium/web-frontend/test/unit/premium/fieldTypes.spec.js b/premium/web-frontend/test/unit/premium/fieldTypes.spec.js new file mode 100644 index 0000000000..225fa7529e --- /dev/null +++ b/premium/web-frontend/test/unit/premium/fieldTypes.spec.js @@ -0,0 +1,31 @@ +import { PremiumTestApp } from '@baserow_premium_test/helpers/premiumTestApp' + +describe('Premium AIFieldType filter delegation', () => { + let testApp = null + let registry = null + + beforeEach(() => { + testApp = new PremiumTestApp() + registry = testApp.getRegistry() + }) + + afterEach(() => { + testApp.afterEach() + }) + + // Guards the regression where AIFieldType only delegated the contains filter + // functions. Without a getStartsWithFilterFunction delegation the base + // implementation returns `() => false`, marking edited rows as non-matching + // client-side while the backend keeps them visible. + test('getStartsWithFilterFunction delegates to the underlying output type', () => { + const aiFieldType = registry.get('field', 'ai') + const field = { ai_output_type: 'text' } + + const filterFunction = aiFieldType.getStartsWithFilterFunction(field) + + expect(filterFunction('Hello world', 'Hello world', 'hello')).toBe(true) + expect(filterFunction('Goodbye world', 'Goodbye world', 'hello')).toBe( + false + ) + }) +}) diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 6d76e3711a..4ce26db41d 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -257,6 +257,7 @@ "containsNot": "doesn't contain", "containsWord": "contains word", "doesntContainWord": "doesn't contain word", + "startsWith": "starts with", "filenameContains": "filename contains", "has": "has", "hasNot": "doesn't have", diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index fb62dc7639..427ddca877 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -162,6 +162,7 @@ import { genericContainsFilter, genericContainsWordFilter, genericHasValueEqualFilter, + genericStartsWithFilter, } from '@baserow/modules/database/utils/fieldFilters' import GridViewFieldFormula from '@baserow/modules/database/components/view/grid/fields/GridViewFieldFormula' import FieldFormulaSubForm from '@baserow/modules/database/components/field/FieldFormulaSubForm' @@ -766,6 +767,13 @@ export class FieldType extends Registerable { return (rowValue, humanReadableRowValue, filterValue) => false } + /** + * Should return a starts with filter function unique for this field type. + */ + getStartsWithFilterFunction(field) { + return (rowValue, humanReadableRowValue, filterValue) => false + } + /** * Converts rowValue to its human readable form first before applying the * filter returned from getContainsFilterFunction. @@ -811,6 +819,25 @@ export class FieldType extends Registerable { ) } + /** + * Converts rowValue to its searchable form first before applying the filter + * returned from getStartsWithFilterFunction. We use `toSearchableString` rather + * than `toHumanReadableString` so the real-time match stays consistent with the + * backend, which filters on the raw value and knows nothing about display + * formatting (e.g. a number's prefix or thousands separator would otherwise + * break the start-anchored match). + */ + startsWithFilter(rowValue, filterValue, field) { + return ( + filterValue === '' || + this.getStartsWithFilterFunction(field)( + rowValue, + this.toSearchableString(field, rowValue), + filterValue + ) + ) + } + /** * Converts rowValue to its human readable form first before applying the field * filter returned by getContainsWordFilterFunction's notted. @@ -1208,6 +1235,10 @@ export class TextFieldType extends FieldType { return genericContainsWordFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } @@ -1328,6 +1359,10 @@ export class LongTextFieldType extends FieldType { return genericContainsWordFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } @@ -1936,6 +1971,10 @@ export class NumberFieldType extends FieldType { return genericContainsFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } @@ -3286,6 +3325,10 @@ export class URLFieldType extends FieldType { return genericContainsWordFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canParseQueryParameter() { return true } @@ -3392,6 +3435,10 @@ export class EmailFieldType extends FieldType { return genericContainsWordFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } @@ -3839,6 +3886,10 @@ export class SingleSelectFieldType extends SelectOptionBaseFieldType { return genericContainsWordFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } @@ -4309,6 +4360,10 @@ export class PhoneNumberFieldType extends FieldType { return genericContainsFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } @@ -4476,6 +4531,19 @@ export class FormulaFieldType extends mix( return underlyingFieldType.getContainsWordFilterFunction() } + /** + * Delegate to the underlying field type so the searchable-string conversion + * (e.g. a number's raw value vs its formatted display) stays consistent with + * the backend, matching how starts_with_query is delegated on the backend. + */ + startsWithFilter(rowValue, filterValue, field) { + const underlyingFieldType = this.app.$registry.get( + 'field', + this._mapFormulaTypeToFieldType(field.formula_type) + ) + return underlyingFieldType.startsWithFilter(rowValue, filterValue, field) + } + toHumanReadableString(field, value) { return this.getFormulaType(field)?.toHumanReadableString(field, value) } @@ -5084,6 +5152,10 @@ export class AutonumberFieldType extends FieldType { return genericContainsFilter } + getStartsWithFilterFunction() { + return genericStartsWithFilter + } + canBeReferencedByFormulaField() { return true } diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index f6bdc505a9..96bf9a21d0 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -72,6 +72,7 @@ import { LinkRowNotContainsFilterType, ContainsWordViewFilterType, DoesntContainWordViewFilterType, + StartsWithViewFilterType, UserIsFilterType, UserIsNotFilterType, DateIsEqualMultiStepViewFilterType, @@ -543,6 +544,7 @@ export default defineNuxtPlugin({ 'viewFilter', new DoesntContainWordViewFilterType(context) ) + $registry.register('viewFilter', new StartsWithViewFilterType(context)) $registry.register( 'viewFilter', new FilenameContainsViewFilterType(context) diff --git a/web-frontend/modules/database/utils/fieldFilters.js b/web-frontend/modules/database/utils/fieldFilters.js index 99c1e1865a..799f2f7c1f 100644 --- a/web-frontend/modules/database/utils/fieldFilters.js +++ b/web-frontend/modules/database/utils/fieldFilters.js @@ -38,6 +38,23 @@ export function genericContainsFilter( return humanReadableRowValue.includes(filterValue) } +export function genericStartsWithFilter( + rowValue, + searchableRowValue, + filterValue +) { + if (searchableRowValue == null) { + return false + } + // The backend runs `__istartswith` on the raw cell value and only strips the + // filter value, so we must not trim the row value here or leading whitespace + // would make the real-time match disagree with the backend query. + searchableRowValue = String(searchableRowValue).toLowerCase() + filterValue = String(filterValue).toLowerCase().trim() + + return searchableRowValue.startsWith(filterValue) +} + export function genericContainsWordFilter( rowValue, humanReadableRowValue, diff --git a/web-frontend/modules/database/viewFilters.js b/web-frontend/modules/database/viewFilters.js index cae760a1bf..00359faba1 100644 --- a/web-frontend/modules/database/viewFilters.js +++ b/web-frontend/modules/database/viewFilters.js @@ -689,6 +689,48 @@ export class ContainsNotViewFilterType extends ViewFilterType { } } +export class StartsWithViewFilterType extends ViewFilterType { + static getType() { + return 'starts_with' + } + + getName() { + const { $i18n: i18n } = this.app + return i18n.t('viewFilter.startsWith') + } + + getInputComponent(field) { + const inputComponent = { + [NumberFieldType.getType()]: ViewFilterTypeNumber, + } + return inputComponent[field?.type] || ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [ + 'text', + 'long_text', + 'url', + 'email', + 'phone_number', + 'number', + 'autonumber', + 'single_select', + FormulaFieldType.compatibleWithFormulaTypes( + 'text', + 'char', + 'url', + 'number', + 'single_select' + ), + ] + } + + matches(rowValue, filterValue, field, fieldType) { + return fieldType.startsWithFilter(rowValue, filterValue, field) + } +} + export class ContainsWordViewFilterType extends ViewFilterType { static getType() { return 'contains_word' diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap index a084c87b02..b4c30e6080 100644 --- a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap @@ -457,6 +457,34 @@ exports[`ViewFilterForm match snapshots > Full view filter component 1`] = ` > + + + + viewFilter.startsWith + + + + + + + +