From d14955cb01f2c87b26782dec1f66a9f5c87eff89 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Fri, 30 Oct 2026 17:25:54 +0100 Subject: [PATCH 1/3] Bump browserslist to support the baseline queries of host apps --- pnpm-lock.yaml | 53 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff2c67c3..70b64d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2314,6 +2314,11 @@ packages: resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} engines: {node: '>=0.10.0'} + baseline-browser-mapping@2.11.34: + resolution: {integrity: sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==} + engines: {node: '>=6.0.0'} + hasBin: true + basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} @@ -2587,8 +2592,8 @@ packages: resolution: {integrity: sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==} hasBin: true - browserslist@4.24.4: - resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + browserslist@4.29.5: + resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2659,6 +2664,9 @@ packages: caniuse-lite@1.0.30001692: resolution: {integrity: sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==} + caniuse-lite@1.0.30001820: + resolution: {integrity: sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==} + capture-exit@2.0.0: resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3346,6 +3354,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.468: + resolution: {integrity: sha512-8XdEp3sIqO+PzmVPxhzqHq/3+yCxp44m27cm2WY1sRn3ZhwKyKmqSi2hx6gEhmnOXmh8NUuYE00z6Onzn5m6DA==} + electron-to-chromium@1.5.80: resolution: {integrity: sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw==} @@ -5552,8 +5563,9 @@ packages: node-notifier@10.0.1: resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.62: + resolution: {integrity: sha512-A0Eq+qVDQI97IbAD6g/HriIuv256el9N8EwF4+xqJ49pwrdRYpdCSmLk0sO4I3fnS6hpYE5v22B73Bk+WArEoQ==} + engines: {node: '>=18'} node-watch@0.7.3: resolution: {integrity: sha512-3l4E8uMPY1HdMMryPRUAl+oIHtXtyiTlIiESNSVSNxcPfzAFzeTbXFQkZfAwBbo0B1qMSG8nUABx+Gd+YrbKrQ==} @@ -7099,8 +7111,8 @@ packages: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} - update-browserslist-db@1.1.2: - resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7427,7 +7439,7 @@ snapshots: dependencies: '@babel/compat-data': 7.26.5 '@babel/core': 7.26.0 - browserslist: 4.24.4 + browserslist: 4.29.5 invariant: 2.2.4 levenary: 1.1.1 semver: 5.7.2 @@ -10162,6 +10174,8 @@ snapshots: mixin-deep: 1.3.2 pascalcase: 0.1.1 + baseline-browser-mapping@2.11.34: {} + basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 @@ -10789,12 +10803,13 @@ snapshots: caniuse-lite: 1.0.30001692 electron-to-chromium: 1.5.80 - browserslist@4.24.4: + browserslist@4.29.5: dependencies: - caniuse-lite: 1.0.30001692 - electron-to-chromium: 1.5.80 - node-releases: 2.0.19 - update-browserslist-db: 1.1.2(browserslist@4.24.4) + baseline-browser-mapping: 2.11.34 + caniuse-lite: 1.0.30001820 + electron-to-chromium: 1.5.468 + node-releases: 2.0.62 + update-browserslist-db: 1.3.4(browserslist@4.29.5) bser@2.1.1: dependencies: @@ -10892,6 +10907,8 @@ snapshots: caniuse-lite@1.0.30001692: {} + caniuse-lite@1.0.30001820: {} + capture-exit@2.0.0: dependencies: rsvp: 4.8.5 @@ -11184,7 +11201,7 @@ snapshots: core-js-compat@3.40.0: dependencies: - browserslist: 4.24.4 + browserslist: 4.29.5 core-js@2.6.12: {} @@ -11450,6 +11467,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.468: {} + electron-to-chromium@1.5.80: {} elliptic@6.6.1: @@ -14615,7 +14634,7 @@ snapshots: uuid: 8.3.2 which: 2.0.2 - node-releases@2.0.19: {} + node-releases@2.0.62: {} node-watch@0.7.3: {} @@ -16400,9 +16419,9 @@ snapshots: upath@1.2.0: optional: true - update-browserslist-db@1.1.2(browserslist@4.24.4): + update-browserslist-db@1.3.4(browserslist@4.29.5): dependencies: - browserslist: 4.24.4 + browserslist: 4.29.5 escalade: 3.2.0 picocolors: 1.1.1 @@ -16572,7 +16591,7 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.14.0 - browserslist: 4.24.4 + browserslist: 4.29.5 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.0 es-module-lexer: 1.6.0 From 097e9caf20f954844b16cdb5104d14a415699db8 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Fri, 30 Oct 2026 17:25:55 +0100 Subject: [PATCH 2/3] Add Shift-click range selection to HyperTableV2 --- README.md | 6 + addon/components/hyper-table-v2/index.hbs | 1 + addon/components/hyper-table-v2/index.ts | 12 + addon/core/handler.ts | 50 ++++ app/styles/columns.less | 2 + .../components/hyper-table-v2-test.ts | 162 ++++++++++++- tests/unit/core/handler-test.ts | 222 +++++++++++++++++- 7 files changed, 453 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 516951e1..bdf91146 100644 --- a/README.md +++ b/README.md @@ -356,11 +356,17 @@ this.tableHandler.clearSelection(); // Update individual row selection this.tableHandler.updateSelection(row); +// Apply the state of the last clicked row up to this one +// Returns false when no range applies (no anchor, removed anchor, or nothing to change) +this.tableHandler.updateSelectionRange(row); + // Access current selection state const selection = this.handler.selection; // Row[] | 'all' const exclusions = this.handler.exclusion; // Row[] (when selection is 'all') ``` +Holding Shift while clicking on a row of the selection column applies the state of the last clicked row to every loaded row in between, in both directions. In global mode, the range updates `exclusion` instead of `selection`. When the range has nothing to change, only the clicked row is toggled. The anchor is reset by `clearSelection`, `selectAllGlobal` and `toggleSelectAll`. + ### Filtering and Ordering Filter and order structures are used internally by the TableHandler to manage table state and are passed to the TableManager for persistence. See `@upfluence/hypertable/core/interfaces/column.ts` for complete type definitions: diff --git a/addon/components/hyper-table-v2/index.hbs b/addon/components/hyper-table-v2/index.hbs index ae92a071..36bb992c 100644 --- a/addon/components/hyper-table-v2/index.hbs +++ b/addon/components/hyper-table-v2/index.hbs @@ -124,6 +124,7 @@ @onClick={{fn this.toggleRowSelection row}} @onHover={{this.onRowHover}} @loading={{row._isLoading}} + {{on "click" (fn this.onSelectionCellClick row) capture=true}} > { } } + @action + onSelectionCellClick(row: Row, event: MouseEvent): void { + if (!event.shiftKey || row._isLoading) return; + + event.preventDefault(); + event.stopPropagation(); + + if (!this.args.handler.updateSelectionRange(row)) { + this.toggleRowSelection(row); + } + } + @action scrollToEnd(): void { const table = this.innerTableElement; diff --git a/addon/core/handler.ts b/addon/core/handler.ts index 1f696fd8..ced8ed08 100644 --- a/addon/core/handler.ts +++ b/addon/core/handler.ts @@ -44,6 +44,7 @@ export default class TableHandler { private _context: unknown; private _renderingResolver?: RendererResolver; private _lastOrderedColumn?: Column; + private _selectionAnchor?: Row; tableManager: TableManager; rowsFetcher: RowsFetcher; @@ -425,6 +426,7 @@ export default class TableHandler { this.selectAllGlobal(); } else { this.selection = this.rows; + this._selectionAnchor = undefined; } } else { this.clearSelection(); @@ -439,6 +441,7 @@ export default class TableHandler { selectAllGlobal(): void { this.selection = 'all'; this.exclusion = []; + this._selectionAnchor = undefined; } /** @@ -449,6 +452,7 @@ export default class TableHandler { clearSelection(): void { this.selection = []; this.exclusion = []; + this._selectionAnchor = undefined; } /** @@ -458,6 +462,7 @@ export default class TableHandler { */ updateSelection(row: Row): void { this.selection = this.selection instanceof Array ? this.selection : []; + this._selectionAnchor = row; if (this.selection.includes(row)) { this.selection = this.selection.filter((selectedRow: Row) => selectedRow !== row); @@ -475,6 +480,8 @@ export default class TableHandler { * @param {Row} row */ updateExclusion(row: Row): void { + this._selectionAnchor = row; + if (this.exclusion.includes(row)) { this.exclusion = this.exclusion.filter((excludedRow: Row) => excludedRow !== row); } else { @@ -482,6 +489,38 @@ export default class TableHandler { } } + updateSelectionRange(row: Row): boolean { + const anchorIndex = this._selectionAnchor ? this.rows.indexOf(this._selectionAnchor) : -1; + const rowIndex = this.rows.indexOf(row); + + if (anchorIndex === -1 || rowIndex === -1) return false; + + const selected = this.isRowSelected(this.rows[anchorIndex]); + const range = this.rows + .slice(Math.min(anchorIndex, rowIndex), Math.max(anchorIndex, rowIndex) + 1) + .filter((rangeRow) => !rangeRow._isLoading); + + if (range.every((rangeRow) => this.isRowSelected(rangeRow) === selected)) return false; + + this._selectionAnchor = row; + + if (this.selection === 'all') { + this.exclusion = this.mergeRange(this.exclusion, range, !selected); + + if (this.exclusion.length === this.rowsMeta?.total) { + this.clearSelection(); + } + } else { + this.selection = this.mergeRange(this.selection, range, selected); + + if (selected && this.selection.length === this.rowsMeta?.total) { + this.selection = 'all'; + } + } + + return true; + } + /** * Toggles an instance of Tether for a given column. * @@ -522,6 +561,17 @@ export default class TableHandler { this.currentPage = 1; } + private isRowSelected(row: Row): boolean { + return this.selection === 'all' ? !this.exclusion.includes(row) : this.selection.includes(row); + } + + private mergeRange(rows: Row[], range: Row[], included: boolean): Row[] { + const rangeRows = new Set(range); + const rowsOutsideRange = rows.filter((row) => !rangeRows.has(row)); + + return included ? [...rowsOutsideRange, ...range] : rowsOutsideRange; + } + private upsertTetherInstance(options: Tether.ITetherOptions): void { if (this.tetherInstance) { this.tetherInstance.setOptions(options); diff --git a/app/styles/columns.less b/app/styles/columns.less index 113e2cc9..33141ada 100644 --- a/app/styles/columns.less +++ b/app/styles/columns.less @@ -169,6 +169,8 @@ .hypertable__cell { border-right: none; + -webkit-user-select: none; + user-select: none; } } diff --git a/tests/integration/components/hyper-table-v2-test.ts b/tests/integration/components/hyper-table-v2-test.ts index 5daa3bd6..8fc363c4 100644 --- a/tests/integration/components/hyper-table-v2-test.ts +++ b/tests/integration/components/hyper-table-v2-test.ts @@ -1,4 +1,4 @@ -import { click, render, findAll, triggerEvent, waitUntil, type TestContext } from '@ember/test-helpers'; +import { click, render, findAll, settled, triggerEvent, waitUntil, type TestContext } from '@ember/test-helpers'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; @@ -9,6 +9,19 @@ import TableHandler from '@upfluence/hypertable/core/handler'; import { TableManager, RowsFetcher, AllRowsFetcher } from '@upfluence/hypertable/test-support'; import { buildColumn } from '@upfluence/hypertable/test-support/table-manager'; +const ROW_CELL = '.hypertable__sticky-columns .hypertable__column:last-child .hypertable__cell'; +const ROW_SELECTION_CELL = '.hypertable__column--selection .hypertable__cell'; +const ROW_SELECTION_CHECKBOX = '.upf-checkbox'; +const SELECTED_COUNT = '.selected-count'; + +function rowSelectionCell(rowPosition: number): string { + return `${ROW_SELECTION_CELL}:nth-of-type(${rowPosition})`; +} + +function rowSelectionCheckbox(rowPosition: number): string { + return `${rowSelectionCell(rowPosition)} ${ROW_SELECTION_CHECKBOX}`; +} + module('Integration | Component | hyper-table-v2', function (hooks) { setupRenderingTest(hooks); @@ -717,6 +730,153 @@ module('Integration | Component | hyper-table-v2', function (hooks) { }); }); + module('when holding Shift while clicking on a row', (hooks) => { + hooks.beforeEach(async function (this: TestContext) { + await render(hbs``); + }); + + test('it selects the rows between the anchor and a row below it', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, this.handler.rows); + assert.dom(`${rowSelectionCheckbox(2)} input`).isChecked(); + assert.dom(SELECTED_COUNT).hasText('3'); + }); + + test('it selects the rows between the anchor and a row above it', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(3)); + await click(rowSelectionCheckbox(1), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, this.handler.rows); + assert.dom(`${rowSelectionCheckbox(2)} input`).isChecked(); + }); + + test('it unselects the range when the anchor has just been unselected', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + await click(rowSelectionCheckbox(3)); + await click(rowSelectionCheckbox(2), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, [this.handler.rows[0]]); + assert.dom(`${rowSelectionCheckbox(2)} input`).isNotChecked(); + assert.dom(SELECTED_COUNT).hasText('1'); + }); + + test('it also works when clicking next to the checkbox', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCell(1)); + await click(rowSelectionCell(3), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, this.handler.rows); + }); + + test('it toggles the row alone when there is no anchor', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(2), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, [this.handler.rows[1]]); + }); + + test('it toggles the row alone when nothing is selected in the range of an unselected anchor', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, [this.handler.rows[2]]); + }); + + test('it toggles the row alone when the whole range is already selected', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + await click(rowSelectionCheckbox(2), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, [this.handler.rows[0], this.handler.rows[2]]); + }); + + test('it ignores a row being loaded', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await this.handler.toggleRowLoadingState(this.handler.rows[2].record_id); + await settled(); + await click(rowSelectionCell(3), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, [this.handler.rows[0]]); + }); + + test('it toggles the row alone after the selection has been cleared', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + this.handler.clearSelection(); + await settled(); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, [this.handler.rows[2]]); + }); + + test('it does not trigger the row-click event', async function (this: TestContext, assert: Assert) { + const onRowClick = sinon.stub(); + this.handler.on('row-click', onRowClick); + + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + + assert.true(onRowClick.notCalled); + }); + + test('it leaves the data cells untouched', async function (this: TestContext, assert: Assert) { + const onRowClick = sinon.stub(); + this.handler.on('row-click', onRowClick); + + await click(ROW_CELL, { shiftKey: true }); + + assert.true(onRowClick.calledOnceWithExactly(this.handler.rows[0])); + assert.deepEqual(this.handler.selection, []); + }); + + module('when all the rows are globally selected', (hooks) => { + hooks.beforeEach(async function (this: TestContext) { + this.handler.selectAllGlobal(); + await settled(); + }); + + test('it excludes the range', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + + assert.strictEqual(this.handler.selection, 'all'); + assert.deepEqual(this.handler.exclusion, this.handler.rows); + assert.dom(`${rowSelectionCheckbox(2)} input`).isNotChecked(); + assert.dom(SELECTED_COUNT).hasText('9'); + }); + + test('it restores the range', async function (this: TestContext, assert: Assert) { + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + await click(rowSelectionCheckbox(3)); + await click(rowSelectionCheckbox(2), { shiftKey: true }); + + assert.deepEqual(this.handler.exclusion, [this.handler.rows[0]]); + assert.dom(SELECTED_COUNT).hasText('11'); + }); + + test('it clears the selection when the range excludes every row', async function (this: TestContext, assert: Assert) { + this.handler.rowsMeta = { total: 3 }; + + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3), { shiftKey: true }); + + assert.deepEqual(this.handler.selection, []); + assert.deepEqual(this.handler.exclusion, []); + }); + }); + }); + + test('clicking on two rows without holding Shift toggles them alone', async function (this: TestContext, assert: Assert) { + await render(hbs``); + + await click(rowSelectionCheckbox(1)); + await click(rowSelectionCheckbox(3)); + + assert.deepEqual(this.handler.selection, [this.handler.rows[0], this.handler.rows[2]]); + }); + test('clicking a one multiline checkbox toggles its selection status', async function (this: TestContext, assert: Assert) { const handlerSpy = sinon.spy(this.handler); await render(hbs``); diff --git a/tests/unit/core/handler-test.ts b/tests/unit/core/handler-test.ts index 71d6f863..772047ff 100644 --- a/tests/unit/core/handler-test.ts +++ b/tests/unit/core/handler-test.ts @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import sinon from 'sinon'; import TableHandler from '@upfluence/hypertable/core/handler'; -import { FieldSize, Row } from '@upfluence/hypertable/core/interfaces'; +import { FieldSize, type Row } from '@upfluence/hypertable/core/interfaces'; import BaseRenderingResolver from '@upfluence/hypertable/core/rendering-resolver'; import { TableManager, RowsFetcher, AllRowsFetcher } from '@upfluence/hypertable/test-support'; @@ -472,6 +472,216 @@ module('Unit | core/handler', function (hooks) { assert.deepEqual(handler.selection, []); }); + module('Handler#updateSelectionRange', (hooks) => { + hooks.beforeEach(function (this: TestContext) { + this.handler = new TableHandler(getContext(), this.tableManager, this.rowsFetcher); + this.handler.rows = buildRows([1, 2, 3, 4, 5, 6]); + this.handler.rowsMeta = { total: 12 }; + }); + + module('in array mode', () => { + test('it selects the rows between the anchor and a row below it', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[1]); + + assert.true(this.handler.updateSelectionRange(this.handler.rows[4])); + assert.deepEqual(this.handler.selection, this.handler.rows.slice(1, 5)); + }); + + test('it selects the rows between the anchor and a row above it', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[4]); + + assert.true(this.handler.updateSelectionRange(this.handler.rows[1])); + assert.deepEqual(this.handler.selection, this.handler.rows.slice(1, 5)); + }); + + test('it keeps the rows selected outside of the range', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[5]); + this.handler.updateSelection(this.handler.rows[0]); + + this.handler.updateSelectionRange(this.handler.rows[2]); + + assert.deepEqual(this.handler.selection, [this.handler.rows[5], ...this.handler.rows.slice(0, 3)]); + }); + + test('it unselects the range when the anchor has just been unselected', function (this: TestContext, assert: Assert) { + this.handler.selection = [...this.handler.rows]; + this.handler.updateSelection(this.handler.rows[1]); + + this.handler.updateSelectionRange(this.handler.rows[4]); + + assert.deepEqual(this.handler.selection, [this.handler.rows[0], this.handler.rows[5]]); + }); + + test('it applies the state of the anchor, whatever the state of the clicked row', function (this: TestContext, assert: Assert) { + this.handler.selection = [...this.handler.rows]; + this.handler.updateSelection(this.handler.rows[4]); + this.handler.updateSelection(this.handler.rows[2]); + + this.handler.updateSelectionRange(this.handler.rows[4]); + + assert.deepEqual(this.handler.selection, [this.handler.rows[0], this.handler.rows[1], this.handler.rows[5]]); + }); + + test('it uses the clicked row as the next anchor', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[0]); + this.handler.updateSelectionRange(this.handler.rows[1]); + + this.handler.updateSelectionRange(this.handler.rows[3]); + + assert.deepEqual(this.handler.selection, this.handler.rows.slice(0, 4)); + }); + + test('it skips the rows being loaded', function (this: TestContext, assert: Assert) { + this.handler.rows[2]._isLoading = true; + this.handler.updateSelection(this.handler.rows[1]); + + this.handler.updateSelectionRange(this.handler.rows[3]); + + assert.deepEqual(this.handler.selection, [this.handler.rows[1], this.handler.rows[3]]); + }); + + test('it switches to the global mode when the range completes the selection', function (this: TestContext, assert: Assert) { + this.handler.rowsMeta = { total: 6 }; + this.handler.updateSelection(this.handler.rows[0]); + + this.handler.updateSelectionRange(this.handler.rows[5]); + + assert.strictEqual(this.handler.selection, 'all'); + }); + + test('it stays in array mode when an unselected range leaves as many rows as the total', function (this: TestContext, assert: Assert) { + const rowsFromPreviousFilters = buildRows([20, 21]); + this.handler.rowsMeta = { total: 6 }; + this.handler.selection = [...rowsFromPreviousFilters, ...this.handler.rows]; + this.handler.updateSelection(this.handler.rows[0]); + + this.handler.updateSelectionRange(this.handler.rows[1]); + + assert.deepEqual(this.handler.selection, [...rowsFromPreviousFilters, ...this.handler.rows.slice(2)]); + }); + }); + + module('in global mode', (hooks) => { + hooks.beforeEach(function (this: TestContext) { + this.handler.selectAllGlobal(); + }); + + test('it excludes the range when the anchor has just been excluded', function (this: TestContext, assert: Assert) { + this.handler.updateExclusion(this.handler.rows[1]); + + assert.true(this.handler.updateSelectionRange(this.handler.rows[4])); + assert.strictEqual(this.handler.selection, 'all'); + assert.deepEqual(this.handler.exclusion, this.handler.rows.slice(1, 5)); + }); + + test('it restores the range when the anchor has just been restored', function (this: TestContext, assert: Assert) { + this.handler.exclusion = this.handler.rows.slice(1, 5); + this.handler.updateExclusion(this.handler.rows[2]); + + this.handler.updateSelectionRange(this.handler.rows[3]); + + assert.deepEqual(this.handler.exclusion, [this.handler.rows[1], this.handler.rows[4]]); + }); + + test('it clears the selection when the range excludes every row', function (this: TestContext, assert: Assert) { + this.handler.rowsMeta = { total: 6 }; + this.handler.updateExclusion(this.handler.rows[0]); + + this.handler.updateSelectionRange(this.handler.rows[5]); + + assert.deepEqual(this.handler.selection, []); + assert.deepEqual(this.handler.exclusion, []); + }); + }); + + module('when no range can be applied', () => { + test('it returns false without any anchor', function (this: TestContext, assert: Assert) { + assert.false(this.handler.updateSelectionRange(this.handler.rows[3])); + assert.deepEqual(this.handler.selection, []); + }); + + test('it returns false when the clicked row is the anchor', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[3]); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[3])); + assert.deepEqual(this.handler.selection, [this.handler.rows[3]]); + }); + + test('it returns false when the anchor is unselected and nothing is selected in the range', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[1]); + this.handler.updateSelection(this.handler.rows[1]); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[4])); + assert.deepEqual(this.handler.selection, []); + }); + + test('it returns false when the anchor is selected and the whole range is selected', function (this: TestContext, assert: Assert) { + this.handler.selection = this.handler.rows.slice(0, 4); + this.handler.updateSelection(this.handler.rows[4]); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[1])); + assert.deepEqual(this.handler.selection, this.handler.rows.slice(0, 5)); + }); + + test('it returns false in global mode when no row of the range is excluded', function (this: TestContext, assert: Assert) { + this.handler.selectAllGlobal(); + this.handler.updateExclusion(this.handler.rows[1]); + this.handler.updateExclusion(this.handler.rows[1]); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[4])); + assert.deepEqual(this.handler.exclusion, []); + }); + + test('it returns false once the rows have been replaced', async function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[1]); + + await this.handler.resetRows(); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[2])); + }); + + test('it returns false when the anchor has been removed from the table', function (this: TestContext, assert: Assert) { + const anchor = this.handler.rows[1]; + this.handler.updateSelection(anchor); + this.handler.removeRow(anchor.record_id); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[3])); + assert.deepEqual(this.handler.selection, [anchor]); + }); + + test('it returns false after Handler#clearSelection', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[1]); + this.handler.clearSelection(); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[3])); + }); + + test('it returns false after Handler#selectAllGlobal', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[1]); + this.handler.selectAllGlobal(); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[3])); + }); + + test('it returns false after Handler#toggleSelectAll on the loaded rows', function (this: TestContext, assert: Assert) { + this.handler.updateSelection(this.handler.rows[1]); + this.handler.toggleSelectAll(true); + + assert.false(this.handler.updateSelectionRange(this.handler.rows[3])); + }); + }); + + test('the anchor still applies after rows have been prepended', function (this: TestContext, assert: Assert) { + const [, anchor, thirdRow, fourthRow] = this.handler.rows; + this.handler.updateSelection(anchor); + this.handler.prependRows(buildRows([7])); + + this.handler.updateSelectionRange(fourthRow); + + assert.deepEqual(this.handler.selection, [anchor, thirdRow, fourthRow]); + }); + }); + module('Handler#onBottomReached', function (hooks) { hooks.beforeEach(function (this: TestContext) { this.handler = new TableHandler(getContext(), this.tableManager, this.rowsFetcher); @@ -623,6 +833,16 @@ module('Unit | core/handler', function (hooks) { }); }); + function buildRows(recordIds: number[]): Row[] { + return recordIds.map((recordId) => ({ + influencerId: recordId, + recordId, + record_id: recordId, + holderId: 57, + holderType: 'list' + })); + } + function populateSelectionAndExclusionHandler(handler: TableHandler): void { const row = { influencerId: 42, From 23ee7bad6ffb773dc12809aec09df3eeb54cd0d0 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Mon, 2 Nov 2026 14:29:05 +0100 Subject: [PATCH 3/3] PR feedback --- addon/core/handler.ts | 50 +++++++++++++++++++++------------ tests/unit/core/handler-test.ts | 4 +-- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/addon/core/handler.ts b/addon/core/handler.ts index ced8ed08..a8e3444c 100644 --- a/addon/core/handler.ts +++ b/addon/core/handler.ts @@ -490,32 +490,21 @@ export default class TableHandler { } updateSelectionRange(row: Row): boolean { - const anchorIndex = this._selectionAnchor ? this.rows.indexOf(this._selectionAnchor) : -1; - const rowIndex = this.rows.indexOf(row); + const anchor = this._selectionAnchor; - if (anchorIndex === -1 || rowIndex === -1) return false; + if (!anchor) return false; - const selected = this.isRowSelected(this.rows[anchorIndex]); - const range = this.rows - .slice(Math.min(anchorIndex, rowIndex), Math.max(anchorIndex, rowIndex) + 1) - .filter((rangeRow) => !rangeRow._isLoading); + const range = this.getRowsBetween(anchor, row); + const selected = this.isRowSelected(anchor); - if (range.every((rangeRow) => this.isRowSelected(rangeRow) === selected)) return false; + if (!range.length || range.every((rangeRow) => this.isRowSelected(rangeRow) === selected)) return false; this._selectionAnchor = row; if (this.selection === 'all') { - this.exclusion = this.mergeRange(this.exclusion, range, !selected); - - if (this.exclusion.length === this.rowsMeta?.total) { - this.clearSelection(); - } + this.applyRangeToExclusion(range, !selected); } else { - this.selection = this.mergeRange(this.selection, range, selected); - - if (selected && this.selection.length === this.rowsMeta?.total) { - this.selection = 'all'; - } + this.applyRangeToSelection(this.selection, range, selected); } return true; @@ -565,6 +554,31 @@ export default class TableHandler { return this.selection === 'all' ? !this.exclusion.includes(row) : this.selection.includes(row); } + private getRowsBetween(from: Row, to: Row): Row[] { + const fromIndex = this.rows.indexOf(from); + const toIndex = this.rows.indexOf(to); + + if (fromIndex === -1 || toIndex === -1) return []; + + return this.rows.slice(Math.min(fromIndex, toIndex), Math.max(fromIndex, toIndex) + 1); + } + + private applyRangeToExclusion(range: Row[], excluded: boolean): void { + this.exclusion = this.mergeRange(this.exclusion, range, excluded); + + if (this.exclusion.length === this.rowsMeta?.total) { + this.clearSelection(); + } + } + + private applyRangeToSelection(selection: Row[], range: Row[], selected: boolean): void { + this.selection = this.mergeRange(selection, range, selected); + + if (selected && this.selection.length === this.rowsMeta?.total) { + this.selection = 'all'; + } + } + private mergeRange(rows: Row[], range: Row[], included: boolean): Row[] { const rangeRows = new Set(range); const rowsOutsideRange = rows.filter((row) => !rangeRows.has(row)); diff --git a/tests/unit/core/handler-test.ts b/tests/unit/core/handler-test.ts index 772047ff..df6f7821 100644 --- a/tests/unit/core/handler-test.ts +++ b/tests/unit/core/handler-test.ts @@ -531,13 +531,13 @@ module('Unit | core/handler', function (hooks) { assert.deepEqual(this.handler.selection, this.handler.rows.slice(0, 4)); }); - test('it skips the rows being loaded', function (this: TestContext, assert: Assert) { + test('it includes the rows being loaded', function (this: TestContext, assert: Assert) { this.handler.rows[2]._isLoading = true; this.handler.updateSelection(this.handler.rows[1]); this.handler.updateSelectionRange(this.handler.rows[3]); - assert.deepEqual(this.handler.selection, [this.handler.rows[1], this.handler.rows[3]]); + assert.deepEqual(this.handler.selection, this.handler.rows.slice(1, 4)); }); test('it switches to the global mode when the range completes the selection', function (this: TestContext, assert: Assert) {