diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 55fa8acf1bc3..c5f5b4f6c4fd 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -40,29 +40,15 @@ def new @category = @project.categories.build end - def create # rubocop:disable Metrics/AbcSize + def create @category = @project.categories.build @category.attributes = permitted_params.category if @category.save - respond_to do |format| - format.html do - flash[:notice] = I18n.t(:notice_successful_create) - redirect_to project_settings_categories_path(@project) - end - format.js do - render locals: { project: @project, category: @category } - end - end + flash[:notice] = I18n.t(:notice_successful_create) + redirect_to project_settings_categories_path(@project) else - respond_to do |format| - format.html do - render action: :new, status: :unprocessable_entity - end - format.js do - render(:update) { |page| page.alert(@category.errors.full_messages.join('\n')) } - end - end + render action: :new, status: :unprocessable_entity end end diff --git a/app/helpers/removed_js_helpers_helper.rb b/app/helpers/removed_js_helpers_helper.rb index 03d66b371780..65d2ac235039 100644 --- a/app/helpers/removed_js_helpers_helper.rb +++ b/app/helpers/removed_js_helpers_helper.rb @@ -45,7 +45,11 @@ def link_to_function(content, function, html_options = {}) # Execute the callback on click def csp_onclick(callback_str, selector, prevent_default: true) content_for(:additional_js_dom_ready) do - "jQuery('#{selector}').click(function() { #{callback_str}; #{prevent_default ? 'return false;' : ''} });\n".html_safe + raw <<~JS # rubocop:disable Rails/OutputSafety + document.querySelector('#{j(selector)}')?.addEventListener('click', function(event) { + #{callback_str&.delete_suffix(';')};#{"\n event.preventDefault();" if prevent_default} + }); + JS end end end diff --git a/app/views/categories/create.js.erb b/app/views/categories/create.js.erb deleted file mode 100644 index e21944ffa7a7..000000000000 --- a/app/views/categories/create.js.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> - -jQuery('#work_package_category_id').empty().append("<%= escape_javascript(options_from_collection_for_select(project.categories, "id", "name", category.id)) %>"); diff --git a/app/views/forums/show.html.erb b/app/views/forums/show.html.erb index f696241a1ccc..869f1fbfe501 100644 --- a/app/views/forums/show.html.erb +++ b/app/views/forums/show.html.erb @@ -27,28 +27,6 @@ See COPYRIGHT and LICENSE files for more details. ++#%> - - <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { @forum.name } @@ -70,7 +48,6 @@ See COPYRIGHT and LICENSE files for more details. leading_icon: :plus, label: t(:label_message_new), tag: :a, - class: "add-message-button", href: url_for({ controller: "/messages", action: "new", forum_id: @forum })) do t(:label_message) end diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index d9070e03b8f9..7d5623131af5 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -200,14 +200,10 @@ See COPYRIGHT and LICENSE files for more details. } wrapper.style.display = ''; -<% end %> -<%= nonced_javascript_tag do %> - (function($) { - // Wrapper for page-specific JS from deprecated inline functions - // no longer available with CSP. - <%= content_for :additional_js_dom_ready %> - }(jQuery)); + // Wrapper for page-specific JS from deprecated inline functions + // no longer available with CSP. + <%= content_for :additional_js_dom_ready %> <% end %>
<%= call_hook :view_layouts_base_body_bottom %> diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb index a2dcaccc55b7..c4c1ffe0eda2 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -47,7 +47,6 @@ See COPYRIGHT and LICENSE files for more details. aria: { label: I18n.t(:button_edit) }, title: I18n.t(:button_edit) ) do |button| - csp_onclick('jQuery("#edit-news").show()', ".edit-news-button") button.with_leading_visual_icon(icon: :pencil) t(:button_edit) end @@ -72,16 +71,6 @@ See COPYRIGHT and LICENSE files for more details. end %> -<% if authorize_for('news', 'edit') %> - -<% end %> <% if @news.summary.present? %>
<%= format_text(@news, :summary) %> diff --git a/app/views/projects/settings/repository/subversion/_existing.html.erb b/app/views/projects/settings/repository/subversion/_existing.html.erb index 0807381f6c4d..e451890a9d10 100644 --- a/app/views/projects/settings/repository/subversion/_existing.html.erb +++ b/app/views/projects/settings/repository/subversion/_existing.html.erb @@ -67,10 +67,16 @@ See COPYRIGHT and LICENSE files for more details. ) %> <%= content_for(:additional_js_dom_ready) do - "jQuery('#repository-password-placeholder') - .change(function() { this.name = 'repository[password]'; }) - .focus(function() { this.value = ''; this.name = 'repository[password]'; }); - ".html_safe + raw <<~JS + const placeholder = document.getElementById('repository-password-placeholder'); + placeholder?.addEventListener('input', function(event) { + placeholder.name = 'repository[password]'; + }); + placeholder?.addEventListener('focus', function(event) { + placeholder.value = ''; + placeholder.name = 'repository[password]'; + }); + JS end %>
diff --git a/app/views/repositories/_revisions.html.erb b/app/views/repositories/_revisions.html.erb index c823bfe20a2e..e234cf338a20 100644 --- a/app/views/repositories/_revisions.html.erb +++ b/app/views/repositories/_revisions.html.erb @@ -121,8 +121,9 @@ See COPYRIGHT and LICENSE files for more details. (line_num == 1), id: "cb-#{line_num}" %> <% csp_onclick( - "jQuery('#cbto-#{line_num + 1}').attr('checked', true);", - "cb-#{line_num}" + "document.getElementById('cbto-#{line_num + 1}').checked = true;", + "#cb-#{line_num}", + prevent_default: false ) %> <% end %> @@ -134,8 +135,9 @@ See COPYRIGHT and LICENSE files for more details. (line_num == 2), id: "cbto-#{line_num}" %> <% csp_onclick( - "if (jQuery('#cb-#{line_num}').attr('checked')) {jQuery('#cb-#{line_num - 1}').attr('checked', true)}", - "cbto-#{line_num}" + "if (document.getElementById('cb-#{line_num}')?.checked) {document.getElementById('cb-#{line_num - 1}').checked = true;}", + "#cbto-#{line_num}", + prevent_default: false ) %> <% end %> diff --git a/app/views/repositories/diff.html.erb b/app/views/repositories/diff.html.erb index 3733ddaafa31..e598558ae2a7 100644 --- a/app/views/repositories/diff.html.erb +++ b/app/views/repositories/diff.html.erb @@ -50,18 +50,16 @@ See COPYRIGHT and LICENSE files for more details. end %>
- <%= form_tag({repo_path: to_path_param(@path)}, method: :get) do %> - <%= hidden_field_tag('rev', params[:rev]) if params[:rev] %> - <%= hidden_field_tag('rev_to', params[:rev_to]) if params[:rev_to] %> - <%= styled_select_tag 'type', options_for_select([[t(:label_diff_inline), "inline"], [t(:label_diff_side_by_side), "sbs"]], @diff_type), id: "repository-diff-type-select" %> + <%= form_tag({ repo_path: to_path_param(@path) }, method: :get, data: { controller: "auto-submit" }) do %> + <%= hidden_field_tag("rev", params[:rev]) if params[:rev] %> + <%= hidden_field_tag("rev_to", params[:rev_to]) if params[:rev_to] %> + <%= styled_select_tag "type", + options_for_select( + [[t(:label_diff_inline), "inline"], + [t(:label_diff_side_by_side), "sbs"]], @diff_type + ), + data: { action: "auto-submit#submit" } %> <% end %> - <%= - content_for(:additional_js_dom_ready) do - "jQuery('#repository-diff-type-select').change(function() { - if (this.value != '') { this.form.submit() } - });".html_safe - end - %>
<% cache(@cache_key) do -%> <%= render partial: 'common/diff', locals: { diff: @diff, diff_type: @diff_type } %> diff --git a/app/views/versions/create.js.erb b/app/views/versions/create.js.erb deleted file mode 100644 index 3107e98ff5e2..000000000000 --- a/app/views/versions/create.js.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) the OpenProject GmbH - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See COPYRIGHT and LICENSE files for more details. - -++#%> - -jQuery('#work_package_version_id').empty().append("<%= escape_javascript(version_options_for_select(versions, version)) %>"); diff --git a/app/views/wiki/destroy.html.erb b/app/views/wiki/destroy.html.erb index 9c0b2e2e0764..96d2ed9189d5 100644 --- a/app/views/wiki/destroy.html.erb +++ b/app/views/wiki/destroy.html.erb @@ -62,7 +62,7 @@ See COPYRIGHT and LICENSE files for more details. <%= styled_select_tag "reassign_to_id", options_for_select(wiki_page_options_for_select(@reassignable_to)), { container_class: "-wide" } %> - <% csp_onclick("jQuery('#todo_reassign').attr('checked', true)", "#reassign_to_id") %> + <% csp_onclick("document.getElementById('todo_reassign').checked = true", "#reassign_to_id") %> <% end %> diff --git a/app/views/wiki/edit_parent_page.html.erb b/app/views/wiki/edit_parent_page.html.erb index 0604858712e7..134339e8acd0 100644 --- a/app/views/wiki/edit_parent_page.html.erb +++ b/app/views/wiki/edit_parent_page.html.erb @@ -39,18 +39,19 @@ See COPYRIGHT and LICENSE files for more details. <%= error_messages_for "page" %> <%= labelled_tabular_form_for @page, url: { id: @page, action: "update_parent_page" } do |f| %> + <% options_for_select = wiki_page_options_for_select(@parent_pages) %>
<%= f.select :parent_id, - wiki_page_options_for_select(@parent_pages), - { label: WikiPage.human_attribute_name(:parent_title), include_blank: false, container_class: "-wide" } %> + options_for_select, + { label: WikiPage.human_attribute_name(:parent_title), include_blank: false, container_class: "-wide" }, + { + size: options_for_select.size + 1, + style: "height:unset", + data: { + controller: "select-autosize", + select_autosize_size_limit_value: 8 + } + } %>
- - <%= nonced_javascript_tag do -%> - jQuery(function() { - var parent_select = jQuery('#wiki_page_parent_id'); - parent_select.attr('size', parent_select.children().length); - } - )); - <% end -%> <%= submit_tag t(:button_save), class: "button -primary" %> <% end %> diff --git a/app/views/work_packages/bulk/destroy.html.erb b/app/views/work_packages/bulk/destroy.html.erb index d9b4db1a9aa1..687a05a218e5 100644 --- a/app/views/work_packages/bulk/destroy.html.erb +++ b/app/views/work_packages/bulk/destroy.html.erb @@ -28,7 +28,16 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= error_messages_for work_packages.first %> -<%= styled_form_tag work_packages_bulk_path, method: :delete, class: "form danger-zone" do %> +<%= + styled_form_tag( + work_packages_bulk_path, + method: :delete, + class: "form danger-zone", + data: { + controller: "show-when-value-selected" + } + ) do +%> <%= back_url_hidden_field_tag unless back_url_is_wp_show? %> <% work_packages.each do |work_package| %> <%= hidden_field_tag "ids[]", work_package.id %> @@ -49,13 +58,21 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_label_tag :to_do_action_destroy, t(:text_destroy) %>
- <%= styled_radio_button_tag "to_do[action]", "destroy" %> + <%= styled_radio_button_tag "to_do[action]", "destroy", nil, + data: { + show_when_value_selected_target: "cause", + target_name: "action" + } %>
<%= styled_label_tag "to_do_action_nullify", t(:text_assign_to_project) %>
- <%= styled_radio_button_tag "to_do[action]", "nullify" %> + <%= styled_radio_button_tag "to_do[action]", "nullify", nil, + data: { + show_when_value_selected_target: "cause", + target_name: "action" + } %>
@@ -63,11 +80,11 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_label_tag "to_do_action_reassign", t(:text_reassign) %>
- <%= styled_radio_button_tag "to_do[action]", "reassign" %> - <% csp_onclick( - 'if(jQuery("#to_do_action_reassign").prop("checked")) { jQuery("#to_do_reassign_to_id").focus(); }', - "#reassign" - ) %> + <%= styled_radio_button_tag "to_do[action]", "reassign", nil, + data: { + show_when_value_selected_target: "cause", + target_name: "action" + } %>
@@ -75,7 +92,15 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_label_tag "to_do_reassign_to_id", t(:text_reassign), class: "form--label sr-only" %> - <%= f.text_field "reassign_to_id", placeholder: WorkPackage.human_attribute_name(:id), value: params[:reassign_to_id], size: 6, onfocus: 'jQuery("#to_do_action_reassign").prop("checked", true);' %> + <%= f.text_field "reassign_to_id", + placeholder: WorkPackage.human_attribute_name(:id), value: params[:reassign_to_id], + size: 6, + hidden: true, + data: { + show_when_value_selected_target: "effect", + target_name: "action", + value: "reassign" + } %>
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 3b10106130d7..6516882b6e42 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -58,8 +58,7 @@ export default defineConfig([ }, ], - // Sometimes we need to shush the TypeScript compiler - 'no-unused-vars': ['error', { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }], + 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }], // Allow short circuit evaluations @@ -154,13 +153,6 @@ export default defineConfig([ '@angular-eslint/template/prefer-control-flow': 'error' } }, - { - files: ['**/*.d.ts'], - rules: { - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': 'off', - }, - }, { files: ['**/*.spec.ts'], plugins: { jasmine }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 53039641370f..e73fdadec87b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,6 +47,7 @@ "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.20", "@hotwired/turbo-rails": "^8.0.20", + "@knowledgecode/delegate": "^0.8.5", "@kolkov/ngx-gallery": "^2.0.1", "@mantine/core": "^8.3.6", "@mantine/hooks": "^8.3.5", @@ -82,6 +83,7 @@ "dom-autoscroller": "^2.2.8", "dom-plane": "^1.0.2", "dragula": "^3.7.3", + "es6-slide-up-down": "^1.0.0", "flatpickr": "^4.6.13", "glob": "^11.0.3", "hammerjs": "^2.0.8", @@ -4970,6 +4972,12 @@ "tslib": "2" } }, + "node_modules/@knowledgecode/delegate": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@knowledgecode/delegate/-/delegate-0.8.5.tgz", + "integrity": "sha512-Wuv1m5t170SWW6R60zgOKg14s230buKfrd8tjpmeG/lKBFcYjQSvZoOZh34isygkvJkBHY2ozdg2E3rzJy3Ehg==", + "license": "MIT" + }, "node_modules/@kolkov/ngx-gallery": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@kolkov/ngx-gallery/-/ngx-gallery-2.0.1.tgz", @@ -11628,6 +11636,12 @@ "node": ">=0.12" } }, + "node_modules/es6-slide-up-down": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es6-slide-up-down/-/es6-slide-up-down-1.0.0.tgz", + "integrity": "sha512-s86t2F+GjPRxiSodC59JRZZaP07Ht03EDU2RDrRCXYk9o3CfTCNjtwqrpoV1BjbCnzXcBoDGj1aZhzZkHzMpWQ==", + "license": "MIT" + }, "node_modules/es6-symbol": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", @@ -26596,6 +26610,11 @@ "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", "dev": true }, + "@knowledgecode/delegate": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@knowledgecode/delegate/-/delegate-0.8.5.tgz", + "integrity": "sha512-Wuv1m5t170SWW6R60zgOKg14s230buKfrd8tjpmeG/lKBFcYjQSvZoOZh34isygkvJkBHY2ozdg2E3rzJy3Ehg==" + }, "@kolkov/ngx-gallery": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@kolkov/ngx-gallery/-/ngx-gallery-2.0.1.tgz", @@ -31088,6 +31107,11 @@ } } }, + "es6-slide-up-down": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es6-slide-up-down/-/es6-slide-up-down-1.0.0.tgz", + "integrity": "sha512-s86t2F+GjPRxiSodC59JRZZaP07Ht03EDU2RDrRCXYk9o3CfTCNjtwqrpoV1BjbCnzXcBoDGj1aZhzZkHzMpWQ==" + }, "es6-symbol": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7225dea73ba7..fe68ebb6a4c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -102,6 +102,7 @@ "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.20", "@hotwired/turbo-rails": "^8.0.20", + "@knowledgecode/delegate": "^0.8.5", "@kolkov/ngx-gallery": "^2.0.1", "@mantine/core": "^8.3.6", "@mantine/hooks": "^8.3.5", @@ -137,6 +138,7 @@ "dom-autoscroller": "^2.2.8", "dom-plane": "^1.0.2", "dragula": "^3.7.3", + "es6-slide-up-down": "^1.0.0", "flatpickr": "^4.6.13", "glob": "^11.0.3", "hammerjs": "^2.0.8", diff --git a/frontend/src/app/core/current-project/current-project.service.spec.ts b/frontend/src/app/core/current-project/current-project.service.spec.ts index 3f3654ad4ff0..59ada2ac2818 100644 --- a/frontend/src/app/core/current-project/current-project.service.spec.ts +++ b/frontend/src/app/core/current-project/current-project.service.spec.ts @@ -30,7 +30,7 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { CurrentProjectService } from './current-project.service'; describe('currentProject service', () => { - let element:JQuery; + let element:HTMLMetaElement; let currentProject:CurrentProjectService; const apiV3Stub:any = { @@ -55,12 +55,12 @@ describe('currentProject service', () => { describe('with a meta value present', () => { beforeEach(() => { - const html = ` - - `; - - element = jQuery(html); - jQuery(document.body).append(element); + element = document.createElement('meta'); + element.setAttribute('name', 'current_project'); + element.dataset.projectName = 'Foo 1234'; + element.dataset.projectId = '1'; + element.dataset.projectIdentifier = 'foobar'; + document.head.appendChild(element); currentProject.detect(); }); diff --git a/frontend/src/app/core/current-project/current-project.service.ts b/frontend/src/app/core/current-project/current-project.service.ts index 454509c1136d..da1425eda32a 100644 --- a/frontend/src/app/core/current-project/current-project.service.ts +++ b/frontend/src/app/core/current-project/current-project.service.ts @@ -29,6 +29,7 @@ import { Injectable } from '@angular/core'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { getMetaElement } from '../setup/globals/global-helpers'; @Injectable({ providedIn: 'root' }) export class CurrentProjectService { @@ -79,11 +80,11 @@ export class CurrentProjectService { * Detect the current project from its meta tag. */ public detect() { - const element:HTMLMetaElement|null = document.querySelector('meta[name=current_project]'); + const element = getMetaElement('current_project'); if (element) { - this.currentId = element.dataset.projectId as string; - this.currentName = element.dataset.projectName as string; - this.currentIdentifier = element.dataset.projectIdentifier as string; + this.currentId = element.dataset.projectId!; + this.currentName = element.dataset.projectName!; + this.currentIdentifier = element.dataset.projectIdentifier!; } else { this.currentId = null; this.currentName = null; diff --git a/frontend/src/app/core/current-user/current-user.module.ts b/frontend/src/app/core/current-user/current-user.module.ts index 125ee43f4795..892368c38147 100644 --- a/frontend/src/app/core/current-user/current-user.module.ts +++ b/frontend/src/app/core/current-user/current-user.module.ts @@ -4,13 +4,13 @@ import { CurrentUserService } from './current-user.service'; import { CurrentUserStore } from './current-user.store'; import { CurrentUserQuery } from './current-user.query'; import { firstValueFrom } from 'rxjs'; +import { getMetaValue } from '../setup/globals/global-helpers'; function loadUserMetadata(currentUserService:CurrentUserService) { - const userMeta = document.querySelector('meta[name=current_user]'); currentUserService.setUser({ - id: userMeta?.dataset.id || null, - name: userMeta?.dataset.name || null, - loggedIn: userMeta?.dataset.loggedIn === 'true', + id: getMetaValue('current_user', 'id', null), + name: getMetaValue('current_user', 'name', null), + loggedIn: getMetaValue('current_user', 'loggedIn') === 'true' }); } diff --git a/frontend/src/app/core/errors/appsignal/appsignal-reporter.ts b/frontend/src/app/core/errors/appsignal/appsignal-reporter.ts index af1b29655916..30be48c84c1b 100644 --- a/frontend/src/app/core/errors/appsignal/appsignal-reporter.ts +++ b/frontend/src/app/core/errors/appsignal/appsignal-reporter.ts @@ -30,6 +30,7 @@ import { debugLog } from 'core-app/shared/helpers/debug_output'; import { ErrorReporterBase, ErrorTags, MessageSeverity } from 'core-app/core/errors/error-reporter-base'; import type { Appsignal } from './appsignal-dependency'; import { Span } from '@appsignal/javascript'; +import { getMetaElement } from 'core-app/core/setup/globals/global-helpers'; export class AppsignalReporter extends ErrorReporterBase { private client:Appsignal; @@ -50,7 +51,7 @@ export class AppsignalReporter extends ErrorReporterBase { constructor() { super(); - const element = document.querySelector('meta[name=openproject_appsignal]') as HTMLElement; + const element = getMetaElement('openproject_appsignal')!; this.loadAppsignal(element); } diff --git a/frontend/src/app/core/errors/configure-reporter.ts b/frontend/src/app/core/errors/configure-reporter.ts index c19892c5e798..c1a884f4b0be 100644 --- a/frontend/src/app/core/errors/configure-reporter.ts +++ b/frontend/src/app/core/errors/configure-reporter.ts @@ -1,9 +1,10 @@ import { ErrorReporterBase } from 'core-app/core/errors/error-reporter-base'; import { AppsignalReporter } from 'core-app/core/errors/appsignal/appsignal-reporter'; import { LocalReporter } from 'core-app/core/errors/local/local-reporter'; +import { getMetaElement } from '../setup/globals/global-helpers'; export function configureErrorReporter():ErrorReporterBase { - const appsignalElement = document.querySelector('meta[name=openproject_appsignal]') as HTMLElement; + const appsignalElement = getMetaElement('openproject_appsignal'); if (appsignalElement !== null) { return new AppsignalReporter(); } diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index f32f7ce0ba32..64ed483ea07a 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -165,7 +165,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { // detect if click is outside or inside the element @HostListener('click', ['$event']) - public handleClick(event:JQuery.TriggeredEvent):void { + public handleClick(event:MouseEvent):void { event.preventDefault(); // handle click on search button @@ -173,7 +173,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { if (this.deviceService.isTablet) { this.toggleMobileSearch(); // open ng-select menu on default - jQuery('.ng-input input').focus(); + document.querySelector('.ng-input input')?.focus(); // only for mobile and not for all devices! // See https://github.com/opf/openproject/commit/a2eb0cd6025f2ecaca00f4ed81c4eb8e9399bd86 event.stopPropagation(); diff --git a/frontend/src/app/core/html/op-title.service.ts b/frontend/src/app/core/html/op-title.service.ts index bd53e664f800..fc6a500ecb2a 100644 --- a/frontend/src/app/core/html/op-title.service.ts +++ b/frontend/src/app/core/html/op-title.service.ts @@ -1,5 +1,6 @@ import { Title } from '@angular/platform-browser'; import { Injectable } from '@angular/core'; +import { getMetaContent } from '../setup/globals/global-helpers'; const titlePartsSeparator = ' | '; @@ -13,8 +14,7 @@ export class OpTitleService { } public get base():string { - const appTitle = document.querySelector('meta[name=app_title]') as HTMLMetaElement; - return appTitle.content; + return getMetaContent('app_title'); } public get titleParts():string[] { diff --git a/frontend/src/app/core/i18n/i18n.service.ts b/frontend/src/app/core/i18n/i18n.service.ts index cf1ca76ad9b8..76cdf244165b 100644 --- a/frontend/src/app/core/i18n/i18n.service.ts +++ b/frontend/src/app/core/i18n/i18n.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { NgSelectConfig } from '@ng-select/ng-select'; import { I18n } from 'i18n-js'; import { FormatNumberOptions, TranslateOptions } from 'i18n-js/src/typing'; +import { getMetaValue } from '../setup/globals/global-helpers'; @Injectable({ providedIn: 'root' }) export class I18nService { @@ -11,8 +12,7 @@ export class I18nService { constructor( private config:NgSelectConfig, ) { - const meta = document.querySelector('meta[name=openproject_initializer]'); - this.instanceLocale = meta?.dataset.instancelocale || 'en'; + this.instanceLocale = getMetaValue('openproject_initializer', 'instanceLocale', 'en'); this.config.addTagText = this.t('js.autocomplete_ng_select.add_tag'); this.config.clearAllText = this.t('js.autocomplete_ng_select.clear_all'); diff --git a/frontend/src/app/core/loading-indicator/loading-indicator.service.ts b/frontend/src/app/core/loading-indicator/loading-indicator.service.ts index c2665bb00399..cff8b3965043 100644 --- a/frontend/src/app/core/loading-indicator/loading-indicator.service.ts +++ b/frontend/src/app/core/loading-indicator/loading-indicator.service.ts @@ -70,7 +70,7 @@ export class LoadingIndicator { `; - constructor(public indicator:JQuery) { + constructor(public indicator:HTMLElement) { } public set promise(promise:Promise) { @@ -87,7 +87,7 @@ export class LoadingIndicator { public start() { // If we're currently having an active indicator, remove that one this.stop(); - this.indicator.prepend(this.indicatorTemplate); + this.indicator.insertAdjacentHTML('afterbegin', this.indicatorTemplate); } public delayedStop(time = 25) { @@ -95,7 +95,7 @@ export class LoadingIndicator { } public stop() { - this.indicator.find('.loading-indicator--background').remove(); + this.indicator.querySelector('.loading-indicator--background')?.remove(); } } @@ -121,7 +121,7 @@ export class LoadingIndicatorService { } // Return an indicator by name or element - public indicator(indicator:string|JQuery):LoadingIndicator { + public indicator(indicator:string|HTMLElement):LoadingIndicator { if (typeof indicator === 'string') { indicator = this.getIndicatorAt(indicator); } @@ -129,7 +129,7 @@ export class LoadingIndicatorService { return new LoadingIndicator(indicator); } - private getIndicatorAt(name:string):JQuery { - return jQuery(indicatorLocationSelector).filter(`[data-indicator-name="${name}"]`); + private getIndicatorAt(name:string):HTMLElement { + return document.querySelector(`${indicatorLocationSelector}[data-indicator-name="${name}"]`)!; } } diff --git a/frontend/src/app/core/main-menu/main-menu-toggle.service.ts b/frontend/src/app/core/main-menu/main-menu-toggle.service.ts index 494e0f901140..c7c087d81096 100644 --- a/frontend/src/app/core/main-menu/main-menu-toggle.service.ts +++ b/frontend/src/app/core/main-menu/main-menu-toggle.service.ts @@ -32,6 +32,7 @@ import { I18nService } from 'core-app/core/i18n/i18n.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; import { DeviceService } from 'core-app/core/browser/device.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { queryVisible } from 'core-app/shared/helpers/dom-helpers'; @Injectable({ providedIn: 'root' }) export class MainMenuToggleService { @@ -51,7 +52,7 @@ export class MainMenuToggleService { private htmlNode = document.getElementsByTagName('html')[0]; - private mainMenu = jQuery('#main-menu')[0]; // main menu, containing sidebar and resizer + private mainMenu = document.querySelector('#main-menu')!; // main menu, containing sidebar and resizer // Notes all changes of the menu size (currently needed in wp-resizer.component.ts) private changeData = new BehaviorSubject(undefined); @@ -110,7 +111,7 @@ export class MainMenuToggleService { } } - public toggleNavigation(event?:JQuery.TriggeredEvent|Event):void { + public toggleNavigation(event?:Event):void { if (event) { event.stopPropagation(); event.preventDefault(); @@ -132,14 +133,15 @@ export class MainMenuToggleService { // This needs to be called after AngularJS has rendered the menu, which happens some when after(!) we leave this // method here. So we need to set the focus after a timeout. setTimeout(() => { - jQuery('#main-menu [class*="-menu-item"]:visible').first().focus(); + const firstVisibleMenuItem = queryVisible('[class*="-menu-item"]', this.mainMenu)[0]; + firstVisibleMenuItem?.focus(); }, 500); } public closeMenu():void { this.setWidth(0); this.changeData.next(0); - jQuery('.searchable-menu--search-input').blur(); + document.querySelectorAll('.searchable-menu--search-input').forEach((input) => input.blur()); } public openMenu():void { @@ -187,7 +189,7 @@ export class MainMenuToggleService { private toggleClassHidden():void { const isHidden = this.elementWidth < this.elementMinWidth; - const hideElements = jQuery('.can-hide-navigation'); - hideElements.toggleClass('hidden-navigation', isHidden); + const hideElements = document.querySelectorAll('.can-hide-navigation'); + hideElements.forEach((hideElement) => hideElement.classList.toggle('hidden-navigation', isHidden)); } } diff --git a/frontend/src/app/core/routing/openproject.routes.ts b/frontend/src/app/core/routing/openproject.routes.ts index bed629c8347e..d535682f01e6 100644 --- a/frontend/src/app/core/routing/openproject.routes.ts +++ b/frontend/src/app/core/routing/openproject.routes.ts @@ -107,7 +107,7 @@ export function updateMenuItem(menuItemClass:string|undefined, action:'add'|'rem return; } - const menuItem = jQuery(`#main-menu .${menuItemClass}`)[0]; + const menuItem = document.querySelector(`#main-menu .${menuItemClass}`); if (!menuItem) { return; diff --git a/frontend/src/app/core/setup/globals/components/admin/backup.component.ts b/frontend/src/app/core/setup/globals/components/admin/backup.component.ts index 215bf6085337..1242bcd5094c 100644 --- a/frontend/src/app/core/setup/globals/components/admin/backup.component.ts +++ b/frontend/src/app/core/setup/globals/components/admin/backup.component.ts @@ -101,7 +101,7 @@ export class BackupComponent implements AfterViewInit { return this.mayIncludeAttachments ? '' : this.text.attachmentsDisabled; } - public triggerBackup(event?:JQuery.TriggeredEvent) { + public triggerBackup(event?:Event) { if (event) { event.stopPropagation(); event.preventDefault(); diff --git a/frontend/src/app/core/setup/globals/global-helpers.ts b/frontend/src/app/core/setup/globals/global-helpers.ts index 449f6b045a49..3cad73607bcb 100644 --- a/frontend/src/app/core/setup/globals/global-helpers.ts +++ b/frontend/src/app/core/setup/globals/global-helpers.ts @@ -47,5 +47,26 @@ export class GlobalHelpers { } export function getMetaElement(name:string):HTMLMetaElement|null { - return document.querySelector(`meta[name=${name}]`); + return document.head.querySelector(`meta[name="${CSS.escape(name)}"]`); +} + +export function getMetaContent(name:string):string; +export function getMetaContent(name:string, defaultValue:T):T; +export function getMetaContent( + name:string, + defaultValue?:T +):string|T { + const content = getMetaElement(name)?.content ?? defaultValue ?? ''; + return content as string|T; +} + +export function getMetaValue(name:string, key:string):string; +export function getMetaValue(name:string, key:string, defaultValue:T):T; +export function getMetaValue( + name:string, + key:string, + defaultValue?:T +):string|T { + const value = getMetaElement(name)?.dataset[key] ?? defaultValue ?? ''; + return value as string|T; } diff --git a/frontend/src/app/core/setup/globals/global-listeners.ts b/frontend/src/app/core/setup/globals/global-listeners.ts index c2a8dca7979e..7b11efdf24c1 100644 --- a/frontend/src/app/core/setup/globals/global-listeners.ts +++ b/frontend/src/app/core/setup/globals/global-listeners.ts @@ -35,7 +35,7 @@ import { performAnchorHijacking } from './global-listeners/link-hijacking'; export function initializeGlobalListeners():void { document .documentElement - .addEventListener('click', (evt:MouseEvent) => { + .addEventListener('click', (evt) => { const target = evt.target as HTMLElement; // Avoid defaulting clicks on elements already removed from DOM @@ -71,11 +71,9 @@ export function initializeGlobalListeners():void { }); // Disable global drag & drop handling, which results in the browser loading the image and losing the page - jQuery(document.documentElement) - .on('dragover drop', (evt:Event) => { - evt.preventDefault(); - return false; - }); + const disableDragDefaults = (evt:Event) => { evt.preventDefault(); }; + document.documentElement.addEventListener('dragover', disableDragDefaults); + document.documentElement.addEventListener('drop', disableDragDefaults); // Bootstrap legacy app code setupServerResponse(); diff --git a/frontend/src/app/core/setup/globals/global-listeners/action-menu.ts b/frontend/src/app/core/setup/globals/global-listeners/action-menu.ts index b5d67d7f82a4..2c7a010c85aa 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/action-menu.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/action-menu.ts @@ -26,7 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ import { ANIMATION_RATE_MS } from 'core-app/core/top-menu/top-menu.service'; -import ClickEvent = JQuery.ClickEvent; +import { slideDown, slideUp } from 'es6-slide-up-down'; /* The action menu is a menu that usually belongs to an OpenProject entity (like an Issue, WikiPage, Meeting, ..). @@ -43,37 +43,39 @@ import ClickEvent = JQuery.ClickEvent; The following code is responsible to open and close the "more functions" submenu. */ -function closeMenu(event:any) { - const menu = jQuery(event.data.menu); +function closeMenu(menu:HTMLElement, event:MouseEvent) { // do not close the menu, if the user accidentally clicked next to a menu item (but still within the menu) - if (event.target !== menu.find(' > li.drop-down.open > ul').get(0)) { - menu.find(' > li.drop-down.open').removeClass('open').find('> ul').slideUp(ANIMATION_RATE_MS); - // no need to watch for clicks, when the menu is already closed - jQuery('html').off('click', closeMenu); + if (event.target !== menu.querySelector(':scope > li.drop-down.open > ul')) { + const li = menu.querySelector(':scope > li.drop-down.open')!; + li.classList.remove('open'); + const ul = li.querySelector(':scope > ul'); + slideUp(ul!, ANIMATION_RATE_MS); } } -function openMenu(menu:JQuery) { - const dropDown = menu.find(' > li.drop-down'); +function openMenu(menu:HTMLElement) { + const dropDown = menu.querySelector(':scope > li.drop-down')!; // do not open a menu, which is already open - if (!dropDown.hasClass('open')) { - dropDown.find('> ul').slideDown(ANIMATION_RATE_MS, () => { - dropDown.find('li > a:first').focus(); + if (!dropDown.classList.contains('open')) { + const ul = dropDown.querySelector(':scope > ul')!; + slideDown(ul, ANIMATION_RATE_MS); + window.requestAnimationFrame(() => { + dropDown.querySelector('li > a:first-child')?.focus(); // when clicking on something, which is not the menu, close the menu - jQuery('html').on('click', { menu: menu.get(0) }, closeMenu); + document.addEventListener('click', (evt) => closeMenu(menu, evt), { once: true }); }); - dropDown.addClass('open'); + dropDown.classList.add('open'); } } // open the given submenu when clicking on it -export function installMenuLogic(menu:JQuery) { - menu.find(' > li.drop-down').on('click', (event:ClickEvent) => { +export function installMenuLogic(menu:HTMLElement) { + menu.querySelector(':scope > li.drop-down')?.addEventListener('click', (event) => { openMenu(menu); // and prevent default action (href) for that element // but not for the menu items. - const target = jQuery(event.target); - if (target.is('.drop-down') || target.closest('li, ul').is('.drop-down')) { + const target = event.target; + if (target instanceof HTMLElement && (target.matches('.drop-down') || target.closest('li, ul')?.matches('.drop-down'))) { event.preventDefault(); } }); diff --git a/frontend/src/app/core/setup/globals/global-listeners/color-preview.ts b/frontend/src/app/core/setup/globals/global-listeners/color-preview.ts index 5bf4605f378b..05c3e2206752 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/color-preview.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/color-preview.ts @@ -33,35 +33,39 @@ * this needs changes */ export function makeColorPreviews() { - jQuery('.color--preview').each(function () { - const preview = jQuery(this); - let input:any; - const target = preview.data('target'); + document.querySelectorAll('.color--preview').forEach(function (preview) { + let input:HTMLInputElement|null = null; + const target = preview.dataset.target; if (target) { - input = jQuery(target); + input = document.querySelector(target); } else { - input = preview.next('input'); + const next = preview.nextElementSibling; + if (next && next instanceof HTMLInputElement) { + input = next; + } } - if (input.length === 0) { + if (input === null) { return; } - const func = function () { + const listener = function () { let previewColor = ''; - if (input.val() && input.val().length > 0) { - previewColor = input.val(); - } else if (input.attr('placeholder') - && input.attr('placeholder').length > 0) { - previewColor = input.attr('placeholder'); + if (input.value && input.value.length > 0) { + previewColor = input.value; + } else if (input.getAttribute('placeholder') + && input.getAttribute('placeholder')!.length > 0) { + previewColor = input.getAttribute('placeholder')!; } - preview.css('background-color', previewColor); + preview.style.backgroundColor = previewColor; }; - input.keyup(func).change(func).focus(func); - func(); + input.addEventListener('keyup', listener); + input.addEventListener('change', listener); + input.addEventListener('focus', listener); + listener(); }); } diff --git a/frontend/src/app/core/setup/globals/global-listeners/danger-zone-validation.ts b/frontend/src/app/core/setup/globals/global-listeners/danger-zone-validation.ts index becadecda6d9..831235d43fad 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/danger-zone-validation.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/danger-zone-validation.ts @@ -30,22 +30,19 @@ // Make the whole danger zone a component the next time this needs changes! export function dangerZoneValidation() { - // This will only work iff there is a single danger zone on the page - const dangerZoneVerification = jQuery('.danger-zone--verification'); - const expectedValue = jQuery('.danger-zone--expected-value'); + // This will only work if there is a single danger zone on the page + const dangerZoneVerification = document.querySelector('.danger-zone--verification')!; + const expectedValue = document.querySelector('.danger-zone--expected-value')?.textContent; // When no expected value is set up, do not disable button - if (!expectedValue[0]) { + if (!expectedValue) { return; } - const expectedText = expectedValue.text(); - dangerZoneVerification.find('input').on('input', () => { - const actualValue = dangerZoneVerification.find('input').val() as string; - if (expectedText.toLowerCase() === actualValue.toLowerCase()) { - dangerZoneVerification.find('button').prop('disabled', false); - } else { - dangerZoneVerification.find('button').prop('disabled', true); - } + const input = dangerZoneVerification.querySelector('input')!; + const button = dangerZoneVerification.querySelector('button')!; + input.addEventListener('input', () => { + const actualValue = input.value; + button.disabled = expectedValue.toLowerCase() !== actualValue.toLowerCase(); }); } diff --git a/frontend/src/app/core/setup/globals/global-listeners/settings.ts b/frontend/src/app/core/setup/globals/global-listeners/settings.ts index e933c2395380..5fc13718760f 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/settings.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/settings.ts @@ -1,4 +1,5 @@ import { retrieveCkEditorInstance } from 'core-app/shared/helpers/ckeditor-helpers'; +import { hideElement, showElement, toggleElement } from 'core-app/shared/helpers/dom-helpers'; import invariant from 'tiny-invariant'; /** @@ -8,20 +9,22 @@ import invariant from 'tiny-invariant'; */ export function listenToSettingChanges() { /** Sync SCM vendor select when enabled SCMs are changed */ - jQuery('[name="settings[enabled_scm][]"]').change(function (this:HTMLInputElement) { - const wasDisabled = !this.checked; - const vendor = this.value; - const select = jQuery('#settings_repositories_automatic_managed_vendor'); - const option = select.find(`option[value="${vendor}"]`); + const enabledScm = document.querySelector('[name="settings[enabled_scm][]"]'); + enabledScm?.addEventListener('change', (event) => { + const checkbox = event.target as HTMLInputElement; + const wasDisabled = !checkbox.checked; + const vendor = checkbox.value; + const select = document.querySelector('#settings_repositories_automatic_managed_vendor')!; + const option = select.querySelector(`option[value="${vendor}"]`); // Skip non-manageable SCMs - if (option.length === 0) { + if (!option) { return; } - option.prop('disabled', wasDisabled); - if (wasDisabled && option.prop('selected')) { - select.val(''); + option.disabled = wasDisabled; + if (wasDisabled && option.selected) { + select.value = ''; } }); @@ -73,43 +76,46 @@ export function listenToSettingChanges() { /* end Javascript for Settings::TextSettingComponent */ /** Toggle notification settings fields */ - jQuery('#email_delivery_method_switch').on('change', function () { - const delivery_method = jQuery(this).val(); - jQuery('.email_delivery_method_settings').hide(); - jQuery(`#email_delivery_method_${delivery_method}`).show(); - }).trigger('change'); + const emailDeliveryMethodSwitch = document.querySelector('#email_delivery_method_switch'); + emailDeliveryMethodSwitch?.addEventListener('change', (event) => { + const delivery_method = (event.target as HTMLSelectElement).value; + document + .querySelectorAll('.email_delivery_method_settings') + .forEach((elem) => hideElement(elem)); + showElement(document.querySelector(`#email_delivery_method_${delivery_method}`)!); + }); + emailDeliveryMethodSwitch?.dispatchEvent(new Event('change', { bubbles: true })); - jQuery('#settings_smtp_authentication').on('change', function () { - const isNone = jQuery(this).val() === 'none'; - jQuery('#settings_smtp_user_name,#settings_smtp_password') - .closest('.form--field') - .toggle(!isNone); + document.querySelector('#settings_smtp_authentication')?.addEventListener('change', (event) => { + const isNone = (event.target as HTMLSelectElement).value === 'none'; + document + .querySelectorAll('#settings_smtp_user_name,#settings_smtp_password') + .forEach((field) => toggleElement(field.closest('.form--field')!, !isNone)); }); /** Toggle repository checkout fieldsets required when option is disabled */ - jQuery('.settings-repositories--checkout-toggle').change(function (this:HTMLInputElement) { - const wasChecked = this.checked; - const fieldset = jQuery(this).closest('fieldset'); - - fieldset - .find('input,select') - .filter(':not([type=checkbox])') - .filter(':not([type=hidden])') - .removeAttr('required') // Rails 4.0 still seems to use attribute - .prop('required', wasChecked); + document.querySelectorAll('.settings-repositories--checkout-toggle').forEach((toggle) => { + toggle.addEventListener('change', (event) => { + const wasChecked = (event.target as HTMLInputElement).checked; + const fieldset = toggle.closest('fieldset')!; + fieldset + .querySelectorAll('input,select:not([type=checkbox],[type=hidden])') + .forEach((field) => { field.required = wasChecked; }); + }); }); /** Toggle highlighted attributes visibility depending on if the highlighting mode 'inline' was selected */ - jQuery('.settings--highlighting-mode select').change(function () { - const highlightingMode = jQuery(this).val(); - jQuery('.settings--highlighted-attributes').toggle(highlightingMode === 'inline'); + document.querySelector('.settings--highlighting-mode select')?.addEventListener('change', (event) => { + const highlightingMode = (event.target as HTMLSelectElement).value; + document.querySelectorAll('.settings--highlighted-attributes') + .forEach((elem) => { toggleElement(elem, highlightingMode === 'inline'); }); }); - jQuery('#tab-content-work_packages form').submit(() => { - const availableAttributes = jQuery(".settings--highlighted-attributes input[type='checkbox']"); - const selectedAttributes = jQuery(".settings--highlighted-attributes input[type='checkbox']:checked"); + document.querySelector('#tab-content-work_packages form')?.addEventListener('submit', () => { + const availableAttributes = document.querySelectorAll(".settings--highlighted-attributes input[type='checkbox']"); + const selectedAttributes = document.querySelectorAll(".settings--highlighted-attributes input[type='checkbox']:checked"); if (selectedAttributes.length === availableAttributes.length) { - availableAttributes.prop('checked', false); + availableAttributes.forEach((availableAttribute) => { availableAttribute.checked = false; }); } }); } diff --git a/frontend/src/app/core/setup/globals/global-listeners/setup-server-response.ts b/frontend/src/app/core/setup/globals/global-listeners/setup-server-response.ts index fd0e40cba3fa..bc58d6a27f44 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/setup-server-response.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/setup-server-response.ts @@ -1,49 +1,25 @@ // Legacy code ported from app/assets/javascripts/application.js.erb +import { delegate } from '@knowledgecode/delegate'; +import { showElement } from 'core-app/shared/helpers/dom-helpers'; +import { slideDown, slideUp } from 'es6-slide-up-down'; + // Do not add stuff here, but ideally remove into components whenever changes are necessary export function setupServerResponse() { - jQuery(document).ajaxComplete(activateFlashNotice); - jQuery(document).ajaxComplete(activateFlashError); - - /* - * 1 - registers a callback which copies the csrf token into the - * X-CSRF-Token header with each ajax request. Necessary to - * work with rails applications which have fixed - * CVE-2011-0447 - * 2 - shows and hides ajax indicator - */ - jQuery(document).ajaxSend((event, request) => { - if (jQuery(event.target.activeElement!).closest('[ajax-indicated]').length - && jQuery('ajax-indicator')) { - jQuery('#ajax-indicator').show(); - } - - const csrf_meta_tag = jQuery('meta[name=csrf-token]'); - - if (csrf_meta_tag) { - const header = 'X-CSRF-Token'; - const token = csrf_meta_tag.attr('content'); - - request.setRequestHeader(header, token!); - } - - request.setRequestHeader('X-Authentication-Scheme', 'Session'); - }); - - // ajaxStop gets called when ALL Requests finish, so we won't need a counter as in PT - jQuery(document).ajaxStop(() => { - if (jQuery('#ajax-indicator')) { - jQuery('#ajax-indicator').hide(); - } - addClickEventToAllErrorMessages(); - }); - // show/hide the files table - jQuery('.attachments h4').click(function () { - jQuery(this).toggleClass('closed').next().slideToggle(100); + document.querySelectorAll('.attachments h4').forEach((heading) => { + heading.addEventListener('click', () => { + const closed = heading.classList.toggle('closed'); + const nextElement = heading.nextElementSibling as HTMLElement; + if (closed) { + slideUp(nextElement, 100); + } else { + slideDown(nextElement, 100); + } + }); }); let resizeTo:any = null; - jQuery(window).on('resize', () => { + window.addEventListener('resize', () => { // wait 200 milliseconds for no further resize event // then readjust breadcrumb @@ -51,23 +27,23 @@ export function setupServerResponse() { clearTimeout(resizeTo); } resizeTo = setTimeout(() => { - jQuery(window).trigger('resizeEnd'); + window.dispatchEvent(new CustomEvent('resizeEnd', { bubbles: true })); }, 200); }); // Do not close the login window when using it - jQuery('#nav-login-content').click((event) => { + document.querySelector('#nav-login-content')?.addEventListener('click', (event) => { event.stopPropagation(); }); // Set focus on first error message - const error_focus = jQuery('a.afocus').first(); - const input_focus = jQuery('.autofocus').first(); - if (error_focus !== undefined) { + const error_focus = document.querySelector('a.afocus'); + const input_focus = document.querySelector('.autofocus'); + if (error_focus) { error_focus.focus(); - } else if (input_focus !== undefined) { + } else if (input_focus) { input_focus.focus(); - if (input_focus[0].tagName === 'INPUT') { + if (input_focus instanceof HTMLInputElement) { input_focus.select(); } } @@ -75,45 +51,53 @@ export function setupServerResponse() { addClickEventToAllErrorMessages(); // Click handler for formatting help - jQuery(document.body).on('click', '.formatting-help-link-button', () => { - window.open(`${window.appBasePath}/help/wiki_syntax`, + delegate(document.body).on('click', '.formatting-help-link-button', (event) => { + window.open( + `${window.appBasePath}/help/wiki_syntax`, '', - 'resizable=yes, location=no, width=600, height=640, menubar=no, status=no, scrollbars=yes'); - return false; + 'resizable=yes, location=no, width=600, height=640, menubar=no, status=no, scrollbars=yes' + ); + event.preventDefault(); + event.stopPropagation(); }); } function addClickEventToAllErrorMessages() { - jQuery('a.afocus').each(function () { - const target = jQuery(this); - target.click((evt) => { - let field = jQuery(`#${target.attr('href')!.substr(1)}`); - if (field === null) { - // Cut off '_id' (necessary for select boxes) - field = jQuery(`#${target.attr('href')!.substr(1).concat('_id')}`); + document.querySelectorAll('a.afocus').forEach((anchor) => { + anchor.addEventListener('click', function (evt) { + evt.preventDefault(); + + const href = anchor.getAttribute('href'); + if (!href?.startsWith('#')) return; + + const id = href.substring(1); + let field = document.getElementById(id); + + if (!field) { + // Try with `_id` suffix (needed for select boxes) + field = document.getElementById(id + '_id'); } - target.unbind(evt); - return false; - }); + + if (field) { + field.focus(); + } + }, { once: true }); }); } export function initMainMenuExpandStatus() { - const wrapper = jQuery('#wrapper'); - const upToggle = jQuery('ul.menu_root.closed li.open a.arrow-left-to-project'); + const wrapper = document.querySelector('#wrapper')!; + const upToggle = document.querySelector('ul.menu_root.closed li.open a.arrow-left-to-project'); - if (upToggle.length === 1 && wrapper.hasClass('hidden-navigation')) { - upToggle.trigger('click'); + if (upToggle && wrapper.classList.contains('hidden-navigation')) { + upToggle.click(); } } -function activateFlash(selector:any) { - const flashMessages = jQuery(selector); +function activateFlash(selector:string) { + const flashMessages = document.querySelectorAll(selector); - flashMessages.each((ix, e) => { - const flashMessage = jQuery(e); - flashMessage.show(); - }); + flashMessages.forEach((flashMessage) => { showElement(flashMessage); }); } export function activateFlashNotice() { @@ -125,8 +109,8 @@ export function activateFlashError() { } export function focusFirstErroneousField() { - const firstErrorSpan = jQuery('span.errorSpan').first(); - const erroneousInput = firstErrorSpan.find('*').filter(':input'); + const firstErrorSpan = document.querySelector('span.errorSpan'); + const erroneousInput = firstErrorSpan?.querySelector('input, select, textarea, button'); - erroneousInput.trigger('focus'); + erroneousInput?.focus(); } diff --git a/frontend/src/app/core/setup/globals/global-listeners/top-menu-scroll.ts b/frontend/src/app/core/setup/globals/global-listeners/top-menu-scroll.ts index 842c1d9ff1e3..a717ac9502d5 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/top-menu-scroll.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/top-menu-scroll.ts @@ -38,17 +38,17 @@ export function scrollHeaderOnMobile() { // Condition needed for safari browser to avoid negative positions const currentScrollPos = scrollableElement.scrollTop < 0 ? 0 : scrollableElement.scrollTop; // Only if sidebar is not opened or search bar is opened - if (!(jQuery('#main').hasClass('hidden-navigation')) - || jQuery('.op-app-header').hasClass('op-app-header_search-open') + if (!(document.querySelector('#main')!.classList.contains('hidden-navigation')) + || document.querySelector('.op-app-header')?.classList.contains('op-app-header_search-open') || Math.abs(currentScrollPos - prevScrollPos) <= headerHeight) { // to avoid flickering at the end of the page return; } if (prevScrollPos !== undefined && currentScrollPos !== undefined && (prevScrollPos > currentScrollPos)) { // Slide top menu in or out of viewport and change viewport height - jQuery('#wrapper').removeClass('_header-scrolled'); + document.querySelector('#wrapper')!.classList.remove('_header-scrolled'); } else { - jQuery('#wrapper').addClass('_header-scrolled'); + document.querySelector('#wrapper')!.classList.add('_header-scrolled'); } prevScrollPos = currentScrollPos; }); diff --git a/frontend/src/app/core/setup/globals/onboarding/onboarding_tour.ts b/frontend/src/app/core/setup/globals/onboarding/onboarding_tour.ts index b299a9094f6c..f6604363719b 100644 --- a/frontend/src/app/core/setup/globals/onboarding/onboarding_tour.ts +++ b/frontend/src/app/core/setup/globals/onboarding/onboarding_tour.ts @@ -19,6 +19,7 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service import 'core-vendor/enjoyhint'; import { wpFullViewOnboardingTourSteps } from 'core-app/core/setup/globals/onboarding/tours/work_package_full_view_tour'; +import { getMetaContent } from '../global-helpers'; declare global { interface Window { @@ -46,16 +47,19 @@ function initializeTour(storageValue:string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment window.onboardingTourInstance = new window.EnjoyHint({ onStart() { - jQuery('#content-wrapper, #menu-sidebar').addClass('-hidden-overflow'); + document.querySelectorAll('#content-wrapper, #menu-sidebar') + .forEach((elem) => elem.classList.add('-hidden-overflow')); sessionStorage.setItem(onboardingTourStorageKey, storageValue); }, onEnd() { sessionStorage.setItem(onboardingTourStorageKey, storageValue); - jQuery('#content-wrapper, #menu-sidebar').removeClass('-hidden-overflow'); + document.querySelectorAll('#content-wrapper, #menu-sidebar') + .forEach((elem) => elem.classList.remove('-hidden-overflow')); }, onSkip() { sessionStorage.setItem(onboardingTourStorageKey, 'skipped'); - jQuery('#content-wrapper, #menu-sidebar').removeClass('-hidden-overflow'); + document.querySelectorAll('#content-wrapper, #menu-sidebar') + .forEach((elem) => elem.classList.remove('-hidden-overflow')); }, }); } @@ -91,8 +95,8 @@ function workPackageFullViewTour() { function ganttTour(configuration:ConfigurationService) { initializeTour('ganttTourFinished'); - const boardsDemoDataAvailable = jQuery('meta[name=boards_demo_data_available]').attr('content') === 'true'; - const teamPlannerDemoDataAvailable = jQuery('meta[name=demo_view_of_type_team_planner_seeded]').attr('content') === 'true'; + const boardsDemoDataAvailable = getMetaContent('boards_demo_data_available') === 'true'; + const teamPlannerDemoDataAvailable = getMetaContent('demo_view_of_type_team_planner_seeded') === 'true'; const eeTokenAvailable = configuration.availableFeatures.includes('board_view'); waitForElement('.work-package--results-tbody', '#content', () => { @@ -121,7 +125,7 @@ function ganttTour(configuration:ConfigurationService) { function boardTour(configuration:ConfigurationService) { initializeTour('boardsTourFinished'); - const teamPlannerDemoDataAvailable = jQuery('meta[name=demo_view_of_type_team_planner_seeded]').attr('content') === 'true'; + const teamPlannerDemoDataAvailable = getMetaContent('demo_view_of_type_team_planner_seeded') === 'true'; const eeTokenAvailable = configuration.availableFeatures.includes('board_view'); waitForElement('wp-single-card', '#content', () => { diff --git a/frontend/src/app/core/setup/globals/onboarding/onboarding_tour_trigger.ts b/frontend/src/app/core/setup/globals/onboarding/onboarding_tour_trigger.ts index d709489d7a86..69326dc7c83a 100644 --- a/frontend/src/app/core/setup/globals/onboarding/onboarding_tour_trigger.ts +++ b/frontend/src/app/core/setup/globals/onboarding/onboarding_tour_trigger.ts @@ -6,6 +6,7 @@ import { waitForElement, } from 'core-app/core/setup/globals/onboarding/helpers'; import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { getMetaContent } from '../global-helpers'; async function triggerTour(name:OnboardingTourNames):Promise { debugLog(`Loading and triggering onboarding tour ${name}`); @@ -25,7 +26,7 @@ export function detectOnboardingTour():void { // ------------------------------- Global ------------------------------- const url = new URL(window.location.href); const isMobile = document.body.classList.contains('-browser-mobile'); - const demoProjectsAvailable = jQuery('meta[name=demo_projects_available]').attr('content') === 'true'; + const demoProjectsAvailable = getMetaContent('demo_projects_available') === 'true'; let currentTourPart = sessionStorage.getItem(onboardingTourStorageKey); let tourCancelled = false; @@ -49,7 +50,7 @@ export function detectOnboardingTour():void { } }); - jQuery('[data-tour-selector="modal-close-button"]')[0].addEventListener('click', () => { + document.querySelector('[data-tour-selector="modal-close-button"]')?.addEventListener('click', () => { tourCancelled = true; void triggerTour('homescreen'); }); diff --git a/frontend/src/app/core/setup/globals/onboarding/tours/boards_tour.ts b/frontend/src/app/core/setup/globals/onboarding/tours/boards_tour.ts index a8a62c72185c..3df8bb11ff0e 100644 --- a/frontend/src/app/core/setup/globals/onboarding/tours/boards_tour.ts +++ b/frontend/src/app/core/setup/globals/onboarding/tours/boards_tour.ts @@ -30,7 +30,7 @@ export function boardTourSteps(edition:'basic'|'enterprise'):OnboardingStep[] { nextButton: { text: I18n.t('js.onboarding.buttons.next') }, containerClass: '-dark -hidden-arrow', onNext() { - jQuery('[data-tour-selector="main-menu--arrow-left_boards"]')[0].click(); + document.querySelector('[data-tour-selector="main-menu--arrow-left_boards"]')?.click(); }, }, ]; @@ -49,7 +49,7 @@ export function navigateToBoardStep(edition:'basic'|'enterprise'):OnboardingStep showSkip: false, nextButton: { text: I18n.t('js.onboarding.buttons.next') }, onNext() { - jQuery('#boards-wrapper>.boards-menu-item ~ .toggler')[0].click(); + document.querySelector('#boards-wrapper>.boards-menu-item ~ .toggler')?.click(); waitForElement( '.op-submenu--item-action', '#main-menu', diff --git a/frontend/src/app/core/setup/globals/onboarding/tours/gantt_tour.ts b/frontend/src/app/core/setup/globals/onboarding/tours/gantt_tour.ts index d8bdf47db37a..94b28abd27bf 100644 --- a/frontend/src/app/core/setup/globals/onboarding/tours/gantt_tour.ts +++ b/frontend/src/app/core/setup/globals/onboarding/tours/gantt_tour.ts @@ -13,7 +13,7 @@ export function ganttOnboardingTourSteps():OnboardingStep[] { showSkip: false, nextButton: { text: I18n.t('js.onboarding.buttons.next') }, onNext() { - jQuery('[data-tour-selector="main-menu--arrow-left_gantt"]')[0].click(); + document.querySelector('[data-tour-selector="main-menu--arrow-left_gantt"]')?.click(); }, }, ]; diff --git a/frontend/src/app/core/setup/globals/onboarding/tours/team_planners_tour.ts b/frontend/src/app/core/setup/globals/onboarding/tours/team_planners_tour.ts index 4a83192669c3..afb5fdd03116 100644 --- a/frontend/src/app/core/setup/globals/onboarding/tours/team_planners_tour.ts +++ b/frontend/src/app/core/setup/globals/onboarding/tours/team_planners_tour.ts @@ -45,7 +45,7 @@ export function navigateToTeamPlannerStep():OnboardingStep { showSkip: false, nextButton: { text: I18n.t('js.onboarding.buttons.next') }, onNext() { - jQuery('.team-planner-view-menu-item ~ .toggler')[0].click(); + document.querySelector('.team-planner-view-menu-item ~ .toggler')?.click(); waitForElement( '.op-submenu--item-action', diff --git a/frontend/src/app/core/setup/globals/onboarding/tours/work_package_full_view_tour.ts b/frontend/src/app/core/setup/globals/onboarding/tours/work_package_full_view_tour.ts index e5a73eea2c7b..01dcde7198c0 100644 --- a/frontend/src/app/core/setup/globals/onboarding/tours/work_package_full_view_tour.ts +++ b/frontend/src/app/core/setup/globals/onboarding/tours/work_package_full_view_tour.ts @@ -8,7 +8,7 @@ export function wpFullViewOnboardingTourSteps():OnboardingStep[] { nextButton: { text: I18n.t('js.onboarding.buttons.next') }, containerClass: '-dark -hidden-arrow', onNext() { - jQuery('.main-menu--arrow-left-to-project')[0].click(); + document.querySelector('.main-menu--arrow-left-to-project')?.click(); }, }, { @@ -16,7 +16,7 @@ export function wpFullViewOnboardingTourSteps():OnboardingStep[] { showSkip: false, nextButton: { text: I18n.t('js.onboarding.buttons.next') }, onNext() { - jQuery('#main-menu-gantt')[0].click(); + document.querySelector('#main-menu-gantt')?.click(); }, }, { diff --git a/frontend/src/app/core/setup/globals/openproject.ts b/frontend/src/app/core/setup/globals/openproject.ts index c404c4c0ff8c..b56f7cbb2951 100644 --- a/frontend/src/app/core/setup/globals/openproject.ts +++ b/frontend/src/app/core/setup/globals/openproject.ts @@ -28,7 +28,7 @@ import { OpenProjectPluginContext } from 'core-app/features/plugins/plugin-context'; import { input, InputState } from '@openproject/reactivestates'; -import { getMetaElement, GlobalHelpers } from 'core-app/core/setup/globals/global-helpers'; +import { getMetaContent, getMetaValue, GlobalHelpers } from 'core-app/core/setup/globals/global-helpers'; import { firstValueFrom } from 'rxjs'; import { ThemeUtils } from './theme-utils'; @@ -70,15 +70,15 @@ export class OpenProject { } public get urlRoot():string { - return getMetaElement('app_base_path')?.content || ''; + return getMetaContent('app_base_path'); } public get environment():string { - return getMetaElement('openproject_initializer')?.dataset.environment || ''; + return getMetaValue('openproject_initializer', 'environment'); } public get edition():string { - return getMetaElement('openproject_initializer')?.dataset.edition || ''; + return getMetaValue('openproject_initializer', 'edition'); } public get isStandardEdition():boolean { diff --git a/frontend/src/app/core/setup/init-locale.ts b/frontend/src/app/core/setup/init-locale.ts index 7b5646c23eaa..e68ed949b721 100644 --- a/frontend/src/app/core/setup/init-locale.ts +++ b/frontend/src/app/core/setup/init-locale.ts @@ -28,14 +28,16 @@ import moment from 'moment'; import { I18n } from 'i18n-js'; +import { getMetaElement } from './globals/global-helpers'; export function initializeLocale() { - const meta = document.querySelector('meta[name=openproject_initializer]'); - const userLocale = meta?.dataset.locale || 'en'; - const defaultLocale = meta?.dataset.defaultlocale || 'en'; - const instanceLocale = meta?.dataset.instancelocale || 'en'; - const firstDayOfWeek = parseInt(meta?.dataset.firstdayofweek || '', 10); // properties of meta.dataset are exposed in lowercase - const firstWeekOfYear = parseInt(meta?.dataset.firstweekofyear || '', 10); // properties of meta.dataset are exposed in lowercase + const meta = getMetaElement('openproject_initializer'); + const getInitializerValue = (key:string, defaultValue = '') => meta?.dataset[key] ?? defaultValue; + const userLocale = getInitializerValue('locale', 'en'); + const defaultLocale = getInitializerValue('defaultlocale', 'en'); + const instanceLocale = getInitializerValue('instancelocale', 'en'); + const firstDayOfWeek = parseInt(getInitializerValue('firstdayofweek'), 10); // properties of meta.dataset are exposed in lowercase + const firstWeekOfYear = parseInt(getInitializerValue('firstweekofyear'), 10); // properties of meta.dataset are exposed in lowercase const i18n = new I18n(); i18n.locale = userLocale; diff --git a/frontend/src/app/core/top-menu/top-menu.service.ts b/frontend/src/app/core/top-menu/top-menu.service.ts index 37d3a1fc6479..d05a13645aa4 100644 --- a/frontend/src/app/core/top-menu/top-menu.service.ts +++ b/frontend/src/app/core/top-menu/top-menu.service.ts @@ -31,6 +31,7 @@ import { Injectable, } from '@angular/core'; import { DOCUMENT } from '@angular/common'; +import { isVisible } from 'core-app/shared/helpers/dom-helpers'; export const ANIMATION_RATE_MS = 100; @@ -45,17 +46,15 @@ export class TopMenuService { private skipContentClickListener():void { // Skip menu on content - const skipLink = this.document.querySelector('#skip-navigation--content') as HTMLElement; + const skipLink = this.document.querySelector('#skip-navigation--content'); skipLink?.addEventListener('click', () => { // Skip to the breadcrumb or the first link in the toolbar or the first link in the content (homescreen) const selectors = '.first-breadcrumb-element a, .toolbar-container a:first-of-type, #content a:first-of-type'; - const visibleLink = jQuery(selectors) - .not(':hidden') - .first(); + const visibleLink = Array + .from(document.querySelectorAll(selectors)) + .find((link) => isVisible(link)); - if (visibleLink.length) { - visibleLink.trigger('focus'); - } + visibleLink?.focus(); }); } } diff --git a/frontend/src/app/core/turbo/turbo-requests.service.ts b/frontend/src/app/core/turbo/turbo-requests.service.ts index 90f2ed387172..71614a5a996f 100644 --- a/frontend/src/app/core/turbo/turbo-requests.service.ts +++ b/frontend/src/app/core/turbo/turbo-requests.service.ts @@ -3,6 +3,7 @@ import { renderStreamMessage } from '@hotwired/turbo'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { TurboHelpers } from 'core-turbo/helpers'; +import { getMetaContent } from '../setup/globals/global-helpers'; @Injectable({ providedIn: 'root' }) export class TurboRequestsService { @@ -35,7 +36,7 @@ export class TurboRequestsService { 'X-Authentication-Scheme': 'Session', }; if(init.method && !(init.method === 'GET' || init.method === 'HEAD')) { - defaultHeaders['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]')?.content; + defaultHeaders['X-CSRF-Token'] = getMetaContent('csrf-token'); } init.headers = { diff --git a/frontend/src/app/features/admin/types/type-form-configuration.component.ts b/frontend/src/app/features/admin/types/type-form-configuration.component.ts index 15d58ccf0bd2..5bd30026a25b 100644 --- a/frontend/src/app/features/admin/types/type-form-configuration.component.ts +++ b/frontend/src/app/features/admin/types/type-form-configuration.component.ts @@ -54,9 +54,9 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen private element:HTMLElement; - private form:JQuery; + private form:HTMLFormElement; - private submit:JQuery; + private submit:HTMLButtonElement; public groups:TypeGroup[] = []; @@ -68,8 +68,14 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen private no_filter_query:string; + private eventListeners = { + typeFormUpdater: () => { + this.updateHiddenFields(); + } + }; + constructor( - private elementRef:ElementRef, + private elementRef:ElementRef, private I18n:I18nService, private dragula:DragulaService, private confirmDialog:ConfirmDialogService, @@ -90,33 +96,29 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen // Hook on form submit this.element = this.elementRef.nativeElement; this.no_filter_query = this.element.dataset.noFilterQuery!; - this.form = jQuery(this.element).closest('form'); - this.submit = this.form.find('.form-configuration--save'); + this.form = this.element.closest('form')!; + this.submit = this.form.querySelector('.form-configuration--save')!; // In the following we are triggering the form submit ourselves to work around // a firefox shortcoming. But to avoid double submits which are sometimes not canceled fast // enough, we need to memoize whether we have already submitted. let submitted = false; - this.form.on('submit', () => { + this.form.addEventListener('submit', () => { submitted = true; }); // Capture mousedown on button because firefox breaks blur on click - this.submit.on('mousedown', () => { + this.submit.addEventListener('mousedown', () => { setTimeout(() => { if (!submitted) { - this.form.trigger('submit'); + this.form.requestSubmit(); } }, 50); - return true; }); // Capture regular form submit - this.form.on('submit.typeformupdater', () => { - this.updateHiddenFields(); - return true; - }); + this.form.addEventListener('submit', this.eventListeners.typeFormUpdater); // Setup groups this.groupsDrake = this @@ -161,7 +163,7 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen } ngAfterViewInit():void { - const menu = jQuery(this.elementRef.nativeElement).find('.toolbar-items'); + const menu = this.elementRef.nativeElement.querySelector('.toolbar-items')!; installMenuLogic(menu); } @@ -240,11 +242,12 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen }, }) .then(() => { - this.form.find('input#type_attribute_groups').val(JSON.stringify([])); + const input = this.form.querySelector('input#type_attribute_groups')!; + input.value = JSON.stringify([]); // Disable our form handler that updates the attribute groups - this.form.off('submit.typeformupdater'); - this.form.trigger('submit'); + this.form.removeEventListener('submit', this.eventListeners.typeFormUpdater); + this.form.requestSubmit(); }) .catch(() => { }); @@ -268,13 +271,13 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen } private updateHiddenFields():void { - const hiddenField = this.form.find('.admin-type-form--hidden-field'); + const hiddenField = this.form.querySelector('.admin-type-form--hidden-field')!; if (this.groups.length === 0) { // Ensure we're adding an empty group if deliberately removing // all values. - hiddenField.val(JSON.stringify([this.emptyGroup])); + hiddenField.value = JSON.stringify([this.emptyGroup]); } else { - hiddenField.val(JSON.stringify(this.groups)); + hiddenField.value = JSON.stringify(this.groups); } } } diff --git a/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts b/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts index fb87f9ea1ab6..b898bfc49821 100644 --- a/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts +++ b/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts @@ -296,12 +296,12 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements // eslint-disable-next-line class-methods-use-this public galleryPreviewOpen():void { - jQuery('.op-app-header').addClass('-no-z-index'); + document.querySelector('.op-app-header')?.classList.add('-no-z-index'); } // eslint-disable-next-line class-methods-use-this public galleryPreviewClose():void { - jQuery('.op-app-header').removeClass('-no-z-index'); + document.querySelector('.op-app-header')?.classList.remove('-no-z-index'); } public selectViewpointInGallery() { diff --git a/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts b/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts index 5db248b92539..14d33fe8f91d 100644 --- a/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts +++ b/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts @@ -45,6 +45,7 @@ import { BIMViewer } from '@xeokit/xeokit-bim-viewer/dist/xeokit-bim-viewer.es'; import { BcfViewpointData, CreateBcfViewpointData } from 'core-app/features/bim/bcf/api/bcf-api.model'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; +import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; export interface XeokitElements { canvasElement:HTMLElement; @@ -152,7 +153,7 @@ export class IFCViewerService extends ViewerBridgeService { const formData = new FormData(); formData.append( 'authenticity_token', - jQuery('meta[name=csrf-token]').attr('content') as string, + getMetaContent('csrf-token') ); formData.append( '_method', diff --git a/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts b/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts index 2cc608d117c2..3778371b6880 100644 --- a/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts +++ b/frontend/src/app/features/bim/ifc_models/toolbar/view-toggle/bcf-view-toggle-dropdown.directive.ts @@ -58,7 +58,7 @@ export class BcfViewToggleDropdownDirective extends OpContextMenuTrigger { super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent):void { + protected open(evt:Event):void { this.buildItems(); this.opContextMenu.show(this, evt); } diff --git a/frontend/src/app/features/boards/board/add-card-dropdown/add-card-dropdown-menu.directive.ts b/frontend/src/app/features/boards/board/add-card-dropdown/add-card-dropdown-menu.directive.ts index 8ce936169fbb..7ca7cdeac5c9 100644 --- a/frontend/src/app/features/boards/board/add-card-dropdown/add-card-dropdown-menu.directive.ts +++ b/frontend/src/app/features/boards/board/add-card-dropdown/add-card-dropdown-menu.directive.ts @@ -56,28 +56,11 @@ export class AddCardDropdownMenuDirective extends OpContextMenuTrigger { super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.items = this.buildItems(); this.opContextMenu.show(this, evt); } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(evt:JQuery.TriggeredEvent) { - const additionalPositionArgs = { - my: 'left top', - at: 'left bottom', - }; - - const position = super.positionArgs(evt); - _.assign(position, additionalPositionArgs); - - return position; - } - private buildItems() { return [ { diff --git a/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts b/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts index ab2f6a07e372..90992070755f 100644 --- a/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts +++ b/frontend/src/app/features/boards/board/board-actions/version/version-action.service.ts @@ -169,7 +169,7 @@ export class BoardVersionActionService extends CachedBoardActionService { // Show link linkText: this.I18n.t('js.boards.version.show_version'), href: this.pathHelper.versionShowPath(id), - onClick: (evt:JQuery.TriggeredEvent) => { + onClick: (evt) => { if (!isClickedWithModifier(evt)) { window.open(this.pathHelper.versionShowPath(id), '_blank'); return true; @@ -183,7 +183,7 @@ export class BoardVersionActionService extends CachedBoardActionService { hidden: !version.$links.update, linkText: this.I18n.t('js.boards.version.edit_version'), href: this.pathHelper.versionEditPath(id), - onClick: (evt:JQuery.TriggeredEvent) => { + onClick: (evt) => { if (!isClickedWithModifier(evt)) { window.open(this.pathHelper.versionEditPath(id), '_blank'); return true; diff --git a/frontend/src/app/features/boards/board/board-list/board-list.component.ts b/frontend/src/app/features/boards/board/board-list/board-list.component.ts index bef95fe310fb..a4cee5ecc028 100644 --- a/frontend/src/app/features/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/features/boards/board/board-list/board-list.component.ts @@ -96,7 +96,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni @Input() public board:Board; /** Access to the loading indicator element */ - @ViewChild('loadingIndicator', { static: true }) indicator:ElementRef; + @ViewChild('loadingIndicator', { static: true }) indicator:ElementRef; /** Access to the card view */ @ViewChild(WorkPackageCardViewComponent) cardView:WorkPackageCardViewComponent; @@ -438,7 +438,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni } private get indicatorInstance() { - return this.loadingIndicator.indicator(jQuery(this.indicator.nativeElement)); + return this.loadingIndicator.indicator(this.indicator.nativeElement); } private setQueryProps(filters:ApiV3Filter[]) { diff --git a/frontend/src/app/features/boards/board/configuration-modal/board-configuration.modal.ts b/frontend/src/app/features/boards/board/configuration-modal/board-configuration.modal.ts index 33b63ad9c52b..0809e4c5911c 100644 --- a/frontend/src/app/features/boards/board/configuration-modal/board-configuration.modal.ts +++ b/frontend/src/app/features/boards/board/configuration-modal/board-configuration.modal.ts @@ -56,7 +56,7 @@ export class BoardConfigurationModalComponent extends OpModalComponent implement } ngOnInit() { - this.$element = this.elementRef.nativeElement as HTMLElement; + this.element = this.elementRef.nativeElement as HTMLElement; this.tabPortalHost = new TabPortalOutlet( this.boardConfigurationService.tabs, @@ -112,6 +112,6 @@ export class BoardConfigurationModalComponent extends OpModalComponent implement } protected get afterFocusOn():HTMLElement { - return this.$element; + return this.element; } } diff --git a/frontend/src/app/features/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts b/frontend/src/app/features/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts index 70f0a85ae10e..0ecf746de4eb 100644 --- a/frontend/src/app/features/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts +++ b/frontend/src/app/features/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts @@ -39,7 +39,7 @@ import { BoardConfigurationModalComponent } from 'core-app/features/boards/board import { BoardService } from 'core-app/features/boards/board/board.service'; import { StateService } from '@uirouter/core'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; -import { triggerEditingEvent } from 'core-app/shared/components/editable-toolbar-title/editable-toolbar-title.component'; +import { selectableTitleIdentifier, triggerEditingEvent } from 'core-app/shared/components/editable-toolbar-title/editable-toolbar-title.component'; @Directive({ selector: '[boardsToolbarMenu]', @@ -69,7 +69,7 @@ export class BoardsToolbarMenuDirective extends OpContextMenuTrigger { }; } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.buildItems(); this.opContextMenu.show(this, evt); } @@ -93,7 +93,7 @@ export class BoardsToolbarMenuDirective extends OpContextMenuTrigger { icon: 'icon-edit', onClick: () => { if (this.board.grid.updateImmediately) { - jQuery('.toolbar-container .editable-toolbar-title--input').trigger(triggerEditingEvent); + document.querySelector(selectableTitleIdentifier)?.dispatchEvent(new CustomEvent(triggerEditingEvent, { bubbles: true })); } return true; diff --git a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts index 59c8356fb790..b05ca8d704ab 100644 --- a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts +++ b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts @@ -335,7 +335,7 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { event.preventDefault(); - const handler = new WorkPackageViewContextMenu(this.injector, workPackageId, jQuery(event.target as HTMLElement)); + const handler = new WorkPackageViewContextMenu(this.injector, workPackageId, event.target as HTMLElement); this.contextMenuService.show(handler, event); } diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts index b2d64952dfe5..ad891028358f 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts @@ -534,22 +534,23 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { const schema = (await this.schemaCache.ensureLoaded(entry as TimeEntryResource)) as TimeEntrySchema; - jQuery(event.el).tooltip({ - content: this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema), - items: '.fc-event', - close() { - jQuery('.ui-helper-hidden-accessible').remove(); - }, - track: true, - }); + const tooltip = document.createElement('tool-tip'); + tooltip.textContent = this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema); + event.el.appendChild(tooltip); + + // TODO: port tooltips + // jQuery(event.el).tooltip({ + // content: this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema), + // items: '.fc-event', + // close() { + // document.querySelectorAll('.ui-helper-hidden-accessible').forEach(element => element.remove()); + // }, + // track: true, + // }); } private removeTooltip(event:CalendarViewEvent):void { - const target = jQuery(event.el); - - if (target.tooltip('instance')) { - jQuery(event.el).tooltip('disable'); - } + // TODO: port tooltips } private prependDuration(event:CalendarViewEvent):void { @@ -561,9 +562,9 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { const formattedDuration = this.timezone.formattedDuration(timeEntry.hours as string); - jQuery(event.el) - .find('.fc-event-title') - .prepend(`
${formattedDuration}
`); + event.el + .querySelector('.fc-event-title') + ?.insertAdjacentHTML('afterbegin', `
${formattedDuration}
`); } /* Fade out event text to the bottom to avoid it being cut of weirdly. @@ -582,19 +583,16 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return; } - const $element = jQuery(event.el); - const fadeout = jQuery('
'); + const element = event.el; + const fadeout = document.createElement('div'); + fadeout.classList.add('fc-fadeout'); const hslaStart = this.colors.toHsla(this.entryName(timeEntry), 0); const hslaEnd = this.colors.toHsla(this.entryName(timeEntry), 100); - fadeout.css('background', `-webkit-linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%`); - - ['-moz-linear-gradient', '-o-linear-gradient', 'linear-gradient', '-ms-linear-gradient'].forEach((style) => { - fadeout.css('background-image', `${style}(${hslaStart} 0%, ${hslaEnd} 100%`); - }); + fadeout.style.backgroundImage = `linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%)`; - $element.append(fadeout); + element.append(fadeout); } private beforeEventRemove(event:CalendarViewEvent):void { diff --git a/frontend/src/app/features/hal/http/openproject-header-interceptor.ts b/frontend/src/app/features/hal/http/openproject-header-interceptor.ts index 035a118f32a1..259ca7db3d69 100644 --- a/frontend/src/app/features/hal/http/openproject-header-interceptor.ts +++ b/frontend/src/app/features/hal/http/openproject-header-interceptor.ts @@ -3,6 +3,7 @@ import { } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Injectable } from '@angular/core'; +import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; export const EXTERNAL_REQUEST_HEADER = 'X-External-Request'; @@ -29,14 +30,14 @@ export class OpenProjectHeaderInterceptor implements HttpInterceptor { } private handleAuthenticatedRequest(req:HttpRequest, next:HttpHandler):Observable> { - const csrf_token:string|undefined = jQuery('meta[name=csrf-token]').attr('content'); + const csrfToken = getMetaContent('csrf-token'); let newHeaders = req.headers .set('X-Authentication-Scheme', 'Session') .set('X-Requested-With', 'XMLHttpRequest'); - if (csrf_token) { - newHeaders = newHeaders.set('X-CSRF-TOKEN', csrf_token); + if (csrfToken) { + newHeaders = newHeaders.set('X-CSRF-TOKEN', csrfToken); } // Clone the request to add the new header diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/click-handler.ts b/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/click-handler.ts index 6ffab7b60990..8299a9a2620f 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/click-handler.ts @@ -7,6 +7,7 @@ import { WorkPackageCardViewService } from 'core-app/features/work-packages/comp import { StateService } from '@uirouter/core'; import { DeviceService } from 'core-app/core/browser/device.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class CardClickHandler implements CardEventHandler { // Injections @@ -24,8 +25,8 @@ export class CardClickHandler implements CardEventHandler { card:WorkPackageCardViewComponent) { } - public get EVENT() { - return 'click.cardView.card'; + public get EVENT():EventType { + return 'click'; } public get SELECTOR() { @@ -33,20 +34,20 @@ export class CardClickHandler implements CardEventHandler { } public eventScope(card:WorkPackageCardViewComponent) { - return jQuery(card.container.nativeElement); + return card.container.nativeElement; } - public handleEvent(card:WorkPackageCardViewComponent, evt:JQuery.TriggeredEvent) { - const target = jQuery(evt.target); + public handleEvent(card:WorkPackageCardViewComponent, evt:MouseEvent) { + const target = evt.target as HTMLElement; // Ignore links - if (target.is('a') || target.parent().is('a')) { + if (target instanceof HTMLAnchorElement || target.parentElement instanceof HTMLAnchorElement) { return true; } // Locate the card from event - const element = target.closest('wp-single-card'); - const wpId = element.data('workPackageId'); + const element = target.closest('wp-single-card')!; + const wpId = element.dataset.workPackageId; if (!wpId) { return true; @@ -57,14 +58,14 @@ export class CardClickHandler implements CardEventHandler { return false; } - protected handleWorkPackage(card:WorkPackageCardViewComponent, wpId:any, element:JQuery, evt:JQuery.TriggeredEvent) { + protected handleWorkPackage(card:WorkPackageCardViewComponent, wpId:any, element:HTMLElement, evt:MouseEvent) { this.setSelection(card, wpId, element, evt); card.itemClicked.emit({ workPackageId: wpId, double: false }); } - protected setSelection(card:WorkPackageCardViewComponent, wpId:string, element:JQuery, evt:JQuery.TriggeredEvent) { - const classIdentifier = element.data('classIdentifier'); + protected setSelection(card:WorkPackageCardViewComponent, wpId:string, element:HTMLElement, evt:MouseEvent) { + const classIdentifier = element.dataset.classIdentifier!; const index = this.wpCardView.findRenderedCard(classIdentifier); // Update single selection if no modifier present diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/double-click-handler.ts b/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/double-click-handler.ts index bce6b1702d31..de5b50a5b4fb 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/double-click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/double-click-handler.ts @@ -4,6 +4,7 @@ import { WorkPackageCardViewComponent } from 'core-app/features/work-packages/co import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service'; import { StateService } from '@uirouter/core'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class CardDblClickHandler implements CardEventHandler { @InjectField() $state:StateService; @@ -14,8 +15,8 @@ export class CardDblClickHandler implements CardEventHandler { card:WorkPackageCardViewComponent) { } - public get EVENT() { - return 'dblclick.cardView.card'; + public get EVENT():EventType { + return 'dblclick'; } public get SELECTOR() { @@ -23,20 +24,20 @@ export class CardDblClickHandler implements CardEventHandler { } public eventScope(card:WorkPackageCardViewComponent) { - return jQuery(card.container.nativeElement); + return card.container.nativeElement; } - public handleEvent(card:WorkPackageCardViewComponent, evt:JQuery.TriggeredEvent) { - const target = jQuery(evt.target); + public handleEvent(card:WorkPackageCardViewComponent, evt:Event) { + const target = evt.target as HTMLElement; // Ignore links - if (target.is('a') || target.parent().is('a')) { + if (target instanceof HTMLAnchorElement || target.parentElement instanceof HTMLAnchorElement) { return true; } // Locate the row from event - const element = target.closest('wp-single-card'); - const wpId = element.data('workPackageId'); + const element = target.closest('wp-single-card')!; + const wpId = element.dataset.workPackageId; if (!wpId) { return true; diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/right-click-handler.ts b/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/right-click-handler.ts index 422095a4f53f..4f09c8f5a3cf 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/right-click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/event-handler/right-click-handler.ts @@ -8,6 +8,7 @@ import { WorkPackageCardViewService } from 'core-app/features/work-packages/comp import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; import { WorkPackageViewContextMenu } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class CardRightClickHandler implements CardEventHandler { // Injections @@ -21,8 +22,8 @@ export class CardRightClickHandler implements CardEventHandler { card:WorkPackageCardViewComponent) { } - public get EVENT() { - return 'contextmenu.cardView.rightclick'; + public get EVENT():EventType { + return 'contextmenu'; // N.B.: contextmenu is not supported by Safari on iOS. } public get SELECTOR() { @@ -30,15 +31,15 @@ export class CardRightClickHandler implements CardEventHandler { } public eventScope(card:WorkPackageCardViewComponent) { - return jQuery(card.container.nativeElement); + return card.container.nativeElement; } - public handleEvent(card:WorkPackageCardViewComponent, evt:JQuery.TriggeredEvent) { - const target = jQuery(evt.target); + public handleEvent(card:WorkPackageCardViewComponent, evt:Event) { + const target = evt.target as HTMLElement; // We want to keep the original context menu on hrefs // (currently, this is only the id) - if (target.closest(`.${uiStateLinkClass}`).length) { + if (target.closest(`.${uiStateLinkClass}`)) { debugLog('Allowing original context menu on state link'); return true; } @@ -47,20 +48,20 @@ export class CardRightClickHandler implements CardEventHandler { evt.stopPropagation(); // Locate the card from event - const element = target.closest('wp-single-card'); - const wpId = element.data('workPackageId'); + const element = target.closest('wp-single-card')!; + const wpId = element.dataset.workPackageId; if (!wpId) { return true; } - const classIdentifier = element.data('classIdentifier'); + const classIdentifier = element.dataset.classIdentifier!; const index = this.wpCardView.findRenderedCard(classIdentifier); if (!this.wpTableSelection.isSelected(wpId)) { this.wpTableSelection.setSelection(wpId, index); } - const handler = new WorkPackageViewContextMenu(this.injector, wpId, jQuery(evt.target) as JQuery, {}, card.showInfoButton); + const handler = new WorkPackageViewContextMenu(this.injector, wpId, evt.target as HTMLElement, {}, card.showInfoButton); this.opContextMenu.show(handler, evt); return false; diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts index 51680f83e495..ed4af6aba7a9 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts @@ -93,12 +93,12 @@ export class TableEditForm extends EditForm { this.resourceSubscription.unsubscribe(); } - public findContainer(fieldName:string):JQuery { - return this.rowContainer.find(`.${tdClassName}.${fieldName} .${editFieldContainerClass}`).first(); + public findContainer(fieldName:string) { + return this.rowContainer?.querySelector(`.${tdClassName}.${fieldName} .${editFieldContainerClass}`); } public findCell(fieldName:string) { - return this.rowContainer.find(`.${tdClassName}.${fieldName}`).first(); + return this.rowContainer?.querySelector(`.${tdClassName}.${fieldName}`); } public activateField(form:EditForm, schema:IFieldSchema, fieldName:string, errors:string[]):Promise { @@ -107,12 +107,12 @@ export class TableEditForm extends EditForm { // Forcibly set the width since the edit field may otherwise // be given more width. Thereby preserve a minimum width of 150. // To avoid flickering content, the padding is removed, too. - const td = this.findCell(fieldName); - td.addClass(editModeClassName); - let width = parseInt(td.css('width')); + const td = this.findCell(fieldName)!; + td.classList.add(editModeClassName); + let width = td.offsetWidth; width = width > 150 ? width - 10 : 150; - td.css('max-width', `${width}px`); - td.css('width', `${width}px`); + td.style.maxWidth = `${width}px`; + td.style.width = `${width}px`; return this.editingPortalService.create( cell, @@ -127,16 +127,16 @@ export class TableEditForm extends EditForm { public reset(fieldName:string, focus?:boolean) { const cell = this.findContainer(fieldName); - const td = this.findCell(fieldName); + const td = this.findCell(fieldName)!; - if (cell.length) { - this.findCell(fieldName).css('width', ''); - this.findCell(fieldName).css('max-width', ''); - this.cellBuilder.refresh(cell[0], this.resource, fieldName); - td.removeClass(editModeClassName); + if (cell) { + td.style.width = ''; + td.style.maxWidth = ''; + this.cellBuilder.refresh(cell, this.resource, fieldName); + td.classList.remove(editModeClassName); if (focus) { - this.FocusHelper.focus(cell[0]); + this.FocusHelper.focus(cell); } } } @@ -154,10 +154,9 @@ export class TableEditForm extends EditForm { protected focusOnFirstError():void { // Focus the first field that is erroneous - jQuery(this.table.tableAndTimelineContainer) - .find(`.${activeFieldContainerClassName}.-error .${activeFieldClassName}`) - .first() - .trigger('focus'); + this.table.tableAndTimelineContainer + ?.querySelector(`.${activeFieldContainerClassName}.-error .${activeFieldClassName}`) + ?.focus(); } /** @@ -181,15 +180,15 @@ export class TableEditForm extends EditForm { const interval = setInterval(() => { const container = this.findContainer(fieldName); - if (container.length > 0) { + if (container) { clearInterval(interval); - resolve(container[0]); + resolve(container); } }, 100); }); } private get rowContainer() { - return jQuery(this.table.tableAndTimelineContainer).find(`.${this.classIdentifier}-table`); + return this.table.tableAndTimelineContainer.querySelector(`.${this.classIdentifier}-table`); } } diff --git a/frontend/src/app/features/work-packages/components/wp-edit/wp-edit-field/wp-replacement-label.component.ts b/frontend/src/app/features/work-packages/components/wp-edit/wp-edit-field/wp-replacement-label.component.ts index 16d52c7e6f80..64f133d7ba50 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit/wp-edit-field/wp-replacement-label.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit/wp-edit-field/wp-replacement-label.component.ts @@ -39,20 +39,20 @@ import { EditFormComponent } from 'core-app/shared/components/fields/edit/edit-f export class WorkPackageReplacementLabelComponent implements OnInit { @Input('fieldName') public fieldName:string; - private $element:JQuery; + private element:HTMLElement; constructor(protected wpeditForm:EditFormComponent, protected elementRef:ElementRef) { } ngOnInit() { - this.$element = jQuery(this.elementRef.nativeElement); + this.element = this.elementRef.nativeElement; } - public activate(evt:JQuery.TriggeredEvent) { + public activate(evt:Event) { // Skip clicks on help texts - const target = jQuery(evt.target); - if (target.closest('.help-text--entry').length) { + const target = evt.target as HTMLElement; + if (target.closest('.help-text--entry')) { return true; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts index 2a36ad1b6f52..374f6c9cc2d0 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts @@ -15,6 +15,7 @@ import { RowsBuilder } from '../rows-builder'; import { GroupHeaderBuilder } from './group-header-builder'; import { GroupedRenderPass } from './grouped-render-pass'; import { groupedRowClassName, groupIdentifier } from './grouped-rows-helpers'; +import { getNodeIndex } from 'core-app/shared/helpers/dom-helpers'; export class GroupedRowsBuilder extends RowsBuilder { // Injections @@ -70,10 +71,10 @@ export class GroupedRowsBuilder extends RowsBuilder { const rendered = this.querySpace.tableRendered.value!; const builder = new GroupHeaderBuilder(this.injector); - jQuery(this.workPackageTable.tableAndTimelineContainer) - .find(`.${rowGroupClassName}`) - .each((i:number, oldRow:Element) => { - const groupIndex = jQuery(oldRow).data('groupIndex'); + this.workPackageTable.tableAndTimelineContainer + .querySelectorAll(`.${rowGroupClassName}`) + .forEach((oldRow) => { + const groupIndex = parseInt(oldRow.dataset.groupIndex || '', 10); const group = groups[groupIndex]; // Refresh the group header @@ -84,14 +85,17 @@ export class GroupedRowsBuilder extends RowsBuilder { } // Set expansion state of contained rows - const affected = jQuery(this.workPackageTable.tableAndTimelineContainer) - .find(`.${groupedRowClassName(groupIndex)}`); - affected.toggleClass(collapsedRowClass, !!group.collapsed); + const affected = Array.from(this.workPackageTable.tableAndTimelineContainer + .querySelectorAll(`.${groupedRowClassName(groupIndex)}`)); + + affected.forEach((el) => el.classList.toggle(collapsedRowClass, !!group.collapsed)); // Update the hidden section of the rendered state - affected.filter(`.${tableRowClassName}`).each((i, el) => { + affected + .filter((el) => el.matches(`.${tableRowClassName}`)) + .forEach((el) => { // Get the index of this row - const index = jQuery(el).index(); + const index = getNodeIndex(el); // Update the hidden state rendered[index].hidden = !!group.collapsed; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts index 1214003a6b43..aa0292f14ab3 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/hierarchy/single-hierarchy-row-builder.ts @@ -50,10 +50,10 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { * Refresh a single row after structural changes. * Remembers and re-adds the hierarchy indicator if necessary. */ - public refreshRow(workPackage:WorkPackageResource, jRow:JQuery):JQuery { + public refreshRow(workPackage:WorkPackageResource, row:HTMLTableRowElement) { // Remove any old hierarchy - const newRow = super.refreshRow(workPackage, jRow); - newRow.find('.wp-table--hierarchy-span').remove(); + const newRow = super.refreshRow(workPackage, row); + newRow.querySelector('.wp-table--hierarchy-span')?.remove(); this.appendHierarchyIndicator(workPackage, newRow); return newRow; @@ -67,7 +67,7 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { const [classes, hidden] = this.ancestorRowData(workPackage); element.classList.add(...classes); - this.appendHierarchyIndicator(workPackage, jQuery(element)); + this.appendHierarchyIndicator(workPackage, element); return [element, hidden]; } @@ -114,28 +114,32 @@ export class SingleHierarchyRowBuilder extends SingleRowBuilder { /** * Append to the row of hierarchy level a hierarchy indicator. * @param workPackage - * @param jRow jQuery row element + * @param row row element * @param level Indentation level */ - private appendHierarchyIndicator(workPackage:WorkPackageResource, jRow:JQuery, level?:number):void { + private appendHierarchyIndicator(workPackage:WorkPackageResource, row:HTMLTableRowElement, level?:number):void { const ancestors = workPackage.getAncestors(); const hierarchyLevel = level === undefined || null ? ancestors.length : level; - const hierarchyElement = this.buildHierarchyIndicator(workPackage, jRow, hierarchyLevel); + const hierarchyElement = this.buildHierarchyIndicator(workPackage, row, hierarchyLevel); - jRow.find('td.subject') - .addClass('-with-hierarchy') - .prepend(hierarchyElement); + const subjectCell = row.querySelector('td.subject'); + if (!subjectCell) return; + + subjectCell.classList.add('-with-hierarchy'); + subjectCell.prepend(hierarchyElement); // Assure that the content is still visible when the hierarchy indentation is very large - jRow.find('td.subject').css('minWidth', `${125 + (hierarchyIndentation * hierarchyLevel)}px`); - jRow.find('td.subject .wp-table--cell-container') - .css('width', `calc(100% - ${hierarchyBaseIndentation}px - ${hierarchyIndentation * hierarchyLevel}px)`); + subjectCell.style.minWidth = `${125 + (hierarchyIndentation * hierarchyLevel)}px`; + const container = subjectCell.querySelector('.wp-table--cell-container'); + if (container) { + container.style.width = `calc(100% - ${hierarchyBaseIndentation}px - ${hierarchyIndentation * hierarchyLevel}px)`; + } } /** * Build the hierarchy indicator at the given indentation level. */ - private buildHierarchyIndicator(workPackage:WorkPackageResource, jRow:JQuery|null, level:number):HTMLElement { + private buildHierarchyIndicator(workPackage:WorkPackageResource, row:HTMLTableRowElement, level:number):HTMLElement { const hierarchyIndicator = document.createElement('span'); const collapsed = this.wpTableHierarchies.collapsed(workPackage.id!); const indicatorWidth = `${hierarchyBaseIndentation + (hierarchyIndentation * level)}px`; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts index fdc24d8f55cb..3cb396b5ebdf 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/primary-render-pass.ts @@ -14,6 +14,8 @@ import { WorkPackageTable } from '../wp-fast-table'; import { ChildRelationsRenderPass, } from 'core-app/features/work-packages/components/wp-fast-table/builders/relations/child-relations-render-pass'; +import { getNodeIndex } from 'core-app/shared/helpers/dom-helpers'; +import invariant from 'tiny-invariant'; export type RenderedRowType = 'primary'|'relations'|'child_relations'; @@ -116,8 +118,8 @@ public readonly injector:Injector, * @param row */ public refresh(row:RowRenderInfo, workPackage:WorkPackageResource, body:HTMLElement) { - const oldRow = jQuery(body).find(`.${row.classIdentifier}`); - let replacement:JQuery|null = null; + const oldRow = body.querySelector(`.${row.classIdentifier}`)!; + let replacement:HTMLElement|null = null; switch (row.renderType) { case 'relations': @@ -131,7 +133,7 @@ public readonly injector:Injector, break; } - if (replacement !== null && oldRow.length) { + if (replacement !== null && oldRow) { oldRow.replaceWith(replacement); } } @@ -150,17 +152,19 @@ public readonly injector:Injector, * 1. Insert into the document fragment after the last match of the selector * 2. Splice into the renderedOrder array. */ - public spliceRow(row:HTMLElement, selector:string, renderedInfo:RowRenderInfo) { + public spliceRow(row:HTMLTableRowElement, selector:string, renderedInfo:RowRenderInfo) { // Insert into table using the selector + const matches = this.tableBody.querySelectorAll(selector); + invariant(matches.length, `No matches found for selector: ${selector}`); + // If it matches multiple, select the last element - const target = jQuery(this.tableBody) - .find(selector) - .last(); + const target = matches[matches.length - 1]; - target.after(row); + // Insert the new row AFTER the target + target.parentNode!.insertBefore(row, target.nextSibling); // Splice the renderedOrder at this exact location - const index = target.index(); + const index = getNodeIndex(target!); this.renderedOrder.splice(index + 1, 0, renderedInfo); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts index 4cac3c2fe15b..a0ca0c73f1ac 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relation-row-builder.ts @@ -37,7 +37,7 @@ public readonly injector:Injector, * @param column * @return {any} */ - public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLElement|null { + public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLTableCellElement|null { // handle relation types if (isRelationColumn(column)) { return this.emptyRelationCell(column); @@ -49,7 +49,7 @@ public readonly injector:Injector, /** * Build the columns on the given empty row */ - public buildEmptyRelationRow(from:WorkPackageResource, to:WorkPackageResource):[HTMLElement, WorkPackageResource] { + public buildEmptyRelationRow(from:WorkPackageResource, to:WorkPackageResource):[HTMLTableRowElement, WorkPackageResource] { // Let the primary row builder build the row const row = this.createEmptyRelationRow(from, to); const [tr] = super.buildEmptyRow(to, row); @@ -87,12 +87,12 @@ relationGroupClass(from.id!), /** * - * @param jRow + * @param row * @param typeLabel * @param columnId */ public appendRelationLabel( - jRow:JQuery, + row:HTMLTableRowElement, typeLabel:string, columnId:string, ):void { @@ -100,8 +100,8 @@ relationGroupClass(from.id!), relationLabel.classList.add('relation-row--type-label', 'badge'); relationLabel.textContent = typeLabel; - jRow.find(`.${relationCellClassName}`).empty(); - jRow.find(`.${relationCellClassName}.${columnId}`).append(relationLabel); + row.querySelector(`.${relationCellClassName}`)!.innerHTML = ''; + row.querySelector(`.${relationCellClassName}.${columnId}`)!.append(relationLabel); } protected emptyRelationCell(column:QueryColumn) { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts index 55831f98f108..a0502809e957 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/relations/relations-render-pass.ts @@ -93,7 +93,7 @@ export class RelationsRenderPass { } protected renderRelationRow( - relationRow:HTMLElement, + relationRow:HTMLTableRowElement, row:RowRenderInfo, label:string, column:QueryColumn, @@ -103,7 +103,7 @@ export class RelationsRenderPass { ) { relationRow.classList.add(...row.additionalClasses); this.relationRowBuilder.appendRelationLabel( - jQuery(relationRow), + relationRow, label, column.id, ); @@ -134,7 +134,7 @@ export class RelationsRenderPass { public refreshRelationRow( renderedRow:RelationRenderInfo, workPackage:WorkPackageResource, - oldRow:JQuery, + oldRow:HTMLTableRowElement, ) { const newRow = this.relationRowBuilder.refreshRow(workPackage, oldRow); this.relationRowBuilder.appendRelationLabel( diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts index 89638411457e..f82de0af6c6b 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder.ts @@ -92,7 +92,7 @@ export class SingleRowBuilder { return columns; } - public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLElement|null { + public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLTableCellElement|null { // handle relation types if (isRelationColumn(column)) { return this.relationCellBuilder.build(workPackage, column); @@ -175,23 +175,24 @@ export class SingleRowBuilder { /** * Refresh a row that is currently being edited, that is, some edit fields may be open */ - public refreshRow(workPackage:WorkPackageResource, jRow:JQuery):JQuery { + public refreshRow(workPackage:WorkPackageResource, row:HTMLTableRowElement):HTMLTableRowElement { // Detach all current edit cells - const cells = jRow.find(`.${tdClassName}`).detach(); + const cells = Array.from(row.querySelectorAll(`.${tdClassName}`)) + .map((el) => el.parentNode!.removeChild(el)); // Remember the order of all new edit cells - const newCells:HTMLElement[] = []; + const newCells:HTMLTableCellElement[] = []; this.augmentedColumns.forEach((column:QueryColumn) => { - const oldTd = cells.filter(`td.${column.id}`); + const oldTd = cells.find((cell) => cell.matches(`td.${column.id}`)); // Treat internal columns specially // and skip the replacement of the column if this is being edited. // But only do that, if the column existed before. Sometimes, e.g. when lacking permissions // the column was not correctly created (with the intended classes). This code then // increases the robustness. - if ((column.id.startsWith('__internal') || this.isColumnBeingEdited(workPackage, column)) && oldTd.length) { - newCells.push(oldTd[0]); + if ((column.id.startsWith('__internal') || this.isColumnBeingEdited(workPackage, column)) && oldTd) { + newCells.push(oldTd); return; } @@ -203,8 +204,8 @@ export class SingleRowBuilder { } }); - jRow.prepend(newCells); - return jRow; + row.prepend(...newCells); + return row; } protected isColumnBeingEdited(workPackage:WorkPackageResource, column:QueryColumn) { @@ -215,24 +216,27 @@ export class SingleRowBuilder { protected buildEmptyRow(workPackage:WorkPackageResource, row:HTMLTableRowElement):[HTMLTableRowElement, boolean] { const change = this.workPackageTable.editing.change(workPackage); - const cells:{ [attribute:string]:JQuery } = {}; + const cells:Record = {}; if (change && !change.isEmpty()) { // Try to find an old instance of this row const oldRow = locateTableRowByIdentifier(this.classIdentifier(workPackage)); change.changedAttributes.forEach((attribute:string) => { - cells[attribute] = oldRow.find(`.${tdClassName}.${attribute}`); + const oldCell = oldRow?.querySelector(`.${tdClassName}.${attribute}`); + if (oldCell) { + cells[attribute] = oldCell; + } }); } this.augmentedColumns.forEach((column:QueryColumn) => { let cell:Element|null; - const oldCell:JQuery|undefined = cells[column.id]; + const oldCell = cells[column.id]; - if (oldCell && oldCell.length) { + if (oldCell) { debugLog(`Rendering previous open column ${column.id} on ${workPackage.id}`); - jQuery(row).append(oldCell); + row.appendChild(oldCell); } else { cell = this.buildCell(workPackage, column); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts index 8f3e7787a6c4..fe8a78cd59d8 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/table-action-renderer.ts @@ -13,7 +13,7 @@ export class TableActionRenderer { constructor(public readonly injector:Injector) { } - public build(workPackage:WorkPackageResource):HTMLElement { + public build(workPackage:WorkPackageResource):HTMLTableCellElement{ // Append details button const td = document.createElement('td'); td.classList.add(tdClassName, contextMenuTdClassName, internalContextMenuColumn.id, 'hide-when-print'); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts index 7371d5991a0d..378dbbf74eb1 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/edit-cell-handler.ts @@ -10,6 +10,7 @@ import { TableEventComponent, TableEventHandler } from '../table-handler-registr import { ClickOrEnterHandler } from '../click-or-enter-handler'; import { WorkPackageTable } from '../../wp-fast-table'; import { tableRowClassName } from '../../builders/rows/single-row-builder'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class EditCellHandler extends ClickOrEnterHandler implements TableEventHandler { // Injections @@ -19,8 +20,8 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa // Keep a reference to all - public get EVENT() { - return 'click.table.cell, keydown.table.cell'; + public get EVENT():EventType[] { + return ['click', 'keydown']; } public get SELECTOR() { @@ -28,21 +29,21 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tableAndTimelineContainer); + return view.workPackageTable.tableAndTimelineContainer; } constructor(public readonly injector:Injector) { super(); } - protected processEvent(table:WorkPackageTable, evt:JQuery.TriggeredEvent):void { + protected processEvent(table:WorkPackageTable, evt:MouseEvent|KeyboardEvent):void { debugLog('Starting editing on cell: ', evt.target); evt.preventDefault(); // Locate the cell from event - const target = jQuery(evt.target).closest(`.${displayClassName}`); + const target = (evt.target as HTMLElement).closest(`.${displayClassName}`); // Get the target field name - const fieldName = target.data('fieldName'); + const fieldName = target?.dataset.fieldName; if (!fieldName) { debugLog('Click handled by cell not a field? ', evt.target); @@ -50,18 +51,21 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa } // Locate the row - const rowElement = target.closest(`.${tableRowClassName}`); + const rowElement = target.closest(`.${tableRowClassName}`)!; // Get the work package we're editing - const workPackageId = rowElement.data('workPackageId'); + const workPackageId = rowElement.dataset.workPackageId!; const workPackage = this.states.workPackages.get(workPackageId).value!; // Get the row context - const classIdentifier = rowElement.data('classIdentifier'); + const classIdentifier = rowElement.dataset.classIdentifier!; // Get any existing edit state for this work package const form = table.editing.startEditing(workPackage, classIdentifier); - // Get the position where the user clicked. - const positionOffset = getPosition(evt); + let positionOffset = 0; + if (evt.type === 'click') { + // Get the position where the user clicked. + positionOffset = getPosition(evt as MouseEvent); + } // Activate the field form.activate(fieldName) @@ -69,6 +73,6 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa handler.$onUserActivate.next(); handler.focus(positionOffset); }) - .catch(() => target.addClass(readOnlyClassName)); + .catch(() => target.classList.add(readOnlyClassName)); } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts index eb6529f27f23..3cb0e34707b6 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/cell/relations-cell-handler.ts @@ -7,13 +7,14 @@ import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { WorkPackageTable } from '../../wp-fast-table'; import { ClickOrEnterHandler } from '../click-or-enter-handler'; import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class RelationsCellHandler extends ClickOrEnterHandler implements TableEventHandler { // Injections @InjectField() wpTableRelationColumns:WorkPackageViewRelationColumnsService; - public get EVENT() { - return 'click.table.relationsCell, keydown.table.relationsCell'; + public get EVENT():EventType[] { + return ['click', 'keydown']; } public get SELECTOR() { @@ -21,24 +22,24 @@ export class RelationsCellHandler extends ClickOrEnterHandler implements TableEv } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tableAndTimelineContainer); + return view.workPackageTable.tableAndTimelineContainer; } constructor(public readonly injector:Injector) { super(); } - protected processEvent(table:WorkPackageTable, evt:JQuery.TriggeredEvent):void { + protected processEvent(table:WorkPackageTable, evt:MouseEvent|KeyboardEvent):void { debugLog('Handled click on relation cell %o', evt.target); evt.preventDefault(); // Locate the relation td - const td = jQuery(evt.target).closest(`.${relationCellTdClassName}`); - const columnId = td.data('columnId'); + const td = (evt.target as HTMLElement).closest(`.${relationCellTdClassName}`); + const columnId = td?.dataset.columnId ?? ''; // Locate the row - const rowElement = jQuery(evt.target).closest(`.${tableRowClassName}`); - const workPackageId = rowElement.data('workPackageId'); + const rowElement = (evt.target as HTMLElement).closest(`.${tableRowClassName}`); + const workPackageId = rowElement?.dataset.workPackageId ?? ''; // If currently expanded if (this.wpTableRelationColumns.getExpandFor(workPackageId) === columnId) { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/click-or-enter-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/click-or-enter-handler.ts index a5f6df36c1e0..fc06dd23250e 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/click-or-enter-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/click-or-enter-handler.ts @@ -2,18 +2,18 @@ import { TableEventComponent } from 'core-app/features/work-packages/components/ import { WorkPackageTable } from '../wp-fast-table'; /** - * Execute the callback if the given JQuery Event is either an ENTER key or a click + * Execute the callback if the given Event is either an ENTER key or a click */ -export function onClickOrEnter(evt:JQuery.TriggeredEvent, callback:() => void) { - if (evt.type === 'click' || (evt.type === 'keydown' && evt.key === 'Enter')) { +export function onClickOrEnter(evt:Event, callback:() => void) { + if (evt.type === 'click' || (evt.type === 'keydown' && (evt as KeyboardEvent).key === 'Enter')) { callback(); } } export abstract class ClickOrEnterHandler { - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent) { + public handleEvent(view:TableEventComponent, evt:MouseEvent|KeyboardEvent) { onClickOrEnter(evt, () => this.processEvent(view.workPackageTable, evt)); } - protected abstract processEvent(table:WorkPackageTable, evt:JQuery.TriggeredEvent):void; + protected abstract processEvent(table:WorkPackageTable, evt:MouseEvent|KeyboardEvent):void; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-click-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-click-handler.ts index e8bc59e63a1b..0f3e30435fb6 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-click-handler.ts @@ -4,26 +4,27 @@ import { contextMenuLinkClassName } from 'core-app/features/work-packages/compon import { TableEventComponent } from 'core-app/features/work-packages/components/wp-fast-table/handlers/table-handler-registry'; import { uiStateLinkClass } from '../../builders/ui-state-link-builder'; import { ContextMenuHandler } from './context-menu-handler'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class ContextMenuClickHandler extends ContextMenuHandler { constructor(public readonly injector:Injector) { super(injector); } - public get EVENT() { - return 'click.table.contextmenu'; + public get EVENT():EventType { + return 'click'; } public get SELECTOR() { return `.${contextMenuLinkClassName}`; } - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent):boolean { - const target = jQuery(evt.target); + public handleEvent(view:TableEventComponent, evt:Event):boolean { + const target = evt.target as HTMLElement; // We want to keep the original context menu on hrefs // (currently, this is only the id - if (target.closest(`.${uiStateLinkClass}`).length) { + if (target.closest(`.${uiStateLinkClass}`)) { debugLog('Allowing original context menu on state link'); return true; } @@ -32,8 +33,8 @@ export class ContextMenuClickHandler extends ContextMenuHandler { evt.stopPropagation(); // Locate the row from event - const element = target.closest(this.rowSelector); - const wpId = element.data('workPackageId'); + const element = target.closest(this.rowSelector); + const wpId = element?.dataset.workPackageId; if (wpId) { this.openContextMenu(view.workPackageTable, evt, wpId); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts index cb913ced64b1..d4165f067745 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-handler.ts @@ -5,6 +5,8 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { WorkPackageTable } from '../../wp-fast-table'; import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; +import { PositionArgs } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export abstract class ContextMenuHandler implements TableEventHandler { // Injections @@ -17,18 +19,18 @@ export abstract class ContextMenuHandler implements TableEventHandler { return `.${tableRowClassName}`; } - public abstract get EVENT():string; + public abstract get EVENT():EventType|EventType[]; public abstract get SELECTOR():string; public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tableAndTimelineContainer); + return view.workPackageTable.tableAndTimelineContainer; } - public abstract handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent):boolean; + public abstract handleEvent(view:TableEventComponent, evt:Event):boolean; - protected openContextMenu(table:WorkPackageTable, evt:JQuery.TriggeredEvent, workPackageId:string, positionArgs?:any):void { - const handler = new WorkPackageTableContextMenu(this.injector, workPackageId, jQuery(evt.target) as JQuery, positionArgs, table); + protected openContextMenu(table:WorkPackageTable, evt:Event, workPackageId:string, positionArgs:PositionArgs = {}):void { + const handler = new WorkPackageTableContextMenu(this.injector, workPackageId, evt.target as HTMLElement, positionArgs, table); this.opContextMenu.show(handler, evt); } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-keyboard-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-keyboard-handler.ts index 105268851a4e..fa2eb45b05c8 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-keyboard-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-keyboard-handler.ts @@ -1,26 +1,27 @@ import { Injector } from '@angular/core'; import { TableEventComponent } from 'core-app/features/work-packages/components/wp-fast-table/handlers/table-handler-registry'; import { ContextMenuHandler } from './context-menu-handler'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class ContextMenuKeyboardHandler extends ContextMenuHandler { constructor(public readonly injector:Injector) { super(injector); } - public get EVENT() { - return 'keydown.table.contextmenu'; + public get EVENT():EventType { + return 'keydown'; } public get SELECTOR() { return this.rowSelector; } - public handleEvent(component:TableEventComponent, evt:JQuery.TriggeredEvent):boolean { + public handleEvent(component:TableEventComponent, evt:KeyboardEvent):boolean { if (!component.workPackageTable.configuration.contextMenuEnabled) { return false; } - const target = jQuery(evt.target); + const target = evt.target as HTMLElement; if (!(evt.key === 'F10' && evt.shiftKey && evt.altKey)) { return true; @@ -30,13 +31,16 @@ export class ContextMenuKeyboardHandler extends ContextMenuHandler { evt.stopPropagation(); // Locate the row from event - const element = target.closest(this.SELECTOR); - const wpId = element.data('workPackageId'); - - // Set position args to open at element - const position = { my: 'left top', at: 'left bottom', of: target }; - - super.openContextMenu(component.workPackageTable, evt, wpId, position); + const element = target.closest(this.SELECTOR)!; + const wpId = element.dataset.workPackageId!; + + super.openContextMenu( + component.workPackageTable, + evt, + wpId, + // Set position args to open at element + { placement: 'bottom-start', reference: target } + ); return false; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-rightclick-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-rightclick-handler.ts index 32b5e5f32b1d..7806fd131c4f 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-rightclick-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/context-menu/context-menu-rightclick-handler.ts @@ -7,6 +7,7 @@ import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { timelineCellClassName } from '../../builders/timeline/timeline-row-builder'; import { uiStateLinkClass } from '../../builders/ui-state-link-builder'; import { ContextMenuHandler } from './context-menu-handler'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class ContextMenuRightClickHandler extends ContextMenuHandler { @InjectField() readonly wpTableSelection:WorkPackageViewSelectionService; @@ -15,8 +16,8 @@ export class ContextMenuRightClickHandler extends ContextMenuHandler { super(injector); } - public get EVENT() { - return 'contextmenu.table.rightclick'; + public get EVENT():EventType { + return 'contextmenu'; // N.B.: contextmenu is not supported by Safari on iOS. } public get SELECTOR() { @@ -24,18 +25,18 @@ export class ContextMenuRightClickHandler extends ContextMenuHandler { } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tableAndTimelineContainer); + return view.workPackageTable.tableAndTimelineContainer; } - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent):boolean { + public handleEvent(view:TableEventComponent, evt:Event):boolean { if (!view.workPackageTable.configuration.contextMenuEnabled) { return false; } - const target = jQuery(evt.target); + const target = evt.target as HTMLElement; // We want to keep the original context menu on hrefs // (currently, this is only the id - if (target.closest(`.${uiStateLinkClass}`).length) { + if (target.closest(`.${uiStateLinkClass}`)) { debugLog('Allowing original context menu on state link'); return true; } @@ -44,8 +45,8 @@ export class ContextMenuRightClickHandler extends ContextMenuHandler { evt.stopPropagation(); // Locate the row from event - const element = target.closest(this.SELECTOR); - const wpId = element.data('workPackageId'); + const element = target.closest(this.SELECTOR); + const wpId = element?.dataset.workPackageId; if (wpId) { const [index] = view.workPackageTable.findRenderedRow(wpId); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts index ca6db7eb4475..63060fbed8a8 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/click-handler.ts @@ -10,6 +10,7 @@ import { debugLog } from 'core-app/shared/helpers/debug_output'; import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { KeepTabService } from '../../../wp-single-view-tabs/keep-tab/keep-tab.service'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class RowClickHandler implements TableEventHandler { // Injections @@ -26,8 +27,8 @@ export class RowClickHandler implements TableEventHandler { constructor(public readonly injector:Injector) { } - public get EVENT() { - return 'click.table.row'; + public get EVENT():EventType { + return 'click'; } public get SELECTOR() { @@ -35,28 +36,28 @@ export class RowClickHandler implements TableEventHandler { } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tbody); + return view.workPackageTable.tbody; } - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent) { - const target = jQuery(evt.target); + public handleEvent(view:TableEventComponent, evt:MouseEvent) { + const target = evt.target as HTMLElement; // Ignore links - if (target.is('a') || target.parent().is('a')) { + if (target instanceof HTMLAnchorElement || target.parentElement instanceof HTMLAnchorElement) { return true; } // Shortcut to any clicks within a cell // We don't want to handle these. - if (target.hasClass(`${displayClassName}`) || target.hasClass(`${activeFieldClassName}`)) { + if (target.classList.contains(`${displayClassName}`) || target.classList.contains(`${activeFieldClassName}`)) { debugLog('Skipping click on inner cell'); return true; } // Locate the row from event - const element = target.closest(this.SELECTOR); - const wpId = element.data('workPackageId'); - const classIdentifier = element.data('classIdentifier'); + const element = target.closest(this.SELECTOR)!; + const wpId = element.dataset.workPackageId; + const classIdentifier = element.dataset.classIdentifier!; if (!wpId) { return true; diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/double-click-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/double-click-handler.ts index 3d4ec9f5ea41..7763f1988347 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/double-click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/double-click-handler.ts @@ -11,6 +11,7 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { tdClassName } from '../../builders/cell-builder'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class RowDoubleClickHandler implements TableEventHandler { // Injections @@ -25,8 +26,8 @@ export class RowDoubleClickHandler implements TableEventHandler { constructor(public readonly injector:Injector) { } - public get EVENT() { - return 'dblclick.table.row'; + public get EVENT():EventType { + return 'dblclick'; } public get SELECTOR() { @@ -34,11 +35,11 @@ export class RowDoubleClickHandler implements TableEventHandler { } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tbody); + return view.workPackageTable.tbody; } - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent) { - const target = jQuery(evt.target); + public handleEvent(view:TableEventComponent, evt:MouseEvent) { + const target = evt.target as HTMLElement; // Skip clicks with modifiers if (isClickedWithModifier(evt)) { @@ -47,17 +48,17 @@ export class RowDoubleClickHandler implements TableEventHandler { // Shortcut to any clicks within a cell // We don't want to handle these. - if (target.hasClass(`${displayClassName}`) || target.hasClass(`${activeFieldClassName}`)) { + if (target.classList.contains(`${displayClassName}`) || target.classList.contains(`${activeFieldClassName}`)) { debugLog('Skipping click on inner cell'); return true; } // Locate the row from event - const element = target.closest(this.SELECTOR).closest(`.${tableRowClassName}`); - const wpId = element.data('workPackageId'); + const element = target.closest(this.SELECTOR)!.closest(`.${tableRowClassName}`)!; + const wpId = element.dataset.workPackageId!; // Ignore links - if (target.is('a') || target.parent().is('a')) { + if (target instanceof HTMLAnchorElement || target.parentElement instanceof HTMLAnchorElement) { return true; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/group-row-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/group-row-handler.ts index 71031d0888ab..798ad03866a9 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/group-row-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/group-row-handler.ts @@ -4,6 +4,7 @@ import { rowGroupClassName } from 'core-app/features/work-packages/components/wp import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { WorkPackageViewCollapsedGroupsService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-collapsed-groups.service'; import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class GroupRowHandler implements TableEventHandler { // Injections @@ -14,8 +15,8 @@ export class GroupRowHandler implements TableEventHandler { constructor(public readonly injector:Injector) { } - public get EVENT() { - return 'click.table.groupheader'; + public get EVENT():EventType { + return 'click'; } public get SELECTOR() { @@ -23,15 +24,15 @@ export class GroupRowHandler implements TableEventHandler { } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tbody); + return view.workPackageTable.tbody; } - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent) { + public handleEvent(view:TableEventComponent, evt:Event) { evt.preventDefault(); evt.stopPropagation(); - const groupHeader = jQuery(evt.target).parents(`.${rowGroupClassName}`); - const groupIdentifier = groupHeader.data('groupIdentifier'); + const groupHeader = (evt.target as HTMLElement).closest(`.${rowGroupClassName}`); + const groupIdentifier = groupHeader?.dataset.groupIdentifier ?? ''; this.workPackageViewCollapsedGroupsService.toggleGroupCollapseState(groupIdentifier); } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts index 7c1fc79632e4..bee49c72d1cc 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/hierarchy-click-handler.ts @@ -6,6 +6,7 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { WorkPackageTable } from '../../wp-fast-table'; import { ClickOrEnterHandler } from '../click-or-enter-handler'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class HierarchyClickHandler extends ClickOrEnterHandler implements TableEventHandler { // Injections @@ -17,8 +18,8 @@ export class HierarchyClickHandler extends ClickOrEnterHandler implements TableE super(); } - public get EVENT() { - return 'click.table.hierarchy'; + public get EVENT():EventType { + return 'click'; } public get SELECTOR() { @@ -26,15 +27,15 @@ export class HierarchyClickHandler extends ClickOrEnterHandler implements TableE } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tbody); + return view.workPackageTable.tbody; } - public processEvent(table:WorkPackageTable, evt:JQuery.TriggeredEvent):void { - const target = jQuery(evt.target); + public processEvent(table:WorkPackageTable, evt:MouseEvent|KeyboardEvent):void { + const target = evt.target as HTMLElement; // Locate the row from event - const element = target.closest(`.${tableRowClassName}`); - const wpId = element.data('workPackageId'); + const element = target.closest(`.${tableRowClassName}`); + const wpId = element?.dataset.workPackageId ?? ''; this.wpTableHierarchies.toggle(wpId); diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/wp-state-links-handler.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/wp-state-links-handler.ts index f566f71a38af..b6373346d06b 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/wp-state-links-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/row/wp-state-links-handler.ts @@ -9,6 +9,7 @@ import { KeepTabService } from '../../../wp-single-view-tabs/keep-tab/keep-tab.s import { tableRowClassName } from '../../builders/rows/single-row-builder'; import { uiStateLinkClass } from '../../builders/ui-state-link-builder'; import { TableEventComponent, TableEventHandler } from '../table-handler-registry'; +import { EventType } from 'core-app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry'; export class WorkPackageStateLinksHandler implements TableEventHandler { // Injections @@ -25,8 +26,8 @@ export class WorkPackageStateLinksHandler implements TableEventHandler { constructor(public readonly injector:Injector) { } - public get EVENT() { - return 'click.table.wpLink'; + public get EVENT():EventType { + return 'click'; } public get SELECTOR() { @@ -34,12 +35,12 @@ export class WorkPackageStateLinksHandler implements TableEventHandler { } public eventScope(view:TableEventComponent) { - return jQuery(view.workPackageTable.tableAndTimelineContainer); + return view.workPackageTable.tableAndTimelineContainer; } protected workPackage:WorkPackageResource; - public handleEvent(view:TableEventComponent, evt:JQuery.TriggeredEvent) { + public handleEvent(view:TableEventComponent, evt:KeyboardEvent) { evt.stopPropagation(); // Avoid the state capture when clicking with modifier to allow browser opening in new tab diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/hierarchy-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/hierarchy-transformer.ts index 365cec2355e0..cdffbbc64b2a 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/hierarchy-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/hierarchy-transformer.ts @@ -15,6 +15,7 @@ import { indicatorCollapsedClass } from 'core-app/features/work-packages/compone import { tableRowClassName } from 'core-app/features/work-packages/components/wp-fast-table/builders/rows/single-row-builder'; import { WorkPackageViewHierarchies } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-table-hierarchies'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { getNodeIndex } from 'core-app/shared/helpers/dom-helpers'; export class HierarchyTransformer { @InjectField() public wpTableHierarchies:WorkPackageViewHierarchiesService; @@ -61,7 +62,11 @@ export class HierarchyTransformer { const rendered = this.querySpace.tableRendered.value!; // Show all hierarchies - jQuery('[class^="__hierarchy-group-"]').removeClass((i:number, classNames:string):string => (classNames.match(/__collapsed-group-\d+/g) || []).join(' ')); + document.querySelectorAll('[class^="__hierarchy-group-"]').forEach((el) => { + Array.from(el.classList) + .filter((className) => /__collapsed-group-\d+/g.test(className)) + .forEach((className) => el.classList.remove(className)); + }); // Mark which rows were hidden by some other hierarchy group // (e.g., by a collapsed parent) @@ -70,7 +75,7 @@ export class HierarchyTransformer { // Hide all collapsed hierarchies _.each(state.collapsed, (isCollapsed:boolean, wpId:string) => { // Toggle the root style - jQuery(`.${hierarchyRootClass(wpId)} .wp-table--hierarchy-indicator`).toggleClass(indicatorCollapsedClass, isCollapsed); + document.querySelector(`.${hierarchyRootClass(wpId)} .wp-table--hierarchy-indicator`)?.classList.toggle(indicatorCollapsedClass, isCollapsed); // Get parent row and mark/unmark it as collapsed const hierarchyRoot = document.querySelector(`.wp-timeline-cell.__hierarchy-root-${wpId}`); @@ -84,15 +89,17 @@ export class HierarchyTransformer { } // Get all affected children rows - const affected = jQuery(`.${hierarchyGroupClass(wpId)}`); + const affected = Array.from(document.querySelectorAll(`.${hierarchyGroupClass(wpId)}`)); // Hide/Show the descendants. - affected.toggleClass(collapsedGroupClass(wpId), isCollapsed); + affected.forEach((el) => el.classList.toggle(collapsedGroupClass(wpId), isCollapsed)); // Update the hidden section of the rendered state - affected.filter(`.${tableRowClassName}`).each((i, el) => { + affected + .filter((el) => el.matches(`.${tableRowClassName}`)) + .forEach((el) => { // Get the index of this row - const index = jQuery(el).index(); + const index = getNodeIndex(el); // Update the hidden state if (!collapsed[index]) { diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts index 0bc61c2daeb0..fba47c3097df 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/selection-transformer.ts @@ -32,9 +32,9 @@ export class SelectionTransformer { .subscribe(() => { this.wpTableFocus.ifShouldFocus((wpId:string) => { const element = locateTableRow(wpId); - if (element.length) { + if (element) { scrollTableRowIntoView(wpId); - this.FocusHelper.focus(element[0]); + this.FocusHelper.focus(element); } }); }); @@ -56,12 +56,14 @@ export class SelectionTransformer { * Update all currently visible rows to match the selection state. */ private renderSelectionState(state:WorkPackageViewSelectionState) { - const context = jQuery(this.table.tableAndTimelineContainer); + const context = this.table.tableAndTimelineContainer; - context.find(`.${tableRowClassName}.${checkedClassName}`).removeClass(checkedClassName); + context.querySelectorAll(`.${tableRowClassName}.${checkedClassName}`).forEach((el) => el.classList.remove(checkedClassName)); _.each(state.selected, (selected:boolean, workPackageId:any) => { - context.find(`.${tableRowClassName}[data-work-package-id="${workPackageId}"]`).toggleClass(checkedClassName, selected); + context.querySelectorAll(`.${tableRowClassName}[data-work-package-id="${workPackageId}"]`).forEach((el) => { + el.classList.toggle(checkedClassName, selected); + }); }); } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/timeline-transformer.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/timeline-transformer.ts index 55bfb83dbea5..3247d6555486 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/timeline-transformer.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/handlers/state/timeline-transformer.ts @@ -27,8 +27,8 @@ export class TimelineTransformer { * Update all currently visible rows to match the selection state. */ private renderVisibility(visible:boolean) { - const container = jQuery(this.table.tableAndTimelineContainer).parent(); - container.find('.work-packages-tabletimeline--timeline-side').toggle(visible); - container.find('.work-packages-tabletimeline--table-side').toggleClass('-timeline-visible', visible); + const container = this.table.tableAndTimelineContainer.parentElement!; + container.querySelectorAll('.work-packages-tabletimeline--timeline-side, .work-packages-tabletimeline--table-side') + .forEach((sideEl) => sideEl.classList.toggle('-timeline-visible', visible)); } } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/wp-table-row-helpers.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/wp-table-row-helpers.ts index 7a264294c174..899b634cc60e 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/wp-table-row-helpers.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/helpers/wp-table-row-helpers.ts @@ -11,12 +11,12 @@ export function relationRowClass():string { return 'wp-table--relations-additional-row'; } -export function locateTableRow(workPackageId:string):JQuery { - return jQuery(`.${rowId(workPackageId)}`); +export function locateTableRow(workPackageId:string) { + return document.querySelector(`.${rowId(workPackageId)}`); } export function locateTableRowByIdentifier(identifier:string) { - return jQuery(`.${identifier}-table`); + return document.querySelector(`.${identifier}-table`); } export function isInsideCollapsedGroup(el?:Element | null) { @@ -42,20 +42,39 @@ export function locatePredecessorBySelector(el:HTMLElement, selector:string):HTM export function scrollTableRowIntoView(workPackageId:string):void { try { - const element = locateTableRow(workPackageId); - const container = element.scrollParent()!; - const containerTop = container.scrollTop()!; - const containerBottom = containerTop + container.height()!; + const element = locateTableRow(workPackageId)!; + const container = getScrollParent(element); + const containerTop = container.scrollTop; + const containerBottom = containerTop + container.clientHeight; - const elemTop = element[0].offsetTop; - const elemBottom = elemTop + element.height()!; + const elemTop = element.offsetTop; + const elemBottom = elemTop + element.offsetHeight; if (elemTop < containerTop) { - container[0].scrollTop = elemTop; + container.scrollTop = elemTop; } else if (elemBottom > containerBottom) { - container[0].scrollTop = elemBottom - container.height()!; + container.scrollTop = elemBottom - container.clientHeight; } } catch (e) { console.warn(`Can't scroll row element into view: ${e}`); } } + +function getScrollParent(element:HTMLElement, includeHidden = false) { + const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/; + + let parent:HTMLElement|null = element.parentElement; + + while (parent && parent !== document.body) { + const style = getComputedStyle(parent); + const overflow = style.overflow + style.overflowY + style.overflowX; + + if (overflowRegex.test(overflow)) { + return parent; + } + + parent = parent.parentElement; + } + + return document.scrollingElement || document.documentElement; +} diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts index 6db045e9e948..f2060d82f8ce 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/wp-fast-table.ts @@ -55,7 +55,7 @@ export class WorkPackageTable { public readonly injector:Injector, public tableAndTimelineContainer:HTMLElement, public scrollContainer:HTMLElement, - public tbody:HTMLElement, + public tbody:HTMLTableSectionElement, public timelineBody:HTMLElement, public timelineController:WorkPackageTimelineTableController, public configuration:WorkPackageTableConfiguration, diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts b/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts index 58e0a321176e..dc479bd3e9ad 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/inline-create-row-builder.ts @@ -41,7 +41,7 @@ export class InlineCreateRowBuilder extends SingleRowBuilder { }; } - public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLElement|null { + public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLTableCellElement|null { switch (column.id) { case internalContextMenuColumn.id: return this.buildCancelButton(); diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts index 044f12183e8f..ecab383fb156 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.component.ts @@ -68,6 +68,7 @@ import { onClickOrEnter } from '../wp-fast-table/handlers/click-or-enter-handler import { HalResourceEditingService, } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; +import { delegate, DelegateEvent } from '@knowledgecode/delegate'; @Component({ selector: '[wpInlineCreate]', @@ -101,7 +102,7 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem private editingSubscription:Subscription|undefined; - private $element:JQuery; + private element:HTMLElement; get isActive():boolean { return this.mode !== 'inactive'; @@ -123,7 +124,7 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem } ngOnInit() { - this.$element = jQuery(this.elementRef.nativeElement); + this.element = this.elementRef.nativeElement; } ngAfterViewInit():void { @@ -156,14 +157,18 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem * which is dynamically inserted into the action row by the inline create renderer. */ private registerCancelHandler() { - this.$element.on('click keydown', `.${inlineCreateCancelClassName}`, (evt:JQuery.TriggeredEvent) => { - onClickOrEnter(evt, () => { + const handler = (evt:DelegateEvent) => { + onClickOrEnter(evt.originalEvent, () => { this.resetRow(); }); evt.stopImmediatePropagation(); return false; - }); + }; + + delegate(this.element) + .on('click', `.${inlineCreateCancelClassName}`, handler) + .on('keydown', `.${inlineCreateCancelClassName}`, handler); } /** @@ -272,9 +277,9 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem private refreshRow() { const builder = new InlineCreateRowBuilder(this.injector, this.table); - const rowElement = this.$element.find(`.${inlineCreateRowClassName}`); + const rowElement = this.element.querySelector(`.${inlineCreateRowClassName}`); - if (rowElement.length && this.currentWorkPackage) { + if (rowElement && this.currentWorkPackage) { builder.refreshRow(this.currentWorkPackage, rowElement); } } @@ -291,7 +296,7 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem const form = this.table.editing.startEditing(wp, builder.classIdentifier(wp)); const [row] = builder.buildNew(wp, form); - this.$element.append(row); + this.element.append(row); return form; } @@ -313,7 +318,7 @@ export class WorkPackageInlineCreateComponent extends UntilDestroyedMixin implem public removeWorkPackageRow() { this.wpCreate.cancelCreation(); this.currentWorkPackage = null; - this.$element.find('.wp-row-new').remove(); + this.element.querySelector('.wp-row-new')?.remove(); if (this.editingSubscription) { this.editingSubscription.unsubscribe(); } diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts index 0b4b42825638..192d43cbcf7f 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts @@ -25,7 +25,7 @@ export class WorkPackageRelationRowComponent extends UntilDestroyedMixin impleme @Input() public groupByWorkPackageType:boolean; - @ViewChild('relationDescriptionTextarea') readonly relationDescriptionTextarea:ElementRef; + @ViewChild('relationDescriptionTextarea') readonly relationDescriptionTextarea:ElementRef; public relationType:string; @@ -112,18 +112,18 @@ export class WorkPackageRelationRowComponent extends UntilDestroyedMixin impleme public startDescriptionEdit() { this.userInputs.showDescriptionEditForm = true; setTimeout(() => { - const textarea = jQuery(this.relationDescriptionTextarea.nativeElement); - const textlen = (textarea.val() as string).length; + const textarea = this.relationDescriptionTextarea.nativeElement; + const textlen = textarea.value.length; // Focus and set cursor to end textarea.focus(); - textarea.prop('selectionStart', textlen); - textarea.prop('selectionEnd', textlen); + textarea.selectionStart = textlen; + textarea.selectionEnd = textlen; }); } - public handleDescriptionKey($event:JQuery.TriggeredEvent) { - if ($event.key === 'Escape') { + public handleDescriptionKey(event:KeyboardEvent) { + if (event.key === 'Escape') { this.cancelDescriptionEdit(); } } @@ -157,7 +157,7 @@ export class WorkPackageRelationRowComponent extends UntilDestroyedMixin impleme } } - public cancelRelationTypeEditOnEscape(evt:JQuery.TriggeredEvent) { + public cancelRelationTypeEditOnEscape(evt:KeyboardEvent) { this.userInputs.showRelationTypesForm = false; } diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts index c12cc223530e..5e79e5fb615d 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts @@ -97,9 +97,9 @@ export class WorkPackageRelationsAutocompleteComponent extends OpAutocompleterCo this.ngZone.runOutsideAngular(() => { setTimeout(() => { this.ngSelectInstance.dropdownPanel.adjustPosition(); - jQuery(this.hiddenOverflowContainer).one('scroll', () => { + document.querySelector(this.hiddenOverflowContainer)?.addEventListener('scroll', () => { this.ngSelectInstance.close(); - }); + }, { once: true }); }, 25); }); } diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts index 967429117693..6e785b501bb1 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts @@ -66,7 +66,7 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme public availableWatchersPath:string; - private $element:JQuery; + private element:HTMLElement; public watching:any[] = []; @@ -94,8 +94,7 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme } public ngOnInit() { - this.$element = jQuery(this.elementRef.nativeElement); - + this.element = this.elementRef.nativeElement; const { workPackageId } = this.uiRouterGlobals.params as unknown as { workPackageId:string }; this.workPackageId = (this.workPackage.id as string) || workPackageId; diff --git a/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts b/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts index 6e2a145dda87..fd521271b3f6 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts @@ -140,7 +140,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen public uiSelfRef:string; - $element:JQuery; + element:HTMLElement; projectStorages = new BehaviorSubject([]); @@ -166,7 +166,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen } public ngOnInit():void { - this.$element = jQuery(this.elementRef.nativeElement as HTMLElement); + this.element = this.elementRef.nativeElement as HTMLElement; this.isNewResource = isNewResource(this.workPackage); @@ -309,7 +309,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen } showTwoColumnLayout():boolean { - return this.$element[0].getBoundingClientRect().width > 750; + return this.element.getBoundingClientRect().width > 750; } private rebuildGroupedFields(change:WorkPackageChangeset, attributeGroups:any) { @@ -431,9 +431,9 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen } private getAttributesGroupId(group:any):string { - const overflowingIdentifier = this.$element - .find(`[data-group-name=\'${group.name}\']`) - .data(overflowingContainerAttribute); + const overflowingIdentifier = this.element + .querySelector(`[data-group-name=\'${group.name}\']`) + ?.dataset[overflowingContainerAttribute]; if (overflowingIdentifier) { return overflowingIdentifier.replace('.__overflowing_', ''); diff --git a/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal.ts b/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal.ts index 4457333ed698..2b9f10d1393b 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal.ts @@ -93,7 +93,7 @@ export class WpTableConfigurationModalComponent extends OpModalComponent impleme } ngOnInit() { - this.$element = this.elementRef.nativeElement as HTMLElement; + this.element = this.elementRef.nativeElement as HTMLElement; this.tabPortalHost = new TabPortalOutlet( this.wpTableConfigurationService.tabs, @@ -149,7 +149,7 @@ export class WpTableConfigurationModalComponent extends OpModalComponent impleme } protected get afterFocusOn():HTMLElement { - return this.$element; + return this.element; } protected loadForm() { diff --git a/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts index db81a0741df0..b18e3ba573bb 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/sort-header/sort-header.directive.ts @@ -203,7 +203,7 @@ export class SortHeaderDirective extends UntilDestroyedMixin implements AfterVie return this.table && this.table.configuration.hierarchyToggleEnabled; } - toggleHierarchy(evt:JQuery.TriggeredEvent) { + toggleHierarchy(evt:Event) { if (this.wpTableHierarchies.toggleState()) { this.wpTableGroupBy.disable(); } diff --git a/frontend/src/app/features/work-packages/components/wp-table/table-actions/actions/unlink-table-action.ts b/frontend/src/app/features/work-packages/components/wp-table/table-actions/actions/unlink-table-action.ts index 9277c8472af0..8b2a4003f09d 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/table-actions/actions/unlink-table-action.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/table-actions/actions/unlink-table-action.ts @@ -47,7 +47,7 @@ export class OpUnlinkTableAction extends OpTableAction { element.classList.add(contextColumnIcon, 'wp-table-action--unlink'); element.dataset.workPackageId = this.workPackage.id!; element.appendChild(opIconElement('icon', 'icon-close')); - jQuery(element).click((event) => { + element.addEventListener('click', (event) => { event.preventDefault(); this.onClick(this.workPackage); }); diff --git a/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts b/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts index 0703ed58d0ce..c15cc1b2da6d 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts @@ -62,8 +62,8 @@ function setupMocks(paginationService:PaginationService) { spyOn(paginationService, 'getPaginationOptions').and.callFake(() => options); } -function pageString(element:JQuery) { - return element.find('.op-pagination--range').text().trim(); +function pageString(element:HTMLElement) { + return element.querySelector('.op-pagination--range')?.textContent?.trim() || ''; } describe('wpTablePagination Directive', () => { @@ -103,7 +103,7 @@ describe('wpTablePagination Directive', () => { setupMocks(paginationService); const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; - const element = jQuery(fixture.elementRef.nativeElement); + const element = fixture.elementRef.nativeElement; app.pagination = new PaginationInstance(1, 0, 10); app.update(); @@ -122,14 +122,15 @@ describe('wpTablePagination Directive', () => { setupMocks(paginationService); const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; - const element = jQuery(fixture.elementRef.nativeElement); + const element = fixture.elementRef.nativeElement; app.pagination = new PaginationInstance(2, 11, 10); app.update(); fixture.detectChanges(); - const liWithNextLink = element.find('.op-pagination--item-link_next').parent('li'); - const attrHidden = liWithNextLink.attr('hidden'); + const liWithNextLink = element.querySelector('.op-pagination--item-link_next')?.parentElement; + expect(liWithNextLink?.matches('li')).toBeTrue(); + const attrHidden = liWithNextLink.getAttribute('hidden'); expect(attrHidden).toBeDefined(); })); }); @@ -139,10 +140,10 @@ describe('wpTablePagination Directive', () => { setupMocks(paginationService); const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; - const element = jQuery(fixture.elementRef.nativeElement); + const element = fixture.elementRef.nativeElement; function numberOfPageNumberLinks() { - return element.find('button[data-rel="next"]').length; + return element.querySelectorAll('button[data-rel="next"]').length; } app.pagination = new PaginationInstance(1, 1, 10); diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts index 2880b5e2ae00..58b06bca585f 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts @@ -187,8 +187,10 @@ export class TimelineCellRenderer { const projection = renderInfo.change.projectedResource; let direction:Exclude; + const target = ev.target as HTMLElement; + // Update the cursor and maybe set start/due values - if (jQuery(ev.target!).hasClass(classNameLeftHandle)) { + if (target.classList.contains(classNameLeftHandle)) { // only left direction = 'left'; this.mouseDirection = 'left'; @@ -196,7 +198,7 @@ export class TimelineCellRenderer { if (projection.startDate === null) { projection.startDate = projection.dueDate; } - } else if (jQuery(ev.target!).hasClass(classNameRightHandle) || dateForCreate) { + } else if (target.classList.contains(classNameRightHandle) || dateForCreate) { // only right direction = 'right'; this.mouseDirection = 'right'; @@ -286,7 +288,7 @@ export class TimelineCellRenderer { element.style.backgroundImage = ''; // required! unable to disable "fade out bar" with css if (renderInfo.viewParams.selectionModeStart === `${renderInfo.workPackage.id!}`) { - jQuery(element).addClass(timelineMarkerSelectionStartClass); + element.classList.add(timelineMarkerSelectionStartClass); element.style.background = 'none'; } } diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts index 7030ac4d72e1..224ecb58743b 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts @@ -124,7 +124,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer { return false; } - const diamond = jQuery('.diamond', element)[0]; + const diamond = element.querySelector('.diamond')!; diamond.style.width = `${15}px`; diamond.style.height = `${15}px`; diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell-mouse-handler.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell-mouse-handler.ts index b976b2e73a4c..4f660b8e60dd 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell-mouse-handler.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell-mouse-handler.ts @@ -45,6 +45,7 @@ import { } from './timeline-cell-renderer'; import { RenderInfo } from '../wp-timeline'; import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive'; +import { target } from 'core-app/shared/helpers/event-helpers'; export function registerWorkPackageMouseHandler(this:void, injector:Injector, @@ -63,7 +64,7 @@ export function registerWorkPackageMouseHandler(this:void, renderInfo.change = halEditing.changeFor(renderInfo.workPackage); let placeholderForEmptyCell:HTMLElement; - const jBody = jQuery('body'); + const bodyTarget = target(document.body); // handles change to existing work packages bar.onmousedown = (ev:MouseEvent) => { @@ -94,7 +95,7 @@ export function registerWorkPackageMouseHandler(this:void, // add/remove css class while drag'n'drop is active const classNameActiveDrag = 'active-drag'; bar.classList.add(classNameActiveDrag); - jBody.on('mouseup.timelinecell', () => bar.classList.remove(classNameActiveDrag)); + bodyTarget.on('mouseup.timelinecell', () => bar.classList.remove(classNameActiveDrag)); workPackageTimeline.disableViewParamsCalculation = true; mouseDownStartDay = getCursorOffsetInDaysFromLeft(ev); @@ -109,14 +110,14 @@ export function registerWorkPackageMouseHandler(this:void, // Determine what attributes of the work package should be changed const direction = renderer.onMouseDown(ev, null, renderInfo, labels); - jBody.on('mousemove.timelinecell', createMouseMoveFn(direction)); - jBody.on('keyup.timelinecell', keyPressFn); - jBody.on('mouseup.timelinecell', () => deactivate(direction, false)); + bodyTarget.on('mousemove.timelinecell', createMouseMoveFn(direction)); + bodyTarget.on('keyup.timelinecell', keyPressFn); + bodyTarget.on('mouseup.timelinecell', () => deactivate(direction, false)); } function createMouseMoveFn(direction:MouseDirection) { - return (ev:JQuery.MouseMoveEvent) => { - const days = getCursorOffsetInDaysFromLeft(ev.originalEvent as MouseEvent) - (mouseDownStartDay as number); + return (ev:MouseEvent) => { + const days = getCursorOffsetInDaysFromLeft(ev) - (mouseDownStartDay as number); const offsetDayCurrent = Math.floor(ev.offsetX / renderInfo.viewParams.pixelPerDay); const dayUnderCursor = renderInfo.viewParams.dateDisplayStart.clone().add(offsetDayCurrent, 'days'); @@ -124,8 +125,7 @@ export function registerWorkPackageMouseHandler(this:void, }; } - function keyPressFn(ev:JQuery.TriggeredEvent) { - const kev:KeyboardEvent = ev.originalEvent as KeyboardEvent; + function keyPressFn(kev:KeyboardEvent) { if (kev.key === 'Escape') { deactivate(null, true); } @@ -182,19 +182,19 @@ export function registerWorkPackageMouseHandler(this:void, return; } - jBody.on('mousemove.emptytimelinecell', mouseMoveOnEmptyCellFn(offsetDayStart, direction)); - jBody.on('mouseup.emptytimelinecell', () => deactivate(direction, false)); + bodyTarget.on('mousemove.emptytimelinecell', mouseMoveOnEmptyCellFn(offsetDayStart, direction)); + bodyTarget.on('mouseup.emptytimelinecell', () => deactivate(direction, false)); cell.onmouseup = () => { deactivate(direction, false); }; - jBody.on('keyup.timelinecell', keyPressFn); + bodyTarget.on('keyup.timelinecell', keyPressFn); }; } function mouseMoveOnEmptyCellFn(offsetDayStart:number, mouseDownType:MouseDirection) { - return (ev:JQuery.MouseMoveEvent) => { + return (ev:MouseEvent) => { placeholderForEmptyCell.remove(); const relativePosition = Math.abs(cell.getBoundingClientRect().x - ev.clientX); const offsetDayCurrent = Math.floor(relativePosition / renderInfo.viewParams.pixelPerDay); @@ -216,8 +216,8 @@ export function registerWorkPackageMouseHandler(this:void, bar.style.pointerEvents = 'auto'; - jBody.off('.timelinecell'); - jBody.off('.emptytimelinecell'); + bodyTarget.off('.timelinecell'); + bodyTarget.off('.emptytimelinecell'); workPackageTimeline.resetCursor(); mouseDownStartDay = null; diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell.ts index 92fc0420511e..584a6be0a772 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/wp-timeline-cell.ts @@ -99,7 +99,10 @@ export class WorkPackageTimelineCell { } public clear() { - this.cellElement.html(''); + if (this.cellElement) { + this.cellElement.innerHTML = ''; + } + this.wpElement = null; } @@ -107,15 +110,15 @@ export class WorkPackageTimelineCell { return this.workPackageTimeline.timelineBody; } - private get cellElement():JQuery { - return this.cellContainer.find(`.${this.classIdentifier}`); + private get cellElement() { + return this.cellContainer.querySelector(`.${this.classIdentifier}`); } private lazyInit(renderer:TimelineCellRenderer, renderInfo:RenderInfo):Promise { - const body = this.workPackageTimeline.timelineBody[0]; + const body = this.workPackageTimeline.timelineBody; const cell = this.cellElement; - if (!cell.length) { + if (!cell) { return Promise.reject('uninitialized'); } @@ -151,7 +154,7 @@ export class WorkPackageTimelineCell { this.halEvents, this.notificationService, this.loadingIndicator, - cell[0], + cell, this.wpElement, this.labels, renderer, diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts index e1f9132cc7f4..4efb83ff7447 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts @@ -96,7 +96,7 @@ import { IDay } from 'core-app/core/state/days/day.model'; standalone: false, }) export class WorkPackageTimelineTableController extends UntilDestroyedMixin implements AfterViewInit { - private $element:JQuery; + private element:HTMLElement; public workPackageTable:WorkPackageTable; @@ -110,9 +110,9 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl private cellsRenderer = new WorkPackageTimelineCellsRenderer(this.injector, this); - public outerContainer:JQuery; + public outerContainer:HTMLElement; - public timelineBody:JQuery; + public timelineBody:HTMLElement; private selectionParams:{ notification:IToast|null } = { notification: null, @@ -165,7 +165,7 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl } ngAfterViewInit() { - this.$element = jQuery(this.elementRef.nativeElement); + this.element = this.elementRef.nativeElement; const scrollBar = document.querySelector('.work-packages-tabletimeline--timeline-side'); if (scrollBar) { @@ -179,11 +179,11 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl }; // Get the outer container for width computation - this.outerContainer = this.$element.find('.wp-table-timeline--outer'); - this.timelineBody = this.$element.find('.wp-table-timeline--body'); + this.outerContainer = this.element.querySelector('.wp-table-timeline--outer')!; + this.timelineBody = this.element.querySelector('.wp-table-timeline--body')!; // Register this instance to the table - this.wpTableComponent.registerTimeline(this, this.timelineBody[0]); + this.wpTableComponent.registerTimeline(this, this.timelineBody); // Refresh on window resize events window.addEventListener('wp-resize.timeline', () => this.refreshRequest.putValue(undefined)); @@ -224,11 +224,12 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl } getAbsoluteLeftCoordinates():number { - return this.$element.offset()!.left; + const rect = this.element.getBoundingClientRect(); + return rect.left + window.pageXOffset; } getParentScrollContainer() { - return this.outerContainer.closest(selectorTimelineSide)[0]; + return this.outerContainer.closest(selectorTimelineSide)!; } get viewParameters():TimelineViewParameters { @@ -259,7 +260,7 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl timeOutput('refreshView() in timeline container', async () => { // Reset the width of the outer container if its content shrinks - this.outerContainer.css('width', 'auto'); + this.outerContainer.style.setProperty('width', 'auto'); this.calculateViewParams(this._viewParameters); @@ -278,8 +279,8 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl // Calculate overflowing width to set to outer container // required to match width in all child divs. // The header is the only one reliable, as it already has the final width. - const currentWidth = this.$element.find(timelineHeaderSelector)[0].scrollWidth; - this.outerContainer.width(currentWidth); + const currentWidth = this.element.querySelector(timelineHeaderSelector)!.scrollWidth; + this.outerContainer.style.setProperty('width', `${currentWidth}px`); // Mark rendering event in a timeout to give DOM some time setTimeout(() => { @@ -335,19 +336,19 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl } forceCursor(cursor:string) { - jQuery(`.${timelineElementCssClass}`).css('cursor', cursor); - jQuery('.wp-timeline-cell').css('cursor', cursor); - jQuery('.hascontextmenu').css('cursor', cursor); - jQuery('.leftHandle').css('cursor', cursor); - jQuery('.rightHandle').css('cursor', cursor); + document.querySelectorAll(`.${timelineElementCssClass}`).forEach((elem) => elem.style.cursor = cursor); + document.querySelectorAll('.wp-timeline-cell').forEach((elem) => elem.style.cursor = cursor); + document.querySelectorAll('.hascontextmenu').forEach((elem) => elem.style.cursor = cursor); + document.querySelectorAll('.leftHandle').forEach((elem) => elem.style.cursor = cursor); + document.querySelectorAll('.rightHandle').forEach((elem) => elem.style.cursor = cursor); } resetCursor() { - jQuery(`.${timelineElementCssClass}`).css('cursor', ''); - jQuery('.wp-timeline-cell').css('cursor', ''); - jQuery('.hascontextmenu').css('cursor', ''); - jQuery('.leftHandle').css('cursor', ''); - jQuery('.rightHandle').css('cursor', ''); + document.querySelectorAll(`.${timelineElementCssClass}`).forEach((elem) => elem.style.cursor = ''); + document.querySelectorAll('.wp-timeline-cell').forEach((elem) => elem.style.cursor = ''); + document.querySelectorAll('.hascontextmenu').forEach((elem) => elem.style.cursor = ''); + document.querySelectorAll('.leftHandle').forEach((elem) => elem.style.cursor = ''); + document.querySelectorAll('.rightHandle').forEach((elem) => elem.style.cursor = ''); } private resetSelectionMode() { @@ -360,8 +361,8 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl Mousetrap.unbind('esc'); - this.$element.removeClass('active-selection-mode'); - jQuery(`.${timelineMarkerSelectionStartClass}`).removeClass(timelineMarkerSelectionStartClass); + this.element.classList.remove('active-selection-mode'); + document.querySelector(`.${timelineMarkerSelectionStartClass}`)?.classList.remove(timelineMarkerSelectionStartClass); this.refreshView(); } @@ -377,7 +378,7 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl Mousetrap.bind('esc', () => this.resetSelectionMode()); this.selectionParams.notification = this.toastService.addNotice(this.text.selectionMode); - this.$element.addClass('active-selection-mode'); + this.element.classList.add('active-selection-mode'); this.refreshView(); } @@ -447,8 +448,7 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl // RR: kept both variants for documentation purpose. // A: calculate the minimal width based on the width of the timeline view // B: calculate the minimal width based on the window width - const width = this.$element.children().width()!; // A - // const width = jQuery('body').width(); // B + const width = this.element.children[0]?.clientWidth || document.body.clientWidth; // A with fallback to B const { pixelPerDay } = currentParams; const visibleDays = Math.ceil((width / pixelPerDay) * 1.5); @@ -484,7 +484,7 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl const workPackagesToCalculateWidthFrom = this.getWorkPackagesToCalculateTimelineWidthFrom(); const daysSpan = calculateDaySpan(workPackagesToCalculateWidthFrom, this.states.workPackages, this._viewParameters); - const timelineWidthInPx = this.$element.parent().width()! - (2 * requiredPixelMarginLeft); + const timelineWidthInPx = this.element.parentElement?.clientWidth! - (2 * requiredPixelMarginLeft); for (const zoomLevel of zoomLevelOrder) { const pixelPerDay = getPixelPerDayForZoomLevel(zoomLevel); diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-relations.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-relations.directive.ts index 45ec80484fcc..5441d6c24e82 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-relations.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-relations.directive.ts @@ -85,7 +85,7 @@ function newSegment(vp:TimelineViewParameters, export class WorkPackageTableTimelineRelations extends UntilDestroyedMixin implements OnInit { @InjectField() querySpace:IsolatedQuerySpace; - private container:JQuery; + private container:HTMLElement; private workPackagesWithRelations:{ [workPackageId:string]:RelationsStateValue } = {}; @@ -99,8 +99,8 @@ export class WorkPackageTableTimelineRelations extends UntilDestroyedMixin imple } ngOnInit() { - const $element = jQuery(this.elementRef.nativeElement); - this.container = $element.find('.wp-table-timeline--relations'); + const element = this.elementRef.nativeElement; + this.container = element.querySelector('.wp-table-timeline--relations'); this.workPackageTimelineTableController .onRefreshRequested('relations', (vp:TimelineViewParameters) => this.refreshView()); @@ -188,12 +188,12 @@ export class WorkPackageTableTimelineRelations extends UntilDestroyedMixin imple private removeRelationElementsForWorkPackage(workPackageId:string) { const className = workPackagePrefix(workPackageId); - const found = this.container.find(`.${className}`); - found.remove(); + const found = this.container.querySelectorAll(`.${className}`); + found.forEach((elem) => elem.remove()); } private removeAllVisibleElements() { - this.container.find(`.${timelineGlobalElementCssClassname}`).remove(); + this.container.querySelectorAll(`.${timelineGlobalElementCssClassname}`).forEach((elem) => elem.remove()); } private renderElements() { diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-static-elements.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-static-elements.directive.ts index 04fc2a3356b1..38db181e62ad 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-static-elements.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-static-elements.directive.ts @@ -45,7 +45,7 @@ import { TodayLineElement } from './wp-timeline.today-line'; standalone: false, }) export class WorkPackageTableTimelineStaticElements implements OnInit { - public $element:HTMLElement; + public element:HTMLElement; private container:HTMLElement; @@ -54,7 +54,7 @@ export class WorkPackageTableTimelineStaticElements implements OnInit { constructor(elementRef:ElementRef, public states:States, public workPackageTimelineTableController:WorkPackageTimelineTableController) { - this.$element = elementRef.nativeElement; + this.element = elementRef.nativeElement; this.elements = [ new TodayLineElement(), @@ -62,7 +62,7 @@ export class WorkPackageTableTimelineStaticElements implements OnInit { } ngOnInit() { - this.container = this.$element.querySelector('.wp-table-timeline--static-elements') as HTMLElement; + this.container = this.element.querySelector('.wp-table-timeline--static-elements') as HTMLElement; this.workPackageTimelineTableController .onRefreshRequested('static elements', (vp:TimelineViewParameters) => this.update(vp)); } diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/grid/wp-timeline-grid.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/grid/wp-timeline-grid.directive.ts index 4a9dd2dfdbba..4b982d1c73aa 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/grid/wp-timeline-grid.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/grid/wp-timeline-grid.directive.ts @@ -50,7 +50,7 @@ import { WeekdayService } from 'core-app/core/days/weekday.service'; export class WorkPackageTableTimelineGrid implements AfterViewInit { private activeZoomLevel:TimelineZoomLevel; - private gridContainer:JQuery; + private gridContainer:HTMLElement; constructor( private elementRef:ElementRef, @@ -59,8 +59,8 @@ export class WorkPackageTableTimelineGrid implements AfterViewInit { ) {} ngAfterViewInit():void { - const $element = jQuery(this.elementRef.nativeElement); - this.gridContainer = $element.find('.wp-table-timeline--grid'); + const element = this.elementRef.nativeElement; + this.gridContainer = element.querySelector('.wp-table-timeline--grid'); this.wpTimeline.onRefreshRequested('grid', (vp:TimelineViewParameters) => this.refreshView(vp)); } @@ -69,7 +69,7 @@ export class WorkPackageTableTimelineGrid implements AfterViewInit { } private renderLabels(vp:TimelineViewParameters):void { - this.gridContainer.empty(); + this.gridContainer.innerHTML = ''; switch (vp.settings.zoomLevel) { case 'days': @@ -170,7 +170,7 @@ export class WorkPackageTableTimelineGrid implements AfterViewInit { cell.classList.add(timelineElementCssClass, timelineGridElementCssClass); cell.style.left = calculatePositionValueForDayCount(vp, start.diff(startView, 'days')); cell.style.width = calculatePositionValueForDayCount(vp, end.diff(start, 'days') + 1); - this.gridContainer[0].appendChild(cell); + this.gridContainer.appendChild(cell); cellCallback(start, cell); } setTimeout(() => { @@ -179,7 +179,7 @@ export class WorkPackageTableTimelineGrid implements AfterViewInit { cell.classList.add(timelineElementCssClass, timelineGridElementCssClass); cell.style.left = calculatePositionValueForDayCount(vp, start.diff(startView, 'days')); cell.style.width = calculatePositionValueForDayCount(vp, end.diff(start, 'days') + 1); - this.gridContainer[0].appendChild(cell); + this.gridContainer.appendChild(cell); cellCallback(start, cell); } }, 0); diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/header/wp-timeline-header.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/header/wp-timeline-header.directive.ts index d05b36f73ea0..f7d40eb6c3f4 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/header/wp-timeline-header.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/header/wp-timeline-header.directive.ts @@ -46,17 +46,17 @@ import { standalone: false, }) export class WorkPackageTimelineHeaderController implements OnInit { - public $element:JQuery; + public element:HTMLElement; private activeZoomLevel:TimelineZoomLevel; - private innerHeader:JQuery; + private innerHeader:HTMLElement; constructor(elementRef:ElementRef, readonly I18n:I18nService, readonly wpTimelineService:WorkPackageViewTimelineService, readonly workPackageTimelineTableController:WorkPackageTimelineTableController) { - this.$element = jQuery(elementRef.nativeElement); + this.element = elementRef.nativeElement; } ngOnInit() { @@ -65,13 +65,13 @@ export class WorkPackageTimelineHeaderController implements OnInit { } refreshView(vp:TimelineViewParameters) { - this.innerHeader = this.$element.find('.wp-table-timeline--header-inner'); + this.innerHeader = this.element.querySelector('.wp-table-timeline--header-inner')!; this.renderLabels(vp); } private renderLabels(vp:TimelineViewParameters):void { - this.innerHeader.empty(); - this.innerHeader.attr('data-current-zoom-level', this.wpTimelineService.zoomLevel); + this.innerHeader.innerHTML = ''; + this.innerHeader.setAttribute('data-current-zoom-level', this.wpTimelineService.zoomLevel); switch (vp.settings.zoomLevel) { case 'days': diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts index 139f5bebaa31..eff7497b1a0e 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts @@ -32,14 +32,14 @@ export class WpTableHoverSync { private lastHoveredElement:Element | null = null; private eventListener = (evt:MouseEvent) => { - const target = evt.target as Element|null; + const target = evt.target as HTMLElement|null; if (target && target !== this.lastHoveredElement) { this.handleHover(target); } this.lastHoveredElement = target; }; - constructor(private tableAndTimeline:JQuery) { + constructor(private tableAndTimeline:HTMLElement) { } activate() { @@ -51,26 +51,17 @@ export class WpTableHoverSync { this.removeAllHoverClasses(); } - private locateHoveredTableRow(child:JQuery):Element | null { - const parent = child.closest('tr'); - if (parent.length === 0) { - return null; - } - return parent[0]; + private locateHoveredTableRow(child:HTMLElement):HTMLTableRowElement | null { + return child.closest('tr'); } - private locateHoveredTimelineRow(child:JQuery):Element | null { - const parent = child.closest('div.wp-timeline-cell'); - if (parent.length === 0) { - return null; - } - return parent[0]; + private locateHoveredTimelineRow(child:HTMLElement):HTMLElement | null { + return child.closest('div.wp-timeline-cell'); } - private handleHover(element:Element) { - const $element = jQuery(element) as JQuery; - const parentTableRow = this.locateHoveredTableRow($element); - const parentTimelineRow = this.locateHoveredTimelineRow($element); + private handleHover(element:HTMLElement) { + const parentTableRow = this.locateHoveredTableRow(element); + const parentTimelineRow = this.locateHoveredTimelineRow(element); // remove all hover classes if cursor does not hover a row if (parentTableRow === null && parentTimelineRow === null) { @@ -89,21 +80,21 @@ export class WpTableHoverSync { const hovered = parentTableRow !== null ? parentTableRow : parentTimelineRow; const wpId = this.extractWorkPackageId(hovered!); - const tableRow:JQuery = this.tableAndTimeline.find(`tr.wp-row-${wpId}`).first(); - const timelineRow:JQuery = this.tableAndTimeline.find(`div.wp-row-${wpId}`).length - ? this.tableAndTimeline.find(`div.wp-row-${wpId}`).first() - : this.tableAndTimeline.find(`div.wp-ancestor-row-${wpId}`).first(); + const tableRow = this.tableAndTimeline.querySelector(`tr.wp-row-${wpId}`); + const timelineRow = this.tableAndTimeline.querySelector(`div.wp-row-${wpId}`) + ? this.tableAndTimeline.querySelector(`div.wp-row-${wpId}`) + : this.tableAndTimeline.querySelector(`div.wp-ancestor-row-${wpId}`); requestAnimationFrame(() => { this.removeAllHoverClasses(); - timelineRow.addClass(cssClassRowHovered); - tableRow.addClass(cssClassRowHovered); + timelineRow?.classList.add(cssClassRowHovered); + tableRow?.classList.add(cssClassRowHovered); }); } private removeAllHoverClasses() { this.tableAndTimeline - .find(`.${cssClassRowHovered}`) - .removeClass(cssClassRowHovered); + .querySelectorAll(`.${cssClassRowHovered}`) + .forEach((elem) => elem.classList.remove(cssClassRowHovered)); } } diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table-scroll-sync.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table-scroll-sync.ts index 0a5540c3cbe6..672641714597 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table-scroll-sync.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table-scroll-sync.ts @@ -26,9 +26,11 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { target } from 'core-app/shared/helpers/event-helpers'; + export const selectorTableSide = '.work-packages-tabletimeline--table-side'; export const selectorTimelineSide = '.work-packages-tabletimeline--timeline-side'; -const jQueryScrollSyncEventNamespace = '.scroll-sync'; +const scrollSyncEventNamespace = '.scroll-sync'; const scrollStep = 15; function getXandYScrollDeltas(ev:WheelEvent):[number, number] { @@ -59,11 +61,9 @@ function getPlattformAgnosticScrollAmount(originalValue:number) { return delta; } -function syncWheelEvent(jev:JQuery.TriggeredEvent, elementTable:JQuery, elementTimeline:JQuery) { - const scrollTarget = jev.target; - const ev:WheelEvent = jev.originalEvent as any; +function syncWheelEvent(ev:WheelEvent, elementTable:HTMLElement, elementTimeline:HTMLElement) { + const scrollTarget = ev.target as HTMLElement; let [deltaX, deltaY] = getXandYScrollDeltas(ev); - if (deltaY === 0) { return; } @@ -72,8 +72,8 @@ function syncWheelEvent(jev:JQuery.TriggeredEvent, elementTable:JQuery, elementT deltaY = getPlattformAgnosticScrollAmount(deltaY); // apply in both divs window.requestAnimationFrame(() => { - elementTable[0].scrollTop = elementTable[0].scrollTop + deltaY; - elementTimeline[0].scrollTop = elementTable[0].scrollTop + deltaY; + elementTable.scrollTop = elementTable.scrollTop + deltaY; + elementTimeline.scrollTop = elementTable.scrollTop + deltaY; scrollTarget.scrollLeft += deltaX; }); @@ -82,11 +82,11 @@ function syncWheelEvent(jev:JQuery.TriggeredEvent, elementTable:JQuery, elementT /** * Activate or deactivate the scroll-sync between the table and timeline view. * - * @param $element true if the timeline is visible, false otherwise. + * @param element true if the timeline is visible, false otherwise. */ -export function createScrollSync($element:JQuery) { - const elTable = jQuery($element).find(selectorTableSide); - const elTimeline = jQuery($element).find(selectorTimelineSide); +export function createScrollSync(element:HTMLElement) { + const elTable = element.querySelector(selectorTableSide)!; + const elTimeline = element.querySelector(selectorTimelineSide)!; return (timelineVisible:boolean) => { // state vars @@ -95,13 +95,13 @@ export function createScrollSync($element:JQuery) { if (timelineVisible) { // setup event listener for table - elTable.on(`wheel${jQueryScrollSyncEventNamespace}`, (jev:JQuery.TriggeredEvent) => { - syncWheelEvent(jev, elTable, elTimeline); + target(elTable).on(`wheel${scrollSyncEventNamespace}`, (ev:WheelEvent) => { + syncWheelEvent(ev, elTable, elTimeline); }); - elTable.on(`scroll${jQueryScrollSyncEventNamespace}`, (ev:JQuery.TriggeredEvent) => { + target(elTable).on(`scroll${scrollSyncEventNamespace}`, (ev:Event) => { syncedLeft = true; if (!syncedRight) { - elTimeline[0].scrollTop = ev.target.scrollTop; + elTimeline.scrollTop = (ev.target as HTMLElement).scrollTop; } if (syncedLeft && syncedRight) { syncedLeft = false; @@ -110,13 +110,13 @@ export function createScrollSync($element:JQuery) { }); // setup event listener for timeline - elTimeline.on(`wheel${jQueryScrollSyncEventNamespace}`, (jev:JQuery.TriggeredEvent) => { - syncWheelEvent(jev, elTable, elTimeline); + target(elTimeline).on(`wheel${scrollSyncEventNamespace}`, (ev:WheelEvent) => { + syncWheelEvent(ev, elTable, elTimeline); }); - elTimeline.on(`scroll${jQueryScrollSyncEventNamespace}`, (ev:JQuery.TriggeredEvent) => { + target(elTimeline).on(`scroll${scrollSyncEventNamespace}`, (ev:Event) => { syncedRight = true; if (!syncedLeft) { - elTable[0].scrollTop = ev.target.scrollTop; + elTable.scrollTop = (ev.target as HTMLElement).scrollTop; } if (syncedLeft && syncedRight) { syncedLeft = false; @@ -124,7 +124,7 @@ export function createScrollSync($element:JQuery) { } }); } else { - elTable.off(jQueryScrollSyncEventNamespace); + target(elTable).off(scrollSyncEventNamespace); } }; } diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts index dbe8d000662c..b8ca1a976596 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table.component.ts @@ -95,7 +95,7 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O public configuration:WorkPackageTableConfiguration; - private $element:JQuery; + private element:HTMLElement; private scrollSyncUpdate:(timelineVisible:boolean) => any; @@ -105,7 +105,7 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O public workPackageTable:WorkPackageTable; - public tbody:JQuery; + public tbody:HTMLTableSectionElement; public query:QueryResource; @@ -157,7 +157,7 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O ngOnInit():void { this.configuration = new WorkPackageTableConfiguration(this.configurationObject); - this.$element = jQuery(this.elementRef.nativeElement); + this.element = this.elementRef.nativeElement; // Clear any old table subscribers this.querySpace.stopAllSubscriptions.next(); @@ -237,16 +237,16 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O } public registerTimeline(controller:WorkPackageTimelineTableController, timelineBody:HTMLElement) { - const tbody = this.$element.find('.work-package--results-tbody'); - const scrollContainer = this.$element.find('.work-package-table--container')[0]; + const tbody = this.element.querySelector('.work-package--results-tbody')!; + const scrollContainer = this.element.querySelector('.work-package-table--container')!; this.workPackageTable = new WorkPackageTable( this.injector, // Outer container for both table + Timeline - this.$element[0], + this.element, // Scroll container for the table/timeline scrollContainer, // Table tbody to insert into - tbody[0], + tbody, // Timeline body to insert into timelineBody, // Timeline controller @@ -266,11 +266,11 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O this.timeline = tableAndTimeline[1]; // sync hover from table to timeline - this.wpTableHoverSync = new WpTableHoverSync(this.$element); + this.wpTableHoverSync = new WpTableHoverSync(this.element); this.wpTableHoverSync.activate(); // sync scroll from table to timeline - this.scrollSyncUpdate = createScrollSync(this.$element); + this.scrollSyncUpdate = createScrollSync(this.element); this.scrollSyncUpdate(this.timelineVisible); this.cdRef.detectChanges(); @@ -281,13 +281,13 @@ export class WorkPackagesTableComponent extends UntilDestroyedMixin implements O } private getTableAndTimelineElement():[HTMLElement, HTMLElement] { - const $tableSide = this.$element.find('.work-packages-tabletimeline--table-side'); - const $timelineSide = this.$element.find('.work-packages-tabletimeline--timeline-side'); + const tableSide = this.element.querySelector('.work-packages-tabletimeline--table-side'); + const timelineSide = this.element.querySelector('.work-packages-tabletimeline--timeline-side'); - if ($timelineSide.length === 0 || $tableSide.length === 0) { + if (!tableSide || !timelineSide) { throw new Error('invalid state'); } - return [$tableSide[0], $timelineSide[0]]; + return [tableSide, timelineSide]; } } diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts index 292a8b348be7..636571d2d30b 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts @@ -1,17 +1,20 @@ import { EventEmitter, InjectionToken, Injector } from '@angular/core'; +import { delegate } from '@knowledgecode/delegate'; + +export type EventType = keyof HTMLElementEventMap; export interface WorkPackageViewEventHandler { /** Event name to register * */ - EVENT:string; + EVENT:EventType|EventType[]; /** Event context CSS selector */ SELECTOR:string; /** Event callback handler */ - handleEvent(view:T, evt:JQuery.TriggeredEvent):void; + handleEvent(view:T, evt:Event):void; /** Event scope method */ - eventScope(view:T):JQuery; + eventScope(view:T):HTMLElement; } export interface WorkPackageViewOutputs { @@ -40,9 +43,12 @@ export abstract class WorkPackageViewHandlerRegistry { this.eventHandlers.map((factory) => { const handler = factory(viewRef); const target = handler.eventScope(viewRef); + const types = Array.isArray(handler.EVENT) ? handler.EVENT : [handler.EVENT]; - target.on(handler.EVENT, handler.SELECTOR, (evt:JQuery.TriggeredEvent) => { - handler.handleEvent(viewRef, evt); + types.forEach((type) => { + delegate(target).on(type, handler.SELECTOR, (evt) => { + handler.handleEvent(viewRef, evt.originalEvent); + }); }); return handler; diff --git a/frontend/src/app/shared/components/autocompleter/create-autocompleter/create-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/create-autocompleter/create-autocompleter.component.ts index 9f230cf1ce39..d1945641373c 100644 --- a/frontend/src/app/shared/components/autocompleter/create-autocompleter/create-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/create-autocompleter/create-autocompleter.component.ts @@ -88,7 +88,7 @@ export class CreateAutocompleterComponent extends UntilDestroyedMixin implements @Output() public onChange = new EventEmitter(); - @Output() public onKeydown = new EventEmitter(); + @Output() public onKeydown = new EventEmitter(); @Output() public onOpen = new EventEmitter(); @@ -158,7 +158,7 @@ export class CreateAutocompleterComponent extends UntilDestroyedMixin implements this.onClose.emit(); } - public keyPressed(event:JQuery.TriggeredEvent) { + public keyPressed(event:KeyboardEvent) { this.onKeydown.emit(event); } diff --git a/frontend/src/app/shared/components/editable-toolbar-title/editable-toolbar-title.component.ts b/frontend/src/app/shared/components/editable-toolbar-title/editable-toolbar-title.component.ts index 91f2183a1d73..6a10ca2d57c7 100644 --- a/frontend/src/app/shared/components/editable-toolbar-title/editable-toolbar-title.component.ts +++ b/frontend/src/app/shared/components/editable-toolbar-title/editable-toolbar-title.component.ts @@ -75,7 +75,7 @@ export class EditableToolbarTitleComponent implements OnInit, OnChanges { return this.editable; } - @ViewChild('editableTitleInput') inputField?:ElementRef; + @ViewChild('editableTitleInput') inputField?:ElementRef; public selectedTitle:string; @@ -101,13 +101,13 @@ export class EditableToolbarTitleComponent implements OnInit, OnChanges { ngOnInit():void { this.text.input_title = `${this.text.click_to_edit} ${this.text.press_enter_to_save}`; - jQuery(this.elementRef.nativeElement).on(triggerEditingEvent, (evt:Event, val = '') => { + this.elementRef.nativeElement.addEventListener(triggerEditingEvent, (evt:CustomEvent) => { // In case we're not editable, ignore request if (!this.inputField) { return; } - this.selectedTitle = val; + this.selectedTitle = evt.detail ?? ''; setTimeout(() => { const field:HTMLInputElement = this.inputField!.nativeElement; field.focus(); @@ -123,7 +123,7 @@ export class EditableToolbarTitleComponent implements OnInit, OnChanges { } if (changes.initialFocus && changes.initialFocus.firstChange && this.inputField!) { - const field:HTMLInputElement = this.inputField.nativeElement; + const field = this.inputField.nativeElement; this.selectInputOnInitalFocus(field); } } @@ -175,7 +175,7 @@ export class EditableToolbarTitleComponent implements OnInit, OnChanges { // Blur this element if (this.inputField) { - (this.inputField.nativeElement as HTMLInputElement).blur(); + this.inputField.nativeElement.blur(); } // Avoid double saving @@ -227,6 +227,6 @@ export class EditableToolbarTitleComponent implements OnInit, OnChanges { } private toggleToolbarButtonVisibility(hidden:boolean):void { - jQuery('.toolbar-items').toggleClass('hidden-for-mobile', hidden); + document.querySelectorAll('.toolbar-items').forEach((toolbarItem) => toolbarItem.classList.toggle('hidden-for-mobile', hidden)); } } diff --git a/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts b/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts index 87b7c69b066e..705674a3ed1f 100644 --- a/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts +++ b/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts @@ -73,12 +73,18 @@ export class CKEditorSetupService { toolbarWrapper.appendChild(editor.ui.view.toolbar.element); // Allow custom events on wrapper to set/get data for debugging - jQuery(wrapper) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-return - .on('op:ckeditor:autosave', () => editor.config.get('autosave').save(editor)) - .on('op:ckeditor:setData', (_, data:string) => editor.setData(data)) - .on('op:ckeditor:clear', () => editor.setData(' ')) - .on('op:ckeditor:getData', (_, cb:(data:string) => void) => cb(editor.getData({ trim: false }))); + wrapper.addEventListener('op:ckeditor:autosave', () => { + editor.config.get('autosave').save(editor); + }); + wrapper.addEventListener('op:ckeditor:setData', (event:CustomEvent) => { + editor.setData(event.detail); + }); + wrapper.addEventListener('op:ckeditor:clear', () => { + editor.setData(' '); + }); + wrapper.addEventListener('op:ckeditor:getData', (event:CustomEvent<(data:string) => void>) => { + event.detail(editor.getData({ trim: false })); + }); return watchdog; }); diff --git a/frontend/src/app/shared/components/fields/display/display-field-renderer.ts b/frontend/src/app/shared/components/fields/display/display-field-renderer.ts index d3b4c8cb716f..abb9ef8d6844 100644 --- a/frontend/src/app/shared/components/fields/display/display-field-renderer.ts +++ b/frontend/src/app/shared/components/fields/display/display-field-renderer.ts @@ -159,8 +159,10 @@ export class DisplayFieldRenderer { if (field.isFormattable && !field.isEmpty()) { try { - titleContent = _.escape(jQuery(`
${labelContent}
`).text()); - } catch (e) { + const parser = new DOMParser(); + const doc = parser.parseFromString(labelContent, 'text/html'); + titleContent = _.escape(doc.body.textContent ?? ''); + } catch { console.error('Failed to parse formattable labelContent'); titleContent = `Label for ${field.displayName}`; } diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/edit-field.component.ts index 2293dc2cfd60..273afd450144 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-field.component.ts @@ -56,8 +56,7 @@ export abstract class EditFieldComponent extends Field implements OnInit, OnDest /** Self reference */ public self = this; - /** JQuery accessor to element ref */ - protected $element:JQuery; + protected element:HTMLElement; constructor( readonly I18n:I18nService, @@ -74,7 +73,7 @@ export abstract class EditFieldComponent extends Field implements OnInit, OnDest } ngOnInit():void { - this.$element = jQuery(this.elementRef.nativeElement as HTMLElement); + this.element = this.elementRef.nativeElement as HTMLElement; this.initialize(); if (this.change.state) { @@ -98,12 +97,9 @@ export abstract class EditFieldComponent extends Field implements OnInit, OnDest } public get overflowingSelector() { - if (this.$element) { - return this.$element - .closest(overflowingContainerSelector) - .data(overflowingContainerAttribute); - } - return null; + return this.element + ?.closest(overflowingContainerSelector) + ?.dataset[overflowingContainerAttribute]; } public get inFlight() { diff --git a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts index 321be3cada1b..6fc0d12939f9 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts @@ -204,10 +204,9 @@ export class EditFormComponent extends EditForm implements OnInit, protected focusOnFirstError():void { // Focus the first field that is erroneous - jQuery(this.elementRef.nativeElement) - .find(`.${activeFieldContainerClassName}.-error .${activeFieldClassName}`) - .first() - .trigger('focus'); + this.elementRef.nativeElement + .querySelector(`.${activeFieldContainerClassName}.-error .${activeFieldClassName}`) + ?.focus(); } private skipField(field:EditableAttributeFieldComponent) { diff --git a/frontend/src/app/shared/components/fields/edit/editing-portal/edit-field-handler.ts b/frontend/src/app/shared/components/fields/edit/editing-portal/edit-field-handler.ts index 2a78b1d51a4d..3a749e9088a7 100644 --- a/frontend/src/app/shared/components/fields/edit/editing-portal/edit-field-handler.ts +++ b/frontend/src/app/shared/components/fields/edit/editing-portal/edit-field-handler.ts @@ -103,7 +103,7 @@ export abstract class EditFieldHandler extends UntilDestroyedMixin { /** * Stop event propagation */ - public abstract stopPropagation(evt:JQuery.TriggeredEvent):boolean; + public abstract stopPropagation(evt:Event):boolean; /** * Focus on the active field. @@ -122,7 +122,7 @@ export abstract class EditFieldHandler extends UntilDestroyedMixin { * In an edit mode, we can't derive from a submit event whether the user pressed enter * (and on what field he did that). */ - public abstract handleUserKeydown(event:JQuery.TriggeredEvent, onlyCancel?:boolean):void; + public abstract handleUserKeydown(event:KeyboardEvent, onlyCancel?:boolean):void; /** * Cancel edit diff --git a/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts b/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts index eeb68f3a6183..e041baa53b54 100644 --- a/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts +++ b/frontend/src/app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler.ts @@ -76,7 +76,7 @@ export class HalResourceEditFieldHandler extends EditFieldHandler { /** * Stop this event from propagating out of the edit field context. */ - public stopPropagation(evt:JQuery.TriggeredEvent) { + public stopPropagation(evt:Event) { evt.stopPropagation(); return false; } @@ -135,7 +135,7 @@ export class HalResourceEditFieldHandler extends EditFieldHandler { * In an edit mode, we can't derive from a submit event whether the user pressed enter * (and on what field he did that). */ - public async handleUserKeydown(event:JQuery.TriggeredEvent, onlyCancel = false) { + public async handleUserKeydown(event:KeyboardEvent, onlyCancel = false) { // Only handle submission in edit mode if (this.inEditMode && !onlyCancel) { if (event.key === 'Enter') { diff --git a/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts index f348d1c7da10..de6c024ca713 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts @@ -142,9 +142,9 @@ export class MultiSelectEditFieldComponent extends EditFieldComponent implements } public onOpen() { - jQuery(this.hiddenOverflowContainer).one('scroll', () => { + document.querySelector(this.hiddenOverflowContainer)?.addEventListener('scroll', () => { this.ngSelectComponent.close(); - }); + }, { once: true }); } public onClose() { diff --git a/frontend/src/app/shared/components/fields/edit/field-types/project-status-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/project-status-edit-field.component.ts index e45602807c7f..8cd45f227128 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/project-status-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/project-status-edit-field.component.ts @@ -37,6 +37,7 @@ import { import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { repositionDropdownBugfix } from 'core-app/shared/components/autocompleter/op-autocompleter/autocompleter.helper'; +import { target } from 'core-app/shared/helpers/event-helpers'; interface ProjectStatusOption { href:string @@ -96,12 +97,12 @@ export class ProjectStatusEditFieldComponent extends EditFieldComponent implemen public onOpen() { repositionDropdownBugfix(this.ngSelectComponent); - jQuery(this.hiddenOverflowContainer).one('scroll.autocompleteContainer', () => { + target(document.querySelector(this.hiddenOverflowContainer)!).one('scroll.autocompleteContainer', () => { this.ngSelectComponent.close(); }); } public onClose() { - jQuery(this.hiddenOverflowContainer).off('scroll.autocompleteContainer'); + target(document.querySelector(this.hiddenOverflowContainer)!).off('scroll.autocompleteContainer'); } } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts index c8ec37c91ee5..443b0ece73b4 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts @@ -79,7 +79,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn onCreate: (newElement:HalResource) => this.onCreate(newElement), onChange: (value:HalResource) => this.onChange(value), onAddNew: (value:HalResource) => this.onNewValueAdded(value), - onKeydown: (event:JQuery.TriggeredEvent) => this.handler.handleUserKeydown(event, true), + onKeydown: (event:KeyboardEvent) => this.handler.handleUserKeydown(event, true), onOpen: () => this.onOpen(), onClose: () => this.onClose(), onAfterViewInit: (component:CreateAutocompleterComponent) => this._autocompleterComponent = component, @@ -230,9 +230,9 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn } public onOpen() { - jQuery(this.hiddenOverflowContainer).one('scroll', () => { + document.querySelector(this.hiddenOverflowContainer)!.addEventListener('scroll', () => { this._autocompleterComponent.closeSelect(); - }); + }, { once: true }); } public onClose() { diff --git a/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts b/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts index c69afa4ea97f..608276ab79e5 100644 --- a/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts @@ -86,7 +86,7 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme public active = false; - private $element:JQuery; + private element:HTMLElement; public destroyed = false; @@ -114,7 +114,7 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme public ngOnInit():void { this.fieldRenderer = new DisplayFieldRenderer(this.injector, 'single-view', this.displayFieldOptions); - this.$element = jQuery(this.elementRef.nativeElement); + this.element = this.elementRef.nativeElement; // Register on the form if we're in an editable context this.editForm?.register(this); @@ -157,7 +157,7 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme this.setActive(false); if (focus) { - setTimeout(() => this.$element.find(`.${displayClassName}`).focus(), 20); + setTimeout(() => this.element.querySelector(`.${displayClassName}`)?.focus(), 20); } } @@ -174,8 +174,9 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme } // Skip activation if the user clicked on a link or within a macro - const target = jQuery(event.target as HTMLElement); - if (target.closest(`a:not(.${displayTriggerLink}),macro`, this.displayContainer.nativeElement).length > 0) { + const target = event.target as HTMLElement; + const foundElement = target.closest(`a:not(.${displayTriggerLink}),macro`); + if (foundElement && this.displayContainer.nativeElement.contains(foundElement)) { return true; } @@ -211,7 +212,7 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme // This can be both a direct click as well as a "click" via keyboard, e.g. the key. if (evt?.type === 'click') { // Get the position where the user clicked. - positionOffset = getPosition(evt); + positionOffset = getPosition(evt as MouseEvent); } void this.activateOnForm() @@ -241,3 +242,4 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme return this.schemaCache.of(this.resource); } } + diff --git a/frontend/src/app/shared/components/grids/grid/area.service.ts b/frontend/src/app/shared/components/grids/grid/area.service.ts index b29d79a95ee5..4875ebeb5d23 100644 --- a/frontend/src/app/shared/components/grids/grid/area.service.ts +++ b/frontend/src/app/shared/components/grids/grid/area.service.ts @@ -150,10 +150,10 @@ export class GridAreaService { // But as scrollIntoView will always readjust the viewport, the result would be an unbearable flicker // which causes e.g. dragging to be impossible. public scrollPlaceholderIntoView() { - const placeholder = jQuery('.grid--area.-placeholder'); + const placeholder = document.querySelector('.grid--area.-placeholder'); - if ((placeholder[0] as any).scrollIntoViewIfNeeded) { - setTimeout(() => (placeholder[0] as any).scrollIntoViewIfNeeded()); + if ((placeholder as any).scrollIntoViewIfNeeded) { + setTimeout(() => (placeholder as any).scrollIntoViewIfNeeded()); } } diff --git a/frontend/src/app/shared/components/grids/widgets/add/add.modal.ts b/frontend/src/app/shared/components/grids/widgets/add/add.modal.ts index f535f0c3eb66..dd6a474db128 100644 --- a/frontend/src/app/shared/components/grids/widgets/add/add.modal.ts +++ b/frontend/src/app/shared/components/grids/widgets/add/add.modal.ts @@ -54,9 +54,9 @@ export class AddGridWidgetModalComponent extends OpModalComponent implements OnI return this.eligibleWidgets.sort((a, b) => a.title.localeCompare(b.title)); } - public select($event:MouseEvent, widget:WidgetRegistration) { + public select(event:MouseEvent, widget:WidgetRegistration) { this.chosenWidget = widget; - this.closeMe($event); + this.closeMe(event); } public trackWidgetBy(_index:number, widget:WidgetRegistration) { diff --git a/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts b/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts index 430294297cd1..dda9fb185440 100644 --- a/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts @@ -38,7 +38,7 @@ export class WidgetCustomTextComponent extends AbstractWidgetComponent implement attachments: this.I18n.t('js.label_attachments'), }; - @ViewChild('displayContainer') readonly displayContainer:ElementRef; + @ViewChild('displayContainer') readonly displayContainer:ElementRef; constructor( protected I18n:I18nService, diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts index 1117f73362df..e8af8ce0abe5 100644 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts +++ b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts @@ -19,6 +19,7 @@ import { IProjectData } from 'core-app/shared/components/searchable-project-list import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; @Component({ selector: '[op-header-project-select-list]', @@ -107,13 +108,13 @@ export class OpHeaderProjectSelectListComponent implements OnInit, OnChanges { } extendedUrl(projectId:string|null):string { - const currentMenuItem = document.querySelector('meta[name="current_menu_item"]')!; + const currentMenuItem = getMetaContent('current_menu_item'); const url = projectId === null ? window.appBasePath : this.pathHelper.projectPath(projectId); if (!currentMenuItem) { return url; } - return `${url}?jump=${encodeURIComponent(currentMenuItem.content)}`; + return `${url}?jump=${encodeURIComponent(currentMenuItem)}`; } } diff --git a/frontend/src/app/shared/components/modal/modal-wrapper-augment.service.ts b/frontend/src/app/shared/components/modal/modal-wrapper-augment.service.ts index dd3d99145970..0745a70867ba 100644 --- a/frontend/src/app/shared/components/modal/modal-wrapper-augment.service.ts +++ b/frontend/src/app/shared/components/modal/modal-wrapper-augment.service.ts @@ -50,37 +50,36 @@ export class OpModalWrapperAugmentService { public setupListener() { const matches = this.documentElement.querySelectorAll('[data-augmented-model-wrapper]'); for (let i = 0; i < matches.length; ++i) { - this.wrapElement(jQuery(matches[i]) as JQuery); + this.wrapElement(matches[i] as HTMLElement); } } /** * Wrap a section[data-augmented-modal-wrapper] element */ - public wrapElement(element:JQuery) { + public wrapElement(element:HTMLElement) { // Find activation link - const activationSelector = element.data('activationSelector') || '.modal-delivery-element--activation-link'; - const activationLink = jQuery(activationSelector); - - const initializeNow = element.data('modalInitializeNow'); + const activationSelector = element.dataset.activationSelector || '.modal-delivery-element--activation-link'; + const activationLink = document.querySelector(activationSelector); + const initializeNow = element.dataset.modalInitializeNow; if (initializeNow) { this.show(element); } else { - activationLink.click((evt:JQuery.TriggeredEvent) => { + activationLink?.addEventListener('click', (evt) => { this.show(element); evt.preventDefault(); }); } } - private show(element:JQuery) { + private show(element:HTMLElement) { // Set modal class name - const modalClassName = element.data('modalClassName'); + const modalClassName = element.dataset.modalClassName; // Set template from wrapped element - const wrappedElement = element.find('.modal-delivery-element'); - let modalBody = wrappedElement.html(); + const wrappedElement = element.querySelector('.modal-delivery-element')!; + let modalBody = wrappedElement.innerHTML; this.opModalService.show( DynamicContentModalComponent, diff --git a/frontend/src/app/shared/components/modal/modal.component.ts b/frontend/src/app/shared/components/modal/modal.component.ts index 62d5ab175fdf..09ea32300b07 100644 --- a/frontend/src/app/shared/components/modal/modal.component.ts +++ b/frontend/src/app/shared/components/modal/modal.component.ts @@ -38,7 +38,7 @@ export abstract class OpModalComponent extends UntilDestroyedMixin implements On /* Reference to service */ protected service:OpModalService = this.locals.service; - public $element:HTMLElement; + public element:HTMLElement; /** Closing event called from the service when closing this modal */ public closingEvent = new EventEmitter(); @@ -60,7 +60,7 @@ export abstract class OpModalComponent extends UntilDestroyedMixin implements On } ngOnInit():void { - this.$element = this.elementRef.nativeElement as HTMLElement; + this.element = this.elementRef.nativeElement as HTMLElement; } ngOnDestroy():void { @@ -100,7 +100,7 @@ export abstract class OpModalComponent extends UntilDestroyedMixin implements On } protected get afterFocusOn():HTMLElement { - return this.$element; + return this.element; } private onResize = debounce(() => this.updateAppHeight(), 10); diff --git a/frontend/src/app/shared/components/modals/modal-wrapper/dynamic-content.modal.ts b/frontend/src/app/shared/components/modals/modal-wrapper/dynamic-content.modal.ts index d6646321ec11..1e65ee532de8 100644 --- a/frontend/src/app/shared/components/modals/modal-wrapper/dynamic-content.modal.ts +++ b/frontend/src/app/shared/components/modals/modal-wrapper/dynamic-content.modal.ts @@ -59,7 +59,7 @@ export class DynamicContentModalComponent extends OpModalComponent implements On super.ngOnInit(); // Append the dynamic body - const wrapper = this.$element.children[0]; + const wrapper = this.element.children[0]; const classes = (this.locals.modalClassName as string) || ''; wrapper.classList.add(...classes.split(' ')); wrapper.innerHTML = this.locals.modalBody as string; diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/op-columns-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/op-columns-context-menu.directive.ts index 2201f15d7cc0..36746d38b6de 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/op-columns-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/op-columns-context-menu.directive.ts @@ -43,6 +43,7 @@ import { QueryColumn } from 'core-app/features/work-packages/components/wp-query import { WpTableConfigurationModalComponent } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal'; import { QUERY_SORT_BY_ASC, QUERY_SORT_BY_DESC } from 'core-app/features/hal/resources/query-sort-by-resource'; import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service'; +import { computePosition, ComputePositionReturn, flip, shift } from '@floating-ui/dom'; @Directive({ selector: '[opColumnsContextMenu]', @@ -73,7 +74,7 @@ export class OpColumnsContextMenu extends OpContextMenuTrigger { super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { if (!this.table.configuration.columnMenuEnabled) { return; } @@ -89,24 +90,19 @@ export class OpColumnsContextMenu extends OpContextMenuTrigger { }; } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(evt:JQuery.TriggeredEvent) { - const additionalPositionArgs = { - of: this.$element.find('.generic-table--sort-header-outer'), - }; - - const position = super.positionArgs(evt); - _.assign(position, additionalPositionArgs); - - return position; + public computePosition(floating:HTMLElement, openerEvent:Event):Promise { + const reference = this.element.querySelector('.generic-table--sort-header-outer')!; + return computePosition(reference, floating, { + placement: this.placement, + middleware: [ + flip(), + shift({ padding: 10 }), + ], + }); } - protected get afterFocusOn():JQuery { - return this.$element.find(`#${this.column.id}`); + protected get afterFocusOn() { + return this.element.querySelector(`#${this.column.id}`)!; } private buildItems() { @@ -194,7 +190,7 @@ export class OpColumnsContextMenu extends OpContextMenuTrigger { setTimeout(() => { if (focusColumn) { - jQuery(`#${focusColumn.id}`).focus(); + document.querySelector(`#${focusColumn.id}`)?.focus(); } }); return true; diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive.ts index 8baa43b53fd5..37a1abf8d20c 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive.ts @@ -3,13 +3,14 @@ import { OPContextMenuService } from 'core-app/shared/components/op-context-menu import { OpContextMenuHandler } from 'core-app/shared/components/op-context-menu/op-context-menu-handler'; import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; import Mousetrap from 'mousetrap'; +import { computePosition, ComputePositionReturn, flip, shift } from '@floating-ui/dom'; @Directive({ selector: '[opContextMenuTrigger]', standalone: false, }) export class OpContextMenuTrigger extends OpContextMenuHandler implements AfterViewInit { - protected $element:JQuery; + protected element:HTMLElement; protected items:OpContextMenuItem[] = []; @@ -21,10 +22,10 @@ export class OpContextMenuTrigger extends OpContextMenuHandler implements AfterV } ngAfterViewInit():void { - this.$element = jQuery(this.elementRef.nativeElement); + this.element = this.elementRef.nativeElement; // Open by clicking the element - this.$element.on('click', (evt:JQuery.TriggeredEvent) => { + this.element.addEventListener('click', (evt) => { evt.preventDefault(); // When clicking the same trigger twice, close the element instead. @@ -36,22 +37,18 @@ export class OpContextMenuTrigger extends OpContextMenuHandler implements AfterV }); // Open with keyboard combination as well - Mousetrap(this.$element[0]).bind('shift+alt+f10', (evt:any) => { + Mousetrap(this.element).bind('shift+alt+f10', (evt:any) => { this.open(evt); }); } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(openerEvent:JQuery.TriggeredEvent) { - return { - my: 'left top', - at: 'left bottom', - of: this.$element, - collision: 'flipfit', - }; + public computePosition(floating:HTMLElement, openerEvent:Event):Promise { + return computePosition(this.element, floating, { + placement: this.placement, + middleware: [ + flip(), + shift({ padding: 10 }), + ], + }); } } diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts index 7ecd36cfad17..fe1939ea3a5b 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts @@ -77,6 +77,8 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { private loadingPromise:PromiseLike; + override readonly placement = 'bottom-end'; + constructor( readonly elementRef:ElementRef, readonly opContextMenu:OPContextMenuService, @@ -117,7 +119,7 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { }); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.loadingPromise.then(() => { this.buildItems(); this.opContextMenu.show(this, evt); @@ -131,38 +133,21 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { }; } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(evt:JQuery.TriggeredEvent) { - const additionalPositionArgs = { - my: 'right top', - at: 'right bottom', - }; - - const position = super.positionArgs(evt); - _.assign(position, additionalPositionArgs); - - return position; - } - public onClose(focus:boolean) { if (focus) { this.afterFocusOn.focus(); } } - private allowQueryAction(event:JQuery.TriggeredEvent, action:any) { + private allowQueryAction(event:Event, action:any) { return this.allowAction(event, 'query', action); } - private allowWorkPackageAction(event:JQuery.TriggeredEvent, action:any) { + private allowWorkPackageAction(event:Event, action:any) { return this.allowAction(event, 'work_packages', action); } - private allowFormAction(event:JQuery.TriggeredEvent, action:string) { + private allowFormAction(event:Event, action:string) { if (this.form.$links[action]) { return true; } @@ -170,7 +155,7 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { return false; } - private allowAction(event:JQuery.TriggeredEvent, modelName:string, action:any) { + private allowAction(event:Event, modelName:string, action:any) { if (this.authorisationService.can(modelName, action)) { return true; } @@ -213,7 +198,7 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { linkText: this.I18n.t('js.toolbar.settings.configure_view'), hidden: this.hideTableOptions, icon: 'icon-settings', - onClick: ($event:JQuery.TriggeredEvent) => { + onClick: (event:MouseEvent) => { this.opContextMenu.close(); this.opModalService.show(WpTableConfigurationModalComponent, this.injector); @@ -269,9 +254,9 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { disabled: !this.query.id || this.authorisationService.cannot('query', 'updateImmediately'), linkText: this.I18n.t('js.toolbar.settings.page_settings'), icon: 'icon-edit', - onClick: ($event:JQuery.TriggeredEvent) => { - if (this.allowQueryAction($event, 'update')) { - jQuery(`${selectableTitleIdentifier}`).trigger(triggerEditingEvent); + onClick: (event:MouseEvent) => { + if (this.allowQueryAction(event, 'update')) { + document.querySelector(`${selectableTitleIdentifier}`)?.dispatchEvent(new CustomEvent(triggerEditingEvent, { bubbles: true })); } return true; @@ -282,11 +267,11 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { disabled: this.authorisationService.cannot('query', 'updateImmediately'), linkText: this.I18n.t('js.toolbar.settings.save'), icon: 'icon-save', - onClick: ($event:JQuery.TriggeredEvent) => { + onClick: (event) => { const { query } = this; - if (!isPersistedResource(query) && this.allowQueryAction($event, 'updateImmediately')) { + if (!isPersistedResource(query) && this.allowQueryAction(event, 'updateImmediately')) { this.opModalService.show(SaveQueryModalComponent, this.injector); - } else if (query.id && this.allowQueryAction($event, 'updateImmediately')) { + } else if (query.id && this.allowQueryAction(event, 'updateImmediately')) { this.wpListService.save(query); } @@ -298,8 +283,8 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { disabled: this.form ? !this.form.$links.create_new : this.authorisationService.cannot('query', 'updateImmediately'), linkText: this.I18n.t('js.toolbar.settings.save_as'), icon: 'icon-save', - onClick: ($event:JQuery.TriggeredEvent) => { - if (this.allowFormAction($event, 'create_new')) { + onClick: (event) => { + if (this.allowFormAction(event, 'create_new')) { this.opModalService.show(SaveQueryModalComponent, this.injector); } @@ -311,8 +296,8 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { disabled: this.authorisationService.cannot('query', 'delete'), linkText: this.I18n.t('js.toolbar.settings.delete'), icon: 'icon-delete', - onClick: ($event:JQuery.TriggeredEvent) => { - if (this.allowQueryAction($event, 'delete') + onClick: (event) => { + if (this.allowQueryAction(event, 'delete') && window.confirm(this.I18n.t('js.text_query_destroy_confirmation'))) { this.wpListService.delete(); } @@ -340,8 +325,8 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { linkText: this.I18n.t('js.toolbar.settings.export'), hidden: this.hideTableOptions, icon: 'icon-export', - onClick: ($event:JQuery.TriggeredEvent) => { - if (this.allowWorkPackageAction($event, 'representations')) { + onClick: (event) => { + if (this.allowWorkPackageAction(event, 'representations')) { const query = this.querySpace.query.value; if (query) { const href = this.buildExportDialogHref(query); @@ -356,8 +341,8 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger { disabled: this.authorisationService.cannot('query', 'unstar') && this.authorisationService.cannot('query', 'star'), linkText: this.I18n.t('js.toolbar.settings.visibility_settings'), icon: 'icon-watched', - onClick: ($event:JQuery.TriggeredEvent) => { - if (this.allowQueryAction($event, 'unstar') || this.allowQueryAction($event, 'star')) { + onClick: (event) => { + if (this.allowQueryAction(event, 'unstar') || this.allowQueryAction(event, 'star')) { this.opModalService.show(QuerySharingModalComponent, this.injector); } diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts index 41816c51bad6..fd1f33e54c74 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts @@ -77,7 +77,7 @@ export class OpTypesContextMenuDirective extends OpContextMenuTrigger { } } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.isOpen = !this.isOpen; if (this.isOpen) { void this @@ -112,10 +112,10 @@ export class OpTypesContextMenuDirective extends OpContextMenuTrigger { href: this.$state.href(this.stateName, { type: type.id! }), ariaLabel: type.name, class: Highlighting.inlineClass('type', type.id!), - onClick: ($event:JQuery.TriggeredEvent) => { + onClick: (event:MouseEvent) => { if (this.routedFromAngular) { this.isOpen = false; - if (isClickedWithModifier($event)) { + if (isClickedWithModifier(event)) { return false; } diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts index 72da3016a743..cded83e7fa9e 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts @@ -38,6 +38,8 @@ import { FormResource } from 'core-app/features/hal/resources/form-resource'; standalone: false, }) export class WorkPackageCreateSettingsMenuDirective extends OpContextMenuTrigger { + override readonly placement = 'bottom-end'; + constructor(readonly elementRef:ElementRef, readonly opContextMenu:OPContextMenuService, readonly states:States, @@ -45,7 +47,7 @@ export class WorkPackageCreateSettingsMenuDirective extends OpContextMenuTrigger super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { const wp = this.states.workPackages.get('new').value; if (wp) { @@ -59,23 +61,6 @@ export class WorkPackageCreateSettingsMenuDirective extends OpContextMenuTrigger } } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(evt:JQuery.TriggeredEvent) { - const additionalPositionArgs = { - my: 'right top', - at: 'right bottom', - }; - - const position = super.positionArgs(evt); - _.assign(position, additionalPositionArgs); - - return position; - } - private buildItems(form:FormResource) { this.items = []; const configureFormLink = form.configureForm; diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-group-toggle-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-group-toggle-dropdown-menu.directive.ts index 879feabe5811..56087eca2ebe 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-group-toggle-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-group-toggle-dropdown-menu.directive.ts @@ -44,7 +44,7 @@ export class WorkPackageGroupToggleDropdownMenuDirective extends OpContextMenuTr super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.buildItems(); this.opContextMenu.show(this, evt); } @@ -62,7 +62,7 @@ export class WorkPackageGroupToggleDropdownMenuDirective extends OpContextMenuTr disabled: this.wpViewCollapsedGroups.allGroupsAreCollapsed, linkText: this.I18n.t('js.button_collapse_all'), icon: 'icon-minus2', - onClick: (evt:JQuery.TriggeredEvent) => { + onClick: (evt) => { this.wpViewCollapsedGroups.setAllGroupsCollapseStateTo(true); return true; @@ -72,7 +72,7 @@ export class WorkPackageGroupToggleDropdownMenuDirective extends OpContextMenuTr disabled: this.wpViewCollapsedGroups.allGroupsAreExpanded, linkText: this.I18n.t('js.button_expand_all'), icon: 'icon-plus', - onClick: (evt:JQuery.TriggeredEvent) => { + onClick: (evt) => { this.wpViewCollapsedGroups.setAllGroupsCollapseStateTo(false); return true; diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts index 93f8851d1e36..630b94b7e7c6 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts @@ -69,7 +69,7 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger { super(elementRef, opContextMenu); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { const change = this.halEditing.changeFor(this.workPackage); change.getForm().then((form:any) => { diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-view-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-view-dropdown-menu.directive.ts index bd7941568af9..83ab62e6d0a0 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-view-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-view-dropdown-menu.directive.ts @@ -54,7 +54,7 @@ export class WorkPackageViewDropdownMenuDirective extends OpContextMenuTrigger { public isOpen = false; - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.isOpen = !this.isOpen; if (this.isOpen) { this.buildItems(); diff --git a/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts b/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts index e3ee84b48842..dd5a4cf30a45 100644 --- a/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts +++ b/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts @@ -42,6 +42,8 @@ import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op standalone: false, }) export class IconTriggeredContextMenuComponent extends OpContextMenuTrigger { + override readonly placement = 'bottom-end'; + constructor( readonly elementRef:ElementRef, readonly opContextMenu:OPContextMenuService, @@ -55,28 +57,11 @@ export class IconTriggeredContextMenuComponent extends OpContextMenuTrigger { @Input() menuItemsFactory:() => Promise; - protected async open(evt:JQuery.TriggeredEvent) { + protected async open(evt:Event) { this.items = await this.buildItems(); this.opContextMenu.show(this, evt); } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(evt:JQuery.TriggeredEvent) { - const additionalPositionArgs = { - my: 'right top', - at: 'right bottom', - }; - - const position = super.positionArgs(evt); - _.assign(position, additionalPositionArgs); - - return position; - } - private async buildItems() { const items:OpContextMenuItem[] = []; diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts b/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts index 4e5dcc151399..d02ac296ccbb 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu-handler.ts @@ -1,3 +1,4 @@ +import { computePosition, ComputePositionReturn, flip, Placement, shift } from '@floating-ui/dom'; import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; @@ -7,10 +8,12 @@ import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destr * This will often be a trigger component, but does not have to be. */ export abstract class OpContextMenuHandler extends UntilDestroyedMixin { - protected $element:JQuery; + protected element:HTMLElement; protected items:OpContextMenuItem[] = []; + protected placement:Placement = 'bottom-start'; + constructor(readonly opContextMenu:OPContextMenuService) { super(); } @@ -22,26 +25,28 @@ export abstract class OpContextMenuHandler extends UntilDestroyedMixin { */ public onClose(focus = false) { if (focus) { - this.afterFocusOn.trigger('focus'); + this.afterFocusOn.focus(); } } - public onOpen(menu:JQuery) { - menu.find('.menu-item').first().trigger('focus'); + public onOpen(menu:HTMLElement) { + menu.querySelector('.menu-item')?.focus(); } /** - * Positioning args for jquery-ui position. + * Compute position for Floating UI. * * @param {Event} openerEvent */ - public positionArgs(openerEvent:JQuery.TriggeredEvent|Event):JQueryUI.JQueryPositionOptions { - return { - my: 'left top', - at: 'right bottom', - of: openerEvent, - collision: 'flipfit', - }; + public computePosition(floating:HTMLElement, openerEvent:Event):Promise { + const reference = openerEvent.target as HTMLElement; + return computePosition(reference, floating, { + placement: this.placement, + middleware: [ + flip(), + shift({ padding: 10 }), + ], + }); } /** @@ -56,11 +61,11 @@ export abstract class OpContextMenuHandler extends UntilDestroyedMixin { /** * Open this context menu */ - protected open(evt:JQuery.TriggeredEvent):void { + protected open(evt:Event):void { this.opContextMenu.show(this, evt); } - protected get afterFocusOn():JQuery { - return this.$element; + protected get afterFocusOn():HTMLElement { + return this.element; } } diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts b/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts index 734d9b887db9..0a4e6dd61620 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts @@ -21,6 +21,7 @@ export class OPContextMenuService { // Allow temporarily disabling the close handler private isOpening = false; + private openSeq = 0; constructor( readonly FocusHelper:FocusHelperService, @@ -48,7 +49,7 @@ export class OPContextMenuService { this.$transitions.onStart({}, () => this.close()); // Listen to keyups on window to close context menus - jQuery(window).on('keydown', (evt:JQuery.TriggeredEvent) => { + window.addEventListener('keydown', (evt) => { if (this.active && evt.key === 'Escape') { this.close(true); } @@ -81,21 +82,48 @@ export class OPContextMenuService { * @param component The context menu component to mount * */ - public show(menu:OpContextMenuHandler, event:JQuery.TriggeredEvent|Event, component:ComponentType = OPContextMenuComponent):void { + public show(menu:OpContextMenuHandler, event:Event, component:ComponentType = OPContextMenuComponent):void { this.close(); - - // Create a portal for the given component class and render it this.isOpening = true; + const seq = this.openSeq += 1; + + // Create and attach portal const portal = new ComponentPortal(component, null, this.injectorFor(menu.locals)); this.bodyPortalHost.attach(portal); - this.portalHostElement.style.display = 'block'; + + // Avoid flicker until positioned + const hostEl = this.portalHostElement; + hostEl.style.visibility = 'hidden'; + hostEl.style.display = 'block'; this.active = menu; - setTimeout(() => { - this.reposition(event); - // Focus on the first element - this.active?.onOpen(this.activeMenu); - this.isOpening = false; + // Wait one frame to ensure component DOM exists, then position + requestAnimationFrame(() => { + if (!this.active || this.openSeq !== seq) { + this.isOpening = false; + return; + } + + void this.reposition(event) + .then(() => { + if (this.active && this.openSeq === seq) { + hostEl.style.visibility = 'visible'; + requestAnimationFrame(() => { + // Defer onOpen to next frame to ensure styles are applied + if (this.active && this.openSeq === seq) { + this.active.onOpen(this.activeMenu); + } + }); + } + }) + .catch((err) => { + // Fail-safe: close if positioning fails + console.error('Context menu positioning failed:', err); + if (this.openSeq === seq) this.close(); + }) + .finally(() => { + if (this.openSeq === seq) this.isOpening = false; + }); }); } @@ -118,18 +146,24 @@ export class OPContextMenuService { this.active = null; } - public reposition(event:JQuery.TriggeredEvent|Event):void { + public reposition(event:Event):Promise { if (!this.active) { - return; + return Promise.resolve(); } - this.activeMenu - .position(this.active.positionArgs(event)) - .css('visibility', 'visible'); + return this.active.computePosition(this.activeMenu, event) + .then(({ x, y }) => { + Object.assign(this.activeMenu.style, { + left: `${x}px`, + top: `${y}px`, + position: 'absolute', + visibility: 'visible' + }); + }); } - public get activeMenu():JQuery { - return jQuery(this.portalHostElement).find('.dropdown'); + public get activeMenu():HTMLElement { + return this.portalHostElement.querySelector('.dropdown')!; } /** diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts b/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts index 31b2027e01ab..077e413e5d4a 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts @@ -13,7 +13,7 @@ export interface OpContextMenuItem { title?:string; divider?:boolean; isHeader?:boolean; - onClick?:($event:JQuery.TriggeredEvent|MouseEvent) => boolean; + onClick?:(event:MouseEvent) => boolean; } export interface OpContextMenuLocalsMap { diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts index b529ee7e6945..56cf2751564f 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts @@ -51,6 +51,8 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger private closeDialogHandler:EventListener = this.handleTimeEntryDialogClose.bind(this); + override readonly placement = 'bottom-end'; + ngAfterViewInit():void { super.ngAfterViewInit(); document.addEventListener('dialog:close', this.closeDialogHandler); @@ -64,7 +66,7 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger document.removeEventListener('dialog:close', this.closeDialogHandler); } - protected open(evt:JQuery.TriggeredEvent) { + protected open(evt:Event) { this.workPackage.project.$load().then(() => { this.authorisationService.initModelAuth('work_package', this.workPackage.$links); @@ -115,23 +117,6 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger } } - /** - * Positioning args for jquery-ui position. - * - * @param {Event} openerEvent - */ - public positionArgs(evt:JQuery.TriggeredEvent) { - const additionalPositionArgs = { - my: 'right top', - at: 'right bottom', - }; - - const position = super.positionArgs(evt); - _.assign(position, additionalPositionArgs); - - return position; - } - private activeForWorkPackage(entry:TimeEntryResource|null):boolean { return !!entry && entry.entity.href === this.workPackage.href; } @@ -196,9 +181,9 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger hidden: action.hidden === true, linkText: I18n.t(`js.button_${key}`), href: action.link, - icon: action.icon ?? `icon-${key}`, - onClick: ($event:JQuery.TriggeredEvent) => { - if (action.link && isClickedWithModifier($event)) { + icon: action.icon || `icon-${key}`, + onClick: (event:MouseEvent) => { + if (action.link && isClickedWithModifier(event)) { return false; } diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts index 9bb6fc04636e..08e49428471f 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-table-context-menu.directive.ts @@ -1,7 +1,7 @@ import { Injector } from '@angular/core'; import { WorkPackageAction } from 'core-app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service'; import { WorkPackageTable } from 'core-app/features/work-packages/components/wp-fast-table/wp-fast-table'; -import { WorkPackageViewContextMenu } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; +import { PositionArgs, WorkPackageViewContextMenu } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; import { WorkPackageViewHierarchyIdentationService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy-indentation.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; @@ -10,10 +10,10 @@ export class WorkPackageTableContextMenu extends WorkPackageViewContextMenu { constructor(public injector:Injector, protected workPackageId:string, - protected $element:JQuery, - protected additionalPositionArgs:any = {}, + protected element:HTMLElement, + additionalPositionArgs:PositionArgs, protected table:WorkPackageTable) { - super(injector, workPackageId, $element, additionalPositionArgs, true); + super(injector, workPackageId, element, additionalPositionArgs, true); } public triggerContextMenuAction(action:WorkPackageAction) { diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts index 7501feb4b68a..80199e1eec4d 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts @@ -31,6 +31,10 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { Placement } from '@floating-ui/dom'; + +export interface PositionArgs { placement?:Placement, reference?:HTMLElement } + export class WorkPackageViewContextMenu extends OpContextMenuHandler { @InjectField() protected states!:States; @@ -67,15 +71,24 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { private copyToClipboardService:CopyToClipboardService; + protected reference:HTMLElement; + constructor( public injector:Injector, protected workPackageId:string, - protected $element:JQuery, - protected additionalPositionArgs:any = {}, + protected element:HTMLElement, + additionalPositionArgs:PositionArgs = {}, protected allowSplitScreenActions:boolean = true, ) { super(injector.get(OPContextMenuService)); this.copyToClipboardService = injector.get(CopyToClipboardService); + + if (typeof additionalPositionArgs.placement !== 'undefined') { + this.placement = additionalPositionArgs.placement; + } + if (typeof additionalPositionArgs.reference !== 'undefined') { + this.reference = additionalPositionArgs.reference; + } } public get locals():OpContextMenuLocalsMap { @@ -86,13 +99,6 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { }; } - public positionArgs(evt:JQuery.TriggeredEvent) { - const position = super.positionArgs(evt); - _.assign(position, this.additionalPositionArgs); - - return position; - } - public triggerContextMenuAction(action:WorkPackageAction) { const { link } = action; const id = this.workPackage.id as string; @@ -196,8 +202,8 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { linkText: action.text, href: action.href, icon: action.icon != null ? action.icon : `icon-${action.key}`, - onClick: ($event:JQuery.TriggeredEvent) => { - if (action.href && isClickedWithModifier($event)) { + onClick: (event:MouseEvent) => { + if (action.href && isClickedWithModifier(event)) { return false; } @@ -216,8 +222,8 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { class: 'openFullScreenView', href: link, linkText: I18n.t('js.button_open_fullscreen'), - onClick: ($event:JQuery.TriggeredEvent) => { - if (isClickedWithModifier($event)) { + onClick: (event) => { + if (isClickedWithModifier(event)) { return false; } @@ -237,8 +243,8 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { { workPackageId: this.workPackageId, tabIdentifier: 'overview' }, ), linkText: I18n.t('js.button_open_details'), - onClick: ($event:JQuery.TriggeredEvent) => { - if (isClickedWithModifier($event)) { + onClick: (event) => { + if (isClickedWithModifier(event)) { return false; } diff --git a/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts b/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts index 98e578437587..3ac45db30839 100644 --- a/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts +++ b/frontend/src/app/shared/components/persistent-toggle/persistent-toggle.component.ts @@ -27,6 +27,7 @@ //++ import { Component, ElementRef, OnInit } from '@angular/core'; +import { slideDown, slideUp } from 'es6-slide-up-down'; @Component({ @@ -42,53 +43,56 @@ export class PersistentToggleComponent implements OnInit { private isHidden = false; /** Element reference */ - private $element:JQuery; + private element:HTMLElement; - private $targetNotification:JQuery; + private targetNotification:HTMLElement|null; - constructor(private elementRef:ElementRef) { + constructor(private elementRef:ElementRef) { } ngOnInit():void { - this.$element = jQuery(this.elementRef.nativeElement); - this.$targetNotification = this.getTargetNotification(); + this.element = this.elementRef.nativeElement; + this.targetNotification = this.getTargetNotification(); - this.identifier = this.$element.data('identifier'); + this.identifier = this.element.dataset.identifier!; this.isHidden = window.OpenProject.guardedLocalStorage(this.identifier) === 'true'; // Set initial state - this.$targetNotification.prop('hidden', !!this.isHidden); - - // Register click handler - this.$element - .parent() - .find('.persistent-toggle--click-handler') - .on('click', () => this.toggle(!this.isHidden)); - - // Register target toaster close icon - this.$targetNotification - .find('.op-toast--close') - .on('click', () => this.toggle(true)); + if (this.targetNotification) { + this.targetNotification.hidden = !!this.isHidden; + + // Register click handler + this.element + .parentElement + ?.querySelector('.persistent-toggle--click-handler') + ?.addEventListener('click', () => this.toggle(!this.isHidden)); + + // Register target toaster close icon + this.targetNotification + .querySelector('.op-toast--close') + ?.addEventListener('click', () => this.toggle(true)); + } } private getTargetNotification() { - return this.$element - .parent() - .find('.persistent-toggle--toaster'); + return this.element + .parentElement! + .querySelector('.persistent-toggle--toaster'); } private toggle(isNowHidden:boolean) { this.isHidden = isNowHidden; window.OpenProject.guardedLocalStorage(this.identifier, (!!isNowHidden).toString()); + const targetNotification = this.targetNotification; + if (!targetNotification) return; + if (isNowHidden) { - this.$targetNotification.slideUp(400, () => { - // Set hidden only after animation completed - this.$targetNotification.prop('hidden', true); - }); + slideUp(targetNotification, 400); + setTimeout(() => { targetNotification.hidden = true; }, 400); } else { - this.$targetNotification.slideDown(400); - this.$targetNotification.prop('hidden', false); + targetNotification.hidden = false; + slideDown(targetNotification, 400); } } } diff --git a/frontend/src/app/shared/components/project-include/list/project-include-list.component.ts b/frontend/src/app/shared/components/project-include/list/project-include-list.component.ts index d2cdd5a62eef..2e9a0c259bd6 100644 --- a/frontend/src/app/shared/components/project-include/list/project-include-list.component.ts +++ b/frontend/src/app/shared/components/project-include/list/project-include-list.component.ts @@ -43,6 +43,7 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { SearchableProjectListService, } from 'core-app/shared/components/searchable-project-list/searchable-project-list.service'; +import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -123,13 +124,13 @@ export class OpProjectIncludeListComponent { } extendedProjectUrl(projectId:string):string { - const currentMenuItem = document.querySelector('meta[name="current_menu_item"]') as HTMLMetaElement; + const currentMenuItem = getMetaContent('current_menu_item'); const url = this.pathHelper.projectPath(projectId); if (!currentMenuItem) { return url; } - return `${url}?jump=${encodeURIComponent(currentMenuItem.content)}`; + return `${url}?jump=${encodeURIComponent(currentMenuItem)}`; } } diff --git a/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts b/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts index 210a10ababd5..f3ffaa646e22 100644 --- a/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts +++ b/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts @@ -66,7 +66,7 @@ export class MainMenuResizerComponent extends UntilDestroyedMixin implements OnI private elementWidth:number; - private mainMenu = jQuery('#main-menu')[0]; + private mainMenu = document.querySelector('#main-menu')!; public moving = false; diff --git a/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts b/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts index 6df43ba8a610..f11a230beded 100644 --- a/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts +++ b/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts @@ -190,7 +190,7 @@ export class WpResizerComponent extends UntilDestroyedMixin implements OnInit, A private applyColumnLayout(checkWidth = 750) { const singleView = document.querySelector("[data-selector='wp-single-view']"); if (singleView) { - jQuery(singleView).toggleClass('work-package--single-view_with-columns', singleView.offsetWidth > checkWidth); + singleView.classList.toggle('work-package--single-view_with-columns', singleView.offsetWidth > checkWidth); } } diff --git a/frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.ts b/frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.ts index 7141da788995..deb22ce3453e 100644 --- a/frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.ts +++ b/frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.ts @@ -192,13 +192,23 @@ export class ScrollableTabsComponent extends UntilDestroyedMixin implements Afte } private scrollIntoVisibleArea(tabId:string) { - const tab:JQuery = jQuery(this.pane).find(`[data-tab-id=${tabId}]`); - const position:JQueryCoordinates = tab.position(); - - const tabRightBorderAt:number = position.left + Number(tab.outerWidth()); + const tab = this.pane.querySelector(`[data-tab-id=${tabId}]`)!; + const position = getPosition(tab); + const tabRightBorderAt = position.left + tab.offsetWidth; if (this.pane.scrollLeft + this.container.clientWidth < tabRightBorderAt) { this.pane.scrollLeft = tabRightBorderAt - this.container.clientWidth + 40; // 40px to not overlap by buttons } } } + +function getPosition(el:HTMLElement) { + const offsetParent = el.offsetParent || document.body; + const elRect = el.getBoundingClientRect(); + const parentRect = offsetParent.getBoundingClientRect(); + + return { + top: elRect.top - parentRect.top - parseFloat(getComputedStyle(offsetParent).borderTopWidth), + left: elRect.left - parentRect.left - parseFloat(getComputedStyle(offsetParent).borderLeftWidth) + }; +} diff --git a/frontend/src/app/shared/components/toaster/toast.service.ts b/frontend/src/app/shared/components/toaster/toast.service.ts index 5b1fff5448e6..c1e6192c713b 100644 --- a/frontend/src/app/shared/components/toaster/toast.service.ts +++ b/frontend/src/app/shared/components/toaster/toast.service.ts @@ -38,7 +38,7 @@ import waitForUploadsFinished from 'core-app/core/upload/wait-for-uploads-finish import { IHalErrorBase, IHalMultipleError, isHalError } from 'core-app/features/hal/resources/error-resource'; export function removeSuccessFlashMessages():void { - jQuery('.op-toast.-success').remove(); + document.querySelectorAll('.op-toast.-success').forEach((flashMessage) => flashMessage.remove()); } export type ToastType = 'success'|'error'|'warning'|'info'|'upload'|'loading'; @@ -61,10 +61,9 @@ export class ToastService { readonly configurationService:ConfigurationService, readonly I18n:I18nService, ) { - jQuery(window).on( - OPToastEvent, - (event:JQuery.TriggeredEvent, toast:IToast) => { this.add(toast); }, - ); + window.addEventListener(OPToastEvent, ({ detail:toast }:CustomEvent) => { + this.add(toast); + }); } /** diff --git a/frontend/src/app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal.ts b/frontend/src/app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal.ts index 50e3a0b41c2d..d7b4b9d6ad8a 100644 --- a/frontend/src/app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal.ts +++ b/frontend/src/app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal.ts @@ -38,7 +38,7 @@ export const WpTableConfigurationModalPrependToken = new InjectionToken { @@ -130,6 +130,6 @@ export class WpGraphConfigurationModalComponent extends OpModalComponent impleme } protected get afterFocusOn():HTMLElement { - return this.$element; + return this.element; } } diff --git a/frontend/src/app/shared/directives/a11y/keyboard-shortcut.service.ts b/frontend/src/app/shared/directives/a11y/keyboard-shortcut.service.ts index 0439e88b8f59..707ced364cdc 100644 --- a/frontend/src/app/shared/directives/a11y/keyboard-shortcut.service.ts +++ b/frontend/src/app/shared/directives/a11y/keyboard-shortcut.service.ts @@ -97,15 +97,15 @@ export class KeyboardShortcutService { const key = accessKeys[keyName]; return () => { - const elem = jQuery(`[accesskey=${key}]:first`); - if (elem.is('input') || elem.attr('id') === 'global-search-input') { + const elem = document.querySelector(`[accesskey="${key}"]`)!; + if (elem instanceof HTMLInputElement || elem.getAttribute('id') === 'global-search-input') { // timeout with delay so that the key is not // triggered on the input - setTimeout(() => this.FocusHelper.focus(elem[0]), 200); - } else if (elem.is('[href]')) { - this.clickLink(elem[0] as HTMLLinkElement); + setTimeout(() => this.FocusHelper.focus(elem), 200); + } else if (elem instanceof HTMLAnchorElement) { + this.clickLink(elem); } else { - elem[0].click(); + elem.click(); } }; } @@ -126,7 +126,7 @@ export class KeyboardShortcutService { } // eslint-disable-next-line class-methods-use-this - clickLink(link:HTMLLinkElement):void { + clickLink(link:HTMLAnchorElement):void { const event = new MouseEvent('click', { view: window, bubbles: true, diff --git a/frontend/src/app/shared/directives/focus/focus-within.directive.ts b/frontend/src/app/shared/directives/focus/focus-within.directive.ts index 5f4b562f2bb5..2e54670550bc 100644 --- a/frontend/src/app/shared/directives/focus/focus-within.directive.ts +++ b/frontend/src/app/shared/directives/focus/focus-within.directive.ts @@ -42,12 +42,12 @@ import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destr export class FocusWithinDirective extends UntilDestroyedMixin implements OnInit { @Input() public selector:string; - constructor(readonly elementRef:ElementRef) { + constructor(readonly elementRef:ElementRef) { super(); } ngOnInit() { - const element = jQuery(this.elementRef.nativeElement); + const element = this.elementRef.nativeElement; const focusedObservable = new BehaviorSubject(false); focusedObservable @@ -56,22 +56,22 @@ export class FocusWithinDirective extends UntilDestroyedMixin implements OnInit auditTime(50), ) .subscribe((focused) => { - element.toggleClass('-focus', focused); + element.classList.toggle('-focus', focused); }); const focusListener = function () { focusedObservable.next(true); }; - element[0].addEventListener('focus', focusListener, true); + element.addEventListener('focus', focusListener, true); const blurListener = function () { focusedObservable.next(false); }; - element[0].addEventListener('blur', blurListener, true); + element.addEventListener('blur', blurListener, true); setTimeout(() => { - element.addClass('op-focus-within'); - element.find(this.selector).addClass('op-focus-within'); + element.classList.add('op-focus-within'); + element.querySelector(this.selector)?.classList.add('op-focus-within'); }, 0); } } diff --git a/frontend/src/app/shared/directives/op-drag-scroll/op-drag-scroll.directive.ts b/frontend/src/app/shared/directives/op-drag-scroll/op-drag-scroll.directive.ts index 5e501bd84af2..31e7ca11b93d 100644 --- a/frontend/src/app/shared/directives/op-drag-scroll/op-drag-scroll.directive.ts +++ b/frontend/src/app/shared/directives/op-drag-scroll/op-drag-scroll.directive.ts @@ -27,17 +27,22 @@ //++ import { Directive, ElementRef, OnInit } from '@angular/core'; +declare global { + interface GlobalEventHandlersEventMap { + 'op:dragscroll': CustomEvent<{x: number, y:number}>; + } +} + @Directive({ selector: 'op-drag-scroll', standalone: false, }) export class OpDragScrollDirective implements OnInit { - constructor(readonly elementRef:ElementRef) { + constructor(readonly elementRef:ElementRef) { } ngOnInit() { - const element = jQuery(this.elementRef.nativeElement); - const eventName = 'op:dragscroll'; + const element = this.elementRef.nativeElement; // Is mouse down? let mousedown = false; @@ -47,7 +52,7 @@ export class OpDragScrollDirective implements OnInit { mousedownY:number; // Mousedown: Potential drag start - element.on('mousedown', (evt) => { + element.addEventListener('mousedown', (evt) => { setTimeout(() => { mousedown = true; mousedownX = evt.clientX; @@ -56,21 +61,25 @@ export class OpDragScrollDirective implements OnInit { }); // Mouseup: Potential drag stop - element.on('mouseup', () => { + element.addEventListener('mouseup', () => { mousedown = false; }); // Mousemove: Report movement if mousedown - element.on('mousemove', (evt) => { + element.addEventListener('mousemove', (evt) => { if (!mousedown) { return; } // Trigger drag scroll event - element.trigger(eventName, { - x: evt.clientX - mousedownX, - y: evt.clientY - mousedownY, - }); + element.dispatchEvent( + new CustomEvent('op:dragscroll', { + detail: { + x: evt.clientX - mousedownX, + y: evt.clientY - mousedownY, + }, + }), + ); // Update last mouse position mousedownX = evt.clientX; diff --git a/frontend/src/app/shared/helpers/dom-helpers.ts b/frontend/src/app/shared/helpers/dom-helpers.ts index 477191f98436..e88c5838d5a5 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.ts @@ -26,6 +26,8 @@ // See COPYRIGHT and LICENSE files for more details. //++ +export const getNodeIndex = (element:Element) => Array.from(element.parentNode!.children).indexOf(element); + /** * Toggles the visibility of an HTMLElement using `hidden` property. * @@ -41,6 +43,10 @@ export function toggleElement(element:HTMLElement, value?:boolean) { } }; +export const showElement = (element:HTMLElement) => toggleElement(element, true); + +export const hideElement = (element:HTMLElement) => toggleElement(element, false); + /** * Toggles the visibility of an Element using a CSS class. * Also takes care of setting `aria-hidden` attribute for accessibility. @@ -69,3 +75,24 @@ export function toggleElementByVisibility(element:HTMLElement, value?:boolean) { value ??= element.style.getPropertyValue('visibility') !== 'visible'; element.style.setProperty('visibility', value ? 'visible' : 'hidden'); }; + +/** + * Mimics jQuery(':visible') + */ +export function isVisible(elem:HTMLElement|null) { + if (!elem) return false; + + // Check if element is in the DOM + if (!document.contains(elem)) return false; + + // Check if dimensions are visible + return !!( + elem.offsetWidth + || elem.offsetHeight + || elem.getClientRects().length + ); +} + +export function queryVisible(selector:string, node:Element|Document = document) { + return Array.from(node.querySelectorAll(selector)).filter(isVisible); +} diff --git a/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts b/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts index 30a30ca318d6..d1a07dd0d978 100644 --- a/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts +++ b/frontend/src/app/shared/helpers/drag-and-drop/dom-autoscroll.service.ts @@ -27,10 +27,11 @@ export class DomAutoscrollService { public pointCB:any; + private abortController:AbortController; + constructor(elements:Element[], params:any) { this.elements = elements; - this.scrollWhenOutside = params.scrollWhenOutside || false; this.maxSpeed = params.maxSpeed || 5; this.margin = params.margin || 10; this.scrollWhenOutside = params.scrollWhenOutside || false; @@ -42,20 +43,29 @@ export class DomAutoscrollService { } public init() { - jQuery(window).on('mousemove.domautoscroll touchmove.domautoscroll', (evt:any) => { + this.abortController = new AbortController(); + const { signal } = this.abortController; + const moveHandler = (evt:Event) => { if (this.down) { this.pointCB(evt); this.onMove(evt); } - }); - jQuery(window).on('mousedown.domautoscroll touchstart.domautoscroll', () => { this.down = true; }); - jQuery(window).on('mouseup.domautoscroll touchend.domautoscroll', () => this.onUp()); - jQuery(window).on('scroll.domautoscroll', (evt:any) => this.setScroll(evt)); + }; + const downHandler = () => { this.down = true; }; + const upHandler = () => { this.onUp(); }; + const scrollHandler = (evt:Event) => { this.setScroll(evt); }; + + window.addEventListener('mousemove', moveHandler, { signal }); + window.addEventListener('touchmove', moveHandler, { signal }); + window.addEventListener('mousedown', downHandler, { signal }); + window.addEventListener('touchstart', downHandler, { signal }); + window.addEventListener('mouseup', upHandler, { signal }); + window.addEventListener('touchend', upHandler, { signal }); + window.addEventListener('scroll', scrollHandler, { signal }); } public destroy() { - jQuery(window).off('.domautoscroll'); - + this.abortController.abort(); this.elements = []; this.cleanAnimation(); } diff --git a/frontend/src/app/shared/helpers/event-helpers.ts b/frontend/src/app/shared/helpers/event-helpers.ts new file mode 100644 index 000000000000..db3e4035939d --- /dev/null +++ b/frontend/src/app/shared/helpers/event-helpers.ts @@ -0,0 +1,149 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +const eventTargetCache = new WeakMap(); + +type EventType = keyof HTMLElementEventMap; +type EventNamespace = string; +type NamespacedEvent = `${EventType}.${EventNamespace}`; +type NamespacedEvents = `.${EventNamespace}`; +type EventListeners = EventListenerOrEventListenerObject[]; + +class EventListenerRegistry { + private listenerMap = new Map>(); + + constructor(private eventTarget:EventTarget) {} + + on( + namespacedEvent:NamespacedEvent, + handler:EventListenerOrEventListenerObject, + options?:AddEventListenerOptions|boolean + ) { + const [namespace, type] = this.getNamespaceAndType(namespacedEvent); + + this.listenerMap.set(namespace, this.listenerMap.get(namespace) ?? new Map()); + const listenersForNamespace = this.listenerMap.get(namespace)!; + + if (!type) return; + + if (!listenersForNamespace.has(type)) { + listenersForNamespace.set(type, [handler]); + } else { + const existingListeners = listenersForNamespace.get(type)!; + if (!existingListeners.includes(handler)) { + existingListeners.push(handler); + } + } + + this.eventTarget.addEventListener(type, handler, options); + } + + one( + namespacedEvent:NamespacedEvent, + handler:EventListenerOrEventListenerObject, + options?:AddEventListenerOptions + ) { + const wrappedHandler:EventListener = (event) => { + this.removeHandlerFromRegistry(namespacedEvent, wrappedHandler); + + if (typeof handler === 'function') { + handler(event); + } else { + handler.handleEvent(event); + } + }; + + this.on(namespacedEvent, wrappedHandler, { ...options, once: true }); + } + + off(namespacedEvent:NamespacedEvent|NamespacedEvents) { + const [namespace, type] = this.getNamespaceAndType(namespacedEvent); + const listenersForNamespace = this.listenerMap.get(namespace); + if (!listenersForNamespace) return; + + if (type) { + const listeners = listenersForNamespace.get(type) ?? []; + listeners.forEach((listener) => { + this.eventTarget.removeEventListener(type, listener); + }); + listenersForNamespace.delete(type); + } else { + listenersForNamespace.forEach((listeners, eventType) => { + listeners.forEach((listener) => { + this.eventTarget.removeEventListener(eventType, listener); + }); + }); + this.listenerMap.delete(namespace); + } + + // Clean up empty namespace + if (listenersForNamespace.size === 0) { + this.listenerMap.delete(namespace); + } + } + + private removeHandlerFromRegistry(namespacedEvent:NamespacedEvent, handler:EventListenerOrEventListenerObject) { + const [namespace, type] = this.getNamespaceAndType(namespacedEvent); + if (!namespace) return; + + const listenersForNamespace = this.listenerMap.get(namespace); + if (!listenersForNamespace) return; + + if (!type) return; + + const listeners = listenersForNamespace.get(type); + if (!listeners) return; + + const index = listeners.indexOf(handler); + if (index > -1) { + listeners.splice(index, 1); + } + + if (listeners.length === 0) { + listenersForNamespace.delete(type); + } + if (listenersForNamespace.size === 0) { + this.listenerMap.delete(namespace); + } + } + + private getNamespaceAndType(namespacedEvent:NamespacedEvent|NamespacedEvents):[EventNamespace, EventType|null] { + const parts = namespacedEvent.split('.').reverse(); + const namespace = parts[0]; + const type = (parts[1] as EventType | undefined) ?? null; + return [namespace, type]; + } +} + +export function target(eventTarget:EventTarget):EventListenerRegistry { + if (!eventTargetCache.has(eventTarget)) { + eventTargetCache.set(eventTarget, new EventListenerRegistry(eventTarget)); + } + + return eventTargetCache.get(eventTarget)!; +} diff --git a/frontend/src/app/shared/helpers/link-handling/link-handling.ts b/frontend/src/app/shared/helpers/link-handling/link-handling.ts index 1956b7ad0bb8..f4a54e393c0f 100644 --- a/frontend/src/app/shared/helpers/link-handling/link-handling.ts +++ b/frontend/src/app/shared/helpers/link-handling/link-handling.ts @@ -26,7 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -export function isClickedWithModifier(event:MouseEvent|JQuery.TriggeredEvent) { +export function isClickedWithModifier(event:MouseEvent) { const modifier = event.ctrlKey || event.shiftKey || event.metaKey; const middleButton = event.button === 1; diff --git a/frontend/src/app/shared/helpers/set-click-position/set-click-position.ts b/frontend/src/app/shared/helpers/set-click-position/set-click-position.ts index a4eb7dd2080d..2d4bb7636604 100644 --- a/frontend/src/app/shared/helpers/set-click-position/set-click-position.ts +++ b/frontend/src/app/shared/helpers/set-click-position/set-click-position.ts @@ -20,20 +20,18 @@ export function setPosition(element:HTMLInputElement, offset:number):void { * @param evt * @return {number} */ -export function getPosition(evt:any):number { - const originalEvt = evt.originalEvent; - +export function getPosition(evt:MouseEvent):number { try { - if (originalEvt.rangeParent) { + if ((evt as any).rangeParent) { const range = document.createRange(); - range.setStart(originalEvt.rangeParent, originalEvt.rangeOffset); + range.setStart((evt as any).rangeParent, (evt as any).rangeOffset); return range.startOffset; } const legacyDocument = document as { caretRangeFromPoint?:(x:number, y:number) => { startOffset:number } }; if (legacyDocument.caretRangeFromPoint) { return legacyDocument - .caretRangeFromPoint((evt as MouseEvent).clientX, (evt as MouseEvent).clientY) + .caretRangeFromPoint(evt.clientX, evt.clientY) .startOffset; } diff --git a/frontend/src/global_styles/layout/work_packages/_table.sass b/frontend/src/global_styles/layout/work_packages/_table.sass index 920b12bc05ab..3e7273eeb144 100644 --- a/frontend/src/global_styles/layout/work_packages/_table.sass +++ b/frontend/src/global_styles/layout/work_packages/_table.sass @@ -139,6 +139,9 @@ body[class*="router--"] // Hinter browser that the content of the flex is contained except for size contain: strict + &.-timeline-visible + display: block + .router--work-packages-base .work-packages-partitioned-page--content-left overflow: hidden diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 726046c40755..7e2649771f14 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -41,11 +41,7 @@ if (environment.production) { // Import the correct locale early on void initializeLocale() .then(() => { - jQuery(() => { - // Now that DOM is loaded, also run the global listeners - initializeGlobalListeners(); + initializeGlobalListeners(); - // Due to the behaviour of the Edge browser we need to wait for 'DOM ready' - void platformBrowserDynamic().bootstrapModule(OpenProjectModule); - }); + void platformBrowserDynamic().bootstrapModule(OpenProjectModule); }); diff --git a/frontend/src/stimulus/controllers/select-autosize.controller.ts b/frontend/src/stimulus/controllers/select-autosize.controller.ts new file mode 100644 index 000000000000..ec925f0f7c89 --- /dev/null +++ b/frontend/src/stimulus/controllers/select-autosize.controller.ts @@ -0,0 +1,63 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; +import { useDebounce, useMutation } from 'stimulus-use'; + +/** + * A simple controller to allow ') - .attr('id', name) - .attr('name', name) - .attr('type', 'file') - .attr('style', 'position:fixed;left:0;bottom:0;z-index:10000') - .appendTo(document.body) - .on('change', function(event) { - input.remove(); - event.stopPropagation(); - - let dataTransfer = { - constructor : DataTransfer, - effectAllowed : 'all', - dropEffect : 'none', - types : [ 'Files' ], - files : input[0].files, - setData : function setData(){}, - getData : function getData(){}, - clearData : function clearData(){}, - setDragImage : function setDragImage(){} - }; - - // If we have stopovers, do those first and then get the target - if (stopovers.length > 0) { - stopovers.forEach((stopover) => dropOnStopover(stopover, dataTransfer)); - - setTimeout(() => { - if (!cancelDrop) { - // After we left the stopover DOM elements, the target element should remain visible. - // If it's not visible, we raise an error. - if (target.offsetParent === null) { - throw new Error("Cannot drop the file on an invisible target"); - }; - dropOnTarget(dataTransfer); - } - }, 2000); - } else { +let input = document.createElement('input'); +input.id = name; +input.name = name; +input.type = 'file'; +input.style.cssText = 'position:fixed;left:0;bottom:0;z-index:10000'; +document.body.appendChild(input); + +input.addEventListener('change', function(event) { + input.remove(); + event.stopPropagation(); + + let dataTransfer = { + constructor : DataTransfer, + effectAllowed : 'all', + dropEffect : 'none', + types : [ 'Files' ], + files : input.files, + setData : function setData(){}, + getData : function getData(){}, + clearData : function clearData(){}, + setDragImage : function setDragImage(){} + }; + + // If we have stopovers, do those first and then get the target + if (stopovers.length > 0) { + stopovers.forEach((stopover) => dropOnStopover(stopover, dataTransfer)); + + setTimeout(() => { + if (!cancelDrop) { + // After we left the stopover DOM elements, the target element should remain visible. + // If it's not visible, we raise an error. + if (target.offsetParent === null) { + throw new Error("Cannot drop the file on an invisible target"); + }; dropOnTarget(dataTransfer); } - }); + }, 2000); + } else { + dropOnTarget(dataTransfer); + } +}); diff --git a/spec/support/components/wysiwyg/wysiwyg_editor.rb b/spec/support/components/wysiwyg/wysiwyg_editor.rb index e9ba8e93d93a..4e46c1113181 100644 --- a/spec/support/components/wysiwyg/wysiwyg_editor.rb +++ b/spec/support/components/wysiwyg/wysiwyg_editor.rb @@ -35,7 +35,7 @@ def set_markdown(text) textarea = container.find(".op-ckeditor-source-element", visible: :all) page.execute_script( - 'jQuery(arguments[0]).trigger("op:ckeditor:setData", arguments[1])', + 'arguments[0].dispatchEvent(new CustomEvent("op:ckeditor:setData", { detail: arguments[1] }))', textarea.native, text ) @@ -44,7 +44,7 @@ def set_markdown(text) def clear textarea = container.find(".op-ckeditor-source-element", visible: :all) page.execute_script( - 'jQuery(arguments[0]).trigger("op:ckeditor:clear")', + 'arguments[0].dispatchEvent(new Event("op:ckeditor:clear"))', textarea.native ) end @@ -52,7 +52,7 @@ def clear def trigger_autosave textarea = container.find(".op-ckeditor-source-element", visible: :all) page.execute_script( - 'jQuery(arguments[0]).trigger("op:ckeditor:autosave")', + 'arguments[0].dispatchEvent(new Event("op:ckeditor:autosave"))', textarea.native ) end diff --git a/spec/support/shared/loading_indicator_saveguard.rb b/spec/support/shared/loading_indicator_saveguard.rb index 8b43f640dde1..e9ee67841d6f 100644 --- a/spec/support/shared/loading_indicator_saveguard.rb +++ b/spec/support/shared/loading_indicator_saveguard.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Method to manually wait for an asynchronous request (through jQuery) to complete. +# Method to manually wait for an asynchronous request to complete. # This applies to all requests through resources as well. # # Note: Use this only if there are no other means of detecting the successful