Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hungry-books-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openproject/primer-view-components': minor
---

Support async loading strategy for FilterableTreeView
9 changes: 0 additions & 9 deletions app/components/primer/alpha/tree_view/sub_tree_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,6 @@ def initialize(
)
end

def render_in(*args, &block)
super.tap do
# check this _after_ rendering so @sub_tree's slots are defined
if @node.select_variant != :none && @sub_tree.defer?
raise ArgumentError, "TreeView does not currently support select variants for sub-trees loaded asynchronously."
end
end
end

private

def base_id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,7 @@
this,
() => Boolean(this.includeFragment),
() => {
this.includeFragment.addEventListener('loadstart', this, {signal})
this.includeFragment.addEventListener('error', this, {signal})
this.includeFragment.addEventListener('include-fragment-replace', this, {signal})
this.includeFragment.addEventListener(
'include-fragment-replaced',
(e: Event) => {
this.#handleIncludeFragmentEvent(e)
},
{signal},
)
this.#setupIncludeFragmentListeners(this.includeFragment)
},
)

Expand Down Expand Up @@ -150,8 +141,6 @@
handleEvent(event: Event) {
if (event.target === this.toggleButton) {
this.#handleToggleEvent(event)
} else if (event.target === this.includeFragment) {
this.#handleIncludeFragmentEvent(event)
} else if (event instanceof KeyboardEvent) {
this.#handleKeyboardEvent(event)
} else if (
Expand Down Expand Up @@ -284,7 +273,8 @@

// request succeeded but element has not yet been replaced
case 'include-fragment-replace':
this.#activeElementIsLoader = document.activeElement === this.loadingIndicator.closest('[role=treeitem]')
this.#activeElementIsLoader =
!!this.loadingIndicator && document.activeElement === this.loadingIndicator.closest('[role=treeitem]')
// Also check if the include-fragment itself has focus (when it has role="treeitem")
if (!this.#activeElementIsLoader && document.activeElement === this.subTree && this.#isIncludeFragment()) {
this.#activeElementIsLoader = true
Expand Down Expand Up @@ -319,6 +309,16 @@
}
}

#setupIncludeFragmentListeners(fragment: TreeViewIncludeFragmentElement) {
const {signal} = this.#abortController
const handler = (e: Event) => this.#handleIncludeFragmentEvent(e)
fragment.addEventListener('loadstart', handler, {signal})
fragment.addEventListener('error', handler, {signal})
fragment.addEventListener('include-fragment-replace', handler, {signal})
fragment.addEventListener('include-fragment-replaced', handler, {signal})
}


Check failure on line 321 in app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts

View workflow job for this annotation

GitHub Actions / eslint

Delete `⏎`
#handleKeyboardEvent(event: KeyboardEvent) {
const node = (event.target as HTMLElement).closest('[role=treeitem]')
if (!node || this.treeView?.getNodeType(node) !== 'sub-tree') {
Expand Down
4 changes: 4 additions & 0 deletions app/components/primer/open_project/filterable_tree_view.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
::highlight(primer-filterable-tree-view-search-results) {
background-color: var(--bgColor-attention-muted);
color: var(--fgColor-default);
}
41 changes: 41 additions & 0 deletions app/components/primer/open_project/filterable_tree_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ module Primer
module OpenProject
# A TreeView and associated filter controls for searching nested hierarchies.
#
# ## Synchronous vs. asynchronous loading
#
# `FilterableTreeView` supports two loading modes:
#
# ### Synchronous (client-side filtering, default)
#
# All nodes are rendered server-side on initial page load. Filtering is done entirely in the browser
# without any additional network requests.
#
# ### Asynchronous (server-side filtering)
#
# Sub-trees can be configured to load their children lazily from the server by calling
# `with_loading_spinner` or `with_loading_skeleton` on a sub-tree slot. When nodes are fetched
# asynchronously, the component passes the current filter state to the server as query parameters:
#
# | Parameter | Description |
# |:---------------|:---------------------------------------------------------------|
# | `filter_query` | The free-form text entered in the filter input. |
# | `filter_mode` | The active filter mode, e.g. `"all"` or `"selected"`. |
#
# The server is responsible for using these parameters to return only matching nodes. When the user
# changes the filter, any expanded async sub-trees are automatically reloaded (with a 300 ms debounce)
# so the server can apply the new filter criteria. Already-loaded sync nodes continue to be filtered
# client-side as usual.
#
# **Important**: when using async loading, the server-rendered nodes must include their correct
# `aria-checked` state. The component does not automatically propagate checked state to
# asynchronously loaded children.
#
# ## Filter controls
#
# `FilterableTreeView`s can be filtered using two controls, both present in the toolbar above the tree:
Expand Down Expand Up @@ -153,13 +182,18 @@ class FilterableTreeView < Primer::Component

DEFAULT_NO_RESULTS_NODE_ARGUMENTS.freeze

# @param src [String] Optional URL for async mode. When provided, changing the filter will send a single
# request to this URL (with `filter_query` and `filter_mode` as query parameters) and replace the
# entire tree with the server response. The server is responsible for returning a `<tree-view>` element
# with `data-target="filterable-tree-view.treeViewList"` and only the nodes that match the filter.
# @param tree_view_arguments [Hash] Arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component.
# @param form_arguments [Hash] Form arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component. These arguments allow the selections made within a `FilterableTreeView` 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.
# @param filter_input_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::TextField) %> component.
# @param filter_mode_control_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::SegmentedControl) %> component.
# @param include_sub_items_check_box_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::CheckBox) %> component.
# @param no_results_node_arguments [Hash] Arguments that will be passed to a <%= link_to_component(Primer::Alpha::TreeView::LeafNode) %> component that appears when no items match the filter criteria.
def initialize(
src: nil,
tree_view_arguments: {},
form_arguments: {},
filter_input_arguments: DEFAULT_FILTER_INPUT_ARGUMENTS.dup,
Expand Down Expand Up @@ -210,6 +244,13 @@ def initialize(
@system_arguments = deny_tag_argument(**system_arguments)
@system_arguments[:tag] = :"filterable-tree-view"

if src
@system_arguments[:data] = merge_data(
@system_arguments,
{ data: { src: src } }
)
end

@no_results_node_arguments = no_results_node_arguments
end

Expand Down
Loading
Loading