From 503176a5e994128d7af9391887a912914e470f04 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Mon, 10 Nov 2025 16:20:44 +0100 Subject: [PATCH 1/2] [#68794] enable hierarchy filters in project lists - https://community.openproject.org/work_packages/68794 - add hierarchy filter to autocompleted filters - add weight and score to filter value label - fix some eslint issues in filter form controller --- app/components/filter/filter_component.rb | 7 ++++++ app/models/custom_field.rb | 23 +++++++++++++++---- app/models/custom_field/hierarchy/item.rb | 14 +++++++++-- .../dynamic/filter/filters-form.controller.ts | 22 ++++++++++-------- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb index 94738cf1d8f..ba55bbffd4b 100644 --- a/app/components/filter/filter_component.rb +++ b/app/components/filter/filter_component.rb @@ -81,6 +81,8 @@ module Filter { autocomplete_options: user_autocomplete_options } when Queries::Filters::Shared::CustomFields::ListOptional { autocomplete_options: custom_field_list_autocomplete_options(filter) } + when Queries::Filters::Shared::CustomFields::Hierarchy + { autocomplete_options: custom_field_hierarchy_autocomplete_options(filter) } when Queries::Projects::Filters::ProjectStatusFilter, Queries::Projects::Filters::TypeFilter { autocomplete_options: list_autocomplete_options(filter) } @@ -101,6 +103,11 @@ module Filter autocomplete_options.merge(options).merge(model: filter.values) end + def custom_field_hierarchy_autocomplete_options(filter) + options = { items: filter.allowed_values.map { |name, id| { name:, id: } } } + autocomplete_options.merge(options).merge(model: filter.values) + end + def list_autocomplete_options(filter) autocomplete_options.merge( items: filter.allowed_values.map { |name, id| { name:, id: } }, diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index e8062b20f75..75e42a36488 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -82,8 +82,9 @@ class CustomField < ApplicationRecord validates :min_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :max_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :min_length, numericality: { less_than_or_equal_to: :max_length, message: :smaller_than_or_equal_to_max_length }, - unless: Proc.new { |cf| cf.max_length.blank? } + validates :min_length, + numericality: { less_than_or_equal_to: :max_length, message: :smaller_than_or_equal_to_max_length }, + unless: Proc.new { |cf| cf.max_length.blank? } validates :multi_value, absence: true, unless: :multi_value_possible? validates :allow_non_open_versions, absence: true, unless: :allow_non_open_versions_possible? @@ -189,6 +190,8 @@ class CustomField < ApplicationRecord possible_versions(obj).pluck(:id).map(&:to_s) when "list" custom_options + when "hierarchy", "weighted_item_list" + custom_field_hierarchy_items else read_attribute(:possible_values) end @@ -212,6 +215,15 @@ class CustomField < ApplicationRecord custom_options.where("position > ?", max_position).destroy_all end + def custom_field_hierarchy_items + items = CustomFields::Hierarchy::HierarchicalItemService + .new + .get_descendants(item: hierarchy_root, include_self: false) + .fmap { |items| items.map { |item| [item.ancestry_path(include_shorts_and_weights: true), item.id] } } + + items.value_or([]) + end + def cast_value(value) return if value.blank? @@ -369,9 +381,10 @@ class CustomField < ApplicationRecord end def possible_version_values_options(obj, options: {}) - possible_versions(obj, options:).references(:project) - .sort - .map { |u| [u.name, u.id.to_s, u.project.name] } + possible_versions(obj, options:) + .references(:project) + .sort + .map { |u| [u.name, u.id.to_s, u.project.name] } end def possible_users(obj) diff --git a/app/models/custom_field/hierarchy/item.rb b/app/models/custom_field/hierarchy/item.rb index 3490e7009ef..ed2e306a587 100644 --- a/app/models/custom_field/hierarchy/item.rb +++ b/app/models/custom_field/hierarchy/item.rb @@ -38,7 +38,17 @@ class CustomField::Hierarchy::Item < ApplicationRecord def to_s = short.nil? ? label : "#{label} (#{short})" - def ancestry_path - self_and_ancestors.filter_map(&:to_s).reverse.join(" / ") + def ancestry_path(include_shorts_and_weights: false) + path = self_and_ancestors.filter_map(&:to_s).reverse.join(" / ") + + return path unless include_shorts_and_weights + + if short.present? + "#{path} (#{short})" + elsif weight.present? + "#{path} #{weight}" + else + path + end end end diff --git a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts index fdf5f2220cb..5b7a4217d65 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts @@ -43,6 +43,8 @@ interface InternalFilterValue { value:string[]; } +type FilterFunc = (_value:T) => boolean; + export default class FiltersFormController extends Controller { static targets = [ 'filterFormToggle', @@ -231,7 +233,7 @@ export default class FiltersFormController extends Controller { ]; selectors.some((selector) => { - const target = element.querySelector(selector) as HTMLElement; + const target = element.querySelector(selector); if (target) { window.setTimeout(() => { @@ -265,7 +267,7 @@ export default class FiltersFormController extends Controller { // it is focused. This handler will find the sibling input of the clear button inside the // PrimerTextField and triggers the input in order to notify the auto-reloading filter mechanism. const element = event.currentTarget as HTMLElement; - const primerTextField = element.closest('primer-text-field') as PrimerTextFieldElement; + const primerTextField = element.closest('primer-text-field')!; const inputElement = primerTextField.inputElement; const inputEvent = new Event('input', { @@ -337,7 +339,7 @@ export default class FiltersFormController extends Controller { // Remove the page parameter when changing filters, so that pagination resets params.delete('page'); params.set('filters', newFilters); - const ajaxIndicator = document.querySelector('#ajax-indicator') as HTMLElement; + const ajaxIndicator = document.querySelector('#ajax-indicator')!; ajaxIndicator.style.display = ''; const pathName = this.urlPathNameValue || window.location.pathname; @@ -394,7 +396,7 @@ export default class FiltersFormController extends Controller { const filters:InternalFilterValue[] = []; advancedFilters.forEach((filter) => { - const filterName = filter.getAttribute('data-filter-name') as string; + const filterName = filter.getAttribute('data-filter-name')!; const filterType = filter.getAttribute('data-filter-type'); const parsedOperator = this.findTargetByName(filterName, this.operatorTargets)?.value; const valueContainer = this.findTargetByName(filterName, this.filterValueContainerTargets); @@ -437,14 +439,14 @@ export default class FiltersFormController extends Controller { private readonly dateFilterTypes = ['datetime_past', 'date']; private parseFilterValue(valueContainer:HTMLElement, filterName:string, filterType:string, operator:string) { - const checkbox = valueContainer.querySelector('input[type="checkbox"]') as HTMLInputElement; + const checkbox = valueContainer.querySelector('input[type="checkbox"]'); if (checkbox) { return [checkbox.checked ? 't' : 'f']; } if (valueContainer.dataset.filterAutocomplete === 'true') { - return (valueContainer.querySelector('input[name="value"]') as HTMLInputElement)?.value.split(','); + return (valueContainer.querySelector('input[name="value"]'))?.value.split(','); } if (this.operatorsWithoutValues.includes(operator)) { @@ -511,7 +513,7 @@ export default class FiltersFormController extends Controller { private findTargetByName( filterName:string, targets:T[], - targetFilter?:(target:T) => boolean, + targetFilter?:FilterFunc, ):T | undefined { return this.findTargetBy( filterName, @@ -524,16 +526,16 @@ export default class FiltersFormController extends Controller { private findTargetById( filterName:string, targets:T[], - targetFilter?:(target:T) => boolean, + targetFilter?:FilterFunc, ):T | undefined { return this.findTargetBy(filterName, (target:T) => target.id, targets, targetFilter); } private findTargetBy( attributeValue:string, - attributeGetter:(target:T) => string | null, + attributeGetter:(_target:T) => string | null, targets:T[], - targetFilter?:(target:T) => boolean, + targetFilter?:FilterFunc, ):T | undefined { return targets.find((target) => { return attributeGetter(target) === attributeValue && (!targetFilter || targetFilter(target)); From f6dab2686fe60aa398c792f11a08de39d45680ae Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Wed, 12 Nov 2025 15:05:56 +0100 Subject: [PATCH 2/2] [#68794] fix creation of hierarchical list custom fields --- app/models/custom_field.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 75e42a36488..f00fde6cbb3 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -216,6 +216,8 @@ class CustomField < ApplicationRecord end def custom_field_hierarchy_items + return [] if hierarchy_root.nil? + items = CustomFields::Hierarchy::HierarchicalItemService .new .get_descendants(item: hierarchy_root, include_self: false)