From 7781461158acea48221c772f3c77be7c045f4dac Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:58:26 +0200 Subject: [PATCH 01/12] feat (database): add pin/freeze columns for grid view (#5055) * feat: add pin/freeze columns for grid view Allow users to freeze up to 4 columns on the left side of the grid view by dragging a handle at the divider between frozen and scrollable sections. Frozen columns stay visible while scrolling horizontally. - Add `frozen_column_count` nullable field to GridView model (null = 1) - Register in GridViewType (allowed_fields, serializer, export/import) - Update leftFields/rightFields computed to split by frozen count - Add GridViewFreezeHandle component with drag-to-snap interaction - Enable per-field width handles on frozen section - Support cross-section field dragging (reorder across freeze boundary) - Hide freeze handle in read-only/public/template views - Disable frozen columns when group-bys are active * fix: multi-cell selection offset with frozen columns The selection used section-relative field indices in GridViewRow while the store held absolute indices, causing selections to render on the wrong columns when frozen columns were active. * fix: update test snapshots and minor cleanup for freeze columns Update Vitest snapshots to include the conditional GridViewFieldDragging component comment. Fix justfile update-snapshots recipe for Vitest. Minor template formatting in GridViewFreezeHandle. * fix: address review feedback for freeze columns - Filter hidden fields in leftFields/checkCanFitFrozenColumns so frozen_column_count refers to visible fields only - Filter hidden fields in GridViewFreezeHandle sortedFields/boundaries so hidden fields don't affect drag snapping or max count - Add null guard for closest('.grid-view') in freeze handle drag - Remove unused tooltip i18n key from en.json - Fix double border at freeze boundary by removing border-right on last column and placeholder in the frozen (left) section - Add frozen_column_count to test assertions for serialized grid views * fix: redesign freeze handle with improved drag UX Replace the blue dot with a solid pill grip that follows the cursor, make the drag line follow the mouse freely with a snap preview line at the nearest column boundary, and add hover hint tooltip. Disable hover visuals during multi-cell selection. * address feedback * address feedback v2 * address copilot feedback * Changed `grid-view__freeze-handle-grip` transition time * Revert last child change * Change line break comment * fix: address feedback v3 --------- Co-authored-by: Bram Wiepjes --- .../0208_gridview_frozen_column_count.py | 19 ++ .../baserow/contrib/database/views/models.py | 3 + .../contrib/database/views/view_types.py | 18 +- .../airtable/test_airtable_handler.py | 1 + .../airtable/test_airtable_view_types.py | 1 + .../api/views/grid/test_grid_view_views.py | 1 + .../database/api/views/test_view_views.py | 2 + .../view/test_view_webhook_event_types.py | 4 + .../unreleased/feature/freeze_columns.json | 9 + .../assets/scss/components/views/grid.scss | 109 ++++++- .../components/view/grid/GridView.vue | 191 ++++++++--- .../view/grid/GridViewFieldDragging.vue | 117 ++++--- .../view/grid/GridViewFieldType.vue | 5 - .../view/grid/GridViewFreezeHandle.vue | 306 ++++++++++++++++++ .../components/view/grid/GridViewHead.vue | 6 - .../components/view/grid/GridViewRow.vue | 15 +- .../components/view/grid/GridViewRows.vue | 11 +- .../components/view/grid/GridViewSection.vue | 62 +--- web-frontend/modules/database/locales/en.json | 4 + .../__snapshots__/publicView.spec.js.snap | 35 +- .../database/__snapshots__/table.spec.js.snap | 24 +- .../gridViewDecoration.spec.js.snap | 105 +++--- 22 files changed, 779 insertions(+), 269 deletions(-) create mode 100644 backend/src/baserow/contrib/database/migrations/0208_gridview_frozen_column_count.py create mode 100644 changelog/entries/unreleased/feature/freeze_columns.json create mode 100644 web-frontend/modules/database/components/view/grid/GridViewFreezeHandle.vue 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/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/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/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/web-frontend/modules/core/assets/scss/components/views/grid.scss b/web-frontend/modules/core/assets/scss/components/views/grid.scss index a67556f88f..6d2bb7dc1b 100644 --- a/web-frontend/modules/core/assets/scss/components/views/grid.scss +++ b/web-frontend/modules/core/assets/scss/components/views/grid.scss @@ -2,6 +2,7 @@ @include absolute(0); overflow: hidden; + isolation: isolate; .scrollbars__vertical-wrapper { // We don't want to scrollbar to go over the header and footer. @@ -52,6 +53,14 @@ .grid-view__left { left: 0; background-color: $palette-neutral-25; + + // Let the last frozen column's resize handle extend past the boundary. + overflow: visible; + z-index: 4; // Above right section (1) and divider (3). + + .grid-view__inner { + overflow: visible; + } } // The width of the first column can be adjusted that is why the left offset is @@ -96,6 +105,82 @@ z-index: 3; } +// The freeze handle starts below the header (33px) so it doesn't block +// per-field column width resize handles in the header. +.grid-view__freeze-handle { + @include absolute(33px, auto, 0, auto); + + width: 12px; + margin-left: -6px; + z-index: 4; + cursor: col-resize; + + &::before { + content: ''; + + @include absolute(0, 5px, 0, 5px); + + border-radius: 2px; + transition: + background-color 0.1s ease, + right 0.1s ease, + left 0.1s ease; + } + + &:hover::before { + background-color: $color-primary-500; + } + + // During drag, increase z-index to stay on top. Line stays below header. + &--dragging { + z-index: 5; + + &::before { + background-color: $color-primary-500; + } + } +} + +// Grip handle: small solid pill that follows the cursor along the line. +.grid-view__freeze-handle-grip { + position: absolute; + left: 50%; + width: 8px; + height: 20px; + margin-left: -4px; + border-radius: 4px; + background-color: $color-primary-500; + pointer-events: none; + z-index: 6; + transition: background-color 0.1s ease; +} + +// Vertical snap preview line shown at the target column boundary during drag. +.grid-view__freeze-snap-line { + @include absolute(0, auto, 0, auto); + + width: 2px; + margin-left: -1px; + background-color: $color-primary-500; + opacity: 0.5; + pointer-events: none; + z-index: 5; +} + +.grid-view__freeze-handle-tooltip { + position: absolute; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + padding: 4px 8px; + background-color: $palette-neutral-1200; + color: $white; + font-size: 12px; + border-radius: 4px; + pointer-events: none; + z-index: 10; +} + .grid-view__head { @include absolute(0, 0, auto, 0); @@ -105,6 +190,10 @@ border-bottom: 1px solid $palette-neutral-400; } +.grid-view__left .grid-view__head { + overflow: visible; +} + .grid-view__head-row-identifier { @include flex-align-items; @@ -213,7 +302,10 @@ border-right: 1px solid $palette-neutral-200; - &.grid-view__placeholder-column--no-border-right, + &.grid-view__placeholder-column--no-border-right { + border-right: none; + } + .grid-view__left & { border-right: none; } @@ -337,8 +429,11 @@ border-bottom: 0; } - &.grid-view__column--no-border-right, - .grid-view__left & { + &.grid-view__column--no-border-right { + border-right: none; + } + + .grid-view__left &:last-child { border-right: none; } @@ -760,6 +855,14 @@ } } +.grid-view__field-dragging-container { + position: absolute; + top: 0; + bottom: 0; + overflow: hidden; + z-index: 4; +} + .grid-view__field-dragging { @include absolute(0, auto); diff --git a/web-frontend/modules/database/components/view/grid/GridView.vue b/web-frontend/modules/database/components/view/grid/GridView.vue index 88d236600a..fb2920409c 100644 --- a/web-frontend/modules/database/components/view/grid/GridView.vue +++ b/web-frontend/modules/database/components/view/grid/GridView.vue @@ -22,15 +22,16 @@ ref="left" class="grid-view__left" :visible-fields="leftFields" + :all-visible-fields="allVisibleFields" :all-fields-in-table="fields" :decorations-by-place="decorationsByPlace" :database="database" :table="table" :view="view" - :include-field-width-handles="false" :include-row-details="!viewHasGroupBys" :include-grid-view-identifier-dropdown="!viewHasGroupBys" :include-group-by="true" + :can-order-fields="frozenColumnCount > 1" :read-only=" readOnly || (!$hasPermission( @@ -48,6 +49,7 @@ :style="{ width: leftWidth + 'px' }" @refresh="$emit('refresh', $event)" @field-created="fieldCreated" + @field-dragging="startCrossSectionFieldDrag($event.field, $event.event)" @row-hover="setRowHover($event.row, $event.value)" @row-context="showRowContext($event.event, $event.row)" @row-dragging="rowDragStart" @@ -76,17 +78,27 @@ class="grid-view__divider" :style="{ left: leftWidth + 'px' }" > - 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/locales/en.json b/web-frontend/modules/database/locales/en.json index 4e27ba1964..fcfb75df1b 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -1115,6 +1115,10 @@ "medium": "Medium", "large": "Large" }, + "gridViewFreezeHandle": { + "freeze": "Freeze 0 columns | Freeze 1 column | Freeze {count} columns", + "hoverHint": "Drag to freeze columns" + }, "configureDataSyncModal": { "title": "Data sync", "syncedFields": "Synced fields", 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
+ +