Skip to content

Commit 971cce2

Browse files
[DREAM-704] Tree view selection based on path identity breaks use cases where similar paths are allowed (#475)
* Keep the node object after selection instead of fetching it by the path * Fix single-select toggle-off and form payload disambiguation - Fix treeViewNodeChecked/Before events reporting wrong checkedValue on toggle-off (was hardcoded 'true', now reflects actual new state) - Align keyboard Space/Enter with click: pressing Space on an already-selected single-select node now deselects it - Include data-node-id as nodeId in hidden form input payload so duplicate-path nodes are distinguishable on the server side * Adapt test to avoid being trapped in the focus zone * Pass nodeId in filterableTreeView as well * Generating component snapshots * Generating component snapshots --------- Co-authored-by: HDinger <7457313+HDinger@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent f1e6aee commit 971cce2

7 files changed

Lines changed: 150 additions & 23 deletions

File tree

.changeset/all-paths-dance.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@openproject/primer-view-components': minor
3+
---
4+
5+
Fix selection of nodes with the same path in Primer::Alpha::TreeView
6+
7+
When a `[role=treeitem]` element carries a `data-node-id` attribute, that id is now included as `nodeId` in the hidden form input payload (`{path, nodeId?, value?}`). Trees with duplicate-path nodes should set `data-node-id` to a stable unique identifier so the server can distinguish which node was selected.
8+
9+
**Breaking change in `TreeViewElement#checkOnlyAtPath`:** if the given path is not found the method is now a no-op. Previously it would uncheck all active nodes before failing to check the missing node, which could be used as an indirect "clear selection" mechanism. Use explicit `setNodeCheckedValue(node, 'false')` calls if that behaviour is needed.

app/components/primer/alpha/tree_view/tree_view.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ export class TreeViewElement extends HTMLElement {
179179
// behavior for these element types is user- or browser-defined
180180
if (!(node instanceof HTMLDivElement)) return
181181

182-
const path = this.getNodePath(node)
183-
const nodeInfo = this.infoFromNode(node, 'true')
182+
const newCheckedValue = this.getNodeCheckedValue(node) === 'true' ? 'false' : 'true'
183+
const nodeInfo = this.infoFromNode(node, newCheckedValue)
184184

185185
const checkSuccess = this.dispatchEvent(
186186
new CustomEvent('treeViewBeforeNodeChecked', {
@@ -192,10 +192,10 @@ export class TreeViewElement extends HTMLElement {
192192

193193
if (!checkSuccess) return
194194

195-
if (this.getNodeCheckedValue(node) === 'true') {
195+
if (newCheckedValue === 'false') {
196196
this.setNodeCheckedValue(node, 'false')
197197
} else {
198-
this.checkOnlyAtPath(path)
198+
this.#checkNodeOnly(node)
199199
}
200200

201201
this.dispatchEvent(
@@ -271,7 +271,11 @@ export class TreeViewElement extends HTMLElement {
271271
} else if (this.selectVariant(node) === 'single') {
272272
event.preventDefault()
273273

274-
this.checkOnlyAtPath(this.getNodePath(node))
274+
if (this.getNodeCheckedValue(node) === 'true') {
275+
this.setNodeCheckedValue(node, 'false')
276+
} else {
277+
this.#checkNodeOnly(node)
278+
}
275279
} else if (node instanceof HTMLAnchorElement) {
276280
// simulate click on space
277281
node.click()
@@ -313,7 +317,7 @@ export class TreeViewElement extends HTMLElement {
313317
}
314318

315319
get activeNodes() {
316-
return document.querySelectorAll('[aria-checked="true"]')
320+
return this.querySelectorAll('[aria-checked="true"]')
317321
}
318322

319323
expandAtPath(path: string[]) {
@@ -352,11 +356,19 @@ export class TreeViewElement extends HTMLElement {
352356
}
353357

354358
checkOnlyAtPath(path: string[]) {
359+
const node = this.nodeAtPath(path)
360+
if (!node) return
361+
362+
this.#checkNodeOnly(node)
363+
}
364+
365+
#checkNodeOnly(node: Element) {
355366
for (const el of this.activeNodes) {
356-
this.uncheckAtPath(this.getNodePath(el))
367+
if (el === node) continue
368+
this.setNodeCheckedValue(el, 'false')
357369
}
358370

359-
this.checkAtPath(path)
371+
this.setNodeCheckedValue(node, 'true')
360372
}
361373

362374
toggleCheckedAtPath(path: string[]) {
@@ -479,10 +491,13 @@ export class TreeViewElement extends HTMLElement {
479491
newInput.removeAttribute('data-target')
480492
newInput.removeAttribute('form')
481493

482-
const payload: {path: string[]; value?: string} = {
494+
const payload: {path: string[]; nodeId?: string; value?: string} = {
483495
path: this.getNodePath(node),
484496
}
485497

498+
const nodeId = node.getAttribute('data-node-id')
499+
if (nodeId) payload.nodeId = nodeId
500+
486501
const inputValue = this.getFormInputValueForNode(node)
487502
if (inputValue) payload.value = inputValue
488503

app/components/primer/open_project/filterable_tree_view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ export class FilterableTreeViewElement extends HTMLElement {
119119
}
120120

121121
this.#checkedNodeIds.set(nodeId, nodeInfo.checkedValue)
122-
const payload: {path: string[]; value?: string} = {path: nodeInfo.path}
122+
const payload: {path: string[]; nodeId?: string; value?: string} = {path: nodeInfo.path}
123+
payload.nodeId = nodeId
123124
const dataValue = node.getAttribute('data-value')
124125
if (dataValue) payload.value = dataValue
125126
this.#checkedNodeFormPayloads.set(nodeId, payload)

previews/primer/alpha/tree_view_preview.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ def form_input(select_variant: :multiple, expanded: true)
197197
})
198198
end
199199

200+
# @label Doubled paths
201+
# @hidden
202+
def doubled_path
203+
render_with_template
204+
end
205+
200206
private
201207

202208
def coerce_bool(value)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<div style="max-width: 400px">
2+
<%= render(Primer::Alpha::TreeView.new) do |tree_view| %>
3+
<% tree_view.with_sub_tree(label: "src", expanded: true, select_variant: :single) do |sub_tree| %>
4+
<% sub_tree.with_leaf(label: "button.rb", select_variant: :single) %>
5+
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: :single) %>
6+
<% end %>
7+
8+
<% tree_view.with_leaf(label: "action_menu.rb", select_variant: :single) %>
9+
<% tree_view.with_leaf(label: "action_menu.rb", select_variant: :single) %>
10+
<% tree_view.with_leaf(label: "action_menu.rb", select_variant: :single) %>
11+
<% end %>
12+
</div>

previews/primer/alpha/tree_view_preview/form_input.html.erb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
<%= form_with(url: primer_view_components.generic_form_submission_path(format: :json)) do |f| %>
22
<%= render(Primer::Alpha::Stack.new) do %>
33
<%= render(Primer::Alpha::TreeView.new(form_arguments: { builder: f, name: "folder_structure" })) do |tree| %>
4-
<% tree.with_sub_tree(label: "src", expanded: expanded, select_variant: select_variant, value: 0) do |sub_tree| %>
5-
<% sub_tree.with_leaf(label: "button.rb", select_variant: select_variant, value: 1, checked: true) %>
6-
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: select_variant, value: 2) %>
4+
<% tree.with_sub_tree(label: "src", expanded: expanded, select_variant: select_variant, value: 0, data: { node_id: "src" }) do |sub_tree| %>
5+
<% sub_tree.with_leaf(label: "button.rb", select_variant: select_variant, value: 1, checked: true, data: { node_id: "src-button-rb" }) %>
6+
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: select_variant, value: 2, data: { node_id: "src-icon-button-rb" }) %>
77
<% end %>
88

9-
<% tree.with_leaf(label: "action_menu.rb", select_variant: select_variant, value: 3) %>
10-
<% tree.with_sub_tree(label: "Docs & legal requirements", select_variant: select_variant, value: 4) do |sub_tree| %>
11-
<% sub_tree.with_leaf(label: "Readme.md", select_variant: select_variant, value: 5) %>
12-
<% sub_tree.with_leaf(label: "Copyright.md", select_variant: select_variant, value: 6) %>
9+
<% tree.with_leaf(label: "action_menu.rb", select_variant: select_variant, value: 3, data: { node_id: "action-menu-rb" }) %>
10+
<% tree.with_sub_tree(label: "Docs & legal requirements", select_variant: select_variant, value: 4, data: { node_id: "docs" }) do |sub_tree| %>
11+
<% sub_tree.with_leaf(label: "Readme.md", select_variant: select_variant, value: 5, data: { node_id: "docs-readme-md" }) %>
12+
<% sub_tree.with_leaf(label: "Copyright.md", select_variant: select_variant, value: 6, data: { node_id: "docs-copyright-md" }) %>
1313
<% end %>
1414
<% end %>
1515

test/system/alpha/tree_view_test.rb

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,78 @@ def test_fires_check_event_after_single_variant
652652
assert_equal details[0]["previousCheckedValue"], "false"
653653
end
654654

655+
def test_single_select_with_duplicate_paths_selects_clicked_node
656+
visit_preview(:doubled_path)
657+
658+
nodes = all(selector_for("action_menu.rb"))
659+
assert_equal 3, nodes.size
660+
661+
nodes[1].click
662+
663+
nodes[1].assert_matches_selector("[aria-checked='true']")
664+
nodes[0].assert_matches_selector("[aria-checked='false']")
665+
666+
nodes[0].click
667+
668+
nodes[0].assert_matches_selector("[aria-checked='true']")
669+
nodes[1].assert_matches_selector("[aria-checked='false']")
670+
end
671+
672+
def test_single_select_with_duplicate_paths_selects_focused_node_on_keyboard
673+
visit_preview(:doubled_path)
674+
675+
nodes = all(selector_for("action_menu.rb"))
676+
assert_equal 3, nodes.size
677+
678+
# icon_button.rb has current: true so it owns tabindex=0. Sending keys directly
679+
# to a tabindex=-1 node is unreliable: focusZone redirects focus to the aria-current
680+
# item on focusin, so Space would land on the wrong node.
681+
# Start from icon_button.rb and navigate down with arrow keys instead.
682+
find('[aria-current]').send_keys(:down)
683+
keyboard.type(:down)
684+
keyboard.type(:space)
685+
686+
nodes[1].assert_matches_selector("[aria-checked='true']")
687+
nodes[0].assert_matches_selector("[aria-checked='false']")
688+
689+
keyboard.type(:up)
690+
keyboard.type(:space)
691+
692+
nodes[0].assert_matches_selector("[aria-checked='true']")
693+
nodes[1].assert_matches_selector("[aria-checked='false']")
694+
end
695+
696+
def test_fires_check_event_after_single_variant_toggle_off
697+
visit_preview(:default, select_variant: :single)
698+
699+
activate_at_path("src")
700+
assert_path_checked "src"
701+
702+
details = capture_event("treeViewNodeChecked") do
703+
activate_at_path("src")
704+
end
705+
706+
assert_equal 1, details.size
707+
assert details[0]["node"]
708+
assert_equal details[0]["path"], ["src"]
709+
assert_equal details[0]["checkedValue"], "false"
710+
assert_equal details[0]["previousCheckedValue"], "true"
711+
end
712+
713+
def test_keyboard_toggles_off_already_selected_single_variant
714+
visit_preview(:doubled_path)
715+
716+
nodes = all(selector_for("action_menu.rb"))
717+
718+
# Navigate from icon_button.rb (aria-current, tabindex=0) to nodes[0]
719+
find('[aria-current]').send_keys(:down)
720+
keyboard.type(:space)
721+
nodes[0].assert_matches_selector("[aria-checked='true']")
722+
723+
keyboard.type(:space)
724+
nodes[0].assert_matches_selector("[aria-checked='false']")
725+
end
726+
655727
def test_fires_event_before_checking
656728
visit_preview(:default, select_variant: :multiple)
657729

@@ -842,8 +914,8 @@ def test_form_submission
842914
# for some reason the JSON response is wrapped in HTML, I have no idea why
843915
response = JSON.parse(find("pre").text)
844916

845-
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
846-
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 1)
917+
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"nodeId\":\"src-button-rb\",\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
918+
assert_equal "{\"path\":[\"action_menu.rb\"],\"nodeId\":\"action-menu-rb\",\"value\":\"3\"}", response.dig("form_params", "folder_structure", 1)
847919
end
848920

849921
def test_initial_form_state
@@ -856,7 +928,7 @@ def test_initial_form_state
856928
# for some reason the JSON response is wrapped in HTML, I have no idea why
857929
response = JSON.parse(find("pre").text)
858930

859-
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
931+
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"nodeId\":\"src-button-rb\",\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
860932
end
861933

862934
def test_form_submission_with_single_select_variant
@@ -870,7 +942,7 @@ def test_form_submission_with_single_select_variant
870942
# for some reason the JSON response is wrapped in HTML, I have no idea why
871943
response = JSON.parse(find("pre").text)
872944

873-
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
945+
assert_equal "{\"path\":[\"action_menu.rb\"],\"nodeId\":\"action-menu-rb\",\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
874946
end
875947

876948
def test_initial_form_state_for_single_select
@@ -883,7 +955,19 @@ def test_initial_form_state_for_single_select
883955
# for some reason the JSON response is wrapped in HTML, I have no idea why
884956
response = JSON.parse(find("pre").text)
885957

886-
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
958+
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"nodeId\":\"src-button-rb\",\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
959+
end
960+
961+
def test_form_payload_includes_node_id_when_present
962+
visit_preview(:form_input, expanded: true, select_variant: :single, route_format: :json)
963+
964+
activate_at_path("action_menu.rb")
965+
966+
find("button[type=submit]").click
967+
968+
response = JSON.parse(find("pre").text)
969+
970+
assert_equal "{\"path\":[\"action_menu.rb\"],\"nodeId\":\"action-menu-rb\",\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
887971
end
888972

889973
def test_single_select_with_special_character
@@ -898,7 +982,7 @@ def test_single_select_with_special_character
898982
# for some reason the JSON response is wrapped in HTML, I have no idea why
899983
response = JSON.parse(find("pre").text)
900984

901-
assert_equal "{\"path\":[\"Docs & legal requirements\"],\"value\":\"4\"}", response.dig("form_params", "folder_structure", 0)
985+
assert_equal "{\"path\":[\"Docs & legal requirements\"],\"nodeId\":\"docs\",\"value\":\"4\"}", response.dig("form_params", "folder_structure", 0)
902986
end
903987

904988
def test_keyboard_focus_moves_to_parent_when_include_fragment_with_role_treeitem_is_collapsed

0 commit comments

Comments
 (0)