Merge pull request #10307 from opf/feature/41340-files-tab-in-work-package-details

[#41340] Files Tab in work package details
pull/10376/head
Oliver Günther 3 years ago committed by GitHub
commit 762241079e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      config/locales/js-en.yml
  2. 56
      frontend/src/app/core/state/attachments/attachment.model.ts
  3. 259
      frontend/src/app/core/state/attachments/attachments.service.ts
  4. 40
      frontend/src/app/core/state/attachments/attacments.store.ts
  5. 2
      frontend/src/app/core/state/collection-store.ts
  6. 9
      frontend/src/app/core/state/openproject-state.module.ts
  7. 18
      frontend/src/app/core/state/principals/principals.service.ts
  8. 2
      frontend/src/app/core/state/views/views.service.ts
  9. 4
      frontend/src/app/features/hal/helpers/is-new-resource.ts
  10. 2
      frontend/src/app/features/plugins/plugin-context.ts
  11. 52
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component.ts
  12. 11
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.html
  13. 26
      frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts
  14. 12
      frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.html
  15. 49
      frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts
  16. 38
      frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts
  17. 14
      frontend/src/app/features/work-packages/openproject-work-packages.module.ts
  18. 7
      frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts
  19. 78
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list-item.component.ts
  20. 35
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list-item.html
  21. 130
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list.component.ts
  22. 13
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list.html
  23. 91
      frontend/src/app/shared/components/attachments/attachments-upload/attachments-upload.component.ts
  24. 11
      frontend/src/app/shared/components/attachments/attachments.html
  25. 17
      frontend/src/app/shared/components/attachments/authoring/authoring.component.html
  26. 54
      frontend/src/app/shared/components/attachments/authoring/authoring.component.ts
  27. 5
      frontend/src/app/shared/components/grids/widgets/custom-text/custom-text-edit-field.service.ts
  28. 2
      frontend/src/vendor/ckeditor/ckeditor.js
  29. 2
      frontend/src/vendor/ckeditor/ckeditor.js.map
  30. 8
      modules/budgets/spec/features/budgets/attachment_upload_spec.rb
  31. 4
      modules/dashboards/spec/features/custom_text_spec.rb
  32. 6
      modules/documents/spec/features/attachment_upload_spec.rb
  33. 4
      modules/my_page/spec/features/my/custom_text_spec.rb
  34. 6
      spec/features/admin/attribute_help_texts_spec.rb
  35. 8
      spec/features/forums/attachment_upload_spec.rb
  36. 1
      spec/features/statuses/read_only_statuses_spec.rb
  37. 13
      spec/features/types/form_configuration_spec.rb
  38. 41
      spec/features/wiki/attachment_upload_spec.rb
  39. 6
      spec/features/work_packages/attachments/attachment_upload_spec.rb
  40. 25
      spec/features/work_packages/navigation_spec.rb

@ -499,7 +499,7 @@ en:
label_global_queries: "Public views"
label_custom_queries: "Private views"
label_columns: "Columns"
label_attachments: Files
label_attachments: Attachments
label_drop_files: Drop files here
label_drop_files_hint: or click to add files
label_drop_folders_hint: You cannot upload folders as an attachment. Please select single files.
@ -1072,7 +1072,7 @@ en:
activity: Activity
relations: Relations
watchers: Watchers
attachments: Attachments
files: Files
time_relative:
days: "days"
weeks: "weeks"

@ -0,0 +1,56 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 { ID } from '@datorama/akita';
import {
IFormattable,
IHalResourceLink,
IHalResourceLinks,
} from 'core-app/core/state/hal-resource';
export interface IAttachmentHalResourceLinks extends IHalResourceLinks {
self:IHalResourceLink;
delete:IHalResourceLink;
container:IHalResourceLink;
author:IHalResourceLink;
downloadLocation:IHalResourceLink;
}
export interface IAttachment {
id:ID;
title:string;
fileName:string;
fileSize:number;
description:IFormattable;
contentType:string;
digest:string;
createdAt:string;
_links:IAttachmentHalResourceLinks;
}

@ -0,0 +1,259 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 { Injectable } from '@angular/core';
import {
HttpClient,
HttpHeaders,
} from '@angular/common/http';
import {
applyTransaction,
QueryEntity,
} from '@datorama/akita';
import {
from,
Observable,
} from 'rxjs';
import {
catchError,
map,
tap,
} from 'rxjs/operators';
import { AttachmentsStore } from 'core-app/core/state/attachments/attacments.store';
import { IAttachment } from 'core-app/core/state/attachments/attachment.model';
import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import {
OpenProjectFileUploadService,
UploadFile,
} from 'core-app/core/file-upload/op-file-upload.service';
import { OpenProjectDirectFileUploadService } from 'core-app/core/file-upload/op-direct-file-upload.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { HalLink } from 'core-app/features/hal/hal-link/hal-link';
import isNewResource, { HAL_NEW_RESOURCE_ID } from 'core-app/features/hal/helpers/is-new-resource';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { insertCollectionIntoState } from 'core-app/core/state/collection-store';
@Injectable()
export class AttachmentsResourceService {
protected store = new AttachmentsStore();
public query = new QueryEntity(this.store);
constructor(private readonly I18n:I18nService,
private readonly http:HttpClient,
private readonly apiV3Service:ApiV3Service,
private readonly fileUploadService:OpenProjectFileUploadService,
private readonly directFileUploadService:OpenProjectDirectFileUploadService,
private readonly configurationService:ConfigurationService,
private readonly toastService:ToastService) { }
/**
* This method ensures that a specific collection is fetched, if not available.
*
* @param key The collection key, of the required collection.
*/
requireCollection(key:string):void {
if (this.store.getValue().collections[key]) {
return;
}
this.fetchAttachments(key).subscribe();
}
/**
* Fetches attachments by the attachment collection self link.
* This link is used as key to store the result collection in the resource store.
*
* @param attachmentsSelfLink The self link of the attachment collection from the parent resource.
*/
fetchAttachments(attachmentsSelfLink:string):Observable<IHALCollection<IAttachment>> {
return this.http
.get<IHALCollection<IAttachment>>(attachmentsSelfLink)
.pipe(
tap((collection) => insertCollectionIntoState(this.store, collection, attachmentsSelfLink)),
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
);
}
/**
* Sends deletion request and updates the store collection of attachments.
*
* @param collectionKey The identifier of the current attachment collection.
* @param attachment The attachment to be deleted.
*/
removeAttachment(collectionKey:string, attachment:IAttachment):Observable<void> {
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http
.delete<void>(attachment._links.delete.href, { withCredentials: true, headers })
.pipe(
tap(() => {
applyTransaction(() => {
this.store.remove(attachment.id);
this.store.update(({ collections }) => (
{
collections: {
...collections,
[collectionKey]: {
...collections[collectionKey],
ids: (collections[collectionKey]?.ids || []).filter((id) => id !== attachment.id),
},
},
}
));
});
}),
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
);
}
/**
* Sends an upload request and updates the store collection of attachments.
*
* @param resource The HAL resource to attach the files to
* @param files The upload files to be attached.
*/
attachFiles(resource:HalResource, files:UploadFile[]):Observable<IAttachment[]> {
const identifier = this.getAttachmentsSelfLink(resource) || HAL_NEW_RESOURCE_ID;
const href = this.getUploadTarget(resource);
const isDirectUpload = !!this.getDirectUploadLink(resource);
return this
.addAttachments(
identifier,
href,
files,
isDirectUpload,
);
}
/**
* Sends an upload request and updates the store collection of attachments.
*
* @param collectionKey The identifier of the current attachment collection.
* @param uploadHref The API target to perform the call against.
* @param files The upload files to be attached.
* @param isDirectUpload whether the provided upload target is a direct upload URL.
*/
addAttachments(
collectionKey:string,
uploadHref:string,
files:UploadFile[],
isDirectUpload = false,
):Observable<IAttachment[]> {
return this
.uploadAttachments(uploadHref, files, isDirectUpload)
.pipe(
tap((attachments) => {
applyTransaction(() => {
this.store.add(attachments);
this.store.update(({ collections }) => (
{
collections: {
...collections,
[collectionKey]: {
...collections[collectionKey],
ids: (collections[collectionKey]?.ids || []).concat(attachments.map((a) => a.id)),
},
},
}
));
});
}),
);
}
private uploadAttachments(href:string, files:UploadFile[], isDirectUpload:boolean):Observable<IAttachment[]> {
const { uploads, finished } = isDirectUpload
? this.directFileUploadService.uploadAndMapResponse(href, files)
: this.fileUploadService.uploadAndMapResponse(href, files);
const message = this.I18n.t('js.label_upload_notification');
const notification = this.toastService.addAttachmentUpload(message, uploads);
return from(finished)
.pipe(
tap(() => {
setTimeout(() => this.toastService.remove(notification), 700);
}),
map((result) => result.map(({ response }) => (response as HalResource).$source as IAttachment)),
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
);
}
/**
* Get the upload target for a HAL resource, depending on its
* persisted state and available links.
*
* This will be one of the following:
* - The direct upload PREPARE URL endpoint for the resource (if direct upload active + resource persisted)
* - The generic prepare URL endpoint (if direct upload active)
* - The resource's own upload HAL link (if persisted)
* - The generic APIv3 attachments endpoint (new resource, no direct upload)
*
* @param resource The resource we're uploading attachments for.
* @returns {string} The API target URL to perform the upload against.
* @private
*/
private getUploadTarget(resource:HalResource):string {
return this.getDirectUploadLink(resource)
|| this.getAttachmentsSelfLink(resource)
|| this.apiV3Service.attachments.path;
}
private getDirectUploadLink(resource:HalResource):string|null {
const links = resource.$links as unknown&{ prepareAttachment:HalLink };
if (links.prepareAttachment) {
return links.prepareAttachment.href as string;
}
if (isNewResource(resource)) {
return this.configurationService.prepareAttachmentURL as string|null;
}
return null;
}
private getAttachmentsSelfLink(resource:HalResource):string|null {
const attachments = resource.attachments as unknown&{ href?:string };
return attachments?.href || null;
}
}

@ -0,0 +1,40 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 { EntityStore, StoreConfig } from '@datorama/akita';
import { IAttachment } from 'core-app/core/state/attachments/attachment.model';
import { CollectionState, createInitialCollectionState } from 'core-app/core/state/collection-store';
export interface AttachmentsState extends CollectionState<IAttachment> {}
@StoreConfig({ name: 'attachments' })
export class AttachmentsStore extends EntityStore<AttachmentsState> {
constructor() {
super(createInitialCollectionState());
}
}

@ -117,7 +117,7 @@ export function insertCollectionIntoState<T extends { id:ID }>(
const ids = collection._embedded.elements?.map((el) => el.id) || [];
applyTransaction(() => {
store.add(collection._embedded.elements);
store.upsertMany(collection._embedded.elements);
store.update(({ collections }) => (
{
collections: {

@ -26,9 +26,8 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import {
NgModule,
} from '@angular/core';
import { NgModule } from '@angular/core';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
import { InAppNotificationsResourceService } from './in-app-notifications/in-app-notifications.service';
import { ProjectsResourceService } from './projects/projects.service';
import { PrincipalsResourceService } from './principals/principals.service';
@ -36,11 +35,11 @@ import { CapabilitiesResourceService } from 'core-app/core/state/capabilities/ca
@NgModule({
providers: [
AttachmentsResourceService,
InAppNotificationsResourceService,
ProjectsResourceService,
PrincipalsResourceService,
CapabilitiesResourceService,
],
})
export class OpenProjectStateModule {
}
export class OpenProjectStateModule {}

@ -24,6 +24,7 @@ import {
import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { PrincipalsStore } from './principals.store';
import { IPrincipal } from './principal.model';
import { IUser } from 'core-app/core/state/principals/user.model';
@EffectHandler
@Injectable()
@ -44,7 +45,22 @@ export class PrincipalsResourceService {
private http:HttpClient,
private apiV3Service:ApiV3Service,
private toastService:ToastService,
) {
) { }
fetchUser(id:string|number):Observable<IUser> {
return this.http
.get<IUser>(this.apiV3Service.users.id(id).path)
.pipe(
tap((data) => {
applyTransaction(() => {
this.store.upsertMany([data]);
});
}),
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
);
}
fetchPrincipals(params:ApiV3ListParameters):Observable<IHALCollection<IPrincipal>> {

@ -53,7 +53,7 @@ export class ViewsResourceService {
.pipe(
tap((events) => {
applyTransaction(() => {
this.store.add(events._embedded.elements);
this.store.upsertMany(events._embedded.elements);
this.store.update(({ collections }) => (
{
collections: {

@ -1,3 +1,5 @@
export const HAL_NEW_RESOURCE_ID = 'new';
export default function isNewResource(resource:{ id:string|null }):boolean {
return !resource.id || resource.id === 'new';
return !resource.id || resource.id === HAL_NEW_RESOURCE_ID;
}

@ -25,6 +25,7 @@ import { HTMLSanitizeService } from '../../core/html-sanitize/html-sanitize.serv
import { DynamicContentModalComponent } from '../../shared/components/modals/modal-wrapper/dynamic-content.modal';
import { PasswordConfirmationModalComponent } from '../../shared/components/modals/request-for-confirmation/password-confirmation.modal';
import { DomAutoscrollService } from 'core-app/shared/helpers/drag-and-drop/dom-autoscroll.service';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
/**
* Plugin context bridge for plugins outside the CLI compiler context
@ -58,6 +59,7 @@ export class OpenProjectPluginContext {
states: this.injector.get<States>(States),
apiV3Service: this.injector.get<ApiV3Service>(ApiV3Service),
configurationService: this.injector.get<ConfigurationService>(ConfigurationService),
attachmentsResourceService: this.injector.get(AttachmentsResourceService),
};
public readonly helpers = {

@ -0,0 +1,52 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 { ChangeDetectionStrategy, Component } from '@angular/core';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { HookService } from 'core-app/features/plugins/hook-service';
@Component({
templateUrl: './op-files-tab.html',
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'op-files-tab',
})
export class WorkPackageFilesTabComponent {
public workPackage:WorkPackageResource;
public text = {
attachments: {
label: this.I18n.t('js.label_attachments'),
},
};
constructor(
readonly I18n:I18nService,
protected hook:HookService,
) { }
}

@ -0,0 +1,11 @@
<div class="work-packages--attachments-container">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text" [textContent]="text.attachments.label"></h3>
</div>
</div>
<op-attachment-list [resource]="workPackage"></op-attachment-list>
<op-attachments-upload [resource]="workPackage"></op-attachments-upload>
</div>

@ -129,12 +129,12 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
},
};
public isNewResource:boolean;
protected firstTimeFocused = false;
$element:JQuery;
isNewResource = isNewResource;
constructor(readonly I18n:I18nService,
protected currentProject:CurrentProjectService,
protected PathHelper:PathHelperService,
@ -152,7 +152,9 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
}
public ngOnInit() {
this.$element = jQuery(this.elementRef.nativeElement);
this.$element = jQuery(this.elementRef.nativeElement as HTMLElement);
this.isNewResource = isNewResource(this.workPackage);
const change = this.halEditing.changeFor<WorkPackageResource, WorkPackageChangeset>(this.workPackage);
this.resourceContextChange.next(this.contextFrom(change.projectedResource));
@ -166,7 +168,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
distinctUntilChanged<ResourceContextChange>((a, b) => _.isEqual(a, b)),
map(() => this.halEditing.changeFor(this.workPackage)),
)
.subscribe((change:WorkPackageChangeset) => this.refresh(change));
.subscribe((changeset:WorkPackageChangeset) => this.refresh(changeset));
// Update the resource context on every update to the temporary resource.
// This allows detecting a changed type value in a new work package.
@ -208,7 +210,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
* Returns whether a group should be hidden due to being empty
* (e.g., consists only of CFs and none of them are active in this project.
*/
public shouldHideGroup(group:GroupDescriptor) {
public shouldHideGroup(group:GroupDescriptor):boolean {
// Hide if the group is empty
const isEmpty = group.members.length === 0;
@ -221,10 +223,10 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
/**
* angular 2 doesn't support track by property any more but requires a custom function
* https://github.com/angular/angular/issues/12969
* @param index
* @param _index
* @param elem
*/
public trackByName(_index:number, elem:{ name:string }) {
public trackByName(_index:number, elem:{ name:string }):string {
return elem.name;
}
@ -256,8 +258,8 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
/*
* Returns the work package label
*/
public get idLabel() {
return `#${this.workPackage.id}`;
public get idLabel():string {
return `#${this.workPackage.id || ''}`;
}
public get projectContextText():string {
@ -337,8 +339,10 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
* combined 'start' and 'due' date field.
*/
private getDateField(change:WorkPackageChangeset):FieldDescriptor {
const object:any = {
const object:FieldDescriptor = {
name: '',
label: this.I18n.t('js.work_packages.properties.date'),
spanAll: false,
multiple: false,
};
@ -359,7 +363,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen
* to the single view.
*
* @param {WorkPackage} workPackage
* @returns {SchemaContext}
* @returns {ResourceContextChange}
*/
private contextFrom(workPackage:WorkPackageResource):ResourceContextChange {
const schema = this.schema(workPackage);

@ -3,22 +3,22 @@
[ngClass]="{'work-package--single-view_with-columns': showTwoColumnLayout()}"
data-selector="wp-single-view">
<div class="wp-new--subject-wrapper"
*ngIf="isNewResource(workPackage)">
*ngIf="isNewResource">
<editable-attribute-field [resource]="workPackage"
[wrapperClasses]="'-no-label'"
[fieldName]="'subject'"></editable-attribute-field>
</div>
<div class="wp-info-wrapper">
<wp-status-button *ngIf="!isNewResource(workPackage)"
<wp-status-button *ngIf="!isNewResource"
[workPackage]="workPackage">
</wp-status-button>
<attribute-help-text [attribute]="'status'"
[attributeScope]="'WorkPackage'"
*ngIf="!isNewResource(workPackage)"></attribute-help-text>
*ngIf="!isNewResource"></attribute-help-text>
<div class="work-packages--info-row"
*ngIf="!isNewResource(workPackage)">
*ngIf="!isNewResource">
<span [textContent]="idLabel"></span>:
<span [textContent]="text.infoRow.createdBy"></span>
<!-- The space has to be in an extra span
@ -61,7 +61,7 @@
<div
class="attributes-group -project-context hide-when-print"
*ngIf="!isNewResource(workPackage) && projectContext && !projectContext.matches"
*ngIf="!isNewResource && projectContext && !projectContext.matches"
>
<div>
<p>
@ -122,7 +122,7 @@
</div>
</div>
<div class="work-packages--attachments attributes-group">
<div class="work-packages--attachments attributes-group" *ngIf="isNewResource">
<div class="work-packages--attachments-container">
<div class="attributes-group--header">
<div class="attributes-group--header-container">

@ -0,0 +1,49 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 { Injector } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
export function workPackageFilesCount(
workPackage:WorkPackageResource,
injector:Injector,
):Observable<number> {
const service = injector.get(AttachmentsResourceService);
return service.query.select()
.pipe(
map((state) => {
const attachmentPath = workPackage.$links.attachments.href;
if (attachmentPath == null) return 0;
return state.collections[attachmentPath]?.ids.length || 0;
}),
);
}

@ -1,3 +1,31 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 { Injectable, Injector } from '@angular/core';
import { from } from 'rxjs';
import { StateService } from '@uirouter/core';
@ -7,10 +35,12 @@ import { WorkPackageRelationsTabComponent } from 'core-app/features/work-package
import { WorkPackageOverviewTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/overview-tab/overview-tab.component';
import { WorkPackageActivityTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.component';
import { WorkPackageWatchersTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component';
import { WorkPackageFilesTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { workPackageWatchersCount } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-watchers-count.function';
import { workPackageRelationsCount } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-relations-count.function';
import { workPackageNotificationsCount } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-notifications-count.function';
import { workPackageFilesCount } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function';
@Injectable({
providedIn: 'root',
@ -30,7 +60,7 @@ export class WorkPackageTabsService {
return [...this.registeredTabs];
}
register(...tabs:WpTabDefinition[]) {
register(...tabs:WpTabDefinition[]):void {
this.registeredTabs = [
...this.registeredTabs,
...tabs,
@ -72,6 +102,12 @@ export class WorkPackageTabsService {
count: workPackageNotificationsCount,
showCountAsBubble: true,
},
{
id: 'files',
component: WorkPackageFilesTabComponent,
name: I18n.t('js.work_packages.tabs.files'),
count: workPackageFilesCount,
},
{
id: 'relations',
component: WorkPackageRelationsTabComponent,

@ -95,8 +95,6 @@ import { WorkPackageRelationQueryComponent } from 'core-app/features/work-packag
import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component';
import { WorkPackageSplitViewComponent } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view.component';
import { WorkPackagesFullViewComponent } from 'core-app/features/work-packages/routing/wp-full-view/wp-full-view.component';
import { AttachmentsUploadComponent } from 'core-app/shared/components/attachments/attachments-upload/attachments-upload.component';
import { AttachmentListComponent } from 'core-app/shared/components/attachments/attachment-list/attachment-list.component';
import { QueryFiltersService } from 'core-app/features/work-packages/components/wp-query/query-filters.service';
import { WorkPackageCardViewComponent } from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component';
import { WorkPackageRelationsService } from 'core-app/features/work-packages/components/wp-relations/wp-relations.service';
@ -156,6 +154,8 @@ import { WorkPackageSplitViewToolbarComponent } from 'core-app/features/work-pac
import { WorkPackageCopyFullViewComponent } from 'core-app/features/work-packages/components/wp-copy/wp-copy-full-view.component';
import { OpenprojectTabsModule } from 'core-app/shared/components/tabs/openproject-tabs.module';
import { TimeEntryChangeset } from 'core-app/features/work-packages/helpers/time-entries/time-entry-changeset';
import { AttachmentsUploadComponent } from 'core-app/shared/components/attachments/attachments-upload/attachments-upload.component';
import { AttachmentListComponent } from 'core-app/shared/components/attachments/attachment-list/attachment-list.component';
import { QueryFiltersComponent } from 'core-app/features/work-packages/components/filters/query-filters/query-filters.component';
import { FilterDateTimesValueComponent } from 'core-app/features/work-packages/components/filters/filter-date-times-value/filter-date-times-value.component';
import { FilterSearchableMultiselectValueComponent } from 'core-app/features/work-packages/components/filters/filter-searchable-multiselect-value/filter-searchable-multiselect-value.component';
@ -170,8 +170,9 @@ import { FilterIntegerValueComponent } from 'core-app/features/work-packages/com
import { WorkPackageFilterContainerComponent } from 'core-app/features/work-packages/components/filters/filter-container/filter-container.directive';
import { FilterBooleanValueComponent } from 'core-app/features/work-packages/components/filters/filter-boolean-value/filter-boolean-value.component';
import { WorkPackageMarkNotificationButtonComponent } from 'core-app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { WorkPackageFilesTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component';
import { WorkPackagesQueryViewService } from 'core-app/features/work-packages/components/wp-list/wp-query-view.service';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
@NgModule({
imports: [
@ -347,6 +348,9 @@ import { WorkPackagesQueryViewService } from 'core-app/features/work-packages/co
WorkPackageRelationsAutocompleteComponent,
WorkPackageBreadcrumbParentComponent,
// Files tab
WorkPackageFilesTabComponent,
// Split view
WorkPackageDetailsViewButtonComponent,
WorkPackageSplitViewComponent,
@ -456,9 +460,9 @@ export class OpenprojectWorkPackagesModule {
return null;
});
hookService.register('workPackageAttachmentUploadComponent', (workPackage:WorkPackageResource) => AttachmentsUploadComponent);
hookService.register('workPackageAttachmentUploadComponent', () => AttachmentsUploadComponent);
hookService.register('workPackageAttachmentListComponent', (workPackage:WorkPackageResource) => AttachmentListComponent);
hookService.register('workPackageAttachmentListComponent', () => AttachmentListComponent);
/** Return specialized work package changeset for editing service */
hookService.register('halResourceChangesetClass', (resource:HalResource) => {

@ -47,6 +47,7 @@ import { HookService } from 'core-app/features/plugins/hook-service';
import { WpSingleViewService } from 'core-app/features/work-packages/routing/wp-view-base/state/wp-single-view.service';
import { Observable } from 'rxjs';
import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
@InjectField() states:States;
@ -65,6 +66,8 @@ export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
@InjectField() authorisationService:AuthorisationService;
@InjectField() attachmentsResourceService:AttachmentsResourceService;
@InjectField() cdRef:ChangeDetectorRef;
@InjectField() readonly titleService:OpTitleService;
@ -162,6 +165,10 @@ export class WorkPackageSingleViewBase extends UntilDestroyedMixin {
// Preselect this work package for future list operations
this.showStaticPagePath = this.PathHelper.workPackagePath(this.workPackageId);
// Fetch attachments of current work package
const attachments = this.workPackage.attachments as unknown&{ href:string };
this.attachmentsResourceService.fetchAttachments(attachments.href).subscribe();
// Listen to tab changes to update the tab label
this.keepTab.observable
.pipe(

@ -27,24 +27,29 @@
//++
import {
Component, EventEmitter, Input, Output,
ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output,
} from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { States } from 'core-app/core/states/states.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import { IAttachment } from 'core-app/core/state/attachments/attachment.model';
import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service';
import { IUser } from 'core-app/core/state/principals/user.model';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
@Component({
selector: 'attachment-list-item',
selector: 'op-attachment-list-item',
templateUrl: './attachment-list-item.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AttachmentListItemComponent {
export class AttachmentListItemComponent implements OnInit {
@Input() public resource:HalResource;
@Input() public attachment:any;
@Input() public attachment:IAttachment;
@Input() public index:any;
@Input() public index:number;
@Input() destroyImmediately = true;
@ -55,30 +60,47 @@ export class AttachmentListItemComponent {
public text = {
dragHint: this.I18n.t('js.attachments.draggable_hint'),
destroyConfirmation: this.I18n.t('js.text_attachment_destroy_confirmation'),
removeFile: (arg:any) => this.I18n.t('js.label_remove_file', arg),
removeFile: (arg:unknown):string => this.I18n.t('js.label_remove_file', arg),
};
constructor(protected halNotification:HalResourceNotificationService,
readonly I18n:I18nService,
readonly states:States,
readonly pathHelper:PathHelperService) {
public get deleteIconTitle():string {
return this.text.removeFile({ fileName: this.attachment.fileName });
}
public author$:Observable<IUser>;
constructor(private readonly principalsResourceService:PrincipalsResourceService,
private readonly I18n:I18nService,
private readonly pathHelper:PathHelperService) {
}
ngOnInit():void {
const authorId = idFromLink(this.attachment._links.author.href);
this.author$ = this.principalsResourceService.query.selectEntity(authorId)
.pipe(
switchMap((user) => (user ? of(user) : this.principalsResourceService.fetchUser(authorId))),
map((user) => user as IUser),
);
}
/**
* Set the appropriate data for drag & drop of an attachment item.
* @param evt DragEvent
*/
public setDragData(evt:DragEvent) {
public setDragData(evt:DragEvent):void {
const url = this.downloadPath;
const previewElement = this.draggableHTML(url);
evt.dataTransfer!.setData('text/plain', url);
evt.dataTransfer!.setData('text/html', previewElement.outerHTML);
evt.dataTransfer!.setData('text/uri-list', url);
evt.dataTransfer!.setDragImage(previewElement, 0, 0);
if (evt.dataTransfer == null) return;
evt.dataTransfer.setData('text/plain', url);
evt.dataTransfer.setData('text/html', previewElement.outerHTML);
evt.dataTransfer.setData('text/uri-list', url);
evt.dataTransfer.setDragImage(previewElement, 0, 0);
}
public draggableHTML(url:string) {
public draggableHTML(url:string):HTMLImageElement|HTMLAnchorElement {
let el:HTMLImageElement|HTMLAnchorElement;
if (this.isImage) {
@ -94,21 +116,20 @@ export class AttachmentListItemComponent {
return el;
}
public get downloadPath() {
return this.pathHelper.attachmentDownloadPath(this.attachment.id, this.fileName);
public get downloadPath():string {
return this.pathHelper.attachmentDownloadPath(String(this.attachment.id), this.fileName);
}
public get isImage() {
public get isImage():boolean {
const ext = this.fileName.split('.').pop() || '';
return AttachmentListItemComponent.imageFileExtensions.indexOf(ext.toLowerCase()) > -1;
}
public get fileName() {
const a = this.attachment;
return a.fileName || a.customName || a.name;
public get fileName():string {
return this.attachment.fileName;
}
public confirmRemoveAttachment($event:JQuery.TriggeredEvent) {
public confirmRemoveAttachment($event:JQuery.TriggeredEvent):boolean {
if (!window.confirm(this.text.destroyConfirmation)) {
$event.stopImmediatePropagation();
$event.preventDefault();
@ -116,13 +137,6 @@ export class AttachmentListItemComponent {
}
this.removeAttachment.emit();
if (this.destroyImmediately) {
this
.resource
.removeAttachment(this.attachment);
}
return false;
}
}

@ -2,32 +2,37 @@
class="form--selected-value--container work-package--attachments--draggable-item"
opFocusWithin=".inplace-editing--trigger-icon"
draggable="true"
data-qa-selector="op-attachment-list-item"
(dragstart)="setDragData($event)"
[title]="text.dragHint"
>
<span class="form--selected-value">
<op-icon icon-classes="icon-context icon-attachment"></op-icon>
<a
class="work-package--attachments--filename"
target="_blank"
rel="noopener"
[attr.href]="downloadPath || '#'">
class="work-package--attachments--filename"
target="_blank"
rel="noopener"
[attr.href]="downloadPath || '#'">
{{ attachment.fileName || attachment.customName || attachment.name }}
{{ fileName }}
<authoring class="work-package--attachments--info"
[createdOn]="attachment.createdAt"
[author]="attachment.author"
[showAuthorAsLink]="false"></authoring>
<op-authoring
class="work-package--attachments--info"
[createdOn]="attachment.createdAt"
[author]="author$ | async"
[showAuthorAsLink]="false"
></op-authoring>
</a>
</span>
<a
href=""
class="form--selected-value--remover work-package--atachments--delete-button"
*ngIf="!!attachment.$links.delete"
(click)="confirmRemoveAttachment($event)">
<op-icon icon-classes="icon-delete"
[icon-title]="text.removeFile({fileName: attachment.fileName})"></op-icon>
href=""
class="form--selected-value--remover work-package--attachments--delete-button"
*ngIf="!!attachment._links.delete"
(click)="confirmRemoveAttachment($event)">
<op-icon
icon-classes="icon-delete"
[icon-title]="deleteIconTitle"
></op-icon>
</a>
<input type="hidden" name="attachments[{{index}}][id]" value="{{attachment.id}}">

@ -27,118 +27,68 @@
//++
import {
ChangeDetectorRef, Component, ElementRef, Input, OnInit,
ChangeDetectionStrategy,
Component,
Input,
OnInit,
} from '@angular/core';
import { trackByProperty } from 'core-app/shared/helpers/angular/tracking-functions';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { filter } from 'rxjs/operators';
import { States } from 'core-app/core/states/states.service';
import { trackByHref } from 'core-app/shared/helpers/angular/tracking-functions';
import { IAttachment } from 'core-app/core/state/attachments/attachment.model';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
import { map, switchMap, tap } from 'rxjs/operators';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { Observable } from 'rxjs';
@Component({
selector: 'attachment-list',
selector: 'op-attachment-list',
templateUrl: './attachment-list.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AttachmentListComponent extends UntilDestroyedMixin implements OnInit {
@Input() public resource:HalResource;
@Input() public destroyImmediately = true;
trackByFileName = trackByProperty('fileName');
trackByHref = trackByHref;
$attachments:Observable<IAttachment[]>;
attachments:HalResource[] = [];
deletedAttachments:HalResource[] = [];
public $element:JQuery;
private get attachmentsSelfLink():string {
const attachments = this.resource.attachments as unknown&{ href:string };
return attachments.href;
}
public $formElement:JQuery;
private get collectionKey():string {
return isNewResource(this.resource) ? 'new' : this.attachmentsSelfLink;
}
constructor(protected elementRef:ElementRef,
protected states:States,
protected cdRef:ChangeDetectorRef,
protected halResourceService:HalResourceService) {
constructor(private readonly attachmentsResourceService:AttachmentsResourceService) {
super();
}
ngOnInit() {
this.$element = jQuery(this.elementRef.nativeElement);
this.updateAttachments();
this.setupResourceUpdateListener();
if (!this.destroyImmediately) {
this.setupAttachmentDeletionCallback();
ngOnInit():void {
// ensure collection is loaded to the store
if (!isNewResource(this.resource)) {
this.attachmentsResourceService.requireCollection(this.attachmentsSelfLink);
}
}
public setupResourceUpdateListener() {
this.states.forResource(this.resource)!
.values$()
this.$attachments = this.attachmentsResourceService.query.select()
.pipe(
this.untilDestroyed(),
filter((newResource) => !!newResource),
)
.subscribe((newResource:HalResource) => {
this.resource = newResource || this.resource;
this.updateAttachments();
this.cdRef.detectChanges();
});
}
ngOnDestroy():void {
super.ngOnDestroy();
if (!this.destroyImmediately) {
this.$formElement.off('submit.attachment-component');
}
map((state) => state.collections[this.collectionKey]?.ids),
switchMap((attachmentIds) => this.attachmentsResourceService.query.selectMany(attachmentIds)),
// store attachments for new resources directly into the resource. This way, the POST request to create the
// resource embeds the attachments and the backend reroutes the anonymous attachments to the resource.
tap((attachments) => {
if (isNewResource(this.resource)) {
this.resource.attachments = { elements: attachments.map((a) => a._links.self) };
}
}),
);
}
public removeAttachment(attachment:HalResource) {
this.deletedAttachments.push(attachment);
// Keep the same object as we would otherwise loose the connection to the
// resource's attachments array. That way, attachments added after removing one would not be displayed.
// This is bad design.
const newAttachments = this.attachments.filter((el) => el !== attachment);
this.attachments.length = 0;
this.attachments.push(...newAttachments);
this.cdRef.detectChanges();
}
private get attachmentsUpdatable() {
return (this.resource.attachments && this.resource.attachmentsBackend);
}
public setupAttachmentDeletionCallback() {
this.$formElement = this.$element.closest('form');
this.$formElement.on('submit.attachment-component', () => {
this.destroyRemovedAttachments();
});
}
private destroyRemovedAttachments() {
this.deletedAttachments.forEach((attachment) => {
this
.resource
.removeAttachment(attachment);
});
}
private updateAttachments() {
if (!this.attachmentsUpdatable) {
this.attachments = this.resource.attachments.elements;
return;
}
this
.resource
.attachments
.updateElements()
.then(() => {
this.attachments = this.resource.attachments.elements;
this.cdRef.detectChanges();
});
public removeAttachment(attachment:IAttachment):void {
this.attachmentsResourceService.removeAttachment(this.collectionKey, attachment).subscribe();
}
}

@ -1,11 +1,10 @@
<div class="work-package--attachments--files" *ngIf="resource">
<ul class="form--selected-value--list"
*ngFor="let attachment of attachments; trackBy:trackByHref; let i = index;">
<attachment-list-item [attachment]="attachment"
[resource]="resource"
[index]="i"
[destroyImmediately]="destroyImmediately"
(removeAttachment)="removeAttachment(attachment)">
</attachment-list-item>
*ngFor="let attachment of $attachments | async; trackBy:trackByFileName; let i = index;">
<op-attachment-list-item [attachment]="attachment"
[resource]="resource"
[index]="i"
(removeAttachment)="removeAttachment(attachment)">
</op-attachment-list-item>
</ul>
</div>

@ -26,53 +26,61 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import {
Component, ElementRef, Input, OnInit, ViewChild,
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnInit,
ViewChild,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { UploadFile } from 'core-app/core/file-upload/op-file-upload.service';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
@Component({
selector: 'attachments-upload',
selector: 'op-attachments-upload',
templateUrl: './attachments-upload.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AttachmentsUploadComponent implements OnInit {
@Input() public resource:HalResource;
@ViewChild('hiddenFileInput') public filePicker:ElementRef;
@ViewChild('hiddenFileInput') public filePicker:ElementRef<HTMLInputElement>;
public draggingOver = false;
public text:any;
public text = {
uploadLabel: this.I18n.t('js.label_add_attachments'),
dropFiles: this.I18n.t('js.label_drop_files'),
dropFilesHint: this.I18n.t('js.label_drop_files_hint'),
foldersWarning: this.I18n.t('js.label_drop_folders_hint'),
};
public maxFileSize:number;
public $element:JQuery;
constructor(readonly I18n:I18nService,
readonly ConfigurationService:ConfigurationService,
private readonly attachmentsResourceService:AttachmentsResourceService,
readonly configurationService:ConfigurationService,
readonly toastService:ToastService,
protected elementRef:ElementRef,
protected halResourceService:HalResourceService) {
this.text = {
uploadLabel: I18n.t('js.label_add_attachments'),
dropFiles: I18n.t('js.label_drop_files'),
dropFilesHint: I18n.t('js.label_drop_files_hint'),
foldersWarning: I18n.t('js.label_drop_folders_hint'),
};
}
protected halResourceService:HalResourceService) { }
ngOnInit() {
this.$element = jQuery(this.elementRef.nativeElement);
ngOnInit():void {
this.$element = jQuery<HTMLElement>(this.elementRef.nativeElement);
this.ConfigurationService.initialized.then(() => this.maxFileSize = this.ConfigurationService.maximumAttachmentFileSize);
void this.configurationService.initialized.then(() => {
this.maxFileSize = this.configurationService.maximumAttachmentFileSize as number;
});
}
public triggerFileInput(event:MouseEvent) {
public triggerFileInput(event:MouseEvent):boolean {
this.filePicker.nativeElement.click();
event.preventDefault();
@ -80,12 +88,15 @@ export class AttachmentsUploadComponent implements OnInit {
return false;
}
public onDropFiles(event:DragEvent) {
event.dataTransfer!.dropEffect = 'copy';
public onDropFiles(event:DragEvent):void {
if (event.dataTransfer === null) return;
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = 'copy';
event.preventDefault();
event.stopPropagation();
const dfFiles = event.dataTransfer!.files;
const dfFiles = event.dataTransfer.files;
const length:number = dfFiles ? dfFiles.length : 0;
const files:UploadFile[] = [];
@ -97,9 +108,10 @@ export class AttachmentsUploadComponent implements OnInit {
this.draggingOver = false;
}
public onDragOver(event:DragEvent) {
if (this.containsFiles(event.dataTransfer)) {
event.dataTransfer!.dropEffect = 'copy';
public onDragOver(event:DragEvent):void {
if (event.dataTransfer !== null && AttachmentsUploadComponent.containsFiles(event.dataTransfer)) {
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = 'copy';
this.draggingOver = true;
}
@ -107,30 +119,30 @@ export class AttachmentsUploadComponent implements OnInit {
event.stopPropagation();
}
public onDragLeave(event:DragEvent) {
public onDragLeave(event:DragEvent):void {
this.draggingOver = false;
event.preventDefault();
event.stopPropagation();
}
public onFilePickerChanged() {
const files:UploadFile[] = Array.from(this.filePicker.nativeElement.files);
public onFilePickerChanged():void {
const fileList = this.filePicker.nativeElement.files;
if (fileList === null) return;
const files:UploadFile[] = Array.from(fileList);
this.uploadFiles(files);
}
private containsFiles(dataTransfer:any) {
if (dataTransfer.types.contains) {
return dataTransfer.types.contains('Files');
}
return (dataTransfer as DataTransfer).types.indexOf('Files') >= 0;
private static containsFiles(dataTransfer:DataTransfer):boolean {
return dataTransfer.types.indexOf('Files') >= 0;
}
protected uploadFiles(files:UploadFile[]):void {
files = files || [];
let uploadFiles = files || [];
const countBefore = files.length;
files = this.filterFolders(files);
uploadFiles = this.filterFolders(uploadFiles);
if (files.length === 0) {
if (uploadFiles.length === 0) {
// If we filtered all files as directories, show a notice
if (countBefore > 0) {
this.toastService.addNotice(this.text.foldersWarning);
@ -139,7 +151,10 @@ export class AttachmentsUploadComponent implements OnInit {
return;
}
this.resource.uploadAttachments(files);
this
.attachmentsResourceService
.attachFiles(this.resource, uploadFiles)
.subscribe();
}
/**
@ -147,7 +162,7 @@ export class AttachmentsUploadComponent implements OnInit {
* or empty file sizes.
* @param files
*/
protected filterFolders(files:UploadFile[]) {
protected filterFolders(files:UploadFile[]):UploadFile[] {
return files.filter((file) => {
// Folders never have a mime type
if (file.type !== '') {

@ -3,14 +3,13 @@
{{ text.attachments }}
</legend>
<div id="attachments_fields">
<attachment-list *ngIf="resource.attachments"
[resource]="resource"
[destroyImmediately]="destroyImmediately">
</attachment-list>
<attachments-upload [resource]="resource"
<op-attachment-list *ngIf="resource.attachments"
[resource]="resource">
</op-attachment-list>
<op-attachments-upload [resource]="resource"
class="hide-when-print"
*ngIf="allowUploading">
</attachments-upload>
</op-attachments-upload>
</div>
</fieldset>

@ -1,20 +1,15 @@
<div class="op-authoring">
<span *ngIf="showAuthorAsLink" [textContent]="author.name"></span>
<span
*ngIf="showAuthorAsLink"
[textContent]="authorName"
></span>
<a
*ngIf="!showAuthorAsLink"
[attr.href]="userLink"
[textContent]="author.name"
[attr.href]="authorLink"
[textContent]="authorName"
></a>
<span class="op-authoring--timestamp" *ngIf="activity">
<a
[attr.title]="time"
[attr.href]="activityFromPath(createdOnTime.format('YYYY-MM-DD'))"
[textContent]="timeago"
></a>
</span>
<span
*ngIf="!activity"
class="op-authoring--timestamp"
[textContent]="timeago"
[attr.title]="time"

@ -26,58 +26,46 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { Component, Input, OnInit } from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import {
ChangeDetectionStrategy, Component, Input, OnInit,
} from '@angular/core';
import { Moment } from 'moment-timezone';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { IUser } from 'core-app/core/state/principals/user.model';
@Component({
templateUrl: './authoring.component.html',
styleUrls: ['./authoring.component.sass'],
selector: 'authoring',
selector: 'op-authoring',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuthoringComponent implements OnInit {
// scope: { createdOn: '=', author: '=', showAuthorAsLink: '=', project: '=', activity: '=' },
@Input('createdOn') createdOn:string;
@Input() createdOn:string;
@Input('author') author:HalResource;
@Input() author:IUser;
@Input('showAuthorAsLink') showAuthorAsLink:boolean;
@Input() showAuthorAsLink:boolean;
@Input('project') project:any;
public createdOnTime:Moment;
@Input('activity') activity:any;
public timeago:string;
public createdOnTime:any;
public time:string;
public timeago:any;
public time:any;
public userLink:string;
public constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService,
readonly timezoneService:TimezoneService) {
public get authorName():string {
return (this.author && this.author.name) || '';
}
public get authorLink():string {
return (this.author && this.PathHelper.userPath(this.author.id)) || '';
}
ngOnInit() {
public constructor(readonly PathHelper:PathHelperService, readonly timezoneService:TimezoneService) { }
ngOnInit():void {
this.createdOnTime = this.timezoneService.parseDatetime(this.createdOn);
this.timeago = this.createdOnTime.fromNow();
this.time = this.createdOnTime.format('LLL');
this.userLink = this.PathHelper.userPath(idFromLink(this.author.href));
}
public activityFromPath(from:any) {
let path = this.PathHelper.projectActivityPath(this.project);
if (from) {
path += `?from=${from}`;
}
return path;
}
}

@ -9,6 +9,7 @@ import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/r
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { UploadFile } from 'core-app/core/file-upload/op-file-upload.service';
import { ICKEditorContext } from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
@Injectable()
export class CustomTextEditFieldService extends EditFieldHandler {
@ -143,9 +144,9 @@ export class CustomTextEditFieldService extends EditFieldHandler {
type: 'full',
macros: 'resource',
} as ICKEditorContext),
canAddAttachments: value.grid.canAddAttachments,
uploadAttachments: (files:UploadFile[]) => value.grid.uploadAttachments(files),
canAddAttachments: value.grid.canAddAttachments as boolean,
_links: {
attachments: (value.grid as HalResource).attachments as { href?:string },
schema: {
href: schemaHref,
},

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -57,13 +57,13 @@ describe 'Upload attachment to budget', js: true do
# adding an image
editor.drag_attachment image_fixture.path, 'Image uploaded on creation'
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
click_on 'Create'
expect(page).to have_selector('#content img', count: 1)
expect(page).to have_content('Image uploaded on creation')
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
within '.toolbar-items' do
click_on "Update"
@ -71,13 +71,13 @@ describe 'Upload attachment to budget', js: true do
editor.drag_attachment image_fixture.path, 'Image uploaded the second time'
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
click_on 'Submit'
expect(page).to have_selector('#content img', count: 2)
expect(page).to have_content('Image uploaded on creation')
expect(page).to have_content('Image uploaded the second time')
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
end
end

@ -137,7 +137,7 @@ describe 'Project description widget on dashboard', type: :feature, js: true do
editor.drag_attachment image_fixture.path, 'Image uploaded'
within custom_text_widget.area do
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
expect(page).to have_no_selector('notifications-upload-progress')
field.save!
@ -150,7 +150,7 @@ describe 'Project description widget on dashboard', type: :feature, js: true do
.to have_selector('#content img', count: 1)
expect(page)
.to have_no_selector('attachment-list-item', text: 'image.png')
.to have_no_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
end
end
end

@ -68,7 +68,7 @@ describe 'Upload attachment to documents',
# adding an image
editor.drag_attachment image_fixture.path, 'Image uploaded on creation'
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
perform_enqueued_jobs do
click_on 'Create'
@ -97,7 +97,7 @@ describe 'Upload attachment to documents',
# editor.click_and_type_slowly 'abc'
SeleniumHubWaiter.wait
editor.drag_attachment image_fixture.path, 'Image uploaded the second time'
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
perform_enqueued_jobs do
click_on 'Save'
@ -107,7 +107,7 @@ describe 'Upload attachment to documents',
expect(page).to have_selector('#content img', count: 2)
expect(page).to have_content('Image uploaded on creation')
expect(page).to have_content('Image uploaded the second time')
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
# Expect a mail to be sent to the user having subscribed to all notifications
expect(ActionMailer::Base.deliveries.size)

@ -100,7 +100,7 @@ describe 'Custom text widget on my page', type: :feature, js: true do
editor.drag_attachment image_fixture.path, 'Image uploaded'
within custom_text_widget.area do
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
expect(page).to have_no_selector('notifications-upload-progress')
field.save!
@ -109,7 +109,7 @@ describe 'Custom text widget on my page', type: :feature, js: true do
.to have_selector('#content img', count: 1)
expect(page)
.to have_no_selector('attachment-list-item', text: 'image.png')
.to have_no_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
end
# ensure no one but the page's user can see the uploaded attachment

@ -57,7 +57,7 @@ describe 'Attribute help texts', js: true do
editor.set_markdown('My attribute help text')
editor.drag_attachment image_fixture.path, 'Image uploaded on creation'
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
click_button 'Save'
expect(instance.help_text).to include 'My attribute help text'
@ -81,7 +81,7 @@ describe 'Attribute help texts', js: true do
# Add an image
# adding an image
editor.drag_attachment image_fixture.path, 'Image uploaded on creation'
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
click_button 'Save'
# Should now show on index for editing
@ -98,7 +98,7 @@ describe 'Attribute help texts', js: true do
modal.expect_edit(admin: true)
# Expect files section to be present
expect(modal.modal_container).to have_selector('.form--fieldset-legend', text: 'FILES')
expect(modal.modal_container).to have_selector('.form--fieldset-legend', text: 'ATTACHMENTS')
expect(modal.modal_container).to have_selector('.work-package--attachments--filename')
modal.close!

@ -58,14 +58,14 @@ describe 'Upload attachment to forum message', js: true do
# adding an image
editor.drag_attachment image_fixture.path, 'Image uploaded on creation'
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
expect(page).not_to have_selector('op-toasters-upload-progress')
show_page = create_page.click_save
expect(page).to have_selector('#content .wiki img', count: 1)
expect(page).to have_content('Image uploaded on creation')
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
within '.toolbar-items' do
click_on "Edit"
@ -78,7 +78,7 @@ describe 'Upload attachment to forum message', js: true do
editor.drag_attachment image_fixture.path, 'Image uploaded the second time'
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
expect(page).not_to have_selector('op-toasters-upload-progress')
show_page.click_save
@ -87,6 +87,6 @@ describe 'Upload attachment to forum message', js: true do
expect(page).to have_content('Image uploaded on creation')
expect(page).to have_content('Image uploaded the second time')
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
end
end

@ -74,6 +74,7 @@ describe 'Read-only statuses affect work package editing',
end
it 'locks the work package on a read only status' do
wp_page.switch_to_tab(tab: 'FILES')
expect(page).to have_selector '.work-package--attachments--drop-box'
subject_field = wp_page.edit_field :subject

@ -52,6 +52,7 @@ describe 'form configuration', type: :feature, js: true do
describe 'default configuration' do
let(:dialog) { ::Components::ConfirmationDialog.new }
before do
login_as(admin)
visit edit_type_tab_path(id: type.id, tab: "form_configuration")
@ -117,7 +118,7 @@ describe 'form configuration', type: :feature, js: true do
wp_page.expect_hidden_field(:done_ratio)
groups = page.all('.attributes-group--header-text').map(&:text)
expect(groups).to eq %w[FILES]
expect(groups).to eq []
expect(page)
.to have_selector('.work-packages--details--description', text: work_package.description)
end
@ -199,7 +200,7 @@ describe 'form configuration', type: :feature, js: true do
# Test the actual type backend
type.reload
expect(type.attribute_groups.map { |el| el.key })
expect(type.attribute_groups.map(&:key))
.to include('Cool Stuff', :estimates_and_time, 'Whatever', 'New Group')
# Visit work package with that type
@ -302,7 +303,7 @@ describe 'form configuration', type: :feature, js: true do
expect(page).to have_selector('.flash.notice', text: 'Successful update.', wait: 10)
end
context 'inactive in project' do
context 'if inactive in project' do
it 'can be added to the type, but is not shown' do
# Visit work package with that type
wp_page.visit!
@ -318,7 +319,7 @@ describe 'form configuration', type: :feature, js: true do
expect(page).to have_selector(".custom-field-#{custom_field.id} td", text: type.name)
id_checkbox = find("#project_work_package_custom_field_ids_#{custom_field.id}")
expect(id_checkbox).to_not be_checked
expect(id_checkbox).not_to be_checked
id_checkbox.set(true)
click_button 'Save'
@ -334,7 +335,7 @@ describe 'form configuration', type: :feature, js: true do
end
end
context 'active in project' do
context 'if active in project' do
let(:project) do
create :project,
types: [type],
@ -364,7 +365,7 @@ describe 'form configuration', type: :feature, js: true do
describe "without EE token" do
let(:dialog) { ::Components::ConfirmationDialog.new }
it "should disable adding and renaming groups" do
it "must disable adding and renaming groups" do
with_enterprise_token(nil)
login_as(admin)
visit edit_type_tab_path(id: type.id, tab: "form_configuration")

@ -51,7 +51,7 @@ describe 'Upload attachment to wiki page', js: true do
# adding an image
editor.drag_attachment image_fixture.path, 'Image uploaded the first time'
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
expect(page).not_to have_selector('op-toasters-upload-progress')
click_on 'Save'
@ -59,7 +59,7 @@ describe 'Upload attachment to wiki page', js: true do
expect(page).to have_text("Successful creation")
expect(page).to have_selector('#content img', count: 1)
expect(page).to have_content('Image uploaded the first time')
expect(page).to have_selector('attachment-list-item', text: 'image.png')
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png')
# required sleep otherwise clicking on the Edit button doesn't do anything
SeleniumHubWaiter.wait
@ -75,7 +75,7 @@ describe 'Upload attachment to wiki page', js: true do
editor.drag_attachment image_fixture.path, 'Image uploaded the second time'
expect(page).not_to have_selector('op-toasters-upload-progress')
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
editor.in_editor do |container, _|
# Expect URL is mapped to the correct URL
@ -95,7 +95,7 @@ describe 'Upload attachment to wiki page', js: true do
expect(page).to have_selector('#content img', count: 2)
# First figcaption is lost by having replaced the markdown
expect(page).to have_content('Image uploaded the second time')
expect(page).to have_selector('attachment-list-item', text: 'image.png', count: 2)
expect(page).to have_selector('[data-qa-selector="op-attachment-list-item"]', text: 'image.png', count: 2)
# Both images rendered referring to the api endpoint
expect(page).to have_selector('img[src^="/api/v3/attachments/"]', count: 2)
@ -103,37 +103,4 @@ describe 'Upload attachment to wiki page', js: true do
expect(wiki_page_content).to have_selector 'figure.op-uc-figure[style="width:50%;"]'
expect(wiki_page_content).to have_selector '.op-uc-image[src^="/api/v3/attachments"]'
end
it 'can upload an image on the new wiki page and recover from an error without losing the attachment (Regression #28171)' do
visit project_wiki_path(project, 'test')
expect(page).to have_selector('#content_page_title')
expect(page).to have_selector('.work-package--attachments--drop-box')
# Upload image to dropzone
expect(page).to have_no_selector('.work-package--attachments--filename')
attachments.attach_file_on_input(image_fixture.path)
expect(page).not_to have_selector('op-toasters-upload-progress')
expect(page).to have_selector('.work-package--attachments--filename', text: 'image.png')
# Assume we could still save the page with an empty title
page.execute_script 'jQuery("#content_page_title").removeAttr("required aria-required");'
# Remove title so we will result in an error
fill_in 'content_page_title', with: ''
click_on 'Save'
expect(page).to have_selector('#errorExplanation', text: "Title can't be blank.")
expect(page).to have_selector('.work-package--attachments--filename', text: 'image.png')
editor.in_editor do |_container, editable|
editable.send_keys 'hello there.'
end
fill_in 'content_page_title', with: 'Test'
click_on 'Save'
expect(page).to have_selector('.controller-wiki.action-show')
expect(page).to have_selector('h2', text: 'Test')
expect(page).to have_selector('.work-package--attachments--filename', text: 'image.png')
end
end

@ -28,7 +28,7 @@ describe 'Upload attachment to work package', js: true do
end
describe 'wysiwyg editor' do
context 'on an existing page' do
context 'when on an existing page' do
before do
wp_page.visit!
wp_page.ensure_page_loaded
@ -76,7 +76,7 @@ describe 'Upload attachment to work package', js: true do
end
end
context 'on a new page' do
context 'when on a new page' do
shared_examples 'it supports image uploads via drag & drop' do
let!(:new_page) { Pages::FullWorkPackageCreate.new }
let!(:type) { create(:type_task) }
@ -155,8 +155,8 @@ describe 'Upload attachment to work package', js: true do
describe 'attachment dropzone' do
it 'can upload an image via attaching and drag & drop' do
wp_page.switch_to_tab(tab: 'FILES')
container = page.find('.wp-attachment-upload')
scroll_to_element(container)
##
# Attach file manually

@ -28,7 +28,7 @@
require 'spec_helper'
RSpec.feature 'Work package navigation', js: true, selenium: true do
describe 'Work package navigation', js: true, selenium: true do
let(:user) { create(:admin) }
let(:project) { create(:project, name: 'Some project', enabled_module_names: [:work_package_tracking]) }
let(:work_package) { build(:work_package, project: project) }
@ -55,7 +55,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
login_as(user)
end
scenario 'all different angular based work package views' do
it 'all different angular based work package views' do
work_package.save!
# deep link global work package index
@ -122,18 +122,18 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
full_work_package = split_project_work_package.switch_to_fullscreen
full_work_package.expect_subject
expect(current_path).to eq project_work_package_path(project, work_package, 'activity')
expect(page).to have_current_path project_work_package_path(project, work_package, 'activity')
project_html_title.expect_first_segment wp_title_segment
# Switch tabs
full_work_package.switch_to_tab tab: :relations
expect(current_path).to eq project_work_package_path(project, work_package, 'relations')
expect(page).to have_current_path project_work_package_path(project, work_package, 'relations')
project_html_title.expect_first_segment wp_title_segment
# Back to split screen using the button
full_work_package.go_back
global_work_packages.expect_work_package_listed(work_package)
expect(current_path).to eq project_work_packages_path(project) + "/details/#{work_package.id}/relations"
expect(page).to have_current_path project_work_packages_path(project) + "/details/#{work_package.id}/relations"
# Link to full screen from index
global_work_packages.open_full_screen_by_link(work_package)
@ -146,7 +146,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
full_work_package.ensure_page_loaded
end
scenario 'loading an unknown work package ID' do
it 'loading an unknown work package ID' do
visit '/work_packages/999999999'
page404 = ::Pages::Page.new
@ -157,7 +157,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
end
# Regression #29994
scenario 'access the work package views directly from a non-angular view' do
it 'access the work package views directly from a non-angular view' do
visit project_path(project)
find('#main-menu-work-packages ~ .toggler').click
@ -168,7 +168,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
expect(page).to have_field('editable-toolbar-title', with: query.name)
end
scenario 'double clicking search result row (Regression #30247)' do
it 'double clicking search result row (Regression #30247)' do
work_package.subject = 'Foobar'
work_package.save!
visit search_path(q: 'Foo', work_packages: 1, scope: :all)
@ -180,7 +180,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
full_page.ensure_page_loaded
end
scenario 'double clicking my page (Regression #30343)' do
it 'double clicking my page (Regression #30343)' do
work_package.author = user
work_package.subject = 'Foobar'
work_package.save!
@ -193,7 +193,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
full_page.ensure_page_loaded
end
scenario 'moving back from gantt to "All open" (Regression #30921)' do
it 'moving back from gantt to "All open" (Regression #30921)' do
wp_table = Pages::WorkPackagesTable.new project
wp_table.visit!
@ -244,7 +244,7 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
end
end
context 'work package with an attachment' do
context 'with work package with an attachment' do
let!(:attachment) { build(:attachment, filename: 'attachment-first.pdf') }
let!(:wp_with_attachment) do
create :work_package, subject: 'WP attachment A', project: project, attachments: [attachment]
@ -258,11 +258,12 @@ RSpec.feature 'Work package navigation', js: true, selenium: true do
full_view = wp_table.open_full_screen_by_link wp_with_attachment
full_view.ensure_page_loaded
full_view.switch_to_tab(tab: 'FILES')
expect(page).to have_selector('.work-package--attachments--filename', text: 'attachment-first.pdf', wait: 10)
end
end
context 'two work packages with card view' do
context 'with two work packages with card view' do
let!(:work_package) { create :work_package, project: project }
let!(:work_package2) { create :work_package, project: project }
let(:display_representation) { ::Components::WorkPackages::DisplayRepresentation.new }

Loading…
Cancel
Save