Skip to content

Commit 5e6c8e8

Browse files
committed
Support TreeView as a form element
1 parent a64d7ce commit 5e6c8e8

7 files changed

Lines changed: 106 additions & 4 deletions

File tree

app/components/primer/alpha/tree_view.html.erb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
<tree-view>
2+
<% if acts_as_form_input? %>
3+
<%= @form_arguments[:builder].hidden_field(@form_arguments[:name], multiple: true, skip_default_ids: true, form: "", data: { target: "tree-view.formInputPrototype" }) %>
4+
<div data-target="tree-view.formInputContainer"></div>
5+
<% end %>
6+
27
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
38
<% nodes.each do |node| %>
49
<%= node %>

app/components/primer/alpha/tree_view.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,9 +407,11 @@ class TreeView < Primer::Component
407407
attr_reader :node_variant
408408

409409
# @param node_variant [Symbol] The variant to use for this node. <%= one_of(Primer::Alpha::TreeView::NODE_VARIANT_OPTIONS) %>
410+
# @param form_arguments [Hash] These arguments allow the selections made within a `TreeView` to be submitted to the server as part of a Rails form. Pass the `builder:` and `name:` options to this hash. `builder:` should be an instance of `ActionView::Helpers::FormBuilder`, which are created by the standard Rails `#form_with` and `#form_for` helpers. The `name:` option is the desired name of the field that will be included in the params sent to the server on form submission.
410411
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>.
411-
def initialize(node_variant: DEFAULT_NODE_VARIANT, **system_arguments)
412+
def initialize(node_variant: DEFAULT_NODE_VARIANT, form_arguments: {}, **system_arguments)
412413
@system_arguments = deny_tag_argument(**system_arguments)
414+
@form_arguments = form_arguments
413415

414416
@node_variant = fetch_or_fallback(NODE_VARIANT_OPTIONS, node_variant, DEFAULT_NODE_VARIANT)
415417

@@ -421,6 +423,10 @@ def initialize(node_variant: DEFAULT_NODE_VARIANT, **system_arguments)
421423
)
422424
end
423425

426+
def acts_as_form_input?
427+
@form_arguments[:builder] && @form_arguments[:name]
428+
end
429+
424430
private
425431

426432
def before_render

