Fix interaction between Filters::FilterForm and the WorkPackage Query

This commit is contained in:
Klaus Zanders
2026-05-29 13:38:58 +02:00
parent ff20cffd49
commit 78abead8b4
9 changed files with 139 additions and 59 deletions
+5 -4
View File
@@ -106,11 +106,12 @@ class Filters::Inputs::BaseFilterForm < ApplicationForm
"filter-name": @filter.name
}
) do |select|
@filter.available_operators.each do |op|
@filter.available_operators.each do |operator|
select.option(
label: op.human_name,
value: op.symbol,
selected: op.symbol == selected_operator
label: operator.human_name,
value: operator.symbol,
selected: operator.symbol == selected_operator,
**(operator.requires_value? ? {} : { "data-no-value": true })
)
end
end
@@ -37,6 +37,27 @@ module Queries::WorkPackages::Filter::FilterForWpMixin
raise NotImplementedError, "There would be too many candidates"
end
# Tell `Filters::FilterForm`'s dispatch to render these filters with a
# server-side autocompleter (the candidate set is too large for an inline
# `<select>`). Mirrors what the legacy Angular WP filter UI does — see
# `filter-searchable-multiselect-value.component.html`, which renders an
# `op-autocompleter` against the `work_packages` resource using the
# `typeahead` filter for search.
#
# Subclasses that override `type` to something other than `:list` (e.g.
# `SearchFilter` / `SubjectOrIdFilter` use `:search`, `RelatableFilter`
# uses `:relation`) get an empty hash so the dispatch falls back to the
# appropriate non-autocomplete input.
def autocomplete_options
return {} unless type == :list
{
component: "opce-autocompleter",
resource: "work_packages",
searchKey: "typeahead"
}
end
def value_objects
objects = visible_scope.find(no_templated_values)
+11
View File
@@ -63,6 +63,7 @@ class Query < ApplicationRecord
validate :validate_timestamps
include Scopes::Scoped
scopes :visible,
:having_views
@@ -217,6 +218,16 @@ class Query < ApplicationRecord
filters.detect { |f| f.name == name }
end
# The manual-sort filter is added programmatically when the user drags
# work packages to reorder them — it has no operator/value UI of its own
# (type `:empty_value`), so it doesn't belong in the picker that
# `Filters::FilterForm` builds. Mirrors how
# `Queries::Filters::AvailableFilters#available_advanced_filters` already
# excludes the inline `name_and_identifier` quick-filter on projects.
def available_advanced_filters
super.grep_v(::Queries::WorkPackages::Filter::ManualSortFilter)
end
def normalized_name
name.parameterize.underscore
end
+9 -8
View File
@@ -69,10 +69,10 @@ en:
not_available_in_semantic_mode: "Reserved identifiers are only available in classic identifier mode."
filter_label: "Filter identifiers"
btn_release: "Release"
released_notice: "Identifier \"%{identifier}\" has been released."
released_notice: 'Identifier "%{identifier}" has been released.'
dialog:
title: "Release identifier"
heading: "Release \"%{identifier}\"?"
heading: 'Release "%{identifier}"?'
description: "Releasing this identifier cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
checkbox_label: "I understand that this cannot be undone."
confirm_button: "Release identifier"
@@ -1548,7 +1548,7 @@ en:
one: "Successfully copied workflow to '%{type_name}' type."
other: "Successfully copied workflow to %{count} types."
new:
title: "Copy workflow of \"%{source_type}\""
title: 'Copy workflow of "%{source_type}"'
form:
matrix_caption: "Workflow matrix"
matrix_caption_assignee: "Workflow matrix for assignee"
@@ -1829,7 +1829,7 @@ en:
changeset:
repository: "Repository"
comment:
commented: "Commented" # an object that this comment belongs to
commented: "Commented" # an object that this comment belongs to
custom_action:
actions: "Actions"
custom_field:
@@ -2124,7 +2124,7 @@ en:
redirect_existing_links: "Redirect existing links"
text: "Page content"
work_package:
ancestor: "Descendants of" # used for filtering of work packages that are descendants of a given work package
ancestor: "Descendants of" # used for filtering of work packages that are descendants of a given work package
begin_insertion: "Begin of the insertion"
begin_deletion: "Begin of the deletion"
children: "Subelements"
@@ -2142,13 +2142,14 @@ en:
false: "working days only"
true: "include non-working days"
journal_internal: Internal Journal
notify: "Notify" # used in custom actions
notify: "Notify" # used in custom actions
parent: "Parent"
parent_issue: "Parent"
parent_work_package: "Parent"
priority: "Priority"
progress: "% Complete"
readonly: "Read only"
relatable: "Relatable to"
remaining_hours: "Remaining work"
remaining_time: "Remaining work"
sequence_number: "Sequence number"
@@ -4861,8 +4862,8 @@ en:
A new user (%{email}) tried to create an account on an OpenProject environment that you manage (%{host}).
The user cannot activate their account since the user limit has been reached.
steps:
a: "Upgrade your payment plan ([here](upgrade_url))" # here turned into a link
b: "Lock or delete an existing user ([here](users_url))" # here turned into a link
a: "Upgrade your payment plan ([here](upgrade_url))" # here turned into a link
b: "Lock or delete an existing user ([here](users_url))" # here turned into a link
label: "To allow the user to sign in you can either: "
more_actions: "More functions"
@@ -153,7 +153,7 @@ export default class FiltersFormController extends Controller {
const filterName = target.getAttribute('data-filter-name');
if (filterName) {
const operator = this.findTargetByName(filterName, this.operatorTargets);
if (operator && this.noValueOperators.includes(operator.value)) {
if (operator && this.operatorRequiresNoValue(operator)) {
target.setAttribute('hidden', '');
}
}
@@ -354,16 +354,24 @@ export default class FiltersFormController extends Controller {
inputElement.dispatchEvent(inputEvent);
}
private readonly noValueOperators = ['*', '!*', 't', 'w'];
private readonly daysOperators = ['>t-', '<t-', 't-', '<t+', '>t+', 't+'];
private readonly onDateOperator = '=d';
private readonly betweenDatesOperator = '<>d';
// Whether the operator currently selected in `operatorElement` declares
// itself value-less. Driven by the `data-no-value` attribute that
// `Filters::Inputs::BaseFilterForm#add_operator` emits on each `<option>`
// whose `Operator.requires_value?` is false — keeps the symbol list in
// one place (Ruby) instead of duplicating it here.
private operatorRequiresNoValue(operatorElement:HTMLSelectElement):boolean {
return operatorElement.selectedOptions[0]?.hasAttribute('data-no-value') ?? false;
}
setValueVisibility({ target, params: { filterName } }:{ target:HTMLSelectElement, params:{ filterName:string } }) {
const selectedOperator = target.value;
const valueContainer = this.findTargetByName(filterName, this.filterValueContainerTargets);
if (valueContainer) {
if (this.noValueOperators.includes(selectedOperator)) {
if (this.operatorRequiresNoValue(target)) {
valueContainer.setAttribute('hidden', '');
} else {
valueContainer.removeAttribute('hidden');
@@ -456,7 +464,9 @@ export default class FiltersFormController extends Controller {
const type = filter.getAttribute('data-filter-type');
const operator = filter.getAttribute('data-filter-operator');
if (name && type && operator) {
const value = this.parseFilterValue(filter, name, type, operator) as string[]|null;
// Quick filters carry an operator that always requires a value
// (the inline search input).
const value = this.parseFilterValue(filter, name, type, operator, false) as string[]|null;
if (value) {
filters.push({ name, operator, value });
@@ -473,11 +483,13 @@ export default class FiltersFormController extends Controller {
advancedFilters.forEach((filter) => {
const filterName = filter.getAttribute('data-filter-name')!;
const filterType = filter.getAttribute('data-filter-type');
const parsedOperator = this.findTargetByName(filterName, this.operatorTargets)?.value;
const operatorTarget = this.findTargetByName(filterName, this.operatorTargets);
const parsedOperator = operatorTarget?.value;
const valueContainer = this.findTargetByName(filterName, this.filterValueContainerTargets);
if (valueContainer && filterName && filterType && parsedOperator) {
const parsedValue = this.parseFilterValue(valueContainer, filterName, filterType, parsedOperator) as string[]|null;
if (valueContainer && filterName && filterType && parsedOperator && operatorTarget) {
const requiresNoValue = this.operatorRequiresNoValue(operatorTarget);
const parsedValue = this.parseFilterValue(valueContainer, filterName, filterType, parsedOperator, requiresNoValue) as string[]|null;
if (parsedValue) {
filters.push({ name: filterName, operator: parsedOperator, value: parsedValue });
@@ -509,10 +521,9 @@ export default class FiltersFormController extends Controller {
return value && value.length > 0 ? value.replace(/"/g, '\\"') : '';
}
private readonly operatorsWithoutValues = ['*', '!*', 't', 'w'];
private readonly dateFilterTypes = ['datetime_past', 'date'];
private parseFilterValue(valueContainer:HTMLElement, filterName:string, filterType:string, operator:string) {
private parseFilterValue(valueContainer:HTMLElement, filterName:string, filterType:string, operator:string, requiresNoValue:boolean) {
const checkbox = valueContainer.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (checkbox) {
@@ -523,7 +534,7 @@ export default class FiltersFormController extends Controller {
return (valueContainer.querySelector<HTMLInputElement>('input[name="value"]'))?.value.split(',');
}
if (this.operatorsWithoutValues.includes(operator)) {
if (requiresNoValue) {
return [];
}
@@ -130,10 +130,7 @@ form forwards it as `appendTo` to every autocompleter it renders.
| You want… | Use |
|----------------------------------------------------------|---------------------------|
| The standard OpenProject filter panel on an index page | `Filter::FilterComponent` |
| Filters inside a dialog or a non-filter form | `Filters::FilterForm` |
| Coordination with a quick filter input in a sub-header | `Filter::FilterComponent` (the sub-header attaches the controller) |
| The serialized filter string in `params` on submit | `Filters::FilterForm` + `hidden_input_name:` |
| Autocompleter dropdowns to escape a clipping container | `Filters::FilterForm` + `autocomplete_append_to:` |
| Filters inside a dialog or a non-filter form | `Filters::FilterForm` + `hidden_input_name:` |
## Stimulus controller placement
@@ -164,6 +161,8 @@ default.
with the same signature, so passing a `Query` (or any of its subclasses)
to `FilterForm` works exactly like passing a `BaseQuery` subclass.
<%= embed OpenProject::Filter::FilterFormPreview, :for_a_work_package_query, panels: %i[preview source] %>
What `FilterForm` does *not* do for you on the legacy side: parsing the
form submission back into a `Query#filters` collection. The work-package
filter pipeline still uses its own serialization (URL `filters=[...]`
@@ -33,12 +33,14 @@ module OpenProject
# @logical_path OpenProject/Filter
class FilterFormPreview < Lookbook::Preview
# @display min_height 600px
# Using the `UserQuery` as an example.
def default
render_with_template(locals: { query: UserQuery.new })
end
# @display min_height 600px
# @label With an active filter
# Using the `UserQuery` as an example.
def with_active_filter
query = UserQuery.new
query.where(:login, "~", ["admin"])
@@ -48,6 +50,7 @@ module OpenProject
# @display min_height 600px
# @label Hidden input mode
# @param output_format [Symbol] select [json, params]
# Using the `UserQuery` as an example.
# This also renders a field that shows the value of the hidden input to show the different serialization formats.
def with_hidden_input(output_format: :json)
render_with_template(locals: { query: UserQuery.new, output_format: output_format.to_sym })
@@ -55,9 +58,20 @@ module OpenProject
# @display min_height 600px
# @label Combined with other inputs
# Using the `UserQuery` as an example
def combined_with_other_inputs
render_with_template(locals: { query: UserQuery.new })
end
# @display min_height 600px
# @label Work package query (legacy `Query`)
# Renders against the legacy work-package `Query` (the one that # predates `Queries::BaseQuery`).
def for_a_work_package_query
query = ::Query.new
query.add_filter(:status_id, "o", [])
query.add_filter(:due_date, "=d", [Date.current.end_of_year.iso8601])
render_with_template(locals: { query: query })
end
end
end
end
@@ -0,0 +1,17 @@
<%# `Query` is the legacy work-package query model. `Filters::FilterForm` %>
<%# works against it the same way it does against `Queries::BaseQuery` %>
<%# subclasses — the only requirement is `available_advanced_filters`, %>
<%# `filters`, and `find_active_filter(name)`, all of which `Query` %>
<%# exposes (the last one was mirrored from `BaseQuery` to bring the %>
<%# legacy query in line with the new form). %>
<%= primer_form_with(url: "/foo", method: :post) do |f| %>
<%=
render(
Filters::FilterForm.new(
f,
query: query,
wrap_with_controller: true
)
)
%>
<% end %>
+38 -33
View File
@@ -31,10 +31,15 @@ en:
authentication_method: Authentication Method
creator: Creator
drive: Drive ID
file_link_origin: "File link origin ID"
host: Host
linkable_to_storage: "Linkable to storage"
linkable_to_storage_url: "Linkable to storage URL"
name: Name
password: Application password
provider_type: Provider type
storage: "Storage"
storage_url: "Storage URL"
tenant: Directory (tenant) ID
errors:
messages:
@@ -72,18 +77,18 @@ en:
errors:
too_many_elements_created_at_once: Too many elements created at once. Expected %{max} at most, got %{actual}.
external_file_storages: External file storages
permission_create_files: 'Automatically managed project folders: Create files'
permission_create_files: "Automatically managed project folders: Create files"
permission_create_files_explanation: This permission is only available for Nextcloud storages
permission_delete_files: 'Automatically managed project folders: Delete files'
permission_delete_files: "Automatically managed project folders: Delete files"
permission_delete_files_explanation: This permission is only available for Nextcloud storages
permission_header_for_project_module_storages: Automatically managed project folders
permission_manage_file_links: Manage file links
permission_manage_files_in_project: Manage files in project
permission_read_files: 'Automatically managed project folders: Read files'
permission_share_files: 'Automatically managed project folders: Share files'
permission_read_files: "Automatically managed project folders: Read files"
permission_share_files: "Automatically managed project folders: Share files"
permission_share_files_explanation: This permission is only available for Nextcloud storages
permission_view_file_links: View file links
permission_write_files: 'Automatically managed project folders: Write files'
permission_write_files: "Automatically managed project folders: Write files"
project_module_storages: Files
project_storages:
edit_project_folder:
@@ -101,25 +106,25 @@ en:
services:
attributes:
nextcloud_sync_service:
add_user_to_group: 'Add User to Group:'
create_folder: 'Managed Project Folder Creation:'
ensure_root_folder_permissions: 'Set Base Folder Permissions:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
remote_folders: 'Read contents of the team folder:'
remove_user_from_group: 'Remove User from Group:'
rename_project_folder: 'Rename managed project Folder:'
add_user_to_group: "Add User to Group:"
create_folder: "Managed Project Folder Creation:"
ensure_root_folder_permissions: "Set Base Folder Permissions:"
hide_inactive_folders: "Hide Inactive Folders Step:"
remote_folders: "Read contents of the team folder:"
remove_user_from_group: "Remove User from Group:"
rename_project_folder: "Rename managed project Folder:"
one_drive_sync_service:
create_folder: 'Managed Project Folder Creation:'
ensure_root_folder_permissions: 'Set Base Folder Permissions:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
remote_folders: 'Read contents of the drive root folder:'
rename_project_folder: 'Rename managed project Folder:'
create_folder: "Managed Project Folder Creation:"
ensure_root_folder_permissions: "Set Base Folder Permissions:"
hide_inactive_folders: "Hide Inactive Folders Step:"
remote_folders: "Read contents of the drive root folder:"
rename_project_folder: "Rename managed project Folder:"
sharepoint_sync_service:
create_folder: 'Managed Project Folder Creation:'
ensure_root_folder_permissions: 'Set Base Folder Permissions:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
remote_folders: 'Read contents of the drive root folder:'
rename_project_folder: 'Rename managed project Folder:'
create_folder: "Managed Project Folder Creation:"
ensure_root_folder_permissions: "Set Base Folder Permissions:"
hide_inactive_folders: "Hide Inactive Folders Step:"
remote_folders: "Read contents of the drive root folder:"
rename_project_folder: "Rename managed project Folder:"
errors:
messages:
error: An unexpected error occurred. Please check OpenProject logs for more information or contact an administrator
@@ -135,8 +140,8 @@ en:
nextcloud_sync_service:
attributes:
add_user_to_group:
conflict: 'The user %{user} could not be added to the %{group} group for the following reason: %{reason}'
failed_to_add: 'The user %{user} could not be added to the %{group} group for the following reason: %{reason}'
conflict: "The user %{user} could not be added to the %{group} group for the following reason: %{reason}"
failed_to_add: "The user %{user} could not be added to the %{group} group for the following reason: %{reason}"
create_folder:
conflict: The folder %{folder_name} already exists on %{parent_location}.
not_found: "%{parent_location} wasn't found."
@@ -149,8 +154,8 @@ en:
not_allowed: The %{username} doesn't have access to the %{group_folder} folder. Please check the folder permissions on Nextcloud.
not_found: "%{group_folder} folder wasn't found. Please check your Nextcloud setup."
remove_user_from_group:
conflict: 'The user %{user} could not be removed from the %{group} group for the following reason: %{reason}'
failed_to_remove: 'The user %{user} could not be removed from the %{group} group for the following reason: %{reason}'
conflict: "The user %{user} could not be removed from the %{group} group for the following reason: %{reason}"
failed_to_remove: "The user %{user} could not be removed from the %{group} group for the following reason: %{reason}"
rename_project_folder:
conflict: OpenProject could not rename the project folder to %{current_path} as a folder with the same name already exists
forbidden: OpenProject user does not have access to %{current_path} folder.
@@ -277,7 +282,7 @@ en:
download_report: Download
rerun_checks: Re-run all checks
run_checks: Run checks now
checked: 'Last check: %{datetime}'
checked: "Last check: %{datetime}"
checks:
ampf_configuration:
client_folder_creation: Automatic folder creation
@@ -287,7 +292,7 @@ en:
header: Automatically managed project folders
project_folders_exist: Project folders exist
project_folders_linked: Project folders linked
team_folder_app: 'Dependency: Team Folders'
team_folder_app: "Dependency: Team Folders"
team_folder_contents: Team folder content
team_folder_presence: Team folder exists
userless_access: Server-side request authentication
@@ -317,7 +322,7 @@ en:
errors:
client_id_invalid: The configured OAuth 2 client id is invalid. Please check the configuration.
client_secret_invalid: The configured OAuth 2 client secret is invalid. Please check the configuration.
nc_dependency_missing: 'A required dependency is missing on the file storage. Please add the following dependency: %{dependency}.'
nc_dependency_missing: "A required dependency is missing on the file storage. Please add the following dependency: %{dependency}."
nc_dependency_version_mismatch: The %{dependency} app version is not supported. Please update your Nextcloud server.
nc_host_not_found: No Nextcloud server found at the configured host url. Please check the configuration.
nc_oauth_request_not_found: The endpoint to fetch the currently connected user was not found. Please check the server logs for further information.
@@ -325,7 +330,7 @@ en:
nc_oauth_token_missing: OpenProject cannot test the user level communication with Nextcloud as the user did not yet link their Nextcloud account.
nc_project_folder_missing: The previously created project folder for project "%{project}" could not be found.
nc_team_folder_not_found: The team folder could not be found.
nc_unexpected_files: 'Unexpected files found in the managed team folder. For example: %{sample}'
nc_unexpected_files: "Unexpected files found in the managed team folder. For example: %{sample}"
nc_unlinked_project_folders: Not all project folders have been created yet (%{actual} / %{expected}). This can indicate errors during the AMPF background synchronization.
nc_userless_access_denied: The configured app password is invalid.
not_configured: The connection could not be validated. Please finish configuration first.
@@ -367,7 +372,7 @@ en:
project_folders:
subtitle: Automatically managed project folders
since: since %{datetime}
synced: 'Last sync: %{datetime}'
synced: "Last sync: %{datetime}"
title: Health status report
health_email_notifications:
description_disabled: Admins will not receive updates by email when there are important updates.
@@ -384,7 +389,7 @@ en:
empty_project_folder_validation: Selecting a folder is mandatory to proceed.
existing_manual_folder: You can designate an existing folder as the root folder for this project. The permissions are however not automatically managed, the administrator needs to manually ensure relevant users have access. The selected folder can be used by multiple projects.
host: Please add the host address of your storage including the https://. It should not be longer than 255 characters.
managed_project_folders_application_password_caption: 'Enable automatic managed folders by copying this value from: %{provider_type_link}.'
managed_project_folders_application_password_caption: "Enable automatic managed folders by copying this value from: %{provider_type_link}."
name: Give your storage a name so that users can differentiate between multiple storages.
new_storage_html: Read our documentation on [setting up a %{provider_name} file storage](docs_url) integration for more information.
nextcloud:
@@ -421,7 +426,7 @@ en:
integration: SharePoint Administration / OpenProject
oauth_configuration: Copy these values from the desired application in the %{application_link_text}.
provider_configuration: Please make sure you have administration privileges in the %{application_link_text} or contact your Microsoft administrator before doing the setup. In the portal, you also need to register an Azure application or use an existing one for authentication.
type: 'Please make sure you have administration privileges in your Nextcloud instance and have the following application installed before doing the setup:'
type: "Please make sure you have administration privileges in your Nextcloud instance and have the following application installed before doing the setup:"
type_link_text: "“Integration OpenProject”"
label_active: Active
label_add_new_storage: Add new storage