From 6f20fa0ac983709ef08ff65c48ae4c8a222ec7e3 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:00:08 +0100 Subject: [PATCH 01/10] feat: add selectingParentSelectsChildren argument to tbody component --- addon/components/ember-tbody/component.js | 13 +++++++++++++ types/components/ember-tbody/component.d.ts | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/addon/components/ember-tbody/component.js b/addon/components/ember-tbody/component.js index 889e6d802..86645dff0 100644 --- a/addon/components/ember-tbody/component.js +++ b/addon/components/ember-tbody/component.js @@ -96,6 +96,14 @@ export default Component.extend({ */ selectingChildrenSelectsParent: defaultTo(true), + /** + When true, this option causes selecting a node to also select all of the node's children. + + @argument selectingParentSelectsChildren + @type boolean + */ + selectingParentSelectsChildren: defaultTo(true), + /** The currently selected rows. Can either be an array or an individual row. @@ -311,6 +319,7 @@ export default Component.extend({ 'selection', 'selectionMatchFunction', 'selectingChildrenSelectsParent', + 'selectingParentSelectsChildren', 'onSelect', function() { @@ -327,6 +336,10 @@ export default Component.extend({ 'selectingChildrenSelectsParent', this.get('selectingChildrenSelectsParent') ); + this.collapseTree.set( + 'selectingParentSelectsChildren', + this.get('selectingParentSelectsChildren') + ); } ), diff --git a/types/components/ember-tbody/component.d.ts b/types/components/ember-tbody/component.d.ts index e7eba74c3..45230452f 100644 --- a/types/components/ember-tbody/component.d.ts +++ b/types/components/ember-tbody/component.d.ts @@ -106,6 +106,11 @@ export interface EmberTbodyArgs { */ selectingChildrenSelectsParent?: boolean; + /** + * When `true`, this option causes selecting a node to also select all of its children. + */ + selectingParentSelectsChildren?: boolean; + /** * The currently selected rows. * Can either be an array or an individual row. From 3226bd4e228ea28a3117b641e15cca66c768d208 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:01:48 +0100 Subject: [PATCH 02/10] feat: take selectingParentSelectsChildren into account for isSelected computed property --- addon/-private/collapse-tree.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/addon/-private/collapse-tree.js b/addon/-private/collapse-tree.js index 286702ded..e32661e15 100644 --- a/addon/-private/collapse-tree.js +++ b/addon/-private/collapse-tree.js @@ -46,12 +46,13 @@ export const TableRowMeta = EmberObject.extend({ // eslint-disable-next-line ember/use-brace-expansion isSelected: computed( - '_tree.{selection.[],selectionMatchFunction}', + '_tree.{selection.[],selectionMatchFunction,selectingParentSelectsChildren}', '_parentMeta.isSelected', function() { let rowValue = get(this, '_rowValue'); let selection = get(this, '_tree.selection'); let selectionMatchFunction = get(this, '_tree.selectionMatchFunction'); + let selectingParentSelectsChildren = get(this, '_tree.selectingParentSelectsChildren'); if (isArray(selection)) { return this.get('isGroupSelected'); @@ -60,7 +61,11 @@ export const TableRowMeta = EmberObject.extend({ let isRowSelection = selectionMatchFunction ? selectionMatchFunction(selection, rowValue) : selection === rowValue; - return isRowSelection || get(this, '_parentMeta.isSelected'); + + // Only consider parent selection if selectingParentSelectsChildren is true + let parentIsSelected = selectingParentSelectsChildren && get(this, '_parentMeta.isSelected'); + + return isRowSelection || parentIsSelected; } ), From 2c0c40aee7f27c57b52ab853851a7a24545507d8 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:02:14 +0100 Subject: [PATCH 03/10] feat: take selectingParentSelectsChildren into account for isGroupSelected computed property --- addon/-private/collapse-tree.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/addon/-private/collapse-tree.js b/addon/-private/collapse-tree.js index e32661e15..85ee7a704 100644 --- a/addon/-private/collapse-tree.js +++ b/addon/-private/collapse-tree.js @@ -70,12 +70,13 @@ export const TableRowMeta = EmberObject.extend({ ), isGroupSelected: computed( - '_tree.{selection.[],selectionMatchFunction}', + '_tree.{selection.[],selectionMatchFunction,selectingParentSelectsChildren}', '_parentMeta.isSelected', function() { let rowValue = get(this, '_rowValue'); let selection = get(this, '_tree.selection'); let selectionMatchFunction = get(this, '_tree.selectionMatchFunction'); + let selectingParentSelectsChildren = get(this, '_tree.selectingParentSelectsChildren'); if (!selection || !isArray(selection)) { return false; @@ -84,7 +85,12 @@ export const TableRowMeta = EmberObject.extend({ let isSelectionMatch = selectionMatchFunction ? selection.filter(item => selectionMatchFunction(item, rowValue)).length > 0 : selection.includes(rowValue); - return isSelectionMatch || get(this, '_parentMeta.isGroupSelected'); + + // Only consider parent selection if selectingParentSelectsChildren is true + let parentIsSelected = + selectingParentSelectsChildren && get(this, '_parentMeta.isGroupSelected'); + + return isSelectionMatch || parentIsSelected; } ), From bc266e7a3a59eff541771059d54b108eabfb4aaa Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:33:07 +0100 Subject: [PATCH 04/10] feat: disallow selectingChildrenSelectsParent being true when selectingParentSelectsChildren is false --- addon/components/ember-tbody/component.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/addon/components/ember-tbody/component.js b/addon/components/ember-tbody/component.js index 86645dff0..acb86aa8d 100644 --- a/addon/components/ember-tbody/component.js +++ b/addon/components/ember-tbody/component.js @@ -305,6 +305,14 @@ export default Component.extend({ 'You must create an with columns before creating an ', !!this.get('unwrappedApi.columnTree') ); + + assert( + 'You cannot set selectingChildrenSelectsParent to true if selectingParentSelectsChildren is false', + !( + this.get('selectingParentSelectsChildren') === false && + this.get('selectingChildrenSelectsParent') === true + ) + ); }, _updateDataTestRowCount() { From de19beefd911f2475770ff506e150425dcc4a713 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:35:51 +0100 Subject: [PATCH 05/10] feat: just toggle the row selection state if selectingParentSelectsChildren is false --- addon/-private/collapse-tree.js | 102 ++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/addon/-private/collapse-tree.js b/addon/-private/collapse-tree.js index 85ee7a704..977726882 100644 --- a/addon/-private/collapse-tree.js +++ b/addon/-private/collapse-tree.js @@ -209,6 +209,7 @@ export const TableRowMeta = EmberObject.extend({ let rowIndex = get(this, 'index'); let isGroupSelected = get(this, 'isGroupSelected'); let selectingChildrenSelectsParent = get(tree, 'selectingChildrenSelectsParent'); + let selectingParentSelectsChildren = get(tree, 'selectingParentSelectsChildren'); let rowMetaCache = get(tree, 'rowMetaCache'); @@ -244,57 +245,68 @@ export const TableRowMeta = EmberObject.extend({ selection.add(tree.objectAt(i)); } } else if (toggle) { - if (isGroupSelected) { - let meta = this; - let currentValue = rowValue; - - // If the parent is selected all of its children are selected. Since - // the current row is going to be removed from the selection, add all - // the sibling rows at each level of its grouping to be explicitly - // selected so their state remains stable. - while (get(meta, '_parentMeta.isSelected')) { - meta = get(meta, '_parentMeta'); - - // Iterate from the parent meta to the "next" tree node. Since this - // is a group it will have at least one child, so there should be at - // least one next row to iterate over. - let expectedChildDepth = get(meta, 'depth') + 1; - let childIndex = get(meta, 'index'); // will be incremented by 1 before use - let child; - while ((child = tree.objectAt(++childIndex))) { - // The currentValue is being toggled, don't add it to the selection - if (child === currentValue) { - continue; - } - - // If the depth of the row is lower than the expectedChildDepth a - // non-child meta has been found (a sibling or something higher. - // That means iterating children is complete, so break. - // - // If the depth is higher than expected then children of a child - // group are being iterated. Skip over them, but don't break since - // there may be a leaf child after a group child. - let childMeta = rowMetaCache.get(child); - let childDepth = get(childMeta, 'depth'); - if (childDepth < expectedChildDepth) { - break; - } - if (childDepth > expectedChildDepth) { - continue; + // If selectingParentSelectsChildren is false, then we can just toggle + // the row. If it is true, then we need to do a bit more work to ensure + // that the selection is stable. + if (!selectingParentSelectsChildren) { + if (isGroupSelected) { + selection.delete(rowValue); + } else { + selection.add(rowValue); + } + } else { + if (isGroupSelected) { + let meta = this; + let currentValue = rowValue; + + // If the parent is selected all of its children are selected. Since + // the current row is going to be removed from the selection, add all + // the sibling rows at each level of its grouping to be explicitly + // selected so their state remains stable. + while (get(meta, '_parentMeta.isSelected')) { + meta = get(meta, '_parentMeta'); + + // Iterate from the parent meta to the "next" tree node. Since this + // is a group it will have at least one child, so there should be at + // least one next row to iterate over. + let expectedChildDepth = get(meta, 'depth') + 1; + let childIndex = get(meta, 'index'); // will be incremented by 1 before use + let child; + while ((child = tree.objectAt(++childIndex))) { + // The currentValue is being toggled, don't add it to the selection + if (child === currentValue) { + continue; + } + + // If the depth of the row is lower than the expectedChildDepth a + // non-child meta has been found (a sibling or something higher. + // That means iterating children is complete, so break. + // + // If the depth is higher than expected then children of a child + // group are being iterated. Skip over them, but don't break since + // there may be a leaf child after a group child. + let childMeta = rowMetaCache.get(child); + let childDepth = get(childMeta, 'depth'); + if (childDepth < expectedChildDepth) { + break; + } + if (childDepth > expectedChildDepth) { + continue; + } + + // Else, this is a child node which must be explictly selected. + // Add it to the list. + selection.add(child); } - // Else, this is a child node which must be explictly selected. - // Add it to the list. - selection.add(child); + selection.delete(currentValue); + currentValue = get(meta, '_rowValue'); } selection.delete(currentValue); - currentValue = get(meta, '_rowValue'); + } else { + selection.add(rowValue); } - - selection.delete(currentValue); - } else { - selection.add(rowValue); } } else { selection.clear(); From 51d46f1cf511ffc031d2f2d60ec561b3192ace81 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:37:13 +0100 Subject: [PATCH 06/10] feat: skip removing the children of a selected parent from the selection array if selectingParentSelectsChildren is false --- addon/-private/collapse-tree.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/addon/-private/collapse-tree.js b/addon/-private/collapse-tree.js index 977726882..584105972 100644 --- a/addon/-private/collapse-tree.js +++ b/addon/-private/collapse-tree.js @@ -330,17 +330,19 @@ export const TableRowMeta = EmberObject.extend({ reduceSelectedRows(selection, groupingCounts, rowMetaCache); } - for (let rowMeta of rowMetas) { - let rowValue = get(rowMeta, '_rowValue'); - let parentMeta = get(rowMeta, '_parentMeta'); + if (selectingParentSelectsChildren) { + for (let rowMeta of rowMetas) { + let rowValue = get(rowMeta, '_rowValue'); + let parentMeta = get(rowMeta, '_parentMeta'); - while (parentMeta) { - if (selection.has(get(parentMeta, '_rowValue'))) { - selection.delete(rowValue); - break; - } + while (parentMeta) { + if (selection.has(get(parentMeta, '_rowValue'))) { + selection.delete(rowValue); + break; + } - parentMeta = get(parentMeta, '_parentMeta'); + parentMeta = get(parentMeta, '_parentMeta'); + } } } From 49737e8a000b9aec24ad3e5a34100a9bfdeba4a9 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:37:35 +0100 Subject: [PATCH 07/10] docs: add selectingParentSelectsChildren to documentation --- tests/dummy/app/templates/docs/guides/body/row-selection.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/dummy/app/templates/docs/guides/body/row-selection.md b/tests/dummy/app/templates/docs/guides/body/row-selection.md index a9c72c78a..0aecd2f49 100644 --- a/tests/dummy/app/templates/docs/guides/body/row-selection.md +++ b/tests/dummy/app/templates/docs/guides/body/row-selection.md @@ -45,7 +45,7 @@ the table. ## Selection Modes -There are three different properties you can use to control the behavior of +There are four different properties you can use to control the behavior of row selection: 1. `checkboxSelectionMode`: This controls the behavior of the checkbox that @@ -66,6 +66,9 @@ checkbox will _not_ be checked. whether selecting all of the children of a given row also selects the row itself. +4. `selectingParentSelectsChildren`: This is a boolean flag that determines +whether selecting a given row also selects all of its children. + {{#docs-demo as |demo|}} {{#demo.example name='selection-modes'}} {{examples/selection-modes @@ -74,6 +77,7 @@ itself. rowSelectionMode=this.rowSelectionMode checkboxSelectionMode=this.checkboxSelectionMode selectingChildrenSelectsParent=this.selectingChildrenSelectsParent + selectingParentSelectsChildren=this.selectingParentSelectsChildren demoSelection=this.demoSelection}} {{/demo.example}} From 8ad555dfbd8fbfee047a06dc927a69c006e890e8 Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:38:21 +0100 Subject: [PATCH 08/10] docs: add selectingParentSelectsChildren to documentation --- .../app/components/examples/selection-modes/template.hbs | 5 +++++ .../dummy/app/controllers/docs/guides/body/row-selection.js | 1 + 2 files changed, 6 insertions(+) diff --git a/tests/dummy/app/components/examples/selection-modes/template.hbs b/tests/dummy/app/components/examples/selection-modes/template.hbs index 758496683..afd6e515a 100644 --- a/tests/dummy/app/components/examples/selection-modes/template.hbs +++ b/tests/dummy/app/components/examples/selection-modes/template.hbs @@ -9,6 +9,7 @@ @rowSelectionMode={{this.rowSelectionMode}} @checkboxSelectionMode={{this.checkboxSelectionMode}} @selectingChildrenSelectsParent={{this.selectingChildrenSelectsParent}} + @selectingParentSelectsChildren={{this.selectingParentSelectsChildren}} @onSelect={{action (mut demoSelection)}} @selection={{this.demoSelection}} @@ -35,5 +36,9 @@

selectingChildrenSelectsParent

+
+

selectingParentSelectsChildren

+ +
{{! END-SNIPPET }} \ No newline at end of file diff --git a/tests/dummy/app/controllers/docs/guides/body/row-selection.js b/tests/dummy/app/controllers/docs/guides/body/row-selection.js index 2a32b4aae..78588337a 100644 --- a/tests/dummy/app/controllers/docs/guides/body/row-selection.js +++ b/tests/dummy/app/controllers/docs/guides/body/row-selection.js @@ -69,6 +69,7 @@ export default Controller.extend({ rowSelectionMode: 'multiple', checkboxSelectionMode: 'multiple', selectingChildrenSelectsParent: true, + selectingParentSelectsChildren: true, rowsWithChildren: computed(function() { let makeRow = (id, { children } = { children: [] }) => { From 144988e304cc51d521cda9fd748fa33c8dad5ffa Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:38:39 +0100 Subject: [PATCH 09/10] test: add selectingParentSelectsChildren to test helper --- tests/helpers/generate-table.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers/generate-table.js b/tests/helpers/generate-table.js index da2c86b87..9f91c3629 100644 --- a/tests/helpers/generate-table.js +++ b/tests/helpers/generate-table.js @@ -54,6 +54,7 @@ const fullTable = hbs` @idForFirstItem={{this.idForFirstItem}} @onSelect={{action this.onSelect}} @selectingChildrenSelectsParent={{this.selectingChildrenSelectsParent}} + @selectingParentSelectsChildren={{this.selectingParentSelectsChildren}} @checkboxSelectionMode={{this.checkboxSelectionMode}} @rowSelectionMode={{this.rowSelectionMode}} @rowToggleMode={{this.rowToggleMode}} From 1400e79bc265bf7f2e367c0eccee0c23d023436c Mon Sep 17 00:00:00 2001 From: lukasnys Date: Thu, 27 Mar 2025 13:49:34 +0100 Subject: [PATCH 10/10] test: write tests for selectingParentSelectsChildren --- .../integration/components/selection-test.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/integration/components/selection-test.js b/tests/integration/components/selection-test.js index 8e098199f..29dec58b6 100644 --- a/tests/integration/components/selection-test.js +++ b/tests/integration/components/selection-test.js @@ -840,6 +840,35 @@ module('Integration | selection', () => { assert.ok(table.validateSelected(1, 2, 3), 'only children are selected'); }); + test('selecting the parent selects all children when enabled', async function(assert) { + await generateTable(this, { + selectingParentSelectsChildren: true, + rowCount: 3, + rowDepth: 2, + }); + + assert.ok(table.validateSelected(), 'rows are not selected'); + + await table.selectRow(0); + + assert.ok(table.validateSelected(0, 1, 2, 3), 'row and its children are selected'); + }); + + test('selecting the parent does not select all children', async function(assert) { + await generateTable(this, { + selectingChildrenSelectsParent: false, + selectingParentSelectsChildren: false, + rowCount: 3, + rowDepth: 2, + }); + + assert.ok(table.validateSelected(), 'rows are not selected'); + + await table.selectRow(0); + + assert.ok(table.validateSelected(0), 'only parent is selected'); + }); + test('rows can be selected using selectionMatchFunction', async function(assert) { let selection = emberA(); let rows = [