app/components/primer/alpha/tree_view/node.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class Node < Primer::Component
7474
# @param select_variant [Symbol] Controls the type of checkbox that appears. <%= one_of(Primer::Alpha::TreeView::Node::SELECT_VARIANT_OPTIONS) %>
7575
# @param checked [Boolean | String] The checked state of the node's checkbox. <%= one_of(Primer::Alpha::TreeView::Node::CHECKED_STATES) %>
7676
# @param disabled [Boolean] Whether or not the node can be activated. Passing `false` here will cause the node to appear visually disabled but it is still keyboard-focusable.
77+
# @param value [String] If this node is checked, this value will be sent to the server on form submission.
7778
# @param content_arguments [Hash] Arguments attached to the node's content, i.e the `<button>` or `<a>` element. <%= link_to_system_arguments_docs %>
7879
def initialize(
7980
path:,
@@ -83,6 +84,7 @@ def initialize(
8384
select_variant: DEFAULT_SELECT_VARIANT,
8485
checked: DEFAULT_CHECKED_STATE,
8586
disabled: false,
87+
value: nil,
8688
**content_arguments
8789
)
8890
@system_arguments = {
@@ -123,8 +125,12 @@ def initialize(
123125
)
124126

125127
@content_arguments[:data] = merge_data(
126-
@content_arguments,
127-
{ data: { path: @path.to_json } }
128+
@content_arguments, {
129+
data: {
130+
value: value,
131+
path: @path.to_json
132+
}
133+
}
128134
)
129135

130136
return unless current?

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import {controller} from '@github/catalyst'
1+
import {controller, target} from '@github/catalyst'
22
import {TreeViewSubTreeNodeElement} from './tree_view_sub_tree_node_element'
33
import {useRovingTabIndex} from './tree_view_roving_tab_index'
44
import type {TreeViewNodeType, TreeViewCheckedValue, TreeViewNodeInfo} from '../../shared_events'
55

66
@controller
77
export class TreeViewElement extends HTMLElement {
8+
@target formInputContainer: HTMLElement
9+
@target formInputPrototype: HTMLInputElement
10+
811
#abortController: AbortController
912

1013
connectedCallback() {
@@ -28,6 +31,47 @@ export class TreeViewElement extends HTMLElement {
2831
}
2932
}).observe(this, {childList: true, subtree: true})
3033

34+
const updateInputsObserver = new MutationObserver(mutations => {
35+
if (!this.formInputContainer) return
36+
37+
// There is another MutationObserver in TreeViewSubTreeNodeElement that manages checking/unchecking
38+
// nodes based on the component's select strategy. These two observers can conflict and cause infinite
39+
// looping, so we make sure something actually changed before computing inputs again.
40+
const somethingChanged = mutations.some(m => {
41+
if (!(m.target instanceof HTMLElement)) return false
42+
return m.target.getAttribute('aria-checked') !== m.oldValue
43+
})
44+
45+
if (!somethingChanged) return
46+
47+
const newInputs = []
48+
49+
// eslint-disable-next-line custom-elements/no-dom-traversal-in-connectedcallback
50+
for (const node of this.querySelectorAll('[role=treeitem][aria-checked=true]')) {
51+
const newInput = this.formInputPrototype.cloneNode() as HTMLInputElement
52+
newInput.removeAttribute('data-target')
53+
newInput.removeAttribute('form')
54+
55+
const payload: {path: string[]; value?: string} = {
56+
path: this.getNodePath(node),
57+
}
58+
59+
const inputValue = this.getFormInputValueForNode(node)
60+
if (inputValue) payload.value = inputValue
61+
62+
newInput.value = JSON.stringify(payload)
63+
newInputs.push(newInput)
64+
}
65+
66+
this.formInputContainer.replaceChildren(...newInputs)
67+
})
68+
69+
updateInputsObserver.observe(this, {
70+
childList: true,
71+
subtree: true,
72+
attributeFilter: ['aria-checked'],
73+
})
74+
3175
// eslint-disable-next-line github/no-then -- We don't want to wait for this to resolve, just get on with it
3276
customElements.whenDefined('tree-view-sub-tree-node').then(() => {
3377
// depends on TreeViewSubTreeNodeElement#eachAncestorSubTreeNode, which may not be defined yet
@@ -173,6 +217,10 @@ export class TreeViewElement extends HTMLElement {
173217
}
174218
}
175219

220+
getFormInputValueForNode(node: Element): string | null {
221+
return node.getAttribute('data-value')
222+
}
223+
176224
getNodePath(node: Element): string[] {
177225
const rawPath = node.getAttribute('data-path')
178226

previews/primer/alpha/tree_view_preview.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ def auto_expansion
151151
end
152152
end
153153

154+
# @label Form input
155+
#
156+
# @param expanded [Boolean] toggle
157+
def form_input(expanded: true)
158+
render_with_template(locals: {
159+
expanded: coerce_bool(expanded)
160+
})
161+
end
162+
154163
private
155164

156165
def coerce_bool(value)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<%= form_with(url: primer_view_components.generic_form_submission_path(format: :json)) do |f| %>
2+
<%= render(Primer::Alpha::Stack.new) do %>
3+
<%= 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: :multiple, value: 0) do |sub_tree| %>
5+
<% sub_tree.with_leaf(label: "button.rb", select_variant: :multiple, value: 1) %>
6+
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: :multiple, value: 2) %>
7+
<% end %>
8+
9+
<% tree.with_leaf(label: "action_menu.rb", select_variant: :multiple, value: 3) %>
10+
<% end %>
11+
12+
<%= render(Primer::Alpha::SubmitButton.new(name: :submit, label: "Submit")) %>
13+
<% end %>
14+
<% end %>

test/system/alpha/tree_view_test.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,5 +785,19 @@ def test_self_select_strategy_checking_sub_tree_does_not_check_children
785785

786786
assert_path_checked "primer", "alpha", "action_bar"
787787
end
788+
789+
790+
def test_form_submission
791+
visit_preview(:form_input, expanded: true, route_format: :json)
792+
793+
check_at_path("action_menu.rb")
794+
795+
find("button[type=submit]").click
796+
797+
# for some reason the JSON response is wrapped in HTML, I have no idea why
798+
response = JSON.parse(find("pre").text)
799+
800+
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
801+
end
788802
end
789803
end

0 commit comments

Comments
 (0)