Replace export modal with job status modal

pull/8453/head
Oliver Günther 4 years ago
parent ddcefeebf7
commit 373b673f1a
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 96
      app/controllers/work_packages/exports_controller.rb
  2. 5
      app/controllers/work_packages_controller.rb
  3. 5
      app/services/work_packages/exports/schedule_service.rb
  4. 18
      app/workers/work_packages/exports/export_job.rb
  5. 1
      config/locales/en.yml
  6. 3
      config/routes.rb
  7. 14
      frontend/src/app/components/modals/export-modal/wp-table-export.modal.html
  8. 113
      frontend/src/app/components/modals/export-modal/wp-table-export.modal.ts
  9. 2
      frontend/src/app/modules/hal/services/hal-resource.service.ts
  10. 5
      frontend/src/app/modules/job-status/display-job-page/display-job-page.component.ts
  11. 29
      frontend/src/app/modules/job-status/job-status-modal/job-status.modal.html
  12. 3
      frontend/src/app/modules/job-status/job-status-modal/job-status.modal.sass
  13. 50
      frontend/src/app/modules/job-status/job-status-modal/job-status.modal.ts
  14. 2
      modules/job_status/config/locales/js-en.yml
  15. 4
      modules/job_status/lib/open_project/job_status/engine.rb
  16. 260
      spec/controllers/work_packages/exports_controller_spec.rb
  17. 13
      spec/features/work_packages/export_spec.rb
  18. 39
      spec/routing/work_packages/exports_spec.rb

@ -1,96 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
class WorkPackages::ExportsController < ApplicationController
self._model_object = WorkPackages::Export
before_action :find_model_object,
:authorize_current_user
before_action :find_job_status, only: :status
def show
if @export.ready?
redirect_to attachment_content_path
else
headers['Link'] = "<#{status_work_packages_export_path(@export.id, format: :json)}> rel=\"status\""
head 202
end
end
def status
if @job_status.success?
headers['Link'] = "<#{attachment_content_path}> rel=\"download\""
end
respond_to do |format|
format.json do
render json: status_json_body(@job_status),
status: 200
end
end
end
private
def status_json_body(job)
body = { status: job_status(job), message: job.message }
if job.success?
body[:link] = attachment_content_path
end
body
end
def job_status(job)
if job.success?
'Completed'
elsif job.failure? || job.error?
'Error'
else
'Processing'
end
end
def authorize_current_user
deny_access(not_found: true) unless @export.visible?(current_user)
end
def attachment_content_path
# Not including the API PathHelper here as it messes up error rendering probably due to it
# including the url helper again.
::API::V3::Utilities::PathHelper::ApiV3Path.attachment_content(@export.attachments.first.id)
end
def find_job_status
@job_status = Delayed::Job::Status.of_reference(@export).first
end
end

@ -83,12 +83,11 @@ class WorkPackagesController < ApplicationController
protected
def export_list(mime_type)
export_storage = WorkPackages::Exports::ScheduleService
job_id = WorkPackages::Exports::ScheduleService
.new(user: current_user)
.call(query: @query, mime_type: mime_type, params: params)
.result
redirect_to work_packages_export_path(export_storage.id)
render plain: job_id
end
def export_single(mime_type)

