Merge branch 'implementation/41849-adapt-the-style-of-the-attachments-section-to-the-new-designs' into implementation/42160-add-download-file-direct-action-to-hover-menu

pull/10834/head
Eric Schubert 2 years ago
commit e2601b02ad
No known key found for this signature in database
GPG Key ID: 1D346C019BD4BAA2
  1. 1
      frontend/src/app/core/state/attachments/attachment.model.ts
  2. 11
      frontend/src/app/core/state/resource-collection.service.ts
  3. 3
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.html
  4. 107
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list-item.component.ts
  5. 80
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list-item.html
  6. 20
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list.component.ts
  7. 22
      frontend/src/app/shared/components/attachments/attachment-list/attachment-list.html
  8. 4
      frontend/src/app/shared/components/file-links/file-link-icons/file-link-list-item-icon.factory.ts
  9. 4
      frontend/src/app/shared/components/file-links/file-link-icons/icon-mappings.ts
  10. 6
      frontend/src/app/shared/components/file-links/file-link-list/file-link-list-item.component.ts
  11. 1
      frontend/src/app/shared/components/file-links/file-link-list/file-link-list-item.html
  12. 5
      frontend/src/app/shared/components/principal/principal-helper.ts
  13. 15
      frontend/src/global_styles/content/work_packages/tabs/_files.sass
  14. 1
      frontend/src/global_styles/content/work_packages/tabs/_tab_content.sass
  15. 11
      modules/meeting/spec/features/meetings_attachments_spec.rb
  16. 2
      spec/features/admin/attribute_help_texts_spec.rb
  17. 9
      spec/features/work_packages/attachments/attachment_upload_spec.rb
  18. 3
      spec/features/work_packages/navigation_spec.rb

