diff --git a/Gemfile.lock b/Gemfile.lock index 8848de372217..73b7374d26bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1425,4 +1425,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.5.13 + 2.6.3 diff --git a/app/components/_index.sass b/app/components/_index.sass index 59f6c3cdfa43..993e12daeb75 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -2,6 +2,7 @@ @import "work_packages/activities_tab/journals/new_component" @import "work_packages/activities_tab/journals/index_component" @import "work_packages/activities_tab/journals/item_component" +@import "work_packages/activities_tab/journals/revision_component" @import "work_packages/activities_tab/journals/item_component/details" @import "work_packages/activities_tab/journals/item_component/add_reactions" @import "work_packages/activities_tab/journals/item_component/reactions" diff --git a/app/components/work_packages/activities_tab/journals/index_component.html.erb b/app/components/work_packages/activities_tab/journals/index_component.html.erb index c541372a20a6..9ef8ded4e52b 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/index_component.html.erb @@ -7,7 +7,7 @@ mb: inner_container_margin_bottom ) do flex_layout(id: insert_target_modifier_id, - data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| + data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| if empty_state? journals_index_container.with_row(mt: 2, mb: 3) do render( @@ -22,12 +22,16 @@ end end - recent_journals.each do |journal| + recent_journals.each do |record| journals_index_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, filter:, - grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] - )) + if record.is_a?(Changeset) + render(WorkPackages::ActivitiesTab::Journals::RevisionComponent.new(changeset: record, filter:)) + else + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal: record, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[record.id] + )) + end end end @@ -48,12 +52,16 @@ else helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals") do flex_layout do |older_journals_container| - older_journals.each do |journal| + older_journals.each do |record| older_journals_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, filter:, - grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] - )) + if record.is_a?(Changeset) + render(WorkPackages::ActivitiesTab::Journals::RevisionComponent.new(changeset: record, filter:)) + else + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal: record, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[record.id] + )) + end end end end diff --git a/app/components/work_packages/activities_tab/journals/index_component.rb b/app/components/work_packages/activities_tab/journals/index_component.rb index 469f0e3d4e11..35535014b0a1 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.rb +++ b/app/components/work_packages/activities_tab/journals/index_component.rb @@ -64,47 +64,62 @@ def journal_sorting_desc? end def base_journals - work_package - .journals - .includes( - :user, - :customizable_journals, - :attachable_journals, - :storable_journals, - :notifications - ) - .reorder(version: journal_sorting) - .with_sequence_version + combine_and_sort_records(fetch_journals, fetch_revisions) + end + + def fetch_journals + API::V3::Activities::ActivityEagerLoadingWrapper.wrap( + work_package + .journals + .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, :notifications) + .reorder(version: journal_sorting) + .with_sequence_version + ) + end + + def fetch_revisions + work_package.changesets.includes(:user, :repository) + end + + def combine_and_sort_records(journals, revisions) + (journals + revisions).sort_by do |record| + timestamp = record_timestamp(record) + journal_sorting_desc? ? [-timestamp, -record.id] : [timestamp, record.id] + end + end + + def record_timestamp(record) + if record.is_a?(API::V3::Activities::ActivityEagerLoadingWrapper) + record.created_at&.to_i + elsif record.is_a?(Changeset) + record.committed_on.to_i + end end def journals - API::V3::Activities::ActivityEagerLoadingWrapper.wrap(base_journals) + base_journals end def recent_journals - recent_ones = if journal_sorting_desc? - base_journals.first(MAX_RECENT_JOURNALS) - else - base_journals.last(MAX_RECENT_JOURNALS) - end - - API::V3::Activities::ActivityEagerLoadingWrapper.wrap(recent_ones) + if journal_sorting_desc? + base_journals.first(MAX_RECENT_JOURNALS) + else + base_journals.last(MAX_RECENT_JOURNALS) + end end def older_journals - older_ones = if journal_sorting_desc? - base_journals.offset(MAX_RECENT_JOURNALS) - else - total = base_journals.count - limit = [total - MAX_RECENT_JOURNALS, 0].max - base_journals.limit(limit) - end - - API::V3::Activities::ActivityEagerLoadingWrapper.wrap(older_ones) + if journal_sorting_desc? + base_journals.drop(MAX_RECENT_JOURNALS) + else + base_journals.take(base_journals.size - MAX_RECENT_JOURNALS) + end end def journal_with_notes - base_journals.where.not(notes: "") + work_package + .journals + .where.not(notes: "") end def wp_journals_grouped_emoji_reactions diff --git a/app/components/work_packages/activities_tab/journals/revision_component.html.erb b/app/components/work_packages/activities_tab/journals/revision_component.html.erb new file mode 100644 index 000000000000..be28bcec0dd7 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/revision_component.html.erb @@ -0,0 +1,81 @@ +<%= + component_wrapper(class: "work-packages-activities-tab-journals-item-component") do + flex_layout(data: { + test_selector: "op-wp-revision-entry-#{changeset.id}" + }) do |revision_container| + revision_container.with_row do + render(border_box_container( + id: "activity-anchor-r#{changeset.revision}", + padding: :condensed, + "aria-label": I18n.t("activities.work_packages.activity_tab.commented") + )) do |border_box_component| + border_box_component.with_header(px: 2, py: 1, data: { test_selector: "op-revision-header" }) do + flex_layout(align_items: :center, justify_content: :space_between, classes: "work-packages-activities-tab-revision-component--header") do |header_container| + header_container.with_column(flex_layout: true, + classes: "work-packages-activities-tab-journals-item-component--header-start-container ellipsis") do |header_start_container| + header_start_container.with_column(mr: 2) do + if changeset.user + render(Users::AvatarComponent.new(user: changeset.user, show_name: false, size: :mini)) + end + end + header_start_container.with_column(mr: 1, flex_layout: true, + classes: "work-packages-activities-tab-journals-item-component--user-name-container hidden-for-desktop") do |user_name_container| + user_name_container.with_row(classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis") do + render_user_name + end + user_name_container.with_row do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mr: 1)) do + committed_text = render(Primer::Beta::Link.new( + href: revision_url, + scheme: :secondary, + underline: false, + font_size: :small, + target: "_blank" + )) do + I18n.t("js.label_committed_link", revision_identifier: short_revision) + end + I18n.t("js.label_committed_at", + committed_revision_link: committed_text.html_safe, + date: format_time(changeset.committed_on)).html_safe + end + end + end + header_start_container.with_column(mr: 1, + classes: "work-packages-activities-tab-journals-item-component--user-name ellipsis hidden-for-mobile") do + render_user_name + end + header_start_container.with_column(mr: 1, classes: "hidden-for-mobile") do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle, mr: 1)) do + committed_text = render(Primer::Beta::Link.new( + href: revision_url, + scheme: :secondary, + underline: false, + font_size: :small, + target: "_blank" + )) do + I18n.t("js.label_committed_link", revision_identifier: short_revision) + end + I18n.t("js.label_committed_at", + committed_revision_link: committed_text.html_safe, + date: format_time(changeset.committed_on)).html_safe + end + end + end + end + end + border_box_component.with_body( + classes: "work-packages-activities-tab-journals-item-component--journal-notes-body", + data: { test_selector: "op-revision-notes-body" } + ) do + render(Primer::Box.new(mt: 1, classes: "op-uc-container")) do + format_text(changeset, :comments) + end + end + end + end + revision_container.with_row(flex_layout: true, classes: "work-packages-activities-tab-revision-component--stem-line-container") do |stem_line_container| + stem_line_container.with_column(border: :left, classes: "work-packages-activities-tab-revision-component--stem-line") + end + end + end +%> \ No newline at end of file diff --git a/app/components/work_packages/activities_tab/journals/revision_component.rb b/app/components/work_packages/activities_tab/journals/revision_component.rb new file mode 100644 index 000000000000..666d1df7a399 --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/revision_component.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2023 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. +# ++ + +require "sanitize" + +module WorkPackages + module ActivitiesTab + module Journals + class RevisionComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(changeset:, filter:) + super + + @changeset = changeset + @filter = filter + end + + def render_committer_name(committer) + render(Primer::Beta::Text.new(font_weight: :bold, mr: 1)) do + remove_email_addresses(committer) + end + end + + def remove_email_addresses(committer) + return "" if committer.blank? + + ERB::Util.html_escape( + Sanitize.fragment( + committer.gsub(%r{<[^>]+@[^>]+>}, ""), + Sanitize::Config::RESTRICTED + ).strip + ) + end + + private + + attr_reader :changeset, :filter + + def render? + filter != :only_comments + end + + def user_name + if changeset.user + changeset.user.name + else + # Extract name from committer string (format: "name ") + changeset.committer.split("<").first.strip + end + end + + def revision_url + repository = changeset.repository + project = repository.project + + show_revision_project_repository_path(project_id: project.id, rev: changeset.revision) + end + + def short_revision + changeset.revision[0..7] + end + + def copy_url_action_item(menu) + menu.with_item(label: t("button_copy_link_to_clipboard"), + tag: :button, + content_arguments: { + data: { + action: "click->work-packages--activities-tab--item#copyActivityUrlToClipboard" + } + }) do |item| + item.with_leading_visual_icon(icon: :copy) + end + end + + def render_user_name + if changeset.user + render_user_link(changeset.user) + else + render_committer_name(changeset.committer) + end + end + + def render_user_link(user) + render(Primer::Beta::Link.new( + href: user_url(user), + target: "_blank", + scheme: :primary, + underline: false, + font_weight: :bold + )) do + changeset.user.name + end + end + end + end + end +end diff --git a/app/components/work_packages/activities_tab/journals/revision_component.sass b/app/components/work_packages/activities_tab/journals/revision_component.sass new file mode 100644 index 000000000000..cb7dd8bd956a --- /dev/null +++ b/app/components/work_packages/activities_tab/journals/revision_component.sass @@ -0,0 +1,7 @@ +.work-packages-activities-tab-revision-component + &--header + min-height: 32px + &--stem-line-container + min-height: 20px + &--stem-line + margin-left: 19px \ No newline at end of file diff --git a/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts index 2424ba3c5403..75c98348ee2f 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/wp-spent-time-display-field.module.ts @@ -82,7 +82,7 @@ export class WorkPackageSpentTimeDisplayField extends WorkDisplayField { ), ) .search( - `fields[]=WorkPackageId&operators[WorkPackageId]=%3D&values[WorkPackageId]=${wpID}&set_filter=1`, + `fields[]=WorkPackageId&operators[WorkPackageId]=%3D_child_work_packages&values[WorkPackageId]=${wpID}&set_filter=1`, ) .toString(); diff --git a/frontend/src/global_styles/content/_forms.sass b/frontend/src/global_styles/content/_forms.sass index ca4f6758f304..9fb3f62243a1 100644 --- a/frontend/src/global_styles/content/_forms.sass +++ b/frontend/src/global_styles/content/_forms.sass @@ -969,4 +969,5 @@ input[type=date], input[type=time] overflow: auto .form-autocompleter-container - overflow: hidden + display: grid + grid-template-columns: minmax(0, auto) diff --git a/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts b/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts new file mode 100644 index 000000000000..72278dfa5759 --- /dev/null +++ b/frontend/src/stimulus/controllers/keep-scroll-position.controller.ts @@ -0,0 +1,83 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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 { ApplicationController } from 'stimulus-use'; + +export default class KeepScrollPositionController extends ApplicationController { + static values = { + url: String, + }; + + static targets = ['triggerButton']; + + declare triggerButtonTarget:HTMLLinkElement; + + declare urlValue:string; + + connect() { + super.connect(); + + window.addEventListener('turbo:load', this.autoscrollToLastKnownPosition.bind(this)); + window.addEventListener('DOMContentLoaded', this.autoscrollToLastKnownPosition.bind(this)); + } + + disconnect() { + super.disconnect(); + } + + triggerButtonTargetConnected() { + this.triggerButtonTarget.addEventListener('click', this.rememberCurrentScrollPosition.bind(this)); + } + + rememberCurrentScrollPosition() { + const currentPosition = document.getElementById('content-body')?.scrollTop; + + if (currentPosition !== undefined) { + sessionStorage.setItem(this.scrollPositionKey(), currentPosition.toString()); + } + } + + autoscrollToLastKnownPosition() { + const lastKnownPos = sessionStorage.getItem(this.scrollPositionKey()); + if (lastKnownPos) { + const content = document.getElementById('content-body'); + + if (content) { + content.scrollTop = parseInt(lastKnownPos, 10); + } + } + + sessionStorage.removeItem(this.scrollPositionKey()); + } + + private scrollPositionKey():string { + return `${this.urlValue}/scrollPosition`; + } +} diff --git a/frontend/src/stimulus/controllers/poll-for-changes.controller.ts b/frontend/src/stimulus/controllers/poll-for-changes.controller.ts index 4b24bb4e64c0..3fdac936b327 100644 --- a/frontend/src/stimulus/controllers/poll-for-changes.controller.ts +++ b/frontend/src/stimulus/controllers/poll-for-changes.controller.ts @@ -36,19 +36,16 @@ export default class PollForChangesController extends ApplicationController { url: String, interval: Number, reference: String, - autoscrollEnabled: Boolean, }; - static targets = ['reloadButton', 'reference']; + static targets = ['reference']; - declare reloadButtonTarget:HTMLLinkElement; declare referenceTarget:HTMLElement; declare readonly hasReferenceTarget:boolean; declare referenceValue:string; declare urlValue:string; declare intervalValue:number; - declare autoscrollEnabledValue:boolean; private interval:number; @@ -60,10 +57,6 @@ export default class PollForChangesController extends ApplicationController { void this.triggerTurboStream(); }, this.intervalValue || 10_000); } - - if (this.autoscrollEnabledValue) { - window.addEventListener('DOMContentLoaded', this.autoscrollToLastKnownPosition.bind(this)); - } } disconnect() { @@ -79,10 +72,6 @@ export default class PollForChangesController extends ApplicationController { return this.referenceValue; } - reloadButtonTargetConnected() { - this.reloadButtonTarget.addEventListener('click', this.rememberCurrentScrollPosition.bind(this)); - } - triggerTurboStream() { void fetch(`${this.urlValue}?reference=${this.buildReference()}`, { headers: { @@ -97,29 +86,4 @@ export default class PollForChangesController extends ApplicationController { } }); } - - rememberCurrentScrollPosition() { - const currentPosition = document.getElementById('content-body')?.scrollTop; - - if (currentPosition !== undefined) { - sessionStorage.setItem(this.scrollPositionKey(), currentPosition.toString()); - } - } - - autoscrollToLastKnownPosition() { - const lastKnownPos = sessionStorage.getItem(this.scrollPositionKey()); - if (lastKnownPos) { - const content = document.getElementById('content-body'); - - if (content) { - content.scrollTop = parseInt(lastKnownPos, 10); - } - } - - sessionStorage.removeItem(this.scrollPositionKey()); - } - - private scrollPositionKey():string { - return `${this.urlValue}/scrollPosition`; - } } diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 022ec08d973b..d7196c7371fb 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -14,6 +14,7 @@ import OpShowWhenValueSelectedController from './controllers/show-when-value-sel import FlashController from './controllers/flash.controller'; import OpProjectsZenModeController from './controllers/dynamic/projects/zen-mode.controller'; import PasswordConfirmationDialogController from './controllers/password-confirmation-dialog.controller'; +import KeepScrollPositionController from './controllers/keep-scroll-position.controller'; declare global { interface Window { @@ -43,3 +44,4 @@ instance.register('show-when-checked', OpShowWhenCheckedController); instance.register('show-when-value-selected', OpShowWhenValueSelectedController); instance.register('table-highlighting', TableHighlightingController); instance.register('projects-zen-mode', OpProjectsZenModeController); +instance.register('keep-scroll-position', KeepScrollPositionController); diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index 0d74c0b80e7d..4f9e57a7c393 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -1,8 +1,8 @@ <%= - helpers.content_controller "poll-for-changes", + helpers.content_controller "poll-for-changes keep-scroll-position", poll_for_changes_url_value: check_for_updates_meeting_path(@meeting), poll_for_changes_interval_value: check_for_updates_interval, - poll_for_changes_autoscroll_enabled_value: true + keep_scroll_position_url_value: meeting_path(@meeting) component_wrapper do render(Primer::OpenProject::PageHeader.new( diff --git a/modules/meeting/app/components/meetings/update_flash_component.rb b/modules/meeting/app/components/meetings/update_flash_component.rb index 0b25370a52e3..0b76b0249781 100644 --- a/modules/meeting/app/components/meetings/update_flash_component.rb +++ b/modules/meeting/app/components/meetings/update_flash_component.rb @@ -43,8 +43,10 @@ def call banner.with_action_button( tag: :a, href: helpers.meeting_path(meeting), - data: { turbo: false, poll_for_changes_target: "reloadButton" }, - size: :medium + size: :medium, + data: { + keep_scroll_position_target: "triggerButton" + } ) { I18n.t("label_meeting_reload") } I18n.t("notice_meeting_updated") diff --git a/modules/meeting/app/components/recurring_meetings/footer_component.html.erb b/modules/meeting/app/components/recurring_meetings/footer_component.html.erb index 9defd037db85..2eea8f375ca3 100644 --- a/modules/meeting/app/components/recurring_meetings/footer_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/footer_component.html.erb @@ -43,7 +43,10 @@ See COPYRIGHT and LICENSE files for more details. scheme: :link, size: :medium, tag: :a, - href: polymorphic_path([@project, @meeting], count: @current_count, direction: @direction) + href: polymorphic_path([@project, @meeting], count: @current_count, direction: @direction), + data: { + keep_scroll_position_target: "triggerButton" + } ) ) do |_c| I18n.t(:label_recurring_meeting_show_more) diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index 2727f7741732..6ba8d651df39 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -1,64 +1,71 @@ -<%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { page_title } - header.with_description { page_description } - header.with_breadcrumbs(breadcrumb_items) +<%= + helpers.content_controller "keep-scroll-position", + keep_scroll_position_url_value: polymorphic_path([@project, @meeting]) - header.with_action_button( - tag: :a, - mobile_label: I18n.t("recurring_meeting.template.label_view_template"), - mobile_icon: :eye, - size: :medium, - href: meeting_path(@meeting.template) - ) { I18n.t("recurring_meeting.template.label_view_template") } + component_wrapper do + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_description { page_description } + header.with_breadcrumbs(breadcrumb_items) - if render_create_button? - header.with_action_menu(menu_arguments: { anchor_align: :end }, - button_arguments: { icon: "op-kebab-vertical", - classes: "hide-when-print", - "aria-label": "Menu", - data: { - "test-selector": "recurring-meeting-action-menu" - } }) do |menu, _button| - - menu.with_item( - label: I18n.t(:label_recurring_meeting_series_edit), - icon: :gear, - href: details_dialog_recurring_meeting_path(@meeting), + header.with_action_button( tag: :a, - content_arguments: { - data: { controller: 'async-dialog' }, - }, - 'aria-label': t(:label_recurring_meeting_series_edit), - test_selector: "edit-meeting-details-button", - ) do |item| - item.with_leading_visual_icon(icon: :pencil) - end + mobile_label: I18n.t("recurring_meeting.template.label_view_template"), + mobile_icon: :eye, + size: :medium, + href: meeting_path(@meeting.template) + ) { I18n.t("recurring_meeting.template.label_view_template") } - menu.with_item( - label: t(:label_icalendar_download), - href: download_ics_recurring_meeting_path(@meeting) - ) do |item| - item.with_leading_visual_icon(icon: :calendar) - end + if render_create_button? + header.with_action_menu(menu_arguments: { anchor_align: :end }, + button_arguments: { icon: "op-kebab-vertical", + classes: "hide-when-print", + "aria-label": "Menu", + data: { + "test-selector": "recurring-meeting-action-menu" + } }) do |menu, _button| - menu.with_item( - label: t('meeting.label_mail_all_participants'), - href: notify_recurring_meeting_path(@meeting), - form_arguments: { method: :post } - ) do |item| - item.with_leading_visual_icon(icon: :mail) - end + menu.with_item( + label: I18n.t(:label_recurring_meeting_series_edit), + icon: :gear, + href: details_dialog_recurring_meeting_path(@meeting), + tag: :a, + content_arguments: { + data: { controller: 'async-dialog' }, + }, + 'aria-label': t(:label_recurring_meeting_series_edit), + test_selector: "edit-meeting-details-button", + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + + menu.with_item( + label: t(:label_icalendar_download), + href: download_ics_recurring_meeting_path(@meeting) + ) do |item| + item.with_leading_visual_icon(icon: :calendar) + end + + menu.with_item( + label: t('meeting.label_mail_all_participants'), + href: notify_recurring_meeting_path(@meeting), + form_arguments: { method: :post } + ) do |item| + item.with_leading_visual_icon(icon: :mail) + end - menu.with_item( - label: I18n.t(:label_recurring_meeting_series_delete), - href: polymorphic_path([@project, @meeting]), - scheme: :danger, - form_arguments: { - method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' } - } - ) do |item| - item.with_leading_visual_icon(icon: :trash) + menu.with_item( + label: I18n.t(:label_recurring_meeting_series_delete), + href: polymorphic_path([@project, @meeting]), + scheme: :danger, + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' } + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end end end end -end %> +%> diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb index f96cb0a43177..9abcfb2f23f8 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb @@ -30,6 +30,7 @@ module RecurringMeetings class ShowPageHeaderComponent < ApplicationComponent + include OpTurbo::Streamable include OpPrimer::ComponentHelpers include ApplicationHelper diff --git a/modules/meeting/spec/features/structured_meetings/structured_meeting_update_flash_spec.rb b/modules/meeting/spec/features/structured_meetings/structured_meeting_update_flash_spec.rb index 7d7479374d53..3ded9726a03b 100644 --- a/modules/meeting/spec/features/structured_meetings/structured_meeting_update_flash_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/structured_meeting_update_flash_spec.rb @@ -112,6 +112,8 @@ end show_page.expect_section(title: "First section") + show_page.visit! + expect(page).to have_no_text I18n.t(:notice_meeting_updated) end # Expect notification in window1 @@ -151,6 +153,10 @@ ## Close meeting find_test_selector("close-meeting-button").click + expect(page).to have_text "This meeting is closed." + + show_page.visit! + expect(page).to have_text "This meeting is closed." end # Expect notification in window1 diff --git a/modules/meeting/spec/support/pages/structured_meeting/show.rb b/modules/meeting/spec/support/pages/structured_meeting/show.rb index ed23fd690724..016047bde790 100644 --- a/modules/meeting/spec/support/pages/structured_meeting/show.rb +++ b/modules/meeting/spec/support/pages/structured_meeting/show.rb @@ -257,11 +257,13 @@ def in_latest_section_form(&) end def add_section(&) - page.within("#meeting-agenda-items-new-button-component") do - click_on I18n.t(:button_add) - click_on "Section" - # wait for the disabled button, indicating the turbo streams are applied - expect(page).to have_css("#meeting-agenda-items-new-button-component button[disabled='disabled']") + retry_block do + page.within("#meeting-agenda-items-new-button-component") do + click_on I18n.t(:button_add) + click_on "Section" + # wait for the disabled button, indicating the turbo streams are applied + expect(page).to have_css("#meeting-agenda-items-new-button-component button[disabled='disabled']") + end end in_latest_section_form(&) diff --git a/modules/reporting/app/models/cost_query/filter/work_package_id.rb b/modules/reporting/app/models/cost_query/filter/work_package_id.rb index 3872127e7cd4..72894bf5dcd2 100644 --- a/modules/reporting/app/models/cost_query/filter/work_package_id.rb +++ b/modules/reporting/app/models/cost_query/filter/work_package_id.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -31,6 +33,10 @@ def self.label WorkPackage.model_name.human end + def self.available_operators + ["=", "!", "=_child_work_packages", "!_child_work_packages"].map(&:to_operator) + end + def self.available_values(*) WorkPackage .where(project_id: Project.allowed_to(User.current, :view_work_packages)) @@ -39,10 +45,6 @@ def self.available_values(*) .map { |id, subject| [text_for_tuple(id, subject), id] } end - def self.available_operators - ["="].map(&:to_operator) - end - ## # Overwrites Report::Filter::Base self.label_for_value method # to achieve a more performant implementation @@ -50,16 +52,21 @@ def self.label_for_value(value) return nil unless value.to_i.to_s == value.to_s # we expect an work_package-id work_package = WorkPackage.find(value.to_i) - [text_for_work_package(work_package), work_package.id] if work_package and work_package.visible?(User.current) + [text_for_work_package(work_package), work_package.id] if work_package&.visible?(User.current) end def self.text_for_tuple(id, subject) str = "##{id} " - str << (subject.length > 30 ? subject.first(26) + "..." : subject) + str << (subject.length > 30 ? "#{subject.first(26)}..." : subject) end - def self.text_for_work_package(i) - i = i.first if i.is_a? Array - text_for_touble(i.id, i.subject) + def self.text_for_work_package(work_package_or_work_package_list) + wp = if work_package_or_work_package_list.is_a?(Array) + work_package_or_work_package_list.first + else + work_package_or_work_package_list + end + + text_for_tuple(wp.id, wp.subject) end end diff --git a/modules/reporting/app/models/cost_query/operator.rb b/modules/reporting/app/models/cost_query/operator.rb index 6c804f850b8c..1441fe83ab33 100644 --- a/modules/reporting/app/models/cost_query/operator.rb +++ b/modules/reporting/app/models/cost_query/operator.rb @@ -50,8 +50,13 @@ def modify(query, field, *_values) def modify(query, field, *values) p_ids = [] values.each do |value| - p_ids += ([value] << Project.find(value).descendants.map(&:id)) + project = Project.visible.find(value) + next unless project + + p_ids << project.id + p_ids += project.descendants.pluck(:id) end + "=".to_operator.modify query, field, p_ids rescue ActiveRecord::RecordNotFound query @@ -63,12 +68,53 @@ def modify(query, field, *values) p_ids = [] values.each do |value| value.to_s.split(",").each do |id| - p_ids += ([id] << Project.find(id).descendants.map(&:id)) + project = Project.visible.find(id) + next unless project + + p_ids << project.id + p_ids += project.descendants.pluck(:id) end end + "!".to_operator.modify query, field, p_ids rescue ActiveRecord::RecordNotFound query end end + + new "=_child_work_packages", validate: :integers, label: :label_is_work_package_with_descendants do + def modify(query, field, *values) + wp_ids = [] + values.each do |value| + work_package = WorkPackage.visible.find(value) + next unless work_package + + wp_ids << work_package.id + wp_ids += work_package.descendants.pluck(:id) + end + + "=".to_operator.modify query, field, wp_ids + rescue ActiveRecord::RecordNotFound + query + end + end + + new "!_child_work_packages", validate: :integers, label: :label_is_not_work_package_with_descendants do + def modify(query, field, *values) + wp_ids = [] + values.each do |value| + value.to_s.split(",").each do |id| + work_package = WorkPackage.visible.find(id) + next unless work_package + + wp_ids << work_package.id + wp_ids += work_package.descendants.pluck(:id) + end + end + + "!".to_operator.modify query, field, wp_ids + rescue ActiveRecord::RecordNotFound + query + end + end end diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml index 6141e7c3f89f..3fa374f78e84 100644 --- a/modules/reporting/config/locales/en.yml +++ b/modules/reporting/config/locales/en.yml @@ -58,6 +58,8 @@ en: label_greater: ">" label_is_not_project_with_subprojects: "is not (includes subprojects)" label_is_project_with_subprojects: "is (includes subprojects)" + label_is_work_package_with_descendants: "is (includes descendants)" + label_is_not_work_package_with_descendants: "is not (includes descendants)" label_work_package_attributes: "Work package attributes" label_less: "<" label_logged_by_reporting: "Logged by" diff --git a/modules/reporting/spec/models/cost_query/filter_spec.rb b/modules/reporting/spec/models/cost_query/filter_spec.rb index c31794ea5781..c1a1ef3f31e1 100644 --- a/modules/reporting/spec/models/cost_query/filter_spec.rb +++ b/modules/reporting/spec/models/cost_query/filter_spec.rb @@ -101,7 +101,7 @@ def create_work_package_with_time_entry(work_package_params = {}, entry_params = activity:) end - it "onlies return entries from the given #{filter}" do + it "only return entries from the given #{filter}" do query.filter field, value: object.id query.result.each do |result| expect(result[field].to_s).to eq(object.id.to_s) @@ -156,7 +156,7 @@ def create_work_package_with_time_entry(work_package_params = {}, entry_params = activity:) end - it "onlies return entries from the given CostQuery::Filter::AuthorId" do + it "only return entries from the given CostQuery::Filter::AuthorId" do query.filter "author_id", value: author.id query.result.each do |result| work_package_id = result["work_package_id"] @@ -312,7 +312,7 @@ def create_matching_object_with_time_entries(factory, work_package_field, entry_ CostQuery::Filter::PriorityId, CostQuery::Filter::TypeId ].each do |filter| - it "onlies allow default operators for #{filter}" do + it "only allow default operators for #{filter}" do expect(filter.new.available_operators.uniq.sort).to eq(CostQuery::Operator.default_operators.uniq.sort) end end @@ -323,7 +323,7 @@ def create_matching_object_with_time_entries(factory, work_package_field, entry_ CostQuery::Filter::CategoryId, CostQuery::Filter::VersionId ].each do |filter| - it "onlies allow default+null operators for #{filter}" do + it "only allow default+null operators for #{filter}" do expect(filter.new.available_operators.uniq.sort).to eq((CostQuery::Operator.default_operators + CostQuery::Operator.null_operators).sort) end end @@ -332,8 +332,13 @@ def create_matching_object_with_time_entries(factory, work_package_field, entry_ [ CostQuery::Filter::WorkPackageId ].each do |filter| - it "onlies allow default operators for #{filter}" do - expect(filter.new.available_operators.uniq).to contain_exactly(CostQuery::Operator.default_operator) + it "allows custom filters#{filter}" do + expect(filter.new.available_operators.uniq).to contain_exactly( + Report::Operator.new("!"), + CostQuery::Operator.new("!_child_work_packages"), + Report::Operator.new("="), + CostQuery::Operator.new("=_child_work_packages") + ) end end @@ -345,7 +350,7 @@ def create_matching_object_with_time_entries(factory, work_package_field, entry_ CostQuery::Filter::StartDate, CostQuery::Filter::DueDate ].each do |filter| - it "onlies allow time operators for #{filter}" do + it "only allow time operators for #{filter}" do expect(filter.new.available_operators.uniq.sort).to eq(CostQuery::Operator.time_operators.sort) end end diff --git a/modules/reporting/spec/models/cost_query/operator_spec.rb b/modules/reporting/spec/models/cost_query/operator_spec.rb index 5e00339452dc..3d440f969889 100644 --- a/modules/reporting/spec/models/cost_query/operator_spec.rb +++ b/modules/reporting/spec/models/cost_query/operator_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -28,171 +30,177 @@ require File.expand_path("#{File.dirname(__FILE__)}/../../spec_helper") -RSpec.describe CostQuery, :reporting_query_helper do +RSpec.describe CostQuery::Operator, :reporting_query_helper do minimal_query let!(:project1) { create(:project, name: "project1", created_at: 5.minutes.ago) } let!(:project2) { create(:project, name: "project2", created_at: 6.minutes.ago) } - describe CostQuery::Operator do - def cost_query(table, field, operator, *values) - sql = CostQuery::SqlStatement.new table - yield sql if block_given? - operator.to_operator.modify sql, field, *values - ActiveRecord::Base.connection.select_all(sql.to_s).to_a - end + let(:admin) { create(:admin) } - def query_on_entries(field, operator, *values) - sql = CostQuery::SqlStatement.for_entries - operator.to_operator.modify sql, field, *values - ActiveRecord::Base.connection.select_all(sql.to_s).to_a - end + before do + allow(User).to receive(:current).and_return(admin) + end - def create_project(options = {}) - create(:project, options) - end + def cost_query(table, field, operator, *values) + sql = CostQuery::SqlStatement.new table + yield sql if block_given? + operator.to_operator.modify sql, field, *values + ActiveRecord::Base.connection.select_all(sql.to_s).to_a + end - it "does =" do - expect(cost_query("projects", "id", "=", project1.id).size).to eq(1) - end + def query_on_entries(field, operator, *values) + sql = CostQuery::SqlStatement.for_entries + operator.to_operator.modify sql, field, *values + ActiveRecord::Base.connection.select_all(sql.to_s).to_a + end - it "does = for multiple values" do - expect(cost_query("projects", "id", "=", project1.id, project2.id).size).to eq(2) - end + def create_project(options = {}) + create(:project, options) + end - it "does = for no values" do - sql = CostQuery::SqlStatement.new "projects" - "=".to_operator.modify sql, "id" - result = (ActiveRecord::Base.connection.select_all sql.to_s) - expect(result).to be_empty - end + it "does =" do + expect(cost_query("projects", "id", "=", project1.id).size).to eq(1) + end - it "does = for nil" do - expect(cost_query("projects", "id", "=", nil).size).to eq(0) - end + it "does = for multiple values" do + expect(cost_query("projects", "id", "=", project1.id, project2.id).size).to eq(2) + end - it "does = for empty string" do - expect(cost_query("projects", "id", "=", "").size).to eq(0) - end + it "does = for no values" do + sql = CostQuery::SqlStatement.new "projects" + "=".to_operator.modify sql, "id" + result = (ActiveRecord::Base.connection.select_all sql.to_s) + expect(result).to be_empty + end - it "does <=" do - expect(cost_query("projects", "id", "<=", project2.id - 1).size).to eq(1) - end + it "does = for nil" do + expect(cost_query("projects", "id", "=", nil).size).to eq(0) + end - it "does >=" do - expect(cost_query("projects", "id", ">=", project1.id + 1).size).to eq(1) - end + it "does = for empty string" do + expect(cost_query("projects", "id", "=", "").size).to eq(0) + end - it "does !" do - expect(cost_query("projects", "id", "!", project1.id).size).to eq(1) - end + it "does <=" do + expect(cost_query("projects", "id", "<=", project2.id - 1).size).to eq(1) + end - it "does ! for empty string" do - expect(cost_query("projects", "id", "!", "").size).to eq(0) - end + it "does >=" do + expect(cost_query("projects", "id", ">=", project1.id + 1).size).to eq(1) + end - it "does ! for multiple values" do - expect(cost_query("projects", "id", "!", project1.id, project2.id).size).to eq(0) - end + it "does !" do + expect(cost_query("projects", "id", "!", project1.id).size).to eq(1) + end - it "does !*" do - expect(cost_query("cost_entries", "project_id", "!*", []).size).to eq(0) - end + it "does ! for empty string" do + expect(cost_query("projects", "id", "!", "").size).to eq(0) + end - it "does ~ (contains)" do - expect(cost_query("projects", "name", "~", "o").size).to eq(Project.all.count { |p| p.name.include?("o") }) - expect(cost_query("projects", "name", "~", "test").size).to eq(Project.all.count { |p| p.name.include?("test") }) - expect(cost_query("projects", "name", "~", "child").size).to eq(Project.all.count { |p| p.name.include?("child") }) - end + it "does ! for multiple values" do + expect(cost_query("projects", "id", "!", project1.id, project2.id).size).to eq(0) + end - it "does !~ (not contains)" do - expect(cost_query("projects", "name", "!~", "o").size).to eq(Project.all.count { |p| p.name.exclude?("o") }) - expect(cost_query("projects", "name", "!~", "test").size).to eq(Project.all.count { |p| p.name.exclude?("test") }) - expect(cost_query("projects", "name", "!~", "child").size).to eq(Project.all.count { |p| p.name.exclude?("child") }) - end + it "does !*" do + expect(cost_query("cost_entries", "project_id", "!*", []).size).to eq(0) + end - it "does c (closed work_package)" do - expect(cost_query("work_packages", "status_id", "c") { |s| s.join Status => [WorkPackage, :status] }.size).to be >= 0 - end + it "does ~ (contains)" do + expect(cost_query("projects", "name", "~", "o").size).to eq(Project.all.count { |p| p.name.include?("o") }) + expect(cost_query("projects", "name", "~", "test").size).to eq(Project.all.count { |p| p.name.include?("test") }) + expect(cost_query("projects", "name", "~", "child").size).to eq(Project.all.count { |p| p.name.include?("child") }) + end - it "does o (open work_package)" do - expect(cost_query("work_packages", "status_id", "o") { |s| s.join Status => [WorkPackage, :status] }.size).to be >= 0 - end + it "does !~ (not contains)" do + expect(cost_query("projects", "name", "!~", "o").size).to eq(Project.all.count { |p| p.name.exclude?("o") }) + expect(cost_query("projects", "name", "!~", "test").size).to eq(Project.all.count { |p| p.name.exclude?("test") }) + expect(cost_query("projects", "name", "!~", "child").size).to eq(Project.all.count { |p| p.name.exclude?("child") }) + end - it "does give the correct number of results when counting closed and open work_packages" do - a = cost_query("work_packages", "status_id", "o") { |s| s.join Status => [WorkPackage, :status] }.size - b = cost_query("work_packages", "status_id", "c") { |s| s.join Status => [WorkPackage, :status] }.size - expect(WorkPackage.count).to eq(a + b) - end + it "does c (closed work_package)" do + expect(cost_query("work_packages", "status_id", "c") { |s| s.join Status => [WorkPackage, :status] }.size).to be >= 0 + end - it "does w (this week)" do - # somehow this test doesn't work on sundays - n = cost_query("projects", "created_at", "w").size - day_in_this_week = Time.zone.now.at_beginning_of_week + 1.day - create(:project, created_at: day_in_this_week) - expect(cost_query("projects", "created_at", "w").size).to eq(n + 1) - create(:project, created_at: day_in_this_week + 7.days) - create(:project, created_at: day_in_this_week - 7.days) - expect(cost_query("projects", "created_at", "w").size).to eq(n + 1) - end + it "does o (open work_package)" do + expect(cost_query("work_packages", "status_id", "o") { |s| s.join Status => [WorkPackage, :status] }.size).to be >= 0 + end - it "does t (today)" do - s = cost_query("projects", "created_at", "t").size - create(:project, created_at: Date.yesterday) - expect(cost_query("projects", "created_at", "t").size).to eq(s) - create(:project, created_at: Time.zone.now) - expect(cost_query("projects", "created_at", "t").size).to eq(s + 1) - end + it "does give the correct number of results when counting closed and open work_packages" do + a = cost_query("work_packages", "status_id", "o") { |s| s.join Status => [WorkPackage, :status] }.size + b = cost_query("work_packages", "status_id", "c") { |s| s.join Status => [WorkPackage, :status] }.size + expect(WorkPackage.count).to eq(a + b) + end - it "does t+ (after the day which is n days in the future)" do - n = cost_query("projects", "created_at", ">t+", 1).size - create(:project, created_at: Time.zone.now) - expect(cost_query("projects", "created_at", ">t+", 1).size).to eq(n) - create(:project, created_at: Date.tomorrow + 1) - expect(cost_query("projects", "created_at", ">t+", 1).size).to eq(n + 1) - end + it "does t- (after the day which is n days ago)" do - n = cost_query("projects", "created_at", ">t-", 1).size - create(:project, created_at: Date.today) - expect(cost_query("projects", "created_at", ">t-", 1).size).to eq(n + 1) - create(:project, created_at: Date.yesterday - 1) - expect(cost_query("projects", "created_at", ">t-", 1).size).to eq(n + 1) - end + it "does t+ (n days in the future)" do + n = cost_query("projects", "created_at", "t+", 1).size + create(:project, created_at: Date.tomorrow) + expect(cost_query("projects", "created_at", "t+", 1).size).to eq(n + 1) + create(:project, created_at: Date.tomorrow + 2) + expect(cost_query("projects", "created_at", "t+", 1).size).to eq(n + 1) + end - it "does t- (n days ago)" do - n = cost_query("projects", "created_at", "t-", 1).size - create(:project, created_at: Date.yesterday) - expect(cost_query("projects", "created_at", "t-", 1).size).to eq(n + 1) - create(:project, created_at: Date.yesterday - 2) - expect(cost_query("projects", "created_at", "t-", 1).size).to eq(n + 1) - end + it "does >t+ (after the day which is n days in the future)" do + n = cost_query("projects", "created_at", ">t+", 1).size + create(:project, created_at: Time.zone.now) + expect(cost_query("projects", "created_at", ">t+", 1).size).to eq(n) + create(:project, created_at: Date.tomorrow + 1) + expect(cost_query("projects", "created_at", ">t+", 1).size).to eq(n + 1) + end - it "does t- (after the day which is n days ago)" do + n = cost_query("projects", "created_at", ">t-", 1).size + create(:project, created_at: Time.zone.today) + expect(cost_query("projects", "created_at", ">t-", 1).size).to eq(n + 1) + create(:project, created_at: Date.yesterday - 1) + expect(cost_query("projects", "created_at", ">t-", 1).size).to eq(n + 1) + end + + it "does t- (n days ago)" do + n = cost_query("projects", "created_at", "t-", 1).size + create(:project, created_at: Date.yesterday) + expect(cost_query("projects", "created_at", "t-", 1).size).to eq(n + 1) + create(:project, created_at: Date.yesterday - 2) + expect(cost_query("projects", "created_at", "t-", 1).size).to eq(n + 1) + end + + it "does d" do - expect(cost_query("projects", "created_at", "<>d", Time.zone.now, 5.minutes.from_now).size).to eq(0) - end + it "does =d" do + # assuming that there aren't more than one project created at the same time + expect(cost_query("projects", "created_at", "=d", Project.order(Arel.sql("id ASC")).first.created_at).size).to eq(1) + end - it "does >d" do - # assuming that all projects were created in the past - expect(cost_query("projects", "created_at", ">d", Time.zone.now).size).to eq(0) - end + it "does d" do + expect(cost_query("projects", "created_at", "<>d", Time.zone.now, 5.minutes.from_now).size).to eq(0) + end + + it "does >d" do + # assuming that all projects were created in the past + expect(cost_query("projects", "created_at", ">d", Time.zone.now).size).to eq(0) + end - describe "arity" do - arities = { "t" => 0, "w" => 0, "<>d" => 2, ">d" => 1 } - arities.each do |o, a| - it("#{o} should take #{a} values") { expect(o.to_operator.arity).to eq(a) } - end + describe "arity" do + arities = { "t" => 0, "w" => 0, "<>d" => 2, ">d" => 1 } + arities.each do |o, a| + it("#{o} should take #{a} values") { expect(o.to_operator.arity).to eq(a) } end end end diff --git a/spec/components/work_packages/activities_tab/journals/revision_component_spec.rb b/spec/components/work_packages/activities_tab/journals/revision_component_spec.rb new file mode 100644 index 000000000000..e982d7b84b05 --- /dev/null +++ b/spec/components/work_packages/activities_tab/journals/revision_component_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- 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. +#++ +# +require "spec_helper" + +RSpec.describe WorkPackages::ActivitiesTab::Journals::RevisionComponent, type: :component do + describe "#remove_email_addresses" do + let(:component) { described_class.new(changeset: build(:changeset), filter: nil) } + + def render_committer(committer) + component.remove_email_addresses(committer) + end + + it "escapes HTML tags" do + committer = "OP User " + result = render_committer(committer) + + expect(result.to_s).to eq("OP User") + expect(result.to_html).not_to include("