@ -37,15 +37,16 @@ class WorkPackages::Exports::ScheduleService
def call(query:, mime_type:, params: {})
export_storage = WorkPackages::Export.create
schedule_export(export_storage, mime_type, params, query)
job = schedule_export(export_storage, mime_type, params, query)
ServiceResult.new success: true, result: export_storage
ServiceResult.new success: true, result: job.job_id
end
private
def schedule_export(export_storage, mime_type, params, query)
WorkPackages::Exports::ExportJob.perform_later(export: export_storage,
user: user,
mime_type: mime_type,
options: params,
query: serialize_query(query),

@ -1,8 +1,8 @@
module WorkPackages
module Exports
class ExportJob < ::ApplicationJob
def perform(export:, mime_type:, query:, query_attributes:, options:)
User.execute_as export.user do
def perform(export:, user:, mime_type:, query:, query_attributes:, options:)
User.execute_as user do
query = set_query_props(query || Query.new, query_attributes)
export_work_packages(export, mime_type, query, options)
@ -14,6 +14,10 @@ module WorkPackages
arguments.first[:export]
end
def updates_own_status?
true
end
private
def export_work_packages(export, mime_type, query, options)
@ -61,9 +65,15 @@ module WorkPackages
end
def store_attachment(storage, file)
Attachments::CreateService
.new(storage, author: storage.user)
attachment = Attachments::CreateService
.new(storage, author: User.current)
.call(uploaded_file: file, description: '')
download_url = ::API::V3::Utilities::PathHelper::ApiV3Path.attachment_content(attachment.id)
update_status status: :success,
message: I18n.t('export.succeeded'),
payload: download_payload(download_url)
end
end
end

@ -1159,6 +1159,7 @@ en:
work_package_note: 'Work Package note added'
export:
succeeded: "The export has completed successfully."
format:
atom: "Atom"
csv: "CSV"

@ -410,9 +410,6 @@ OpenProject::Application.routes.draw do
namespace :work_packages do
match 'auto_complete' => 'auto_completes#index', via: %i[get post]
resources :exports, only: [:show] do
get 'status', action: :status, on: :member
end
resources :calendar, controller: 'calendars', only: [:index]
resource :bulk, controller: 'bulk', only: %i[edit update destroy]
# FIXME: this is kind of evil!! We need to remove this soonest and

@ -23,19 +23,5 @@
</a>
</li>
</ul>
<div *ngIf="isLoading">
<div class="loading-indicator--location"
data-indicator-name="modal">
</div>
<div [textContent]="text.exportPreparing"></div>
</div>
<a #downloadLink
id="download-link"
[title]="'Export'"
class="button"
download
[attr.href]="downloadHref">
</a>
</div>
</div>

@ -16,29 +16,21 @@ import {
import {switchMap, takeWhile, map} from 'rxjs/operators';
import {interval, Observable, Subscription} from 'rxjs';
import {NotificationsService} from "core-app/modules/common/notifications/notifications.service";
import {JobStatusModal} from "core-app/modules/job-status/job-status-modal/job-status.modal";
interface ExportLink extends HalLink {
identifier:string;
}
/*
/**
Modal for exporting work packages to different formats. The user may choose from a variety of formats (e.g. PDF and CSV).
The backend may choose to provide the export right away (synchronously) or delayed. In the later case, the modal will poll the
backend until the export is done.
Because the modal has to deal with both cases, without knowing upfront whether the download will be delayed or not, it needs
to treat every download expecting it to be delayed. Because of this, the modal cannot simply provide download hrefs which would
allow the browser to download the export but rather has to first check whether an export is delayed or not, and if it is delayed,
it has to wait until the export is ready. Because of the necessary check, the modal has a hidden link that it clicks on to perform
the actual download once the export is ready (delayed or not).
The modal might also be used to only display the progress of an export. This will happen if a link for exporting is provided via the locals.
*/
@Component({
templateUrl: './wp-table-export.modal.html',
styleUrls: ['./wp-table-export.modal.sass']
})
export class WpTableExportModal extends OpModalComponent implements OnInit, OnDestroy {
export class WpTableExportModal extends OpModalComponent implements OnInit {
/* Close on escape? */
public closeOnEscape = true;
@ -55,12 +47,6 @@ export class WpTableExportModal extends OpModalComponent implements OnInit, OnDe
exportPreparing: this.I18n.t('js.label_export_preparing')
};
public downloadHref:string;
public isLoading = false;
private subscription?:Subscription;
@ViewChild('downloadLink') downloadLink:ElementRef;
constructor(@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly I18n:I18nService,
readonly elementRef:ElementRef,
@ -77,7 +63,7 @@ export class WpTableExportModal extends OpModalComponent implements OnInit, OnDe
super.ngOnInit();
if (this.locals.link) {
this.downloadSwitched(this.locals.link);
this.requestExport(this.locals.link);
} else {
this.querySpace.results
.valuesPromise()
@ -85,11 +71,6 @@ export class WpTableExportModal extends OpModalComponent implements OnInit, OnDe
}
}
ngOnDestroy() {
super.ngOnDestroy();
this.safeUnsubscribe();
}
private buildExportOptions(results:WorkPackageCollectionResource) {
return results.representations.map(format => {
const link = format.$link as ExportLink;
@ -105,55 +86,30 @@ export class WpTableExportModal extends OpModalComponent implements OnInit, OnDe
private triggerByLink(url:string, event:MouseEvent) {
event.preventDefault();
this.downloadSwitched(url);
}
private downloadSwitched(url:string) {
this
.performRequest(url)
.requestExport(url)
.subscribe(
(data) => {
if (data.status === 200) {
this.download(data.url!);
}
if (data.status === 202 && this.linkHeaderUrl(data)) {
this.pollUntilDownload(this.linkHeaderUrl(data)!);
}
},
(error:HttpErrorResponse) => this.handleError(error.message));
}
private pollUntilDownload(url:string) {
this.isLoading = true;
this.subscription = interval(1000)
.pipe(
switchMap(() => this.performRequest(url)),
takeWhile(response => response.status === 200, true),
map(response => JSON.parse(response.body)),
takeWhile(body => body.status === 'Processing', true),
withDelayedLoadingIndicator(this.loadingIndicator.getter('modal')),
).subscribe(body => {
if (body.status === 'Completed') {
this.download(body.link!);
} else if (body.status === 'Error') {
this.handleError(body.message);
}
},
error => this.handleError(error.message),
() => this.isLoading = false
);
jobId => this.replaceWithJobModal(jobId),
error => this.handleError(error)
);
}
private performRequest(url:string):Observable<HttpResponse<any>> {
/**
* Request the export link and return the job ID to observe
*
* @param url
*/
private requestExport(url:string):Observable<string> {
return this
.httpClient
.get(url, { observe: 'response', responseType: 'text' });
.get(url, { observe: 'body', responseType: 'text' });
}
private replaceWithJobModal(jobId:string) {
this.service.show(JobStatusModal, 'global', { jobId: jobId });
}
private handleError(error:string) {
this.isLoading = false;
this.notifications.addError(error || this.I18n.t('js.error.internal'));
}
@ -175,35 +131,4 @@ export class WpTableExportModal extends OpModalComponent implements OnInit, OnDe
protected get afterFocusOn():JQuery {
return jQuery('#work-packages-settings-button');
}
private download(url:string) {
this.downloadHref = url;
setTimeout(() => {
this.downloadLink.nativeElement.click();
this.service.close();
});
}
private linkHeaderUrl(data:HttpResponse<any>) {
let link = data.headers.get('link');
if (!link) {
return null;
}
let match = link.match(/<([^>]+)>/);
if (!match) {
return null;
} else {
return match[1];
}
}
private safeUnsubscribe() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}

@ -307,7 +307,7 @@ export class HalResourceService {
}
/**
* Get the hal type for an attribute.
* Get the hal type for an attributeex
*
* @param type
* @param attribute

@ -11,13 +11,12 @@ import {take} from "rxjs/operators";
export class DisplayJobPageComponent implements AfterViewInit, OnDestroy {
private modal?:OpModalComponent;
constructor(private injector:Injector,
private $state:StateService,
constructor(private $state:StateService,
private modalService:OpModalService) {
}
ngAfterViewInit() {
this.modal = this.modalService.show(JobStatusModal, this.injector, { jobId: this.$state.params.jobId });
this.modal = this.modalService.show(JobStatusModal, 'global', { jobId: this.$state.params.jobId });
this.modal
.closingEvent
.pipe(

@ -1,5 +1,5 @@
<div class="op-modal--portal">
<div class="op-modal--modal-container wp-table--configuration-modal"
<div class="op-modal--modal-container job-status--modal"
tabindex="0">
<div class="op-modal--modal-header">
<a class="op-modal--modal-close-button">
@ -16,22 +16,21 @@
<div>
<div class="loading-indicator--location"
data-indicator-name="modal">
<div class="status-icon-wrapper" *ngIf="!isLoading">
<ng-container [ngSwitch]="status">
<span *ngSwitchCase="'error'" class="icon-big icon-error"></span>
<span *ngSwitchCase="'success'" class="icon-big icon-checkmark"></span>
</ng-container>
<div class="status-icon-wrapper" *ngIf="!isLoading && statusIcon">
<span [ngClass]="statusIcon" class="icon-big"></span>
</div>
</div>
<div [textContent]="message"></div>
<div>
<span [textContent]="message"></span>
<span [hidden]="!downloadHref">
{{ text.download_starts }}
<a #downloadLink
download
[textContent]="text.click_to_download"
[attr.href]="downloadHref">
</a>
</span>
</div>
</div>
<a #downloadLink
id="download-link"
[title]="'Export'"
class="button"
download
[attr.href]="downloadHref">
</a>
</div>
</div>

@ -1,6 +1,3 @@
#download-link
display: none
.loading-indicator--location
height: 220px

@ -1,4 +1,4 @@
import {ChangeDetectorRef, Component, ElementRef, Inject, OnInit} from '@angular/core';
import {ChangeDetectorRef, Component, ElementRef, Inject, OnInit, ViewChild} from '@angular/core';
import {OpModalLocalsMap} from 'core-components/op-modals/op-modal.types';
import {OpModalComponent} from 'core-components/op-modals/op-modal.component';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@ -31,6 +31,8 @@ export class JobStatusModal extends OpModalComponent implements OnInit {
title: this.I18n.t('js.job_status.title'),
closePopup: this.I18n.t('js.close_popup_title'),
redirect: this.I18n.t('js.job_status.redirect'),
download_starts: this.I18n.t('js.job_status.download_starts'),
click_to_download: this.I18n.t('js.job_status.click_to_download'),
};
/** The job ID reference */
@ -42,12 +44,17 @@ export class JobStatusModal extends OpModalComponent implements OnInit {
/** The current status */
public status:JobStatusEnum;
/** An associated icon to render, if any */
public statusIcon:string|null;
/** Public message to show */
public message:string;
/** A link in case the job results in a download */
public downloadHref:string|null = null;
@ViewChild('downloadLink') private downloadLink:ElementRef<HTMLInputElement>;
constructor(@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly I18n:I18nService,
@ -67,8 +74,6 @@ export class JobStatusModal extends OpModalComponent implements OnInit {
}
private listenOnJobStatus() {
this.isLoading = true;
timer(0, 2000)
.pipe(
switchMap(() => this.performRequest()),
@ -76,12 +81,27 @@ export class JobStatusModal extends OpModalComponent implements OnInit {
this.untilDestroyed(),
withDelayedLoadingIndicator(this.loadingIndicator.getter('modal')),
).subscribe(
object => this.onResponse(object),
object => this.onResponse(object),
error => this.handleError(error.message),
() => this.isLoading = false
);
}
private iconForStatus():string|null {
switch (this.status) {
case "cancelled":
case "failure":
case "error":
return 'icon-error';
break;
case "success":
return "icon-checkmark";
break;
default:
return null;
}
}
/**
* Determine whether the given status continues the timer
* @param response
@ -92,16 +112,32 @@ export class JobStatusModal extends OpModalComponent implements OnInit {
private onResponse(response:JobStatusInterface) {
let status = this.status = response.status;
this.message = response.message ||
this.I18n.t(`js.job_status.generic_messages.${status}`, { defaultValue: status });
const redirectUrl:string|undefined = response.payload?.redirect;
if (response.payload) {
this.handleRedirect(response.payload?.redirect);
this.handleDownload(response.payload?.download);
}
this.statusIcon = this.iconForStatus();
this.cdRef.detectChanges();
}
private handleRedirect(redirectUrl?:string) {
if (redirectUrl !== undefined) {
this.message += `. ${this.text.redirect}`;
setTimeout(() => window.location.href = redirectUrl, 2000);
this.message += `. ${this.text.redirect}`;
}
}
this.cdRef.detectChanges();
private handleDownload(downloadUrl?:string) {
if (downloadUrl !== undefined) {
this.downloadHref = downloadUrl;
// Click download link manually
setTimeout(() => this.downloadLink.nativeElement.click(), 50);
}
}
private performRequest():Observable<JobStatusInterface> {

@ -1,6 +1,8 @@
en:
js:
job_status:
download_starts: 'The download should start automatically.'
click_to_download: 'Or click here to download.'
title: 'Background job status'
redirect: 'You are being redirected.'
generic_messages:

@ -48,12 +48,14 @@ module OpenProject::JobStatus
"#{root}/job_statuses/#{uuid}"
end
config.to_prepare do
initializer 'job_status.event_listener' do
# Extends the ActiveJob adapter in use (DelayedJob) by a Status which lives
# indenpendently from the job itself (which is deleted once successful or after max attempts).
# That way, the result of a background job is available even after the original job is gone.
EventListener.register!
end
config.to_prepare do
# Register the cron job to clear statuses periodically
::Cron::CronJob.register! ::JobStatus::Cron::ClearOldJobStatusJob
end

@ -1,260 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe WorkPackages::ExportsController, type: :controller do
include ::API::V3::Utilities::PathHelper
before do
login_as current_user
end
let(:user) { FactoryBot.build_stubbed(:user) }
let(:current_user) { user }
let!(:export) do
FactoryBot.build_stubbed(:work_packages_export, user: user).tap do |export|
allow(WorkPackages::Export)
.to receive(:find)
.and_raise(ActiveRecord::RecordNotFound)
allow(WorkPackages::Export)
.to receive(:find)
.with(export.id.to_s)
.and_return(export)
end
end
let!(:attachment) do
FactoryBot.build_stubbed(:attachment, container: export).tap do |a|
export_attachments = attachment_done ? [a] : []
allow(export)
.to receive(:attachments)
.and_return(export_attachments)
end
end
let(:status) { 'in_queue' }
let(:job_message) { '' }
let!(:job_status) do
FactoryBot.build_stubbed(:delayed_job_status, status: status, message: job_message).tap do |s|
relation = double('AR relation')
allow(JobStatus::Status)
.to receive(:of_reference)
.with(export)
.and_return(relation)
allow(relation)
.to receive(:first)
.and_return(s)
end
end
let(:attachment_done) { true }
describe 'show' do
context 'with an existing id' do
before do
get 'show', params: { id: export.id }
end
context 'with the attachment being ready' do
it 'redirects to the download location' do
expect(response)
.to redirect_to api_v3_paths.attachment_content(attachment.id)
end
end
context 'with the attachment not being ready' do
let(:attachment_done) { false }
it 'returns 202 ACCEPTED' do
expect(response.status)
.to eql 202
end
it 'returns the status location via the link header' do
expect(response.headers['Link'])
.to eql "</work_packages/exports/#{export.id}/status.json> rel=\"status\""
end
end
context 'with the export belonging to a different user' do
let(:current_user) { FactoryBot.build_stubbed(:user) }
it 'returns 404 NOT FOUND' do
expect(response.status)
.to eql 404
end
end
end
context 'with an inexisting id' do
before do
get 'show', params: { id: export.id + 5 }
end
it 'returns 404 NOT FOUND' do
expect(response.status)
.to eql 404
end
end
end
describe 'status' do
context 'with an existing id' do
before do
get 'status', params: { id: export.id, format: :json }
end
context 'with the associated job status signaling success' do
let(:status) { :success }
it 'returns 200 OK' do
expect(response.status)
.to eql 200
end
it 'links to the download location' do
expect(response.headers['Link'])
.to eql "<#{api_v3_paths.attachment_content(attachment.id)}> rel=\"download\""
end
it 'reads "Completed"' do
expect(response.body)
.to be_json_eql({ "status": "Completed",
"message": "",
"link": api_v3_paths.attachment_content(attachment.id) }.to_json)
end
end
context 'with the associated job status being in_queue' do
let(:status) { 'in_queue' }
it 'returns 200 OK' do
expect(response.status)
.to eql 200
end
it 'has no link to the download' do
expect(response.headers['Link'])
.to be nil
end
it 'reads "Processing"' do
expect(response.body)
.to be_json_eql({ "status": "Processing",
"message": "" }.to_json)
end
end
context 'with the associated job status being in_process' do
let(:status) { :in_process }
it 'returns 200 OK' do
expect(response.status)
.to eql 200
end
it 'has no link to the download' do
expect(response.headers['Link'])
.to be nil
end
it 'reads "Processing"' do
expect(response.body)
.to be_json_eql({ "status": "Processing",
"message": "" }.to_json)
end
end
context 'with the associated job status being error' do
let(:status) { :error }
let(:job_message) { 'This errored.' }
it 'returns 200 OK' do
expect(response.status)
.to eql 200
end
it 'has no link to the download' do
expect(response.headers['Link'])
.to be nil
end
it 'reads "Error" with the error message' do
expect(response.body)
.to be_json_eql({ "status": "Error",
"message": job_message }.to_json)
end
end
context 'with the associated job status being failure' do
let(:status) { :failure }
let(:job_message) { 'This failed.' }
it 'returns 200 OK' do
expect(response.status)
.to eql 200
end
it 'has no link to the download' do
expect(response.headers['Link'])
.to be nil
end
it 'reads "Error" with the error message' do
expect(response.body)
.to be_json_eql({ "status": "Error",
"message": job_message }.to_json)
end
end
context 'with the export belonging to a different user' do
let(:current_user) { FactoryBot.build_stubbed(:user) }
it 'returns 404 NOT FOUND' do
expect(response.status)
.to eql 404
end
end
end
context 'with an inexisting id' do
before do
get 'show', params: { id: export.id + 5, format: :json }
end
it 'returns 404 NOT FOUND' do
expect(response.status)
.to eql 404
end
end
end
end

@ -69,13 +69,16 @@ describe 'work package export', type: :feature do
settings_menu.open_and_choose 'Export ...'
click_on export_type
# Expect to get a response regarding queuing
expect(page).to have_content 'The job has been queued and will be processed shortly.',
wait: 10
begin
perform_enqueued_jobs
rescue
# nothing
end
if wait_for_downloads
# Wait for the file to download
::DownloadedFile.wait_for_download
@ -190,6 +193,9 @@ describe 'work package export', type: :feature do
export!
expect(page).to have_selector('.job-status--modal .icon-checkmark', wait: 10)
expect(page).to have_content('The export has completed successfully.')
expect(subject).to have_text(wp_1.description)
expect(subject).to have_text(wp_2.description)
expect(subject).to have_text(wp_3.description)
@ -227,10 +233,7 @@ describe 'work package export', type: :feature do
export!(false)
expect(page)
.not_to have_content I18n.t('js.label_export_preparing')
expect(page)
.to have_content I18n.t(:error_pdf_export_too_many_columns)
.to have_content(I18n.t(:error_pdf_export_too_many_columns), wait: 10)
end
end
end

@ -1,39 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'work_package exports routes', type: :routing do
it '/work_packages/exports/:id GET routes to work_packages/exports#show' do
expect(get('/work_packages/exports/5')).to route_to('work_packages/exports#show', id: "5")
end
it '/work_packages/exports/:id/status GET routes to work_packages/exports#status' do
expect(get('/work_packages/exports/5/status')).to route_to('work_packages/exports#status', id: "5")
end
end
Loading…
Cancel
Save