diff --git a/backend/src/baserow/contrib/automation/api/workflows/views.py b/backend/src/baserow/contrib/automation/api/workflows/views.py index 1ef249caa7..609c14acd5 100644 --- a/backend/src/baserow/contrib/automation/api/workflows/views.py +++ b/backend/src/baserow/contrib/automation/api/workflows/views.py @@ -379,6 +379,7 @@ class AsyncPublishAutomationWorkflowView(APIView): 400: get_error_schema( [ "ERROR_REQUEST_BODY_VALIDATION", + "ERROR_MAX_JOB_COUNT_EXCEEDED", ] ), 404: get_error_schema(["ERROR_AUTOMATION_WORKFLOW_DOES_NOT_EXIST"]), @@ -388,6 +389,7 @@ class AsyncPublishAutomationWorkflowView(APIView): @map_exceptions( { AutomationWorkflowDoesNotExist: ERROR_AUTOMATION_WORKFLOW_DOES_NOT_EXIST, + MaxJobCountExceeded: ERROR_MAX_JOB_COUNT_EXCEEDED, } ) def post(self, request, workflow_id: int): diff --git a/backend/src/baserow/contrib/builder/api/domains/views.py b/backend/src/baserow/contrib/builder/api/domains/views.py index 7d32071148..8a0a0b79af 100644 --- a/backend/src/baserow/contrib/builder/api/domains/views.py +++ b/backend/src/baserow/contrib/builder/api/domains/views.py @@ -17,6 +17,7 @@ validate_body, validate_body_custom_fields, ) +from baserow.api.jobs.errors import ERROR_MAX_JOB_COUNT_EXCEEDED from baserow.api.jobs.serializers import JobSerializer from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema from baserow.api.utils import ( @@ -49,6 +50,7 @@ from baserow.contrib.builder.exceptions import BuilderDoesNotExist from baserow.contrib.builder.handler import BuilderHandler from baserow.core.exceptions import ApplicationDoesNotExist +from baserow.core.jobs.exceptions import MaxJobCountExceeded from baserow.core.jobs.registries import job_type_registry @@ -331,6 +333,7 @@ class AsyncPublishDomainView(APIView): 400: get_error_schema( [ "ERROR_REQUEST_BODY_VALIDATION", + "ERROR_MAX_JOB_COUNT_EXCEEDED", ] ), 404: get_error_schema(["ERROR_APPLICATION_DOES_NOT_EXIST"]), @@ -341,6 +344,7 @@ class AsyncPublishDomainView(APIView): { ApplicationDoesNotExist: ERROR_APPLICATION_DOES_NOT_EXIST, DomainDoesNotExist: ERROR_DOMAIN_DOES_NOT_EXIST, + MaxJobCountExceeded: ERROR_MAX_JOB_COUNT_EXCEEDED, } ) def post(self, request, domain_id: int): diff --git a/backend/src/baserow/contrib/database/api/tokens/views.py b/backend/src/baserow/contrib/database/api/tokens/views.py index 23c4e9dfc7..cc266f2899 100755 --- a/backend/src/baserow/contrib/database/api/tokens/views.py +++ b/backend/src/baserow/contrib/database/api/tokens/views.py @@ -55,9 +55,9 @@ class TokensView(APIView): def get(self, request): """Lists all the tokens of a user.""" - tokens = Token.objects.filter(user=request.user).prefetch_related( - "tokenpermission_set" - ) + tokens = Token.objects.filter( + user=request.user, workspace__workspaceuser__user=request.user + ).prefetch_related("tokenpermission_set") serializer = TokenSerializer(tokens, many=True) return Response(serializer.data) diff --git a/backend/src/baserow/contrib/database/migrations/0208_gridview_frozen_column_count.py b/backend/src/baserow/contrib/database/migrations/0208_gridview_frozen_column_count.py new file mode 100644 index 0000000000..8d78aaafe1 --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0208_gridview_frozen_column_count.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("database", "0207_fix_data_sync_missing_primary_field"), + ] + + operations = [ + migrations.AddField( + model_name="gridview", + name="frozen_column_count", + field=models.PositiveSmallIntegerField( + default=1, + db_default=1, + ), + ), + ] diff --git a/backend/src/baserow/contrib/database/views/models.py b/backend/src/baserow/contrib/database/views/models.py index b119b3dffe..d4ee02962c 100644 --- a/backend/src/baserow/contrib/database/views/models.py +++ b/backend/src/baserow/contrib/database/views/models.py @@ -594,6 +594,9 @@ class RowHeightSizes(models.TextChoices): max_length=10, db_default="small", ) + # Number of frozen (pinned) columns including the primary field. Max defined in + # the serializer. + frozen_column_count = models.PositiveSmallIntegerField(default=1, db_default=1) class GridViewFieldOptionsManager(models.Manager): diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py index 7348f2e176..d566057197 100644 --- a/backend/src/baserow/contrib/database/views/view_types.py +++ b/backend/src/baserow/contrib/database/views/view_types.py @@ -85,7 +85,7 @@ class GridViewType(ViewType): has_public_info = True can_group_by = True when_shared_publicly_requires_realtime_events = True - allowed_fields = ["row_identifier_type", "row_height_size"] + allowed_fields = ["row_identifier_type", "row_height_size", "frozen_column_count"] field_options_allowed_fields = [ "width", "hidden", @@ -93,7 +93,20 @@ class GridViewType(ViewType): "aggregation_type", "aggregation_raw_type", ] - serializer_field_names = ["row_identifier_type", "row_height_size"] + serializer_field_names = [ + "row_identifier_type", + "row_height_size", + "frozen_column_count", + ] + serializer_field_overrides = { + "frozen_column_count": serializers.IntegerField( + min_value=0, + max_value=4, + required=False, + default=1, + help_text="Number of frozen columns including the primary field.", + ), + } api_exceptions_map = { GridViewAggregationDoesNotSupportField: ERROR_AGGREGATION_DOES_NOT_SUPPORTED_FIELD, @@ -123,6 +136,7 @@ def export_serialized( ) serialized["row_identifier_type"] = grid.row_identifier_type serialized["row_height_size"] = grid.row_height_size + serialized["frozen_column_count"] = grid.frozen_column_count serialized_field_options = [] for field_option in grid.get_field_options(): diff --git a/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py b/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py index d600b534ea..aec2a979d9 100644 --- a/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py +++ b/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py @@ -525,6 +525,43 @@ def test_publish_workflow(api_client, data_fixture): } +@pytest.mark.django_db +def test_publish_workflow_error_max_job_count_exceeded(api_client, data_fixture): + user = data_fixture.create_user() + automation = data_fixture.create_automation_application(user) + workflow_1 = data_fixture.create_automation_workflow( + user, automation=automation, name="test1" + ) + workflow_2 = data_fixture.create_automation_workflow( + user, automation=automation, name="test2" + ) + workflow_3 = data_fixture.create_automation_workflow( + user, automation=automation, name="test3" + ) + + token = data_fixture.generate_token(user) + for workflow_id in [workflow_1.id, workflow_2.id]: + url = reverse(API_URL_WORKFLOW_PUBLISH, kwargs={"workflow_id": workflow_id}) + response = api_client.post( + url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_202_ACCEPTED + + url = reverse(API_URL_WORKFLOW_PUBLISH, kwargs={"workflow_id": workflow_3.id}) + response = api_client.post( + url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json() == { + "detail": "Max running job count for this type is exceeded.", + "error": "ERROR_MAX_JOB_COUNT_EXCEEDED", + } + + @pytest.mark.django_db def test_publish_workflow_error_invalid_workflow(api_client, data_fixture): _, token = data_fixture.create_user_and_token() diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py index 79158751cd..63e375f9c7 100644 --- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py +++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py @@ -373,6 +373,41 @@ def test_publish_builder(mock_run_async_job, api_client, data_fixture): assert args[0][0] == response_json["id"] +@pytest.mark.django_db(transaction=True) +@patch("baserow.core.jobs.handler.run_async_job") +def test_publish_builder_max_job_count_exceeded( + mock_run_async_job, api_client, data_fixture +): + user, token = data_fixture.create_user_and_token() + builder = data_fixture.create_builder_application(user=user) + data_fixture.create_builder_page(builder=builder, user=user) + domain = data_fixture.create_builder_custom_domain( + domain_name="test.getbaserow.io", builder=builder + ) + url = reverse( + "api:builder:domains:publish", + kwargs={"domain_id": domain.id}, + ) + + # Simulate publishing 2 builder apps. Since we're patching run_async_job, + # the jobs will never complete. + api_client.post( + url, {"domain_id": domain.id}, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + api_client.post( + url, {"domain_id": domain.id}, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + response = api_client.post( + url, + {"domain_id": domain.id}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_MAX_JOB_COUNT_EXCEEDED" + + @pytest.mark.django_db def test_get_elements_of_public_builder(api_client, data_fixture): user = data_fixture.create_user() diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py index 0d6a6c9dbb..0b09f0e51c 100644 --- a/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py +++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py @@ -376,6 +376,7 @@ def test_to_baserow_database_export(): "order": 1, "row_identifier_type": "count", "row_height_size": "small", + "frozen_column_count": 1, "filter_type": "AND", "filters_disabled": False, "filters": [], diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py index b12f94b739..2c9439aa10 100644 --- a/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py +++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py @@ -262,6 +262,7 @@ def test_import_grid_view(): "ownership_type": "collaborative", "public": False, "row_height_size": "medium", + "frozen_column_count": 1, "row_identifier_type": "count", "sortings": [], "type": "grid", diff --git a/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py b/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py index 0d90b97106..b658f69100 100644 --- a/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py +++ b/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py @@ -79,6 +79,35 @@ def test_list_tokens(api_client, data_fixture): } +@pytest.mark.django_db +def test_list_tokens_excludes_tokens_for_workspaces_user_left(api_client, data_fixture): + user, jwt_token = data_fixture.create_user_and_token() + workspace_1 = data_fixture.create_workspace(user=user) + workspace_2 = data_fixture.create_workspace(user=user) + token_1 = data_fixture.create_token(user=user, workspace=workspace_1) + data_fixture.create_token(user=user, workspace=workspace_2) + + url = reverse("api:database:tokens:list") + + response = api_client.get(url, HTTP_AUTHORIZATION=f"JWT {jwt_token}") + assert response.status_code == HTTP_200_OK + assert len(response.json()) == 2 + + workspace_2.workspaceuser_set.filter(user=user).delete() + + response = api_client.get(url, HTTP_AUTHORIZATION=f"JWT {jwt_token}") + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert len(response_json) == 1 + assert response_json[0]["id"] == token_1.id + + data_fixture.create_user_workspace(user=user, workspace=workspace_2) + + response = api_client.get(url, HTTP_AUTHORIZATION=f"JWT {jwt_token}") + assert response.status_code == HTTP_200_OK + assert len(response.json()) == 2 + + @pytest.mark.django_db def test_create_token(api_client, data_fixture): user, token = data_fixture.create_user_and_token() diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py index d9708f2d28..8be2833487 100644 --- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py @@ -3349,6 +3349,7 @@ def test_get_public_grid_view(api_client, data_fixture): "type": "grid", "row_identifier_type": grid_view.row_identifier_type, "row_height_size": grid_view.row_height_size, + "frozen_column_count": 1, "show_logo": True, "allow_public_export": False, "ownership_type": "collaborative", diff --git a/backend/tests/baserow/contrib/database/api/views/test_view_views.py b/backend/tests/baserow/contrib/database/api/views/test_view_views.py index df5e99c860..25a9e4d787 100644 --- a/backend/tests/baserow/contrib/database/api/views/test_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/test_view_views.py @@ -1017,6 +1017,7 @@ def test_user_with_password_can_get_info_about_a_public_password_protected_view( "type": "grid", "row_identifier_type": grid_view.row_identifier_type, "row_height_size": grid_view.row_height_size, + "frozen_column_count": 1, "show_logo": grid_view.show_logo, "allow_public_export": grid_view.allow_public_export, "ownership_type": "collaborative", @@ -1048,6 +1049,7 @@ def test_user_with_password_can_get_info_about_a_public_password_protected_view( "type": "grid", "row_identifier_type": grid_view.row_identifier_type, "row_height_size": grid_view.row_height_size, + "frozen_column_count": 1, "show_logo": grid_view.show_logo, "allow_public_export": grid_view.allow_public_export, "ownership_type": "collaborative", diff --git a/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py b/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py index e9ed1607ad..7c57a0adb9 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py +++ b/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py @@ -50,6 +50,7 @@ def test_view_created_event_type(data_fixture): "public": False, "slug": view.slug, "row_height_size": "small", + "frozen_column_count": 1, }, } @@ -93,6 +94,7 @@ def test_view_created_event_type_test_payload(data_fixture): "row_identifier_type": "id", "public": False, "row_height_size": "small", + "frozen_column_count": 1, }, } @@ -147,6 +149,7 @@ def test_view_updated_event_type(data_fixture): "public": False, "slug": view.slug, "row_height_size": "small", + "frozen_column_count": 1, }, } @@ -190,6 +193,7 @@ def test_view_updated_event_type_test_payload(data_fixture): "row_identifier_type": "id", "public": False, "row_height_size": "small", + "frozen_column_count": 1, }, } diff --git a/changelog/entries/unreleased/bug/5002_allow_advanced_formula_for_json_body_content_of_http_request.json b/changelog/entries/unreleased/bug/5002_allow_advanced_formula_for_json_body_content_of_http_request.json new file mode 100644 index 0000000000..afd57a8a93 --- /dev/null +++ b/changelog/entries/unreleased/bug/5002_allow_advanced_formula_for_json_body_content_of_http_request.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Allow advanced formula for JSON body content of HTTP request node", + "issue_origin": "github", + "issue_number": 5002, + "domain": "integration", + "bullet_points": [], + "created_at": "2026-04-08" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/5144_filter_tokens_by_workspace_membership.json b/changelog/entries/unreleased/bug/5144_filter_tokens_by_workspace_membership.json new file mode 100644 index 0000000000..da735b183e --- /dev/null +++ b/changelog/entries/unreleased/bug/5144_filter_tokens_by_workspace_membership.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Filter tokens by workspace membership", + "issue_origin": "github", + "issue_number": 5144, + "domain": "core", + "bullet_points": [], + "created_at": "2026-04-08" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/ensure_that_the_correct_error_is_shown_when_exceeding_the_ma.json b/changelog/entries/unreleased/bug/ensure_that_the_correct_error_is_shown_when_exceeding_the_ma.json new file mode 100644 index 0000000000..4c498dee08 --- /dev/null +++ b/changelog/entries/unreleased/bug/ensure_that_the_correct_error_is_shown_when_exceeding_the_ma.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Ensure that the correct error is shown when exceeding the max publish job count.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-03-30" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/ensure_the_correct_error_is_shown_when_a_page_is_not_found.json b/changelog/entries/unreleased/bug/ensure_the_correct_error_is_shown_when_a_page_is_not_found.json new file mode 100644 index 0000000000..529946dcb1 --- /dev/null +++ b/changelog/entries/unreleased/bug/ensure_the_correct_error_is_shown_when_a_page_is_not_found.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Ensure the correct error is shown when a page is not found.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-03-30" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/ensure_the_max_publish_job_count_error_is_shown_when_exceedi.json b/changelog/entries/unreleased/bug/ensure_the_max_publish_job_count_error_is_shown_when_exceedi.json new file mode 100644 index 0000000000..dd0daad7cc --- /dev/null +++ b/changelog/entries/unreleased/bug/ensure_the_max_publish_job_count_error_is_shown_when_exceedi.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Ensure the max publish job count error is shown when exceeding the limit for publishing an Automation Workflow.", + "issue_origin": "github", + "issue_number": null, + "domain": "automation", + "bullet_points": [], + "created_at": "2026-04-01" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/fix_link_row_checkbox.json b/changelog/entries/unreleased/bug/fix_link_row_checkbox.json new file mode 100644 index 0000000000..aefdf76969 --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_link_row_checkbox.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix linked row modal checkbox", + "issue_origin": null, + "issue_number": null, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-08" +} diff --git a/changelog/entries/unreleased/bug/fixed_a_bug_where_an_unknown_timezone_could_cause_a_data_sou.json b/changelog/entries/unreleased/bug/fixed_a_bug_where_an_unknown_timezone_could_cause_a_data_sou.json new file mode 100644 index 0000000000..ebf394cd02 --- /dev/null +++ b/changelog/entries/unreleased/bug/fixed_a_bug_where_an_unknown_timezone_could_cause_a_data_sou.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fixed a bug where an unknown timezone could cause a data source dispatch to fail.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-03-31" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/fixed_a_bug_where_reopening_the_publish_modal_while_the_buil.json b/changelog/entries/unreleased/bug/fixed_a_bug_where_reopening_the_publish_modal_while_the_buil.json new file mode 100644 index 0000000000..709555a23c --- /dev/null +++ b/changelog/entries/unreleased/bug/fixed_a_bug_where_reopening_the_publish_modal_while_the_buil.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fixed a bug where reopening the Publish modal while the builder is still publishing can cause an error.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-03-30" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/feature/3673_view_level_permissions.json b/changelog/entries/unreleased/feature/3673_view_level_permissions.json new file mode 100644 index 0000000000..24f10587e2 --- /dev/null +++ b/changelog/entries/unreleased/feature/3673_view_level_permissions.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Introduced restricted view ownership type for view-level permissions.", + "issue_origin": "github", + "issue_number": 3673, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-08" +} diff --git a/changelog/entries/unreleased/feature/freeze_columns.json b/changelog/entries/unreleased/feature/freeze_columns.json new file mode 100644 index 0000000000..6eded7e26a --- /dev/null +++ b/changelog/entries/unreleased/feature/freeze_columns.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Allow freezing (pinning) up to 4 columns on the left side of the grid view.", + "issue_origin": "github", + "issue_number": 2047, + "domain": "database", + "bullet_points": [], + "created_at": "2026-03-25" +} diff --git a/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/serializers.py b/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/serializers.py index f6ef8ce103..a7ce794dd1 100644 --- a/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/serializers.py +++ b/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/serializers.py @@ -39,6 +39,7 @@ class Meta: "results_count", "source_table_id", "source_field_id", + "whole_words", "source_workspace_id", "source_database_id", "created_on", @@ -99,6 +100,7 @@ class DataScanWriteSerializer(serializers.Serializer): ) source_table_id = serializers.IntegerField(required=False, allow_null=True) source_field_id = serializers.IntegerField(required=False, allow_null=True) + whole_words = serializers.BooleanField(required=False) def validate_source_field_id(self, value): if value is not None: @@ -137,6 +139,7 @@ class DataScanCreateSerializer(DataScanWriteSerializer): default="manual", ) scan_all_workspaces = serializers.BooleanField(default=True) + whole_words = serializers.BooleanField(default=True) def validate(self, data): scan_type = data.get("scan_type") diff --git a/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/views.py b/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/views.py index ea2989d154..539ce2b8d3 100644 --- a/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/views.py +++ b/enterprise/backend/src/baserow_enterprise/api/admin/data_scanner/views.py @@ -117,6 +117,7 @@ def post(self, request, data): list_items=data.get("list_items", []), source_table_id=data.get("source_table_id"), source_field_id=data.get("source_field_id"), + whole_words=data.get("whole_words", True), ) return Response(DataScanSerializer(scan).data) diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index fde8d97625..0d5d53a783 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -343,7 +343,6 @@ def ready(self): from baserow.contrib.database.ws.views.rows.registries import ( view_realtime_rows_registry, ) - from baserow.core.feature_flags import feature_flag_is_enabled from baserow.ws.registries import page_registry from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType from baserow_enterprise.ws.pages import RestrictedViewPageType @@ -351,8 +350,7 @@ def ready(self): RestrictedViewRealtimeRowsType, ) - if feature_flag_is_enabled("view_permissions"): - view_ownership_type_registry.register(RestrictedViewOwnershipType()) + view_ownership_type_registry.register(RestrictedViewOwnershipType()) page_registry.register(RestrictedViewPageType()) view_realtime_rows_registry.register(RestrictedViewRealtimeRowsType()) diff --git a/enterprise/backend/src/baserow_enterprise/data_scanner/handler.py b/enterprise/backend/src/baserow_enterprise/data_scanner/handler.py index e276a7f244..632f60d70f 100644 --- a/enterprise/backend/src/baserow_enterprise/data_scanner/handler.py +++ b/enterprise/backend/src/baserow_enterprise/data_scanner/handler.py @@ -1,7 +1,8 @@ import re import traceback +from collections import defaultdict from datetime import datetime, timedelta -from typing import Optional +from typing import Callable, Optional from django.contrib.auth.models import AbstractUser from django.contrib.postgres.search import SearchQuery @@ -12,6 +13,7 @@ from baserow.contrib.database.fields.models import Field from baserow.contrib.database.search.handler import SearchHandler +from baserow.contrib.database.search.models import AbstractSearchValue from baserow.contrib.database.table.models import Table from baserow.core.models import Workspace from baserow_enterprise.data_scanner.constants import ( @@ -35,6 +37,14 @@ from baserow_enterprise.features import DATA_SCANNER from baserow_premium.license.handler import LicenseHandler +TOKEN_MAP = { + "A": "[A-Za-z]", + "D": "[0-9]", + "X": ".", +} + +TOKEN_CHARS = {"A", "D", "X"} + def convert_pattern_to_regex(pattern: str) -> str: """ @@ -44,18 +54,12 @@ def convert_pattern_to_regex(pattern: str) -> str: - `A` -> any letter `[A-Za-z]` - `D` -> any digit `[0-9]` - `X` -> any character `.` - - `\\c` -> literal character `c` + - ``\\c`` -> literal character ``c`` :param pattern: The custom pattern string (e.g. `AADDAAAADDDDDDDDDD`). :return: A regex string equivalent. """ - TOKEN_MAP = { - "A": "[A-Za-z]", - "D": "[0-9]", - "X": ".", - } - parts: list[str] = [] i = 0 while i < len(pattern): @@ -73,6 +77,80 @@ def convert_pattern_to_regex(pattern: str) -> str: return "".join(parts) +def _pattern_has_special_chars(pattern: str) -> bool: + """ + Returns True if the pattern contains escaped literal characters that are not + alphanumeric (e.g. ``\\-``, ``\\.``). These characters are stripped during tsvector + tokenization so pattern matching against the search table requires a two-phase + approach. + """ + + i = 0 + while i < len(pattern): + char = pattern[i] + if char == "\\" and i + 1 < len(pattern): + next_char = pattern[i + 1] + if not next_char.isalnum(): + return True + i += 2 + elif char in TOKEN_CHARS: + i += 1 + else: + if not char.isalnum(): + return True + i += 1 + return False + + +def _build_broad_token_regex(pattern: str) -> str: + """ + Builds a broad regex that can match individual tsvector tokens. The pattern is + split on escaped special characters and unescaped non-alphanumeric literals, and + the longest resulting token-level regex fragment is returned. This is used as a + fast pre-filter on the search table before verifying against actual cell values. + + For example ``DDDD\\-DD\\-DD`` yields token fragments ``[0-9]{4}``, ``[0-9]{2}``, + ``[0-9]{2}`` and returns the longest one: ``[0-9][0-9][0-9][0-9]``. + """ + + fragments: list[str] = [] + current: list[str] = [] + i = 0 + while i < len(pattern): + char = pattern[i] + if char == "\\" and i + 1 < len(pattern): + next_char = pattern[i + 1] + if next_char.isalnum(): + # Escaped alphanumeric literal – stays in the current token. + current.append(re.escape(next_char)) + else: + # Escaped special char – token boundary. + if current: + fragments.append("".join(current)) + current = [] + i += 2 + elif char in TOKEN_MAP: + current.append(TOKEN_MAP[char]) + i += 1 + else: + if char.isalnum(): + current.append(re.escape(char)) + else: + # Unescaped special char – token boundary. + if current: + fragments.append("".join(current)) + current = [] + i += 1 + + if current: + fragments.append("".join(current)) + + if not fragments: + return convert_pattern_to_regex(pattern) + + return max(fragments, key=len) + + def _check_data_scanner_access(user: AbstractUser) -> None: """ Verifies that the given user holds the DATA_SCANNER enterprise feature @@ -101,6 +179,7 @@ def create_scan( list_items: Optional[list[str]] = None, source_table_id: Optional[int] = None, source_field_id: Optional[int] = None, + whole_words: bool = True, ) -> DataScan: """ Creates a new data scan configuration. @@ -116,6 +195,7 @@ def create_scan( :param list_items: Values to match when scan_type is `list_of_values`. :param source_table_id: Source table ID when scan_type is `list_table`. :param source_field_id: Source field ID when scan_type is `list_table`. + :param whole_words: When True, only match whole words/tokens. :return: The newly created DataScan instance. """ @@ -127,6 +207,7 @@ def create_scan( pattern=pattern, frequency=frequency, scan_all_workspaces=scan_all_workspaces, + whole_words=whole_words, created_by=user, source_table_id=source_table_id if scan_type == SCAN_TYPE_LIST_TABLE @@ -180,6 +261,7 @@ def update_scan(user: AbstractUser, scan_id: int, **kwargs) -> DataScan: "scan_all_workspaces", "source_table_id", "source_field_id", + "whole_words", ] for field_name in simple_fields: if field_name in kwargs: @@ -380,8 +462,26 @@ def run_scan(scan_id: int) -> None: if scan.scan_type == SCAN_TYPE_PATTERN: regex = convert_pattern_to_regex(scan.pattern) - pre_computed["regex"] = regex - pre_computed["compiled"] = re.compile(regex, re.IGNORECASE) + has_special = _pattern_has_special_chars(scan.pattern) + pre_computed["has_special_chars"] = has_special + + if has_special: + # The full regex cannot be matched against tsvector text because + # tokenization strips special characters. Use a broad token-level + # regex for the search table pre-filter and the full regex for + # verification against actual cell values. + broad_regex = _build_broad_token_regex(scan.pattern) + pre_computed["broad_regex"] = broad_regex + + if scan.whole_words: + # PostgreSQL POSIX word boundaries + pre_computed["pg_regex"] = r"\m" + regex + r"\M" + pre_computed["compiled"] = re.compile( + r"\b" + regex + r"\b", re.IGNORECASE + ) + else: + pre_computed["pg_regex"] = regex + pre_computed["compiled"] = re.compile(regex, re.IGNORECASE) elif scan.scan_type == SCAN_TYPE_LIST_OF_VALUES: pre_computed["values"] = list( @@ -433,38 +533,25 @@ def run_scan(scan_id: int) -> None: ) if scan.scan_type == SCAN_TYPE_PATTERN: - matches = ( - search_model.objects.annotate( - text_value=Cast("value", TextField()) - ) - .filter(text_value__iregex=pre_computed["regex"]) - .values_list("field_id", "row_id", "text_value") - ) - new_results_count += ( - DataScannerHandler._process_pattern_matches( - scan, - matches, - pre_computed["compiled"], - now, - trashed_field_ids, - ) - ) - elif scan.scan_type == SCAN_TYPE_LIST_OF_VALUES: - new_results_count += DataScannerHandler._run_list_scan( + new_results_count += DataScannerHandler._run_pattern_scan( scan, search_model, - pre_computed["values"], + pre_computed, now, trashed_field_ids, ) - elif scan.scan_type == SCAN_TYPE_LIST_TABLE: + elif scan.scan_type in ( + SCAN_TYPE_LIST_OF_VALUES, + SCAN_TYPE_LIST_TABLE, + ): + exclude_table_id = pre_computed.get("exclude_table_id") new_results_count += DataScannerHandler._run_list_scan( scan, search_model, pre_computed["values"], now, trashed_field_ids, - exclude_table_id=pre_computed["exclude_table_id"], + exclude_table_id=exclude_table_id, ) scan.results.filter(last_identified_on__lt=now).delete() @@ -491,6 +578,55 @@ def run_scan(scan_id: int) -> None: scan, new_results_count ) + @staticmethod + def _run_pattern_scan( + scan: DataScan, + search_model: "AbstractSearchValue", + pre_computed: dict, + now: datetime, + trashed_field_ids: set[int], + ) -> int: + """ + Runs a pattern scan against the workspace search tables. The search table is + used as a fast pre-filter to find candidate rows, then actual cell values are + looked up via ``get_search_expression`` to verify matches. + + For patterns with special characters (e.g. hyphens) a broad token-level regex + is used for the pre-filter because tsvector tokenization strips those + characters. + + :param scan: The scan being executed. + :param search_model: The Django model for the workspace search table. + :param pre_computed: Dict with `pg_regex`, `compiled`, `has_special_chars`, and + optionally `broad_regex`. + :param now: The current timestamp used for result bookkeeping. + :param trashed_field_ids: Set of field IDs to exclude. + :return: The number of newly created results. + """ + + compiled_regex = pre_computed["compiled"] + has_special = pre_computed["has_special_chars"] + search_regex = ( + pre_computed["broad_regex"] if has_special else pre_computed["pg_regex"] + ) + + candidates = list( + search_model.objects.annotate(text_value=Cast("value", TextField())) + .filter(text_value__iregex=search_regex) + .values_list("field_id", "row_id") + ) + + def match_fn(value: str) -> Optional[str]: + m = compiled_regex.search(value) + return m.group(0) if m else None + + all_matches = DataScannerHandler._verify_candidates( + candidates, match_fn, trashed_field_ids + ) + return DataScannerHandler._bulk_upsert_results( + scan, all_matches, now, trashed_field_ids + ) + @staticmethod def _run_list_scan( scan: DataScan, @@ -502,8 +638,8 @@ def _run_list_scan( ) -> int: """ Searches the workspace search table for rows matching any of the given - values using PostgreSQL full-text search. Processes values in batches - and bulk-upserts results. + values using PostgreSQL full-text search, then verifies matches against + actual cell values via ``get_search_expression``. :param scan: The scan being executed. :param search_model: The Django model for the workspace search table. @@ -525,126 +661,173 @@ def _run_list_scan( ) ) - all_matches: list[tuple[int, int, str]] = [] + # Collect candidate (field_id, row_id) pairs from the search table. + all_candidates: list[tuple[int, int]] = [] batch_size = 100 for i in range(0, len(values), batch_size): batch = values[i : i + batch_size] - # Build a list of (sanitized_query, original_value) pairs, skipping values - # that produce an empty sanitized string. - sanitized_pairs: list[tuple[str, str]] = [] + sanitized_parts: list[str] = [] for search_value in batch: - sanitized = SearchHandler.escape_postgres_query(search_value) + sanitized = DataScannerHandler._escape_list_value( + search_value, whole_words=scan.whole_words + ) if sanitized: - sanitized_pairs.append((sanitized, search_value)) + sanitized_parts.append(sanitized) - if not sanitized_pairs: + if not sanitized_parts: continue - # Combine all sanitized values into a single OR tsquery so we - # execute one database query per batch instead of one per value. - # Each individual tsquery is wrapped in parentheses to preserve - # the phrase (<->) operator precedence within each value. - combined_raw = " | ".join(f"({s})" for s, _ in sanitized_pairs) + combined_raw = " | ".join(f"({s})" for s in sanitized_parts) combined_query = SearchQuery( combined_raw, search_type="raw", config=SearchHandler.search_config(), ) - matches = ( - search_model.objects.filter(value=combined_query) - .annotate(text_value=Cast("value", TextField())) - .values_list("field_id", "row_id", "text_value") - ) - for field_id, row_id, text_value in matches: - if field_id in excluded_field_ids: - continue - matched_value = DataScannerHandler._find_list_match( - text_value, sanitized_pairs - ) - all_matches.append((field_id, row_id, matched_value)) + for field_id, row_id in search_model.objects.filter( + value=combined_query + ).values_list("field_id", "row_id"): + if field_id not in excluded_field_ids: + all_candidates.append((field_id, row_id)) + + # Build match function: find which list value matches the cell. + if scan.whole_words: + # Whole-word matching: the search value must appear as a complete word in + # the cell. We use regex word boundaries for this. + word_patterns = [ + (v, re.compile(r"\b" + re.escape(v) + r"\b", re.IGNORECASE)) + for v in values + ] + def match_fn(cell_value: str) -> Optional[str]: + for original, pattern in word_patterns: + if pattern.search(cell_value): + return original + return None + else: + values_pairs = [(v, v.lower()) for v in values] + + def match_fn(cell_value: str) -> Optional[str]: + cell_lower = cell_value.lower() + for original, lower_v in values_pairs: + if lower_v in cell_lower: + return original + return None + + all_matches = DataScannerHandler._verify_candidates( + all_candidates, match_fn, trashed_field_ids + ) return DataScannerHandler._bulk_upsert_results( scan, all_matches, now, trashed_field_ids ) @staticmethod - def _find_list_match( - tsvector_text: str, - sanitized_pairs: list[tuple[str, str]], - ) -> str: + def _verify_candidates( + candidates: list[tuple[int, int]], + match_fn: Callable[[str], Optional[str]], + trashed_field_ids: set[int], + ) -> list[tuple[int, int, str]]: """ - Given a tsvector text representation and the list of (sanitized_query, - original_value) pairs used to build the combined OR query, determines which - original value matched. Extracts tokens from the tsvector and checks which - query's terms are all present. - - :param tsvector_text: The text representation of a tsvector value. - :param sanitized_pairs: List of (sanitized_query, original_value). - :return: The original value that matched, or the first value as - fallback. + Given ``(field_id, row_id)`` candidate pairs from a search-table pre-filter, + looks up actual cell values using each field type's `get_search_expression` and + applies *match_fn* to determine true matches. + + :param candidates: List of (field_id, row_id) pairs. + :param match_fn: Receives a cell's string value and returns the + matched substring/value on success, or ``None`` to skip. + :param trashed_field_ids: Set of field IDs to exclude. + :return: List of (field_id, row_id, matched_value) triples. """ - tokens = {m.group(1) for m in re.finditer(r"'([^']*)'", tsvector_text)} - for sanitized, original in sanitized_pairs: - # Extract bare terms from the sanitized tsquery, stripping dollar-quoting, - # positional operators, and wildcards. - terms = re.findall(r"\$\$([^$]+)\$\$", sanitized) - if terms and all(term.lower() in tokens for term in terms): - return original - return sanitized_pairs[0][1] + candidates = [ + (fid, rid) for fid, rid in candidates if fid not in trashed_field_ids + ] + if not candidates: + return [] - @staticmethod - def _extract_matching_token(tsvector_text: str, compiled_regex: re.Pattern) -> str: - """ - Extracts the first matching token from a tsvector text representation. + candidate_field_ids = {field_id for field_id, _ in candidates} + tables = Table.objects.filter(field__id__in=candidate_field_ids).distinct() - A tsvector cast to text looks like `'nl23ingb0001234321':2 'test':1,3`. - Each token is a single-quoted string followed by `:` and position info. - We test each token against the compiled pattern regex and return the - first match. The token is already lowercased by PostgreSQL. + # Build a field_id → (table, row_ids) index by iterating tables and checking + # which candidate field_ids belong to each table's model. + candidate_map: dict[int, set[int]] = defaultdict(set) + for field_id, row_id in candidates: + candidate_map[field_id].add(row_id) - :param tsvector_text: The text representation of a tsvector value. - :param compiled_regex: A compiled regex to match tokens against. - :return: The first matching token, or the raw tsvector_text as fallback. - """ + all_matches: list[tuple[int, int, str]] = [] + for table in tables: + model = table.get_model() + table_field_ids = candidate_field_ids & set(model._field_objects.keys()) + if not table_field_ids: + continue + + row_ids: set[int] = set() + candidate_set: set[tuple[int, int]] = set() + for field_id in table_field_ids: + for row_id in candidate_map[field_id]: + row_ids.add(row_id) + candidate_set.add((field_id, row_id)) + + qs = model.objects.filter(id__in=row_ids) + + annotation_to_field_id: dict[str, int] = {} + annotations = {} + for field_id in table_field_ids: + field_object = model._field_objects.get(field_id) + if field_object is None: + continue + field = field_object["field"] + field_type = field_object["type"] + annotation_name = f"_scan_f{field_id}" + # Adding the field specific `get_search_expression` as annotation gives + # us the full searchable string as text, which is exactly what we want + # to use for the verification of the match. + annotations[annotation_name] = field_type.get_search_expression( + field, qs + ) + annotation_to_field_id[annotation_name] = field_id + + if not annotations: + continue + + annotation_names = list(annotation_to_field_id.keys()) + rows = qs.annotate(**annotations).values_list("id", *annotation_names) - for m in re.finditer(r"'([^']*)'", tsvector_text): - token = m.group(1) - if compiled_regex.search(token): - return token - return tsvector_text + for row_data in rows: + row_id = row_data[0] + for i, annotation_name in enumerate(annotation_names): + field_id = annotation_to_field_id[annotation_name] + if (field_id, row_id) not in candidate_set: + continue + value = row_data[i + 1] + if value is None: + continue + matched = match_fn(str(value)) + if matched is not None: + all_matches.append((field_id, row_id, matched)) + + return all_matches @staticmethod - def _process_pattern_matches( - scan: DataScan, - matches, - compiled_regex: re.Pattern, - now: datetime, - trashed_field_ids: set[int], - ) -> int: + def _escape_list_value(value: str, whole_words: bool = True) -> str: """ - Processes raw pattern matches from the database and bulk-upserts results. + Escapes a search value for use in a PostgreSQL tsquery. When `whole_words` is + True the trailing `:*` prefix wildcard is omitted so that only exact token + matches are returned. - :param scan: The scan being executed. - :param matches: An iterable of (field_id, row_id, text_value) tuples. - :param compiled_regex: The compiled pattern regex. - :param now: The current timestamp used for result bookkeeping. - :param trashed_field_ids: Set of field IDs to exclude because the - field, table, or database is trashed. - :return: The number of newly created results. + :param value: The raw search value. + :param whole_words: When True, do not add the `:*` wildcard. + :return: A sanitized tsquery fragment, or an empty string. """ - all_matches: list[tuple[int, int, str]] = [] - for field_id, row_id, text_value in matches: - matched = DataScannerHandler._extract_matching_token( - text_value, compiled_regex - ) - all_matches.append((field_id, row_id, matched)) - - return DataScannerHandler._bulk_upsert_results( - scan, all_matches, now, trashed_field_ids - ) + text = SearchHandler.escape_query(value) + if not text or not text.strip(): + return "" + words = text.strip().split() + parts = " <-> ".join(f"$${w}$$" for w in words) + if not whole_words: + parts = f"{parts}:*" + return parts @staticmethod def _bulk_upsert_results( diff --git a/enterprise/backend/src/baserow_enterprise/data_scanner/models.py b/enterprise/backend/src/baserow_enterprise/data_scanner/models.py index 91d7a4b8cf..75c087d418 100644 --- a/enterprise/backend/src/baserow_enterprise/data_scanner/models.py +++ b/enterprise/backend/src/baserow_enterprise/data_scanner/models.py @@ -56,6 +56,7 @@ class DataScan(models.Model): source_field = models.ForeignKey( Field, on_delete=models.SET_NULL, null=True, blank=True ) + whole_words = models.BooleanField(db_default=True, default=True) class Meta: ordering = ["-created_on"] diff --git a/enterprise/backend/src/baserow_enterprise/migrations/0060_datascan_whole_words_datascanresult_cell_value.py b/enterprise/backend/src/baserow_enterprise/migrations/0060_datascan_whole_words_datascanresult_cell_value.py new file mode 100644 index 0000000000..094d5a7a85 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/migrations/0060_datascan_whole_words_datascanresult_cell_value.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "baserow_enterprise", + "0059_datascanresultexportjob_datascan_datascanlistitem_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="datascan", + name="whole_words", + field=models.BooleanField(db_default=True, default=True), + ), + ] diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/admin/data_scanner/test_data_scanner_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/admin/data_scanner/test_data_scanner_views.py index 7e9f384236..8fffcc5af8 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/api/admin/data_scanner/test_data_scanner_views.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/admin/data_scanner/test_data_scanner_views.py @@ -93,6 +93,7 @@ def test_list_scans(api_client, enterprise_data_fixture): "pattern": "AA", "frequency": "manual", "scan_all_workspaces": True, + "whole_words": True, "workspace_ids": [], "is_running": False, "last_run_started_at": None, @@ -420,6 +421,43 @@ def test_create_scan_list_table_incompatible_source_field( assert response.status_code == HTTP_400_BAD_REQUEST +@pytest.mark.data_scanner +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_scan_whole_words(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + _, token = enterprise_data_fixture.create_user_and_token(is_staff=True) + + # Default should be True. + response = api_client.post( + reverse("api:enterprise:admin:data_scanner:list"), + { + "name": "Default whole_words", + "scan_type": "pattern", + "pattern": "AA", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()["whole_words"] is True + + # Explicitly setting whole_words=False should be forwarded. + response = api_client.post( + reverse("api:enterprise:admin:data_scanner:list"), + { + "name": "Disable whole_words", + "scan_type": "pattern", + "pattern": "AA", + "whole_words": False, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()["whole_words"] is False + + @pytest.mark.data_scanner @pytest.mark.django_db @override_settings(DEBUG=True) @@ -535,6 +573,7 @@ def test_get_scan(api_client, enterprise_data_fixture): "pattern": "AA", "frequency": "manual", "scan_all_workspaces": True, + "whole_words": True, "workspace_ids": [], "is_running": False, "last_run_started_at": None, diff --git a/enterprise/backend/tests/baserow_enterprise_tests/data_scanner/test_data_scanner_handler.py b/enterprise/backend/tests/baserow_enterprise_tests/data_scanner/test_data_scanner_handler.py index 900a4ef81e..1b31954386 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/data_scanner/test_data_scanner_handler.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/data_scanner/test_data_scanner_handler.py @@ -1,4 +1,3 @@ -import re from datetime import timedelta from unittest.mock import patch @@ -14,8 +13,13 @@ ) from baserow_enterprise.data_scanner.handler import ( DataScannerHandler, + _build_broad_token_regex, + _pattern_has_special_chars, convert_pattern_to_regex, ) + +# _build_broad_token_regex and _pattern_has_special_chars are still used as +# internal helpers in the handler. They are imported here for unit testing. from baserow_enterprise.data_scanner.models import ( DataScan, DataScanListItem, @@ -89,21 +93,23 @@ def test_convert_pattern_to_regex_trailing_backslash(): @pytest.mark.data_scanner -def test_extract_matching_token(): - compiled = re.compile(r"[A-Za-z][A-Za-z][0-9][0-9]", re.IGNORECASE) - - token = DataScannerHandler._extract_matching_token( - "'ab12':1 'something':2", compiled - ) - assert token == "ab12" +def test_pattern_has_special_chars(): + assert _pattern_has_special_chars("DDDD\\-DD\\-DD") is True + assert _pattern_has_special_chars("DDDD\\.DD") is True + assert _pattern_has_special_chars("AADDDD") is False + assert _pattern_has_special_chars("\\N\\LDDAAAADDDDDDDDDD") is False + assert _pattern_has_special_chars("\\N\\L\\-DD") is True @pytest.mark.data_scanner -def test_extract_matching_token_no_match_returns_raw(): - compiled = re.compile(r"[A-Za-z][A-Za-z][0-9][0-9]", re.IGNORECASE) +def test_build_broad_token_regex(): + # For DDDD-DD-DD, the longest token fragment is DDDD -> [0-9]{4 chars} + broad = _build_broad_token_regex("DDDD\\-DD\\-DD") + assert broad == "[0-9][0-9][0-9][0-9]" - token = DataScannerHandler._extract_matching_token("'hello':1", compiled) - assert token == "'hello':1" + # For AA.DD, the longest is AA -> [A-Za-z][A-Za-z] + broad = _build_broad_token_regex("AA\\.DD") + assert broad == "[A-Za-z][A-Za-z]" @pytest.mark.data_scanner @@ -1653,3 +1659,262 @@ def test_run_pattern_scan_excludes_trashed_field( scan.refresh_from_db() assert scan.last_error is None or scan.last_error == "" assert scan.results.count() == 0 + + +@pytest.mark.data_scanner +def test_convert_pattern_to_regex_special_characters(): + """ + Escaped special characters like hyphens must be preserved in the + generated regex so that patterns like ``DDDD\\-DD\\-DD`` can match + values such as ``2021-01-01``. + """ + + regex = convert_pattern_to_regex("DDDD\\-DD\\-DD") + assert regex == "[0-9][0-9][0-9][0-9]\\-[0-9][0-9]\\-[0-9][0-9]" + + import re + + compiled = re.compile(regex, re.IGNORECASE) + assert compiled.search("2021-01-01") is not None + assert compiled.search("9999-12-31") is not None + assert compiled.search("not a date") is None + + +@pytest.mark.data_scanner +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_run_pattern_scan_with_special_characters( + enterprise_data_fixture, populate_search_table +): + """ + A pattern containing escaped special characters (e.g. hyphens) must + match cell values in the actual user table even though tsvector + tokenization would strip those characters. + """ + + enterprise_data_fixture.enable_enterprise() + user = enterprise_data_fixture.create_user(is_staff=True) + table, fields, rows = enterprise_data_fixture.build_table( + user=user, + columns=[("Date", "text")], + rows=[ + ["2021-01-01"], + ["not a date"], + ["1999-12-31"], + ], + ) + field = fields[0] + populate_search_table(table, field, rows) + + workspace = table.database.workspace + scan = DataScannerHandler.create_scan( + user=user, + name="Date Pattern Test", + scan_type="pattern", + pattern="DDDD\\-DD\\-DD", + scan_all_workspaces=False, + workspace_ids=[workspace.id], + ) + + DataScannerHandler.run_scan(scan.id) + + scan.refresh_from_db() + assert scan.is_running is False + assert scan.last_error is None or scan.last_error == "" + + results = list( + scan.results.order_by("row_id").values_list("row_id", "matched_value") + ) + assert len(results) == 2 + assert results[0][0] == rows[0].id + assert results[0][1] == "2021-01-01" + assert results[1][0] == rows[2].id + assert results[1][1] == "1999-12-31" + + +@pytest.mark.data_scanner +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_run_pattern_scan_whole_words_true( + enterprise_data_fixture, populate_search_table +): + """ + When ``whole_words=True``, a pattern like ``DDDD`` must match ``1234`` + but not ``12345`` or ``1234test``. + """ + + enterprise_data_fixture.enable_enterprise() + user = enterprise_data_fixture.create_user(is_staff=True) + table, fields, rows = enterprise_data_fixture.build_table( + user=user, + columns=[("Code", "text")], + rows=[ + ["1234"], + ["12345"], + ["1234test"], + ["1234 test"], + ["test 1234 test"], + ], + ) + populate_search_table(table, fields[0], rows) + + workspace = table.database.workspace + scan = DataScannerHandler.create_scan( + user=user, + name="Whole Words Pattern", + scan_type="pattern", + pattern="DDDD", + whole_words=True, + scan_all_workspaces=False, + workspace_ids=[workspace.id], + ) + + DataScannerHandler.run_scan(scan.id) + + scan.refresh_from_db() + assert scan.is_running is False + assert scan.last_error is None or scan.last_error == "" + + matched_row_ids = set(scan.results.values_list("row_id", flat=True)) + assert rows[0].id in matched_row_ids # "1234" -> match + assert rows[1].id not in matched_row_ids # "12345" -> no match + assert rows[2].id not in matched_row_ids # "1234test" -> no match + assert rows[3].id in matched_row_ids # "1234 test" -> match + assert rows[4].id in matched_row_ids # "test 1234 test" -> match + + +@pytest.mark.data_scanner +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_run_pattern_scan_whole_words_false( + enterprise_data_fixture, populate_search_table +): + """ + When ``whole_words=False``, a pattern like ``DDDD`` must also match + ``12345`` because ``1234`` is contained within it. + """ + + enterprise_data_fixture.enable_enterprise() + user = enterprise_data_fixture.create_user(is_staff=True) + table, fields, rows = enterprise_data_fixture.build_table( + user=user, + columns=[("Code", "text")], + rows=[ + ["1234"], + ["12345"], + ["1234test"], + ], + ) + populate_search_table(table, fields[0], rows) + + workspace = table.database.workspace + scan = DataScannerHandler.create_scan( + user=user, + name="Partial Pattern", + scan_type="pattern", + pattern="DDDD", + whole_words=False, + scan_all_workspaces=False, + workspace_ids=[workspace.id], + ) + + DataScannerHandler.run_scan(scan.id) + + scan.refresh_from_db() + assert scan.is_running is False + assert scan.last_error is None or scan.last_error == "" + + matched_row_ids = set(scan.results.values_list("row_id", flat=True)) + assert rows[0].id in matched_row_ids # "1234" -> match + assert rows[1].id in matched_row_ids # "12345" -> match (partial) + assert rows[2].id in matched_row_ids # "1234test" -> match (partial) + + +@pytest.mark.data_scanner +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_run_list_scan_correct_matched_value( + enterprise_data_fixture, populate_search_table +): + """ + When scanning for a list of values, the matched_value must correctly + identify which search term matched, even when the cell value is longer + than the search term (prefix matching). + """ + + enterprise_data_fixture.enable_enterprise() + user = enterprise_data_fixture.create_user(is_staff=True) + table, fields, rows = enterprise_data_fixture.build_table( + user=user, + columns=[("Name", "text")], + rows=[["Fred"], ["Susan"], ["John"]], + ) + field = fields[0] + workspace = table.database.workspace + + populate_search_table(table, field, rows) + + scan = DataScannerHandler.create_scan( + user=user, + name="Name List Scan", + scan_type="list_of_values", + list_items=["Fred", "Susan", "John"], + scan_all_workspaces=False, + workspace_ids=[workspace.id], + whole_words=False, + ) + + DataScannerHandler.run_scan(scan.id) + + scan.refresh_from_db() + assert scan.is_running is False + assert scan.last_error is None or scan.last_error == "" + + results = {r.row_id: r.matched_value for r in scan.results.all()} + assert results.get(rows[0].id) == "Fred" + assert results.get(rows[1].id) == "Susan" + assert results.get(rows[2].id) == "John" + + +@pytest.mark.data_scanner +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_run_list_scan_whole_words_true(enterprise_data_fixture, populate_search_table): + """ + When ``whole_words=True``, list-of-values scans must only match exact + tokens, not prefixes. + """ + + enterprise_data_fixture.enable_enterprise() + user = enterprise_data_fixture.create_user(is_staff=True) + table, fields, rows = enterprise_data_fixture.build_table( + user=user, + columns=[("Name", "text")], + rows=[["John"], ["Johnny"], ["Johnson"]], + ) + field = fields[0] + workspace = table.database.workspace + + populate_search_table(table, field, rows) + + scan = DataScannerHandler.create_scan( + user=user, + name="Whole Words List", + scan_type="list_of_values", + list_items=["John"], + scan_all_workspaces=False, + workspace_ids=[workspace.id], + whole_words=True, + ) + + DataScannerHandler.run_scan(scan.id) + + scan.refresh_from_db() + assert scan.is_running is False + assert scan.last_error is None or scan.last_error == "" + + # Only exact match "John" should be found, not "Johnny" or "Johnson". + matched_row_ids = set(scan.results.values_list("row_id", flat=True)) + assert rows[0].id in matched_row_ids + assert rows[1].id not in matched_row_ids + assert rows[2].id not in matched_row_ids diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/admin/dataScanner/DataScanForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/admin/dataScanner/DataScanForm.vue index 3914aa3c0a..885318d7da 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/admin/dataScanner/DataScanForm.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/admin/dataScanner/DataScanForm.vue @@ -168,6 +168,19 @@ + + + {{ $t('dataScanner.wholeWordsCheckbox') }} + + + + - 0 " - > + :view="view" + :database="database" + :fields="fields" + :field-options="fieldOptions" + :read-only=" + readOnly || + !$hasPermission( + 'database.table.view.update', + view, + database.workspace.id + ) + " + :row-details-width="gridViewRowDetailsWidth" + :left-width="leftWidth" + :get-field-width="getFieldWidth" + @frozen-count-change="onFrozenCountDragChange" + > + 0 }, - primaryFieldIsSticky() { - return this.canFitInTwoColumns && !this.viewHasGroupBys + frozenColumnCount() { + return this.view.frozen_column_count ?? 1 + }, + hasFrozenColumns() { + return ( + this.canFitFrozenColumns && + !this.viewHasGroupBys && + this.frozenColumnCount > 0 + ) + }, + isEditable() { + return ( + !this.readOnly && + this.$hasPermission( + 'database.table.view.update', + this.view, + this.database.workspace.id + ) + ) }, + /** + * Returns the fields that should be displayed in the frozen left section. + * Takes the first N *visible* fields in sort order (primary always first). + */ leftFields() { - if (this.primaryFieldIsSticky) { - return this.fields.filter((field) => field.primary) - } else { + if (!this.hasFrozenColumns) { return [] } + const fieldOptions = this.fieldOptions + const sorted = this.fields + .slice() + .filter(filterVisibleFieldsFunction(fieldOptions)) + .sort(sortFieldsByOrderAndIdFunction(fieldOptions, true)) + return sorted.slice(0, this.frozenColumnCount) }, + /** + * Returns the fields that should be displayed in the scrollable right section. + */ rightFields() { - if (this.primaryFieldIsSticky) { - return this.fields.filter((field) => !field.primary) - } else { + if (!this.hasFrozenColumns) { return this.fields } + const leftIds = new Set(this.leftFields.map((f) => f.id)) + return this.fields.filter((f) => !leftIds.has(f.id)) }, leftFieldsWidth() { return this.leftFields.reduce( @@ -559,6 +622,20 @@ export default { this.activeGroupByWidth ) }, + /** + * All non-primary visible fields in order, used by the cross-section + * field dragging component when frozen columns > 1. + */ + allDraggableFields() { + return this.allVisibleFields.filter((f) => !f.primary) + }, + crossSectionDraggingOffset() { + const primary = this.fields.find((f) => f.primary) + return ( + this.gridViewRowDetailsWidth + + (primary ? this.getFieldWidth(primary) : 0) + ) + }, activeSearchTerm() { return this.$store.getters[ `${this.storePrefix}view/grid/getActiveSearchTerm` @@ -586,6 +663,12 @@ export default { // When a field is added or removed, we want to update the scrollbars. this.fieldsUpdated() }, + 'view.frozen_column_count'() { + // When the frozen column count changes (e.g. real-time sync from another + // user), recalculate the viewport fit and update scrollbars. Use $nextTick + // so the DOM reflects the new leftWidth before scrollbar recalculates. + this.$nextTick(() => this.fieldsUpdated()) + }, row: { deep: true, handler(newRow, prevRow) { @@ -682,6 +765,30 @@ export default { ) }, methods: { + onFrozenCountDragChange() { + // During drag we don't persist anything — the freeze handle component + // handles the optimistic save on mouseup. + }, + /** + * Returns a non-scrolling element for the cross-section field dragging. + * The grid view container itself doesn't scroll horizontally, which is + * correct since the dragging operates across both sections. + */ + getCrossSectionScrollElement() { + return this.$refs.gridView + }, + getCrossSectionScrollableElement() { + return this.$refs.right.$el + }, + /** + * Called when a non-primary field header is dragged in either section. + * Delegates to the shared cross-section field dragging component. + */ + startCrossSectionFieldDrag(field, event) { + if (this.$refs.crossSectionFieldDragging && !field.primary) { + this.$refs.crossSectionFieldDragging.start(field, event) + } + }, /** * Method to scroll viewport to a DOM element * Scroll direction can be limited to only one axis (both, vertical, horizontal) @@ -738,7 +845,7 @@ export default { if (scrollDirection !== 'vertical') { const fieldPrimary = field.primary - if (elementLeft < 0 && (!this.primaryFieldIsSticky || !fieldPrimary)) { + if (elementLeft < 0 && (!this.hasFrozenColumns || !fieldPrimary)) { // If the field isn't visible in the viewport we need to scroll left in order // to show it. this.horizontalScroll( @@ -747,7 +854,7 @@ export default { this.$refs.scrollbars.updateHorizontal() } else if ( elementRight > horizontalContainerWidth && - (!this.primaryFieldIsSticky || !fieldPrimary) + (!this.hasFrozenColumns || !fieldPrimary) ) { // If the field isn't visible in the viewport we need to scroll right in order // to show it. @@ -836,7 +943,7 @@ export default { // When anything related to the fields has been updated, it could be that it // doesn't fit in two columns anymore. Calling this method checks that. - this.checkCanFitInTwoColumns() + this.checkCanFitFrozenColumns() }, /** * Calls action in the store to refresh row directly from the backend - f. ex. @@ -1671,32 +1778,30 @@ export default { } }, /** - * This method figures out whether the first two columns have enough space to be - * usable using the primary field width. It updates the `canFitInTwoColumns` - * property accordingly. + * Checks whether the frozen columns fit in the viewport with at least 300px + * remaining for the scrollable section. Updates `canFitFrozenColumns`. */ - checkCanFitInTwoColumns() { - // In some cases this method is called when the component hasn't fully been - // loaded. This will make sure we don't change the state before that initial load. + checkCanFitFrozenColumns() { if (!this.$refs.gridView) { return } - // We're using `allVisibleFields` because it shouldn't matter if the primary - // field is in the left or right section. - const primary = this.allVisibleFields.find((f) => f.primary) - const maxWidth = - this.gridViewRowDetailsWidth + - (primary ? this.getFieldWidth(primary) : 0) + - 300 - - this.canFitInTwoColumns = this.$refs.gridView.clientWidth > maxWidth + const fieldOptions = this.fieldOptions + const sorted = this.fields + .slice() + .filter(filterVisibleFieldsFunction(fieldOptions)) + .sort(sortFieldsByOrderAndIdFunction(fieldOptions, true)) + const frozenWidth = sorted + .slice(0, this.frozenColumnCount) + .reduce((sum, field) => sum + this.getFieldWidth(field), 0) + const maxWidth = this.gridViewRowDetailsWidth + frozenWidth + 300 + this.canFitFrozenColumns = this.$refs.gridView.clientWidth > maxWidth }, /** * Event called when the grid view element window resizes. */ onWindowResize() { - this.checkCanFitInTwoColumns() + this.checkCanFitFrozenColumns() // Update the window height to dynamically show the right amount of rows. const height = this.$refs.left.$refs.body.clientHeight diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue index 6bf4fc70bf..cf27abc723 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue @@ -1,12 +1,22 @@ @@ -32,10 +42,6 @@ export default { required: false, default: 0, }, - containerWidth: { - type: Number, - required: true, - }, readOnly: { type: Boolean, required: true, @@ -44,6 +50,16 @@ export default { type: Function, required: true, }, + getScrollableElement: { + type: Function, + required: false, + default: null, + }, + frozenSectionWidth: { + type: Number, + required: false, + default: 0, + }, }, emits: ['scroll'], data() { @@ -62,12 +78,17 @@ export default { mouseStartY: 0, // The horizontal scrollbar offset starting position. scrollStart: 0, + // The visual left position of the field at drag start (in grid coords). + initialVisualLeft: 0, // The width of the dragging animation, this is equal to the width of the field. draggingWidth: 0, // The position of the dragging animation. draggingLeft: 0, // The position of the target indicator where the field is going to be moved to. targetLeft: 0, + // The left offset of the clipping container. When scrolled, clips the frozen + // area so scrolled-out positions aren't visible behind frozen fields. + clipOffset: 0, // The mouse move event. lastMoveEvent: null, // Indicates if the user is auto scrolling at the moment. @@ -83,6 +104,20 @@ export default { this.cancel() }, methods: { + _getScrollableElement() { + return this.getScrollableElement + ? this.getScrollableElement() + : this.getScrollElement() + }, + _contentToVisual(contentPos) { + if ( + this.frozenSectionWidth > 0 && + contentPos >= this.frozenSectionWidth + ) { + return contentPos - this._getScrollableElement().scrollLeft + } + return contentPos + }, getFieldLeft(id) { let left = 0 for (let i = 0; i < this.fields.length; i++) { @@ -105,7 +140,10 @@ export default { this.moved = false this.mouseStartX = event.clientX this.mouseStartY = event.clientY - this.scrollStart = this.getScrollElement().scrollLeft + const scrollable = this._getScrollableElement() + this.scrollStart = scrollable.scrollLeft + const contentLeft = this.offset + this.getFieldLeft(field.id) + this.initialVisualLeft = this._contentToVisual(contentLeft) this.draggingLeft = 0 this.targetLeft = 0 @@ -151,31 +189,32 @@ export default { } } - // This is the horizontally scrollable element. + // The positioning element for coordinate calculations (getBoundingClientRect). const element = this.getScrollElement() + // The element that actually scrolls horizontally. + const scrollable = this._getScrollableElement() this.draggingWidth = this.getFieldWidth(this.field) + this.clipOffset = scrollable.scrollLeft > 0 ? this.frozenSectionWidth : 0 - // Calculate the left position of the dragging animation. This is the transparent - // overlay that has the same width as the field. - this.draggingLeft = - this.offset + - Math.min( - this.getFieldLeft(this.field.id) + - event.clientX - - this.mouseStartX + - this.getScrollElement().scrollLeft - - this.scrollStart, - this.containerWidth - this.draggingWidth - ) + // The overlay is in the non-scrolling gridView container, so draggingLeft is + // the visual position directly. We anchor to the field's visual position at + // drag start and track mouse movement from there. + const unclampedLeft = + this.initialVisualLeft + event.clientX - this.mouseStartX + const visibleWidth = this.frozenSectionWidth + scrollable.clientWidth + this.draggingLeft = Math.min( + unclampedLeft, + visibleWidth - this.draggingWidth + ) - // Calculate which after which field we want to place the field that is currently - // being dragged. This is named the target. We also calculate what position the - // field would have for visualisation purposes. + // Calculate which field we want to place the dragged field after. mouseLeft is + // in content-space (accounts for scroll) so it can be compared against the + // cumulative field widths in the loop. const mouseLeft = event.clientX - element.getBoundingClientRect().left + - element.scrollLeft + scrollable.scrollLeft let left = this.offset for (let i = 0; i < this.fields.length; i++) { const width = this.getFieldWidth(this.fields[i]) @@ -189,12 +228,12 @@ export default { this.targetFieldId = 0 // The value 1 makes sure it is visible instead of falling outside of the // view port. - this.targetLeft = Math.max(this.offset, 1) + this.targetLeft = Math.max(this._contentToVisual(this.offset), 1) break } if (mouseLeft > leftHalf && mouseLeft < rightHalf) { this.targetFieldId = this.fields[i].id - this.targetLeft = left + width + this.targetLeft = this._contentToVisual(left + width) break } left += width @@ -204,23 +243,27 @@ export default { // moving the element outside of the view port at the left or right side, we // might need to initiate that process. if (!this.autoScrolling || !startAutoScroll) { - const relativeLeft = this.draggingLeft - element.scrollLeft - const relativeRight = relativeLeft + this.getFieldWidth(this.field) - const maxScrollLeft = element.scrollWidth - element.clientWidth + const maxScrollLeft = scrollable.scrollWidth - scrollable.clientWidth let speed = 0 - if (relativeLeft < 0 && element.scrollLeft > 0) { - // If the dragging animation falls out of the left side of the viewport we - // need to auto scroll to the left. - speed = -Math.ceil(Math.min(Math.abs(relativeLeft), 100) / 20) + if ( + unclampedLeft < this.frozenSectionWidth && + scrollable.scrollLeft > 0 + ) { + // If the dragging animation enters the frozen area, auto scroll left. + speed = -Math.ceil( + Math.min(Math.abs(unclampedLeft - this.frozenSectionWidth), 100) / + 20 + ) } else if ( - relativeRight > element.clientWidth && - element.scrollLeft < maxScrollLeft + unclampedLeft + this.draggingWidth > visibleWidth && + scrollable.scrollLeft < maxScrollLeft ) { // If the dragging animation falls out of the right side of the viewport we // need to auto scroll to the right. speed = Math.ceil( - Math.min(relativeRight - element.clientWidth, 100) / 20 + Math.min(unclampedLeft + this.draggingWidth - visibleWidth, 100) / + 20 ) } diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue index 1d6afd8cbe..664e5cf73f 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue @@ -282,7 +282,6 @@ +
+
+
+
+ {{ dragging ? tooltipText : $t('gridViewFreezeHandle.hoverHint') }} +
+
+ + + diff --git a/web-frontend/modules/database/components/view/grid/GridViewHead.vue b/web-frontend/modules/database/components/view/grid/GridViewHead.vue index dd97c04ee2..3d41701812 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewHead.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewHead.vue @@ -40,7 +40,6 @@ :field="field" :all-fields-in-table="allFieldsInTable" :filters="view.filters" - :include-field-width-handles="includeFieldWidthHandles" :read-only="readOnly" :store-prefix="storePrefix" @refresh="$emit('refresh', $event)" @@ -120,11 +119,6 @@ export default { type: Array, required: true, }, - includeFieldWidthHandles: { - type: Boolean, - required: false, - default: () => false, - }, includeRowDetails: { type: Boolean, required: false, diff --git a/web-frontend/modules/database/components/view/grid/GridViewRow.vue b/web-frontend/modules/database/components/view/grid/GridViewRow.vue index 12aa3d8c73..e2c32f4144 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRow.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRow.vue @@ -194,6 +194,10 @@ export default { type: Array, required: true, }, + allVisibleFields: { + type: Array, + required: true, + }, allFieldsInTable: { type: Array, required: true, @@ -229,11 +233,6 @@ export default { type: Number, required: true, }, - primaryFieldIsSticky: { - type: Boolean, - required: false, - default: () => true, - }, }, emits: [ 'update', @@ -392,9 +391,9 @@ export default { rowId ) - const allFieldIds = this.visibleFields.map((field) => field.id) - let fieldIndex = allFieldIds.findIndex((id) => field.id === id) - fieldIndex += !field.primary && this.primaryFieldIsSticky ? 1 : 0 + const fieldIndex = this.allVisibleFields.findIndex( + (f) => f.id === field.id + ) const [minRow, maxRow] = this.$store.getters[ diff --git a/web-frontend/modules/database/components/view/grid/GridViewRows.vue b/web-frontend/modules/database/components/view/grid/GridViewRows.vue index 11cd07ef8e..7c043b6e27 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRows.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRows.vue @@ -15,8 +15,8 @@ :row="row" :rendered-fields="renderedFields" :visible-fields="visibleFields" + :all-visible-fields="allVisibleFields" :all-fields-in-table="allFieldsInTable" - :primary-field-is-sticky="primaryFieldIsSticky" :field-widths="fieldWidths" :include-row-details="includeRowDetails" :include-group-by="includeGroupBy" @@ -77,6 +77,10 @@ export default { type: Array, required: true, }, + allVisibleFields: { + type: Array, + required: true, + }, /** * All the fields in the table, regardless of the visibility, or whether they * should be rendered. @@ -116,11 +120,6 @@ export default { type: Number, required: true, }, - primaryFieldIsSticky: { - type: Boolean, - required: false, - default: () => true, - }, rowsAtEndOfGroups: { type: Set, required: true, diff --git a/web-frontend/modules/database/components/view/grid/GridViewSection.vue b/web-frontend/modules/database/components/view/grid/GridViewSection.vue index 3ee6d8a7bb..fcdc8e08e6 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewSection.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewSection.vue @@ -35,7 +35,6 @@ :view="view" :all-fields-in-table="allFieldsInTable" :visible-fields="visibleFields" - :include-field-width-handles="includeFieldWidthHandles" :include-row-details="includeRowDetails" :include-add-field="includeAddField" :include-grid-view-identifier-dropdown=" @@ -46,11 +45,7 @@ :store-prefix="storePrefix" @field-created="$emit('field-created', $event)" @refresh="$emit('refresh', $event)" - @dragging=" - canOrderFields && - !$event.field.primary && - $refs.fieldDragging.start($event.field, $event.event) - " + @dragging="handleFieldDragging($event)" >
- @@ -194,7 +171,6 @@ import GridViewPlaceholder from '@baserow/modules/database/components/view/grid/ import GridViewGroups from '@baserow/modules/database/components/view/grid/GridViewGroups' import GridViewRows from '@baserow/modules/database/components/view/grid/GridViewRows' import GridViewRowAdd from '@baserow/modules/database/components/view/grid/GridViewRowAdd' -import GridViewFieldDragging from '@baserow/modules/database/components/view/grid/GridViewFieldDragging' import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers' import GridViewFieldFooter from '@baserow/modules/database/components/view/grid/GridViewFieldFooter' import HorizontalResize from '@baserow/modules/core/components/HorizontalResize' @@ -209,7 +185,6 @@ export default { GridViewGroups, GridViewRows, GridViewRowAdd, - GridViewFieldDragging, GridViewFieldFooter, }, mixins: [gridViewHelpers], @@ -218,6 +193,10 @@ export default { type: Array, required: true, }, + allVisibleFields: { + type: Array, + required: true, + }, allFieldsInTable: { type: Array, required: true, @@ -238,11 +217,6 @@ export default { type: Object, required: true, }, - includeFieldWidthHandles: { - type: Boolean, - required: false, - default: () => true, - }, includeRowDetails: { type: Boolean, required: false, @@ -268,11 +242,6 @@ export default { required: false, default: () => false, }, - primaryFieldIsSticky: { - type: Boolean, - required: false, - default: () => true, - }, readOnly: { type: Boolean, required: true, @@ -302,6 +271,7 @@ export default { 'row-context', 'scroll', 'field-created', + 'field-dragging', 'refresh', ], data() { @@ -337,20 +307,6 @@ export default { return width }, - draggingFields() { - return this.visibleFields.filter((f) => !f.primary) - }, - draggingOffset() { - let offset = this.visibleFields - .filter((f) => f.primary) - .reduce((sum, f) => sum + this.getFieldWidth(f), 0) - - if (this.includeRowDetails) { - offset += this.gridViewRowDetailsWidth - } - - return offset - }, groupByDividers() { if (!this.includeGroupBy) { return [] @@ -602,6 +558,10 @@ export default { } }, methods: { + handleFieldDragging(event) { + if (!this.canOrderFields || event.field.primary) return + this.$emit('field-dragging', event) + }, /** * For performance reasons we only want to render the cells are visible in the * viewport. This method makes sure that the right cells/fields are visible. It's diff --git a/web-frontend/modules/database/components/view/grid/SimpleGrid.vue b/web-frontend/modules/database/components/view/grid/SimpleGrid.vue index 7c7f37c514..a5af7d0dd7 100644 --- a/web-frontend/modules/database/components/view/grid/SimpleGrid.vue +++ b/web-frontend/modules/database/components/view/grid/SimpleGrid.vue @@ -69,7 +69,10 @@ " class="simple-grid__cell-checkbox" > - +
@@ -266,7 +261,6 @@ import { helpers, } from '@vuelidate/validators' import { uuid } from '@baserow/modules/core/utils/string' -import { BASEROW_FORMULA_MODES } from '@baserow/modules/core/formula/constants' export default { name: 'CoreHTTPRequestService', @@ -301,9 +295,6 @@ export default { } }, computed: { - BASEROW_FORMULA_MODES() { - return BASEROW_FORMULA_MODES - }, methods() { return [ { name: 'GET', value: 'GET' }, diff --git a/web-frontend/modules/integrations/core/components/services/CoreSMTPEmailServiceForm.vue b/web-frontend/modules/integrations/core/components/services/CoreSMTPEmailServiceForm.vue index ad9129caab..e45d631e1a 100644 --- a/web-frontend/modules/integrations/core/components/services/CoreSMTPEmailServiceForm.vue +++ b/web-frontend/modules/integrations/core/components/services/CoreSMTPEmailServiceForm.vue @@ -115,11 +115,7 @@ > @@ -184,8 +180,10 @@ export default { } }, computed: { - BASEROW_FORMULA_MODES() { - return BASEROW_FORMULA_MODES + bodyFormulaMode() { + return this.values.body_type !== 'html' + ? BASEROW_FORMULA_MODES + : ['raw', 'simple'] }, showInstanceSmtpOption() { return Boolean(this.service?.instance_smtp_settings_enabled) diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap index ae72d67931..1c4064e9b5 100644 --- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap +++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap @@ -401,18 +401,6 @@ exports[`Public View Page Tests > Can see a publicly shared grid view 1`] = `
-
-
-
-
Can see a publicly shared grid view 1`] = `
+ + -
-
-
-
+ + -
-
-
-
@@ -433,10 +425,10 @@ exports[`Table Component Tests > Adding a row to a table increases the row count -
-
-
-
+ + -
-
-
-
Default component with first_cell
+ +