mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
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:
@@ -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: } },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user