@ -39,6 +39,7 @@ export interface IAttachmentHalResourceLinks extends IHalResourceLinks {
container:IHalResourceLink;
author:IHalResourceLink;
downloadLocation:IHalResourceLink;
staticDownloadLocation:IHalResourceLink;
}
export interface IAttachment {

@ -85,16 +85,11 @@ export abstract class ResourceCollectionService<T> {
}
/**
* Lookup a single entity from the store
* Checks, if the store already has a resource loaded by id.
* @param id
*/
lookupMultiple(id:ID):Observable<T> {
return this
.query
.selectEntity(id)
.pipe(
filter((entity) => entity !== undefined),
) as Observable<T>;
exists(id:ID):boolean {
return this.query.hasEntity(id);
}
/**

@ -9,6 +9,7 @@
*ngIf="canViewFileLinks$ | async"
class="op-tab-content--header"
>
<span class="spot-icon spot-icon_attachment op-files-tab--icon_clip"></span>
<span class="op-tab-content--header-text" [textContent]="text.attachments.label"></span>
</div>
@ -23,7 +24,7 @@
class="op-tab-content--tab-section"
>
<div class=op-tab-content--header>
<span class="spot-icon spot-icon_nextcloud-circle"></span>
<span class="spot-icon spot-icon_nextcloud-circle op-files-tab--icon_nextcloud"></span>
<span class="op-tab-content--header-text" [textContent]="storage.name"></span>
</div>

@ -27,34 +27,47 @@
//++
import {
ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output,
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, first } 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 { IPrincipal } from 'core-app/core/state/principals/principal.model';
import { IAttachment } from 'core-app/core/state/attachments/attachment.model';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service';
import { IUser } from 'core-app/core/state/principals/user.model';
import { PrincipalRendererService } from 'core-app/shared/components/principal/principal-renderer.service';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { IFileIcon } from 'core-app/shared/components/file-links/file-link-icons/icon-mappings';
import {
getIconForMimeType,
} from 'core-app/shared/components/file-links/file-link-icons/file-link-list-item-icon.factory';
@Component({
selector: 'op-attachment-list-item',
// eslint-disable-next-line @angular-eslint/component-selector
selector: '[op-attachment-list-item]',
templateUrl: './attachment-list-item.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AttachmentListItemComponent implements OnInit {
@Input() public resource:HalResource;
export class AttachmentListItemComponent extends UntilDestroyedMixin implements OnInit, AfterViewInit {
@Input() public attachment:IAttachment;
@Input() public index:number;
@Input() destroyImmediately = true;
@Output() public removeAttachment = new EventEmitter<void>();
@ViewChild('avatar') avatar:ElementRef;
static imageFileExtensions:string[] = ['jpeg', 'jpg', 'gif', 'bmp', 'png'];
public text = {
@ -67,23 +80,57 @@ export class AttachmentListItemComponent implements OnInit {
return this.text.removeFile({ fileName: this.attachment.fileName });
}
public author$:Observable<IUser>;
public author$:Observable<IPrincipal>;
public timestampText:string;
public fileIcon:IFileIcon;
constructor(private readonly principalsResourceService:PrincipalsResourceService,
private viewInitialized$ = new BehaviorSubject<boolean>(false);
constructor(
private readonly I18n:I18nService,
private readonly pathHelper:PathHelperService) {
private readonly pathHelper:PathHelperService,
private readonly timezoneService:TimezoneService,
private readonly principalsResourceService:PrincipalsResourceService,
private readonly principalRendererService:PrincipalRendererService,
) {
super();
}
ngOnInit():void {
this.fileIcon = getIconForMimeType(this.attachment.contentType);
const authorId = idFromLink(this.attachment._links.author.href);
this.author$ = this
.principalsResourceService
.lookup(authorId)
.pipe(
switchMap((user) => (user ? of(user) : this.principalsResourceService.fetchUser(authorId))),
map((user) => user as IUser),
);
if (!this.principalsResourceService.exists(authorId)) {
this.principalsResourceService.fetchUser(authorId).subscribe();
}
this.timestampText = this.timezoneService.parseDatetime(this.attachment.createdAt).fromNow();
this.author$ = this.principalsResourceService.lookup(authorId).pipe(first());
combineLatest([
this.author$,
this.viewInitialized$.pipe(distinctUntilChanged()),
]).pipe(this.untilDestroyed())
.subscribe(([user, initialized]) => {
if (!initialized) {
return;
}
this.principalRendererService.render(
this.avatar.nativeElement,
user,
{ hide: true, link: false },
{ hide: false, size: 'mini' },
);
});
}
ngAfterViewInit():void {
this.viewInitialized$.next(true);
}
/**
@ -108,29 +155,25 @@ export class AttachmentListItemComponent implements OnInit {
if (this.isImage) {
el = document.createElement('img');
el.src = url;
el.textContent = this.fileName;
el.textContent = this.attachment.fileName;
} else {
el = document.createElement('a');
el.href = url;
el.textContent = this.fileName;
el.textContent = this.attachment.fileName;
}
return el;
}
public get downloadPath():string {
return this.pathHelper.attachmentDownloadPath(String(this.attachment.id), this.fileName);
private get downloadPath():string {
return this.pathHelper.attachmentDownloadPath(String(this.attachment.id), this.attachment.fileName);
}
public get isImage():boolean {
const ext = this.fileName.split('.').pop() || '';
private get isImage():boolean {
const ext = this.attachment.fileName.split('.').pop() || '';
return AttachmentListItemComponent.imageFileExtensions.indexOf(ext.toLowerCase()) > -1;
}
public get fileName():string {
return this.attachment.fileName;
}
public confirmRemoveAttachment($event:JQuery.TriggeredEvent):boolean {
if (!window.confirm(this.text.destroyConfirmation)) {
$event.stopImmediatePropagation();

@ -1,39 +1,49 @@
<li
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 || '#'">
<div class="spot-list--item-floating-wrapper">
<a
class="spot-list--item-action op-files-tab--file-list-item-action"
[href]="attachment._links.staticDownloadLocation.href"
target="_blank"
(dragstart)="setDragData($event)"
>
<span
class="spot-icon spot-icon_{{fileIcon.icon}} op-files-tab--icon_{{fileIcon.clazz}}"
></span>
{{ fileName }}
<span
[textContent]="attachment.fileName"
[title]="attachment.fileName"
class="spot-list--item-title op-files-tab--file-list-item-title"
data-qa-selector="op-files-tab--file-list-item-title"
></span>
<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--attachments--delete-button"
*ngIf="!!attachment._links.delete"
(click)="confirmRemoveAttachment($event)">
<op-icon
icon-classes="icon-delete"
[icon-title]="deleteIconTitle"
></op-icon>
<span
class="op-files-tab--file-list-item-text op-files-tab--color-grey"
[textContent]="timestampText"
></span>
<div
#avatar
class="op-files-tab--file-list-item-avatar"
></div>
</a>
<div class="spot-list--item-floating-actions op-files-tab--file-list-item-floating-actions">
<a
class="spot-link"
[title]="(author$ | async)?.name"
[href]="(author$ | async)?._links.showUser.href"
>
<span class="spot-icon spot-icon_user"></span>
</a>
<button
*ngIf="!!this.attachment._links.delete"
class="spot-link"
[title]="deleteIconTitle"
(click)="confirmRemoveAttachment($event)"
>
<span class="spot-icon spot-icon_delete"></span>
</button>
</div>
</div>
<input type="hidden" name="attachments[{{index}}][id]" value="{{attachment.id}}">
</li>
<!-- Needed, if attachment-list-item is used within a form, to provide the added attachment to the serializer. -->
<input type="hidden" name="attachments[{{index}}][id]" value="{{attachment.id}}">

@ -32,14 +32,14 @@ import {
Input,
OnInit,
} from '@angular/core';
import { trackByProperty } from 'core-app/shared/helpers/angular/tracking-functions';
import { map, tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { IAttachment } from 'core-app/core/state/attachments/attachment.model';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
import { tap } from 'rxjs/operators';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { Observable } from 'rxjs';
@Component({
selector: 'op-attachment-list',
@ -49,8 +49,6 @@ import { Observable } from 'rxjs';
export class AttachmentListComponent extends UntilDestroyedMixin implements OnInit {
@Input() public resource:HalResource;
trackByFileName = trackByProperty('fileName');
$attachments:Observable<IAttachment[]>;
private get attachmentsSelfLink():string {
@ -62,7 +60,10 @@ export class AttachmentListComponent extends UntilDestroyedMixin implements OnIn
return isNewResource(this.resource) ? 'new' : this.attachmentsSelfLink;
}
constructor(private readonly attachmentsResourceService:AttachmentsResourceService) {
constructor(
private readonly timezoneService:TimezoneService,
private readonly attachmentsResourceService:AttachmentsResourceService,
) {
super();
}
@ -72,11 +73,18 @@ export class AttachmentListComponent extends UntilDestroyedMixin implements OnIn
this.attachmentsResourceService.requireCollection(this.attachmentsSelfLink);
}
const compareCreatedAtTimestamps = (a:IAttachment, b:IAttachment):number => {
const rightCreatedAt = this.timezoneService.parseDatetime(b.createdAt);
const leftCreatedAt = this.timezoneService.parseDatetime(a.createdAt);
return rightCreatedAt.isBefore(leftCreatedAt) ? -1 : 1;
};
this.$attachments = this
.attachmentsResourceService
.collection(this.collectionKey)
.pipe(
this.untilDestroyed(),
map((attachments) => attachments.sort(compareCreatedAtTimestamps)),
// 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) => {

@ -1,10 +1,16 @@
<div class="work-package--attachments--files" *ngIf="resource">
<ul class="form--selected-value--list"
*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>
<div
*ngIf="resource"
class="work-package--attachments--files op-attachments-list"
>
<ul class="spot-list spot-list_compact op-files-tab--file-list">
<li
*ngFor="let attachment of $attachments | async; let i = index;"
op-attachment-list-item
class="spot-list--item op-files-tab--file-list-item"
data-qa-selector="op-attachment-list-item"
[attachment]="attachment"
[index]="i"
(removeAttachment)="removeAttachment(attachment)"
></li>
</ul>
</div>

@ -28,10 +28,10 @@
import {
fileIconMappings,
IFileLinkListItemIcon,
IFileIcon,
} from 'core-app/shared/components/file-links/file-link-icons/icon-mappings';
export function getIconForMimeType(mimeType?:string):IFileLinkListItemIcon {
export function getIconForMimeType(mimeType?:string):IFileIcon {
if (mimeType && fileIconMappings[mimeType]) {
return fileIconMappings[mimeType];
}

@ -26,12 +26,12 @@
// See COPYRIGHT and LICENSE files for more details.
//++
export interface IFileLinkListItemIcon {
export interface IFileIcon {
icon:'image1'|'movie'|'file-text'|'export-pdf-descr'|'file-doc'|'file-sheet'|'file-presentation'|'folder'|'ticket'
clazz:'pdf'|'img'|'txt'|'doc'|'sheet'|'presentation'|'form'|'dir'|'mov'|'default'
}
export const fileIconMappings:Record<string, IFileLinkListItemIcon> = {
export const fileIconMappings:Record<string, IFileIcon> = {
'application/pdf': { icon: 'export-pdf-descr', clazz: 'pdf' },
'image/jpeg': { icon: 'image1', clazz: 'img' },

@ -45,7 +45,7 @@ import {
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { PrincipalRendererService } from 'core-app/shared/components/principal/principal-renderer.service';
import { IFileLinkListItemIcon } from 'core-app/shared/components/file-links/file-link-icons/icon-mappings';
import { IFileIcon } from 'core-app/shared/components/file-links/file-link-icons/icon-mappings';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
@ -68,7 +68,7 @@ export class FileLinkListItemComponent implements OnInit, AfterViewInit {
public infoTimestampText:string;
public fileLinkIcon:IFileLinkListItemIcon;
public fileLinkIcon:IFileIcon;
public text = {
title: {
@ -100,7 +100,7 @@ export class FileLinkListItemComponent implements OnInit, AfterViewInit {
if (this.originData.lastModifiedByName) {
this.principalRendererService.render(
this.avatar.nativeElement,
{ name: this.originData.lastModifiedByName, href: '/users/1' },
{ name: this.originData.lastModifiedByName, href: '/external_users/1' },
{ hide: true, link: false },
{ hide: false, size: 'mini' },
);

@ -11,6 +11,7 @@
<span
[textContent]="fileLink.originData.name"
[title]="fileLink.originData.name"
class="spot-list--item-title op-files-tab--file-list-item-title"
></span>

@ -30,8 +30,7 @@ import { PrincipalLike } from 'core-app/shared/components/principal/principal-ty
import { IPrincipal } from 'core-app/core/state/principals/principal.model';
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
export type PrincipalType = 'user'|'placeholder_user'|'group';
export type PrincipalPluralType = 'users'|'placeholder_users'|'groups';
export type PrincipalType = 'user'|'placeholder_user'|'group'|'external_user';
/*
* This function is a helper that wraps around the old HalResource based principal type and the new interface based one.
@ -51,7 +50,7 @@ export function hrefFromPrincipal(p:IPrincipal|PrincipalLike):string {
return '';
}
export function typeFromHref(href:string):PrincipalType|null {
const match = /\/(user|group|placeholder_user)s\/\d+$/.exec(href);
const match = /\/(user|group|placeholder_user|external_user)s\/\d+$/.exec(href);
if (!match) {
return null;

@ -26,7 +26,8 @@
// See COPYRIGHT and LICENSE files for more details.
//++
.op-files-tab
.op-files-tab,
.op-attachments-list
.op-files-tab--file-list
.op-files-tab--file-list-item
.op-files-tab--file-list-item-action
@ -43,6 +44,7 @@
word-break: normal
overflow: hidden
text-overflow: ellipsis
padding-right: $spot_spacing-0-5
.op-files-tab--file-list-item-text
@include spot-caption()
@ -72,6 +74,7 @@
&--storage-info-box
margin-top: 0.875rem
display: grid
align-items: center
grid-template: "icon text" "button button" / auto 1fr
.spot-icon.big
@ -80,6 +83,7 @@
width: 3.375rem
height: 3.375rem
font-size: 1.25rem
margin-right: $spot_spacing-0-5
.text-box
grid-area: text
@ -98,6 +102,9 @@
display: flex
justify-content: end
> .button_no-margin
margin-top: $spot_spacing-0-75
&--icon
&_pdf
color: #B93A33
@ -128,3 +135,9 @@
&_default
color: #9A9A9A
&_clip
color: #333333
&_nextcloud
color: #1A67A3

@ -54,4 +54,3 @@
width: $spot-spacing-1_5
height: $spot-spacing-1_5
margin-right: $spot-spacing-0-5
color: #1A67A3

@ -36,9 +36,9 @@ describe 'Add an attachment to a meeting (agenda)', js: true do
end
describe 'wysiwyg editor' do
context 'on an existing page' do
context 'if on an existing page' do
it 'can upload an image via drag & drop' do
target = find('.ck-content')
find('.ck-content')
editor.expect_button 'Insert image'
@ -63,16 +63,17 @@ describe 'Add an attachment to a meeting (agenda)', js: true do
##
# Attach file manually
expect(page).to have_no_selector('.work-package--attachments--filename')
expect(page).to have_no_selector('[data-qa-selector="op-files-tab--file-list-item-title"]')
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', wait: 5)
expect(page).to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]', text: 'image.png', wait: 5)
##
# and via drag & drop
attachments.drag_and_drop_file(container, 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', count: 2, wait: 5)
expect(page)
.to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]', text: 'image.png', count: 2, wait: 5)
end
end
end

@ -99,7 +99,7 @@ describe 'Attribute help texts', js: true do
# Expect files section to be present
expect(modal.modal_container).to have_selector('.form--fieldset-legend', text: 'ATTACHMENTS')
expect(modal.modal_container).to have_selector('.work-package--attachments--filename')
expect(modal.modal_container).to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]')
modal.close!

@ -252,7 +252,7 @@ describe 'Upload attachment to work package', js: true do
:center,
page.find('[data-qa-tab-id="files"]')
expect(page).to have_selector('.work-package--attachments--filename', text: 'image.png', wait: 10)
expect(page).to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]', text: 'image.png', wait: 10)
expect(page).not_to have_selector('op-toasters-upload-progress')
wp_page.expect_tab 'Files'
end
@ -274,16 +274,17 @@ describe 'Upload attachment to work package', js: true do
##
# Attach file manually
expect(page).to have_no_selector('.work-package--attachments--filename')
expect(page).to have_no_selector('[data-qa-selector="op-files-tab--file-list-item-title"]')
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', wait: 5)
expect(page).to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]', text: 'image.png', wait: 5)
##
# and via drag & drop
attachments.drag_and_drop_file(container, 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', count: 2, wait: 5)
expect(page)
.to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]', text: 'image.png', count: 2, wait: 5)
end
end
end

@ -259,7 +259,8 @@ describe 'Work package navigation', js: true, selenium: true do
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)
expect(page)
.to have_selector('[data-qa-selector="op-files-tab--file-list-item-title"]', text: 'attachment-first.pdf', wait: 10)
end
end

Loading…
Cancel
Save