Merge pull request #7454 from opf/feature/30501-Render-text-field-links-in-the-backend-independently-of-the-user

[30501] Render text field links in the backend independently of the user

[ci skip]
pull/7449/head
Oliver Günther 5 years ago committed by GitHub
commit 7ce157e5c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      app/assets/stylesheets/openproject/_generic.sass
  2. 61
      app/helpers/work_packages_helper.rb
  3. 8
      frontend/src/app/angular4-modules.ts
  4. 24
      frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html
  5. 36
      frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass
  6. 80
      frontend/src/app/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts
  7. 88
      frontend/src/app/globals/global-listeners/preview-trigger.service.ts
  8. 12
      lib/open_project/text_formatting/matchers/link_handlers/colon_separator.rb
  9. 8
      lib/open_project/text_formatting/matchers/link_handlers/hash_separator.rb
  10. 9
      lib/open_project/text_formatting/matchers/link_handlers/revisions.rb
  11. 17
      lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
  12. 4
      spec/lib/api/v3/repositories/revision_representer_spec.rb
  13. 93
      spec/lib/open_project/text_formatting/markdown/markdown_spec.rb
  14. 2
      spec/requests/api/v3/render_resource_spec.rb

@ -92,3 +92,11 @@
.-required-highlighting
border: 1px solid red
.-no-width
display: block
width: 0
.-no-height
display: block
height: 0

@ -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] = " (<del>#{journal.details[date].first}</del>)".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?

@ -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();

@ -0,0 +1,24 @@
<div class="preview-modal--container" *ngIf="workPackage">
<span class="type"
[ngClass]="highlightingColor(workPackage.type)"
[textContent]="workPackage.type.name">
</span>
<span class="subject -bold"
[textContent]="workPackage.subject">
</span>
<span class="author -italic -small-font"
[textContent]="'Created by ' + workPackage.author.name">
</span>
<div [ngClass]="'status ' + highlightingColor(workPackage.status, true)" >
<span [textContent]="workPackage.status.name"></span>
</div>
<div class="assignee" *ngIf="workPackage.assignee">
<user-avatar [user]="workPackage.assignee"
data-class-list="avatar-mini">
</user-avatar>
</div>
</div>

@ -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

@ -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!);
}
}
}

@ -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;
}
}

@ -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

@ -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

@ -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 },

@ -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)

@ -95,7 +95,7 @@ describe ::API::V3::Repositories::RevisionRepresenter do
id = work_package.id
str = 'Totally references <a'
str << " class=\"issue work_package status-1 priority-1\""
str << " class=\"issue work_package preview-trigger status-1 priority-1\""
str << " title=\"#{work_package.subject} (#{work_package.status})\""
str << " href=\"/work_packages/#{id}\">"
str << "##{id}</a>"
@ -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

@ -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("<p>#{issue_link}, [#{issue_link}], (#{issue_link}) and #{issue_link}.</p>") }
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("<p>#{issue_link}</p>") }
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("<p>user##{linked_project_member.id}</p>")
is_expected.to be_html_eql("<p>#{link_to(linked_project_member.name, { controller: :users, action: :show, id: linked_project_member.id }, title: "User #{linked_project_member.name}", class: 'user-mention')}</p>")
}
end
end
@ -434,7 +355,7 @@ describe OpenProject::TextFormatting,
subject { format_text("user:\"#{linked_project_member.login}\"") }
it {
is_expected.to be_html_eql("<p>user:\"#{linked_project_member.login}\"</p>")
is_expected.to be_html_eql("<p>#{link_to(linked_project_member.name, { controller: :users, action: :show, id: linked_project_member.id }, title: "User #{linked_project_member.name}", class: 'user-mention')}</p>")
}
end
end
@ -706,7 +627,7 @@ describe OpenProject::TextFormatting,
let(:expected) do
<<~EXPECTED
<p><a class="wiki-page" href="/projects/#{project.identifier}/wiki/cookbook-documentation">CookBook documentation</a></p>
<p><a class="issue work_package status-3 priority-1 created-by-me" href="/work_packages/#{issue.id}" title="#{issue.subject} (#{issue.status})">##{issue.id}</a></p>
<p><a class="issue work_package preview-trigger status-3 priority-1 created-by-me" href="/work_packages/#{issue.id}" title="#{issue.subject} (#{issue.status})">##{issue.id}</a></p>
<pre><code>
[[CookBook documentation]]

@ -83,7 +83,7 @@ describe 'API v3 Render resource', type: :request do
let(:title) { "#{work_package.subject} (#{work_package.status})" }
let(:text) {
'<p>Hello World! Have a look at <a '\
"class=\"issue work_package status-1 priority-1\" "\
"class=\"issue work_package preview-trigger status-1 priority-1\" "\
"href=\"#{href}\" "\
"title=\"#{title}\">##{id}</a></p>"
}

Loading…
Cancel
Save