diff --git a/app/assets/stylesheets/openproject/_generic.sass b/app/assets/stylesheets/openproject/_generic.sass index 32cb206992..5582932d30 100644 --- a/app/assets/stylesheets/openproject/_generic.sass +++ b/app/assets/stylesheets/openproject/_generic.sass @@ -92,3 +92,11 @@ .-required-highlighting border: 1px solid red + +.-no-width + display: block + width: 0 + +.-no-height + display: block + height: 0 diff --git a/app/helpers/work_packages_helper.rb b/app/helpers/work_packages_helper.rb index 0ece0f99ff..ed83dfe5f6 100644 --- a/app/helpers/work_packages_helper.rb +++ b/app/helpers/work_packages_helper.rb @@ -151,65 +151,6 @@ module WorkPackagesHelper end.html_safe end - def work_package_quick_info(work_package, only_path: true) - changed_dates = {} - - journals = work_package.journals.where(['created_at >= ?', Date.today.to_time - 7.day]) - .order(Arel.sql('created_at desc')) - - journals.each do |journal| - break if changed_dates['start_date'] && changed_dates['due_date'] - - ['start_date', 'due_date'].each do |date| - if changed_dates[date].nil? && - journal.details[date] && - journal.details[date].first - changed_dates[date] = " (#{journal.details[date].first})".html_safe - end - end - end - - - link = link_to_work_package(work_package, status: true, only_path: only_path) - - # Don't print dates if neither start nor due set - start = work_package.start_date&.to_s - due = work_package.due_date&.to_s - - if start.nil? && due.nil? - return link - end - - # Otherwise, print concise - # (2018-01-01 -) - # (- 2018-01-01) - # (2018-01-01 - 2018-01-01) - link << - if start.nil? - " (- #{due})" - elsif due.nil? - " (#{start} -)" - else - " (#{start} - #{due})" - end - - link - end - - def work_package_quick_info_with_description(work_package, lines = 3, only_path: true) - description = truncated_work_package_description(work_package, lines) - - link = work_package_quick_info(work_package, only_path: only_path) - - attributes = info_user_attributes(work_package) - - link += content_tag(:div, attributes, class: 'indent quick_info attributes') - - link += content_tag(:div, description, class: 'indent quick_info description') - - link - end - def work_package_list(work_packages, &_block) ancestors = [] work_packages.each do |work_package| @@ -238,7 +179,7 @@ module WorkPackagesHelper # Returns a string of css classes that apply to the issue def work_package_css_classes(work_package) # TODO: remove issue once css is cleaned of it - s = 'issue work_package'.html_safe + s = 'issue work_package preview-trigger'.html_safe s << " status-#{work_package.status.position}" if work_package.status s << " priority-#{work_package.priority.position}" if work_package.priority s << ' closed' if work_package.closed? diff --git a/frontend/src/app/angular4-modules.ts b/frontend/src/app/angular4-modules.ts index 056aa716f0..e8236c3a72 100644 --- a/frontend/src/app/angular4-modules.ts +++ b/frontend/src/app/angular4-modules.ts @@ -89,6 +89,8 @@ import {FormsCacheService} from "core-components/forms/forms-cache.service"; import {OpenprojectAdminModule} from "core-app/modules/admin/openproject-admin.module"; import {OpenprojectDashboardsModule} from "core-app/modules/dashboards/openproject-dashboards.module"; import {OpenprojectWorkPackageGraphsModule} from "core-app/modules/work-package-graphs/openproject-work-package-graphs.module"; +import {WpPreviewModal} from "core-components/modals/preview-modal/wp-preview-modal/wp-preview.modal"; +import {PreviewTriggerService} from "core-app/globals/global-listeners/preview-trigger.service"; @NgModule({ imports: [ @@ -172,6 +174,7 @@ import {OpenprojectWorkPackageGraphsModule} from "core-app/modules/work-package- // Augmenting Rails ModalWrapperAugmentService, + PreviewTriggerService, ], declarations: [ OpContextMenuTrigger, @@ -180,6 +183,7 @@ import {OpenprojectWorkPackageGraphsModule} from "core-app/modules/work-package- ConfirmDialogModal, DynamicContentModal, PasswordConfirmationModal, + WpPreviewModal, // Main menu MainMenuResizerComponent, @@ -202,6 +206,7 @@ import {OpenprojectWorkPackageGraphsModule} from "core-app/modules/work-package- ConfirmDialogModal, PasswordConfirmationModal, AttributeHelpTextModal, + WpPreviewModal, // Main menu MainMenuResizerComponent, @@ -233,6 +238,7 @@ export function initializeServices(injector:Injector) { const ExternalQueryConfiguration = injector.get(ExternalQueryConfigurationService); const ExternalRelationQueryConfiguration = injector.get(ExternalRelationQueryConfigurationService); const ModalWrapper = injector.get(ModalWrapperAugmentService); + const PreviewTrigger = injector.get(PreviewTriggerService); const EditorMacros = injector.get(EditorMacrosService); const mainMenuNavigationService = injector.get(MainMenuNavigationService); @@ -241,6 +247,8 @@ export function initializeServices(injector:Injector) { // Setup modal wrapping ModalWrapper.setupListener(); + PreviewTrigger.setupListener(); + // Setup query configuration listener ExternalQueryConfiguration.setupListener(); ExternalRelationQueryConfiguration.setupListener(); diff --git a/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html b/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html new file mode 100644 index 0000000000..553ca424dd --- /dev/null +++ b/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html @@ -0,0 +1,24 @@ +
+ + + + + + + + + +
+ +
+ +
+ + +
+
diff --git a/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass b/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass new file mode 100644 index 0000000000..69d97caba6 --- /dev/null +++ b/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass @@ -0,0 +1,36 @@ +@import "helpers" + +.preview-modal--container + position: absolute + z-index: 5000 + min-width: 200px + max-width: 400px + min-height: 100px + max-height: 300px + background: white + padding: 15px + border: 1px solid lightgrey + border-radius: 3px + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.5) + + display: grid + grid-template: auto auto auto / auto auto 1fr 1fr + grid-column-gap: 5px + grid-row-gap: 10px + grid-template-areas: "type subject subject subject" "author author author author" "status status . assignee" + +.status + grid-area: status + padding: 0px 10px + +.type + grid-area: type + +.author + grid-area: author + +.subject + grid-area: subject + +.assignee + grid-area: assignee diff --git a/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts b/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts new file mode 100644 index 0000000000..db287ad5dc --- /dev/null +++ b/frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts @@ -0,0 +1,80 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// 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 doc/COPYRIGHT.rdoc for more details. +// ++ + +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnInit} from "@angular/core"; +import {OpModalComponent} from "core-components/op-modals/op-modal.component"; +import {OpModalLocalsToken, OpModalService} from "core-components/op-modals/op-modal.service"; +import {OpModalLocalsMap} from "core-components/op-modals/op-modal.types"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; +import {WorkPackageDmService} from "core-app/modules/hal/dm-services/work-package-dm.service"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; +import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions"; + +@Component({ + templateUrl: './wp-preview.modal.html', + styleUrls: ['./wp-preview.modal.sass'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WpPreviewModal extends OpModalComponent implements OnInit { + public workPackage:WorkPackageResource; + + public text = { + created_by: this.i18n.t('js.label_created_by'), + }; + + constructor(readonly elementRef:ElementRef, + @Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap, + readonly cdRef:ChangeDetectorRef, + readonly i18n:I18nService, + readonly workPackageDmService:WorkPackageDmService, + readonly opModalService:OpModalService) { + super(locals, cdRef, elementRef); + } + + + ngOnInit() { + super.ngOnInit(); + const workPackageLink = this.locals.workPackageLink; + const workPackageId = HalResource.idFromLink(workPackageLink); + + this.workPackageDmService.loadWorkPackageById(workPackageId) + .then((workPackage:WorkPackageResource) => { + this.workPackage = workPackage; + this.cdRef.detectChanges(); + }); + } + + highlightingColor(resource:HalResource, background:boolean = false) { + if (background) { + return Highlighting.backgroundClass(resource.$halType.toLowerCase(), resource.id!); + } else { + return Highlighting.inlineClass(resource.$halType.toLowerCase(), resource.id!); + } + } +} diff --git a/frontend/src/app/globals/global-listeners/preview-trigger.service.ts b/frontend/src/app/globals/global-listeners/preview-trigger.service.ts new file mode 100644 index 0000000000..d173b07d05 --- /dev/null +++ b/frontend/src/app/globals/global-listeners/preview-trigger.service.ts @@ -0,0 +1,88 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// 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 doc/COPYRIGHT.rdoc for more details. +// ++ + + +import {Injectable, Injector} from "@angular/core"; +import {OpModalService} from "core-components/op-modals/op-modal.service"; +import {WpPreviewModal} from "core-components/modals/preview-modal/wp-preview-modal/wp-preview.modal"; +import {OpModalComponent} from "core-components/op-modals/op-modal.component"; + +@Injectable() +export class PreviewTriggerService { + private previewModal:OpModalComponent; + private modalElement:HTMLElement; + + constructor(readonly opModalService:OpModalService, + readonly injector:Injector) { + } + + setupListener() { + jQuery(document.body).on('mouseenter', '.preview-trigger', (e) => { + e.preventDefault(); + e.stopPropagation(); + const el = jQuery(e.target); + + this.previewModal = this.opModalService.show(WpPreviewModal, this.injector, { workPackageLink: el.attr("href") }); + this.modalElement = this.previewModal.elementRef.nativeElement; + jQuery(this.modalElement).position({ + my: 'left top', + at: 'left bottom', + of: el, + collision: 'flipfit' + }); + + jQuery(this.modalElement).addClass('-no-width -no-height'); + }); + + jQuery(document.body).on('mouseleave', '.preview-trigger', (e:JQueryEventObject) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.isMouseOverPreview(e)) { + jQuery(this.modalElement).on('mouseleave', () => { + this.opModalService.close(); + }); + } else { + this.opModalService.close(); + } + }); + } + + private isMouseOverPreview(evt:JQueryEventObject) { + const previewElement = jQuery(this.modalElement.children[0]); + if (previewElement && previewElement.offset()) { + let horizontalHover = evt.pageX >= Math.floor(previewElement.offset()!.left) && + evt.pageX < previewElement.offset()!.left + previewElement.width()!; + let verticalHover = evt.pageY >= Math.floor(previewElement.offset()!.top) && + evt.pageY < previewElement.offset()!.top + previewElement.height()!; + return horizontalHover && verticalHover; + } + return false; + } + +} diff --git a/lib/open_project/text_formatting/matchers/link_handlers/colon_separator.rb b/lib/open_project/text_formatting/matchers/link_handlers/colon_separator.rb index 3363b931f7..b196c83748 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/colon_separator.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/colon_separator.rb @@ -82,7 +82,7 @@ module OpenProject::TextFormatting::Matchers private def render_version - if project && (version = project.versions.visible.find_by(name: oid)) + if project && (version = project.versions.find_by(name: oid)) link_to h(version.name), { only_path: context[:only_path], controller: '/versions', action: 'show', id: version }, class: 'version' @@ -90,8 +90,9 @@ module OpenProject::TextFormatting::Matchers end def render_commit - if project&.repository && (changeset = Changeset.visible.where(['repository_id = ? AND scmid LIKE ?', project.repository.id, "#{oid}%"]).first) - link_to h("#{project_prefix}#{name}"), + if project&.repository && + (changeset = Changeset.where(['repository_id = ? AND scmid LIKE ?', project.repository.id, "#{oid}%"]).first) + link_to h("#{matcher.project_prefix}#{matcher.identifier}"), { only_path: context[:only_path], controller: '/repositories', action: 'revision', project_id: project, rev: changeset.identifier }, class: 'changeset', title: truncate_single_line(changeset.comments, length: 100) @@ -99,7 +100,7 @@ module OpenProject::TextFormatting::Matchers end def render_source - if project&.repository && User.current.allowed_to?(:browse_repository, project) + if project&.repository matcher.identifier =~ %r{\A[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?\z} path = $1 rev = $3 @@ -128,7 +129,6 @@ module OpenProject::TextFormatting::Matchers def render_project p = Project - .visible .where(['projects.identifier = :s OR LOWER(projects.name) = :s', { s: oid.downcase }]) .first if p @@ -137,7 +137,7 @@ module OpenProject::TextFormatting::Matchers end def render_user - if (user = User.in_visible_project.find_by(login: oid)) + if (user = User.find_by(login: oid)) link_to_user(user, only_path: context[:only_path], class: 'user-mention') end end diff --git a/lib/open_project/text_formatting/matchers/link_handlers/hash_separator.rb b/lib/open_project/text_formatting/matchers/link_handlers/hash_separator.rb index 27f0c35f8a..d82ec3ac93 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/hash_separator.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/hash_separator.rb @@ -60,7 +60,7 @@ module OpenProject::TextFormatting::Matchers private def render_version - version = Version.visible.find_by(id: oid) + version = Version.find_by(id: oid) if version link_to h(version.name), { only_path: context[:only_path], controller: '/versions', action: 'show', id: version }, @@ -69,21 +69,21 @@ module OpenProject::TextFormatting::Matchers end def render_message - message = Message.visible.includes(:parent).find_by(id: oid) + message = Message.includes(:parent).find_by(id: oid) if message link_to_message(message, { only_path: context[:only_path] }, class: 'message') end end def render_project - p = Project.visible.find_by(id: oid) + p = Project.find_by(id: oid) if p link_to_project(p, { only_path: context[:only_path] }, class: 'project') end end def render_user - user = User.in_visible_project.find_by(id: oid) + user = User.find_by(id: oid) if user link_to_user(user, only_path: context[:only_path], class: 'user-mention') end diff --git a/lib/open_project/text_formatting/matchers/link_handlers/revisions.rb b/lib/open_project/text_formatting/matchers/link_handlers/revisions.rb index f2fb082419..09df28602f 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/revisions.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/revisions.rb @@ -32,8 +32,8 @@ module OpenProject::TextFormatting::Matchers module LinkHandlers class Revisions < Base ## - # Match work package links. - # Condition: Separator is #|##|### + # Match revision links. + # Condition: Separator is r # Condition: Prefix is nil def applicable? matcher.prefix.nil? && matcher.sep == 'r' @@ -42,14 +42,13 @@ module OpenProject::TextFormatting::Matchers # # Examples: # - # #1234, ##1234, ###1234 + # r11, r13 def call # don't handle link unless repository exists return nil unless project && project.repository - changeset = Changeset.visible.find_by(repository_id: project.repository.id, revision: matcher.identifier) + changeset = project.repository.find_changeset_by_name(matcher.identifier) - # don't handle link unless changeset can be seen if changeset link_to(h("#{matcher.project_prefix}r#{matcher.identifier}"), { only_path: context[:only_path], controller: '/repositories', action: 'revision', project_id: project, rev: changeset.revision }, diff --git a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb index 935b8ab1a5..e791a52799 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb @@ -65,23 +65,14 @@ module OpenProject::TextFormatting::Matchers end def render_work_package_link(work_package) - if matcher.sep == '##' - return work_package_quick_info(work_package, only_path: context[:only_path]) - elsif matcher.sep == '###' && !context[:no_nesting] - return work_package_quick_info_with_description(work_package, only_path: context[:only_path]) - end - - if matcher.sep == '#' || (matcher.sep == '###' && context[:no_nesting]) - link_to("#{matcher.sep}#{work_package.id}", - work_package_path_or_url(id: work_package.id, only_path: context[:only_path]), - class: work_package_css_classes(work_package), - title: "#{truncate(work_package.subject, escape: false, length: 100)} (#{work_package.status.try(:name)})") - end + link_to("##{work_package.id}", + work_package_path_or_url(id: work_package.id, only_path: context[:only_path]), + class: work_package_css_classes(work_package), + title: "#{truncate(work_package.subject, escape: false, length: 100)} (#{work_package.status.try(:name)})") end def find_work_package(oid) WorkPackage - .visible .includes(:status) .references(:statuses) .find_by(id: oid) diff --git a/spec/lib/api/v3/repositories/revision_representer_spec.rb b/spec/lib/api/v3/repositories/revision_representer_spec.rb index a9f1da8909..77fffc5414 100644 --- a/spec/lib/api/v3/repositories/revision_representer_spec.rb +++ b/spec/lib/api/v3/repositories/revision_representer_spec.rb @@ -95,7 +95,7 @@ describe ::API::V3::Repositories::RevisionRepresenter do id = work_package.id str = 'Totally references " str << "##{id}" @@ -104,7 +104,7 @@ describe ::API::V3::Repositories::RevisionRepresenter do before do allow(User).to receive(:current).and_return(FactoryBot.build_stubbed(:admin)) allow(WorkPackage) - .to receive_message_chain('visible.includes.references.find_by') + .to receive_message_chain('includes.references.find_by') .and_return(work_package) end diff --git a/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb b/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb index 35439dc90a..ea4ad07400 100644 --- a/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb +++ b/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb @@ -101,16 +101,8 @@ describe OpenProject::TextFormatting, before do allow(project).to receive(:repository).and_return(repository) - changesets = [changeset1, changeset2] - - allow(Changeset).to receive(:visible).and_return(changesets) - - changesets.each do |changeset| - allow(changesets) - .to receive(:find_by) - .with(repository_id: project.repository.id, revision: changeset.revision) - .and_return(changeset) - end + allow(repository).to receive(:find_changeset_by_name).with(changeset1.revision).and_return(changeset1) + allow(repository).to receive(:find_changeset_by_name).with(changeset2.revision).and_return(changeset2) end context 'Single link' do @@ -222,7 +214,7 @@ describe OpenProject::TextFormatting, let(:issue_link) do link_to("##{issue.id}", work_package_path(issue), - class: 'issue work_package status-3 priority-1 created-by-me', title: "#{issue.subject} (#{issue.status})") + class: 'issue work_package preview-trigger status-3 priority-1 created-by-me', title: "#{issue.subject} (#{issue.status})") end context 'Plain issue link' do @@ -231,55 +223,6 @@ describe OpenProject::TextFormatting, it { is_expected.to be_html_eql("

#{issue_link}, [#{issue_link}], (#{issue_link}) and #{issue_link}.

") } end - describe 'quickinfo' do - subject { format_text("###{issue.id}") } - - let(:issue) do - FactoryBot.create :work_package, - project: project, - author: project_member, - type: project.types.first, - start_date: start_date, - due_date: due_date - end - - context 'no dates' do - let(:start_date) { nil } - let(:due_date) { nil } - - it 'prints no quickinfo with dates' do - puts subject - end - end - - context 'start date' do - let(:start_date) { Date.today } - let(:due_date) { nil } - - it 'prints no quickinfo with dates' do - expect(subject).to include "(#{start_date.to_s} -)" - end - end - - context 'due date' do - let(:start_date) { nil } - let(:due_date) { Date.today } - - it 'prints quickinfo with start date' do - expect(subject).to include "(- #{due_date.to_s})" - end - end - - context 'both date' do - let(:start_date) { Date.today } - let(:due_date) { Date.today + 1.day } - - it 'prints quickinfo with dates' do - expect(subject).to include "(#{start_date.to_s} - #{due_date.to_s})" - end - end - end - context 'Plain issue link with braces' do subject { format_text("foo (bar ##{issue.id})") } @@ -312,7 +255,7 @@ describe OpenProject::TextFormatting, let(:issue_link) do link_to("##{issue.id}", work_package_path(issue), - class: 'issue work_package status-3 priority-1 created-by-me', + class: 'issue work_package preview-trigger status-3 priority-1 created-by-me', title: "#{issue.subject} (#{issue.status})") end @@ -320,28 +263,6 @@ describe OpenProject::TextFormatting, it { is_expected.to be_html_eql("

#{issue_link}

") } end - context 'Cyclic Description Links' do - let(:issue2) do - FactoryBot.create :work_package, - project: project, - author: project_member, - type: project.types.first - end - - before do - issue2.description = "####{issue.id}" - issue2.save! - issue.description = "####{issue2.id}" - issue.save! - end - - subject { format_text issue, :description } - - it "doesn't replace description links with a cycle" do - expect(subject).to match("###{issue.id}") - end - end - context 'Description links' do subject { format_text issue, :description } @@ -402,7 +323,7 @@ describe OpenProject::TextFormatting, subject { format_text("user##{linked_project_member.id}") } it { - is_expected.to be_html_eql("

user##{linked_project_member.id}

") + is_expected.to be_html_eql("

#{link_to(linked_project_member.name, { controller: :users, action: :show, id: linked_project_member.id }, title: "User #{linked_project_member.name}", class: 'user-mention')}

") } end end @@ -434,7 +355,7 @@ describe OpenProject::TextFormatting, subject { format_text("user:\"#{linked_project_member.login}\"") } it { - is_expected.to be_html_eql("

user:\"#{linked_project_member.login}\"

") + is_expected.to be_html_eql("

#{link_to(linked_project_member.name, { controller: :users, action: :show, id: linked_project_member.id }, title: "User #{linked_project_member.name}", class: 'user-mention')}

") } end end @@ -706,7 +627,7 @@ describe OpenProject::TextFormatting, let(:expected) do <<~EXPECTED

CookBook documentation

-

##{issue.id}

+

##{issue.id}


           [[CookBook documentation]]
 
diff --git a/spec/requests/api/v3/render_resource_spec.rb b/spec/requests/api/v3/render_resource_spec.rb
index 7a4b4c2034..4de97d87fc 100644
--- a/spec/requests/api/v3/render_resource_spec.rb
+++ b/spec/requests/api/v3/render_resource_spec.rb
@@ -83,7 +83,7 @@ describe 'API v3 Render resource', type: :request do
             let(:title) { "#{work_package.subject} (#{work_package.status})" }
             let(:text) {
               '

Hello World! Have a look at ##{id}

" }