Merge pull request #20982 from opf/bug/68794-hierarchy-and-weighted-items-list-cfs-cannot-be-used-to-filter-on-project-list

[#68794] enable hierarchy filters in project lists
This commit is contained in:
Eric Schubert
2025-11-12 15:50:12 +01:00
committed by GitHub
4 changed files with 51 additions and 17 deletions
@@ -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: } },
+20 -5
View File
@@ -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,17 @@ class CustomField < ApplicationRecord
custom_options.where("position > ?", max_position).destroy_all
end
def custom_field_hierarchy_items
return [] if hierarchy_root.nil?
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 +383,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)
+12 -2
View File
@@ -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
@@ -43,6 +43,8 @@ interface InternalFilterValue {
value:string[];
}
type FilterFunc<T> = (_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<HTMLElement>(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<PrimerTextFieldElement>('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<HTMLElement>('#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<HTMLInputElement>('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<HTMLInputElement>('input[name="value"]'))?.value.split(',');
}
if (this.operatorsWithoutValues.includes(operator)) {
@@ -511,7 +513,7 @@ export default class FiltersFormController extends Controller {
private findTargetByName<T extends HTMLElement>(
filterName:string,
targets:T[],
targetFilter?:(target:T) => boolean,
targetFilter?:FilterFunc<T>,
):T | undefined {
return this.findTargetBy(
filterName,
@@ -524,16 +526,16 @@ export default class FiltersFormController extends Controller {
private findTargetById<T extends HTMLElement>(
filterName:string,
targets:T[],
targetFilter?:(target:T) => boolean,
targetFilter?:FilterFunc<T>,
):T | undefined {
return this.findTargetBy(filterName, (target:T) => target.id, targets, targetFilter);
}
private findTargetBy<T extends HTMLElement>(
attributeValue:string,
attributeGetter:(target:T) => string | null,
attributeGetter:(_target:T) => string | null,
targets:T[],
targetFilter?:(target:T) => boolean,
targetFilter?:FilterFunc<T>,
):T | undefined {
return targets.find((target) => {
return attributeGetter(target) === attributeValue && (!targetFilter || targetFilter(target));