Merge pull request #10658 from opf/implementation/42158-add-direct-action-hover-menu

[#42158] Add direct action hover menu
pull/10800/head
Eric Schubert 2 years ago committed by GitHub
commit 213b025367
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      config/locales/js-en.yml
  2. 19
      frontend/src/app/core/state/attachments/attachments.service.ts
  3. 21
      frontend/src/app/core/state/collection-store.ts
  4. 2
      frontend/src/app/core/state/file-links/file-link.model.ts
  5. 21
      frontend/src/app/core/state/file-links/file-links.service.ts
  6. 26
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.html
  7. 29
      frontend/src/app/shared/components/file-links/file-link-icons/file-link-list-item-icon.factory.ts
  8. 102
      frontend/src/app/shared/components/file-links/file-link-icons/icon-mappings.ts
  9. 59
      frontend/src/app/shared/components/file-links/file-link-list/file-link-list-item.component.ts
  10. 62
      frontend/src/app/shared/components/file-links/file-link-list/file-link-list-item.html
  11. 69
      frontend/src/app/shared/components/file-links/file-link-list/file-link-list.component.ts
  12. 45
      frontend/src/app/shared/components/file-links/file-link-list/file-link-list.html
  13. 43
      frontend/src/app/shared/components/file-links/file-link-list/storage-information.html
  14. 3
      frontend/src/app/spot/icon-font/src/add-link.svg
  15. 3
      frontend/src/app/spot/icon-font/src/nextcloud.svg
  16. 3
      frontend/src/app/spot/icon-font/src/remove-link.svg
  17. 1113
      frontend/src/app/spot/styles/sass/common/icon.sass
  18. 555
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.svg
  19. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.ttf
  20. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.woff
  21. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.woff2
  22. 124
      frontend/src/global_styles/content/work_packages/tabs/_files.sass
  23. 25
      frontend/src/global_styles/content/work_packages/tabs/_tab_content.sass
  24. 1113
      frontend/src/global_styles/fonts/_openproject_icon_definitions.sass
  25. 3
      frontend/src/global_styles/fonts/_openproject_icon_font.lsg
  26. 2
      modules/storages/spec/features/show_file_links_spec.rb

@ -421,6 +421,7 @@ en:
label_learn_more_link: "Learn more" label_learn_more_link: "Learn more"
label_less_or_equal: "<=" label_less_or_equal: "<="
label_less_than_ago: "less than days ago" label_less_than_ago: "less than days ago"
label_link_files_in_storage: "Link files in %{storageType}"
label_loading: "Loading..." label_loading: "Loading..."
label_login_to_storage: "Login to %{storageType}" label_login_to_storage: "Login to %{storageType}"
label_mail_notification: "Email notifications" label_mail_notification: "Email notifications"
@ -429,7 +430,6 @@ en:
label_meeting_minutes: "Minutes" label_meeting_minutes: "Minutes"
label_menu_collapse: "collapse" label_menu_collapse: "collapse"
label_menu_expand: "expand" label_menu_expand: "expand"
label_modified_at: "Modified: %{date}"
label_more_than_ago: "more than days ago" label_more_than_ago: "more than days ago"
label_next: "Next" label_next: "Next"
label_no_color: "No color" label_no_color: "No color"
@ -438,8 +438,7 @@ en:
label_no_start_date: "no start date" label_no_start_date: "no start date"
label_no_storage_connection: "No %{storageType} connection" label_no_storage_connection: "No %{storageType} connection"
label_no_value: "No value" label_no_value: "No value"
label_no_file_links_header: "Link files in %{storageType}" label_no_file_links: "In order to link files to this work package please do it via %{storageType}."
label_no_file_links_content: "In order to link files to this work package please do it via %{storageType}."
label_none: "none" label_none: "none"
label_not_contains: "doesn't contain" label_not_contains: "doesn't contain"
label_not_equals: "is not" label_not_equals: "is not"
@ -448,6 +447,8 @@ en:
label_open_context_menu: "Open context menu" label_open_context_menu: "Open context menu"
label_open_storage: "Open %{storageType}" label_open_storage: "Open %{storageType}"
label_open_work_packages: "open" label_open_work_packages: "open"
label_open_file_link: 'Open file on storage'
label_open_file_link_location: 'Open file in location'
label_password: "Password" label_password: "Password"
label_previous: "Previous" label_previous: "Previous"
label_per_page: "Per page:" label_per_page: "Per page:"
@ -460,6 +461,7 @@ en:
label_remove: "Remove" label_remove: "Remove"
label_remove_column: "Remove column" label_remove_column: "Remove column"
label_remove_columns: "Remove selected columns" label_remove_columns: "Remove selected columns"
label_remove_file_link: "Remove file link"
label_remove_row: "Remove row" label_remove_row: "Remove row"
label_report: "Report" label_report: "Report"
label_repository_plural: "Repositories" label_repository_plural: "Repositories"

@ -56,7 +56,7 @@ import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; 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 isNewResource, { HAL_NEW_RESOURCE_ID } from 'core-app/features/hal/helpers/is-new-resource';
import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { insertCollectionIntoState } from 'core-app/core/state/collection-store'; import { insertCollectionIntoState, removeEntityFromCollectionAndState } from 'core-app/core/state/collection-store';
import { import {
CollectionStore, CollectionStore,
ResourceCollectionService, ResourceCollectionService,
@ -119,22 +119,7 @@ export class AttachmentsResourceService extends ResourceCollectionService<IAttac
return this.http return this.http
.delete<void>(attachment._links.delete.href, { withCredentials: true, headers }) .delete<void>(attachment._links.delete.href, { withCredentials: true, headers })
.pipe( .pipe(
tap(() => { tap(() => removeEntityFromCollectionAndState(this.store, attachment.id, collectionKey)),
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) => { catchError((error) => {
this.toastService.addError(error); this.toastService.addError(error);
throw error; throw error;

@ -85,6 +85,27 @@ export function insertCollectionIntoState<T extends { id:ID }>(
}); });
} }
export function removeEntityFromCollectionAndState<T extends { id:ID }>(
store:EntityStore<CollectionState<T>>,
entityId:ID,
collectionUrl:string,
):void {
applyTransaction(() => {
store.remove(entityId);
store.update(({ collections }) => (
{
collections: {
...collections,
[collectionUrl]: {
...collections[collectionUrl],
ids: (collections[collectionUrl]?.ids || []).filter((id) => id !== entityId),
},
},
}
));
});
}
export function collectionFrom<T>(elements:T[]):IHALCollection<T> { export function collectionFrom<T>(elements:T[]):IHALCollection<T> {
const count = elements.length; const count = elements.length;

@ -44,7 +44,7 @@ export interface IFileLinkHalResourceLinks extends IHalResourceLinks {
staticOriginOpenLocation:IHalResourceLink; staticOriginOpenLocation:IHalResourceLink;
} }
interface IFileLinkOriginData { export interface IFileLinkOriginData {
name:string; name:string;
mimeType?:string; mimeType?:string;
size?:number; size?:number;

@ -27,7 +27,7 @@
//++ //++
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { from } from 'rxjs'; import { from } from 'rxjs';
import { import {
catchError, catchError,
@ -41,7 +41,7 @@ import { IFileLink } from 'core-app/core/state/file-links/file-link.model';
import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { FileLinksStore } from 'core-app/core/state/file-links/file-links.store'; import { FileLinksStore } from 'core-app/core/state/file-links/file-links.store';
import { insertCollectionIntoState } from 'core-app/core/state/collection-store'; import { insertCollectionIntoState, removeEntityFromCollectionAndState } from 'core-app/core/state/collection-store';
import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { import {
CollectionStore, CollectionStore,
@ -90,4 +90,21 @@ export class FileLinksResourceService extends ResourceCollectionService<IFileLin
protected createStore():CollectionStore<IFileLink> { protected createStore():CollectionStore<IFileLink> {
return new FileLinksStore(); return new FileLinksStore();
} }
remove(collectionKey:string, fileLink:IFileLink):void {
if (!fileLink._links.delete) {
return;
}
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
this.http
.delete<void>(fileLink._links.delete.href, { withCredentials: true, headers })
.pipe(
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
)
.subscribe(() => removeEntityFromCollectionAndState(this.store, fileLink.id, collectionKey));
}
} }

@ -1,7 +1,15 @@
<div class="op-tab-content--container"> <div
<section data-qa-selector="op-tab-content--tab-section"> class="op-tab-content--container op-files-tab"
<div *ngIf="(canViewFileLinks$ | async) === true" class="op-tab-content--header"> >
<h3 class="op-tab-content--header-text" [textContent]="text.attachments.label"></h3> <section
class="op-tab-content--tab-section"
data-qa-selector="op-tab-content--tab-section"
>
<div
*ngIf="canViewFileLinks$ | async"
class="op-tab-content--header"
>
<span class="op-tab-content--header-text" [textContent]="text.attachments.label"></span>
</div> </div>
<op-attachment-list [resource]="workPackage"></op-attachment-list> <op-attachment-list [resource]="workPackage"></op-attachment-list>
@ -9,10 +17,14 @@
<op-attachments-upload [resource]="workPackage"></op-attachments-upload> <op-attachments-upload [resource]="workPackage"></op-attachments-upload>
</section> </section>
<section *ngFor="let storage of storages$ | async" data-qa-selector="op-tab-content--tab-section"> <section
*ngFor="let storage of storages$ | async"
data-qa-selector="op-tab-content--tab-section"
class="op-tab-content--tab-section"
>
<div class=op-tab-content--header> <div class=op-tab-content--header>
<op-icon icon-classes="icon3 icon-nextcloud-circle op-files-tab--color-blue-deep"></op-icon> <span class="spot-icon spot-icon_nextcloud-circle"></span>
<h3 class="op-tab-content--header-text" [textContent]="storage.name"></h3> <span class="op-tab-content--header-text" [textContent]="storage.name"></span>
</div> </div>
<op-file-link-list <op-file-link-list

@ -26,30 +26,15 @@
// See COPYRIGHT and LICENSE files for more details. // See COPYRIGHT and LICENSE files for more details.
//++ //++
export interface IFileLinkListItemIcon { import {
icon:'image1'|'movie'|'file-text'|'export-pdf-descr'|'file-doc'|'file-sheet'|'file-presentation'|'folder'|'ticket' fileIconMappings,
color:'red'|'blue'|'blue-deep'|'blue-dark'|'turquoise'|'green'|'grey-dark'|'grey'|'orange' IFileLinkListItemIcon,
} } from 'core-app/shared/components/file-links/file-link-icons/icon-mappings';
const mimeTypeIconMap:{ [mimeType:string]:IFileLinkListItemIcon; } = {
'image/*': { icon: 'image1', color: 'blue-dark' },
'text/plain': { icon: 'file-text', color: 'grey-dark' },
'application/pdf': { icon: 'export-pdf-descr', color: 'red' },
'application/vnd.oasis.opendocument.text': { icon: 'file-doc', color: 'blue-deep' },
'application/vnd.oasis.opendocument.spreadsheet': { icon: 'file-sheet', color: 'green' },
'application/vnd.oasis.opendocument.presentation': { icon: 'file-presentation', color: 'turquoise' },
'application/x-op-directory': { icon: 'folder', color: 'blue' },
default: { icon: 'ticket', color: 'grey-dark' },
};
export function getIconForMimeType(mimeType?:string):IFileLinkListItemIcon { export function getIconForMimeType(mimeType?:string):IFileLinkListItemIcon {
if (mimeType?.startsWith('image/')) { if (mimeType && fileIconMappings[mimeType]) {
return mimeTypeIconMap['image/*']; return fileIconMappings[mimeType];
}
if (mimeType && mimeTypeIconMap[mimeType]) {
return mimeTypeIconMap[mimeType];
} }
return mimeTypeIconMap.default; return fileIconMappings.default;
} }

@ -0,0 +1,102 @@
// -- 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.
//++
export interface IFileLinkListItemIcon {
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> = {
'application/pdf': { icon: 'export-pdf-descr', clazz: 'pdf' },
'image/jpeg': { icon: 'image1', clazz: 'img' },
'image/png': { icon: 'image1', clazz: 'img' },
'image/gif': { icon: 'image1', clazz: 'img' },
'image/svg+xml': { icon: 'image1', clazz: 'img' },
'image/tiff': { icon: 'image1', clazz: 'img' },
'image/bmp': { icon: 'image1', clazz: 'img' },
'image/webp': { icon: 'image1', clazz: 'img' },
'image/heic': { icon: 'image1', clazz: 'img' },
'image/heif': { icon: 'image1', clazz: 'img' },
'image/avif': { icon: 'image1', clazz: 'img' },
'image/cgm': { icon: 'image1', clazz: 'img' },
'text/plain': { icon: 'file-text', clazz: 'txt' },
'text/html': { icon: 'file-text', clazz: 'txt' },
'application/rtf': { icon: 'file-text', clazz: 'txt' },
'application/xml': { icon: 'file-text', clazz: 'txt' },
'application/xhtml+xml': { icon: 'file-text', clazz: 'txt' },
'application/x-tex': { icon: 'file-text', clazz: 'txt' },
'application/vnd.oasis.opendocument.text': { icon: 'file-doc', clazz: 'doc' },
'application/vnd.oasis.opendocument.text-template': { icon: 'file-doc', clazz: 'doc' },
'application/msword': { icon: 'file-doc', clazz: 'doc' },
'application/vnd.apple.pages': { icon: 'file-doc', clazz: 'doc' },
'application/vnd.stardivision.writer': { icon: 'file-doc', clazz: 'doc' },
'application/x-abiword': { icon: 'file-doc', clazz: 'doc' },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { icon: 'file-doc', clazz: 'doc' },
'font/otf': { icon: 'file-doc', clazz: 'doc' },
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { icon: 'file-sheet', clazz: 'sheet' },
'application/vnd.oasis.opendocument.spreadsheet': { icon: 'file-sheet', clazz: 'sheet' },
'application/vnd.oasis.opendocument.spreadsheet-template': { icon: 'file-sheet', clazz: 'sheet' },
'application/vnd.ms-excel': { icon: 'file-sheet', clazz: 'sheet' },
'application/vnd.stardivision.calc': { icon: 'file-sheet', clazz: 'sheet' },
'application/vnd.apple.numbers': { icon: 'file-sheet', clazz: 'sheet' },
'application/x-starcalc': { icon: 'file-sheet', clazz: 'sheet' },
'application/x-quattro-pro': { icon: 'file-sheet', clazz: 'sheet' },
'application/csv': { icon: 'file-sheet', clazz: 'sheet' },
'application/vnd.oasis.opendocument.presentation': { icon: 'file-presentation', clazz: 'presentation' },
'application/vnd.oasis.opendocument.presentation-template': { icon: 'file-presentation', clazz: 'presentation' },
'application/vnd.apple.keynote': { icon: 'file-presentation', clazz: 'presentation' },
'application/vnd.ms-powerpoint': { icon: 'file-presentation', clazz: 'presentation' },
'application/vnd.openxmlformats-officedocument.presentationml.presentation': {
icon: 'file-presentation',
clazz: 'presentation',
},
'application/vnd.stardivision.impress': { icon: 'file-presentation', clazz: 'presentation' },
'application/mathematica': { icon: 'file-presentation', clazz: 'presentation' },
'video/mp4': { icon: 'movie', clazz: 'mov' },
'video/x-m4v': { icon: 'movie', clazz: 'mov' },
'video/avi': { icon: 'movie', clazz: 'mov' },
'video/quicktime': { icon: 'movie', clazz: 'mov' },
'video/webm': { icon: 'movie', clazz: 'mov' },
'video/mpg': { icon: 'movie', clazz: 'mov' },
'video/x-matroska': { icon: 'movie', clazz: 'mov' },
'video/mp1s': { icon: 'movie', clazz: 'mov' },
'video/mp2p': { icon: 'movie', clazz: 'mov' },
'video/3gpp': { icon: 'movie', clazz: 'mov' },
'video/3gpp-tt': { icon: 'movie', clazz: 'mov' },
'video/3gpp-2': { icon: 'movie', clazz: 'mov' },
'application/x-op-directory': { icon: 'folder', clazz: 'dir' },
default: { icon: 'ticket', clazz: 'default' },
};

@ -27,44 +27,83 @@
//++ //++
import { import {
ChangeDetectionStrategy, Component, Input, OnInit, AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core'; } from '@angular/core';
import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { IFileLink } from 'core-app/core/state/file-links/file-link.model'; import { IFileLink, IFileLinkOriginData } from 'core-app/core/state/file-links/file-link.model';
import { import {
getIconForMimeType, getIconForMimeType,
IFileLinkListItemIcon, } from 'core-app/shared/components/file-links/file-link-icons/file-link-list-item-icon.factory';
} from 'core-app/shared/components/file-links/file-link-list/file-link-list-item-icon.factory';
import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { I18nService } from 'core-app/core/i18n/i18n.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';
@Component({ @Component({
selector: 'op-file-link-list-item', // eslint-disable-next-line @angular-eslint/component-selector
selector: '[op-file-link-list-item]',
templateUrl: './file-link-list-item.html', templateUrl: './file-link-list-item.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FileLinkListItemComponent implements OnInit { export class FileLinkListItemComponent implements OnInit, AfterViewInit {
@Input() public resource:HalResource; @Input() public resource:HalResource;
@Input() public fileLink:IFileLink; @Input() public fileLink:IFileLink;
@Input() public index:number; @Input() public index:number;
@Input() public allowEditing = false;
@Output() public removeFileLink = new EventEmitter<void>();
@ViewChild('avatar') avatar:ElementRef;
public infoTimestampText:string; public infoTimestampText:string;
public fileLinkIcon:IFileLinkListItemIcon; public fileLinkIcon:IFileLinkListItemIcon;
public text = {
title: {
openFile: this.i18n.t('js.label_open_file_link'),
openFileLocation: this.i18n.t('js.label_open_file_link_location'),
removeFileLink: this.i18n.t('js.label_remove_file_link'),
},
};
constructor( constructor(
private readonly i18n:I18nService, private readonly i18n:I18nService,
private readonly timezoneService:TimezoneService, private readonly timezoneService:TimezoneService,
private readonly principalRendererService:PrincipalRendererService,
) {} ) {}
private get originData():IFileLinkOriginData {
return this.fileLink.originData;
}
ngOnInit():void { ngOnInit():void {
if (this.fileLink.originData.lastModifiedAt) { if (this.originData.lastModifiedAt) {
const date = this.timezoneService.formattedDate(this.fileLink.originData.lastModifiedAt); this.infoTimestampText = this.timezoneService.parseDatetime(this.originData.lastModifiedAt).fromNow();
this.infoTimestampText = this.i18n.t('js.label_modified_at', { date });
} }
this.fileLinkIcon = getIconForMimeType(this.fileLink.originData.mimeType); this.fileLinkIcon = getIconForMimeType(this.originData.mimeType);
}
ngAfterViewInit():void {
if (this.originData.lastModifiedByName) {
this.principalRendererService.render(
this.avatar.nativeElement,
{ name: this.originData.lastModifiedByName, href: '/users/1' },
{ hide: true, link: false },
{ hide: false, size: 'mini' },
);
}
} }
} }

@ -1,25 +1,45 @@
<div <div class="spot-list--item-floating-wrapper">
class="op-files-tab--file-list-item--content file-name"
>
<op-icon
icon-classes="icon4 icon-{{fileLinkIcon.icon}}"
class="op-files-tab--color-{{fileLinkIcon.color}}"
></op-icon>
<a <a
[textContent]="fileLink.originData.name" class="spot-list--item-action op-files-tab--file-list-item-action"
></a> [href]="fileLink._links.staticOriginOpen.href"
</div> [title]="fileLink.originData.name"
target="_blank"
>
<span
class="spot-icon spot-icon_{{fileLinkIcon.icon}} op-files-tab--icon_{{fileLinkIcon.clazz}}"
></span>
<span
[textContent]="fileLink.originData.name"
class="spot-list--item-title op-files-tab--file-list-item-title"
></span>
<div <span
class="op-files-tab--file-list-item--content file-info" class="op-files-tab--file-list-item-text"
> [textContent]="infoTimestampText"
<span ></span>
class="op-files-tab--file-list-item--file-info-timestamp op-files-tab--color-grey"
[textContent]="infoTimestampText"
></span>
<span <div
[textContent]="fileLink.originData.lastModifiedByName" #avatar
></span> 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]="text.title.openFileLocation"
[href]="fileLink._links.staticOriginOpenLocation.href"
target="_blank"
>
<span class="spot-icon spot-icon_folder-open"></span>
</a>
<button
*ngIf="allowEditing"
class="spot-link"
[title]="text.title.removeFileLink"
(click)="removeFileLink.emit()"
>
<span class="spot-icon spot-icon_remove-link"></span>
</button>
</div>
</div> </div>

@ -29,11 +29,12 @@
import { import {
ChangeDetectionStrategy, Component, Input, OnInit, ChangeDetectionStrategy, Component, Input, OnInit,
} from '@angular/core'; } from '@angular/core';
import { Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { IFileLink } from 'core-app/core/state/file-links/file-link.model'; import { IFileLink } from 'core-app/core/state/file-links/file-link.model';
import { IStorage } from 'core-app/core/state/storages/storage.model'; import { IStorage } from 'core-app/core/state/storages/storage.model';
import { FileLinksResourceService } from 'core-app/core/state/file-links/file-links.service'; import { FileLinksResourceService } from 'core-app/core/state/file-links/file-links.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { I18nService } from 'core-app/core/i18n/i18n.service'; import { I18nService } from 'core-app/core/i18n/i18n.service';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
@ -58,15 +59,17 @@ export class FileLinkListComponent extends UntilDestroyedMixin implements OnInit
informationBoxIcon:string; informationBoxIcon:string;
showInformationBox = false; allowEditing = false;
showFileLinks = false; showInformationBox$ = new BehaviorSubject<boolean>(false);
showFileLinks$ = new BehaviorSubject<boolean>(false);
private readonly storageTypeMap:{ [urn:string]:string; } = { private readonly storageTypeMap:{ [urn:string]:string; } = {
'urn:openproject-org:api:v3:storages:Nextcloud': 'Nextcloud', 'urn:openproject-org:api:v3:storages:Nextcloud': 'Nextcloud',
}; };
private text:{ text:{
infoBox:{ infoBox:{
emptyStorageHeader:string, emptyStorageHeader:string,
emptyStorageContent:string, emptyStorageContent:string,
@ -76,12 +79,16 @@ export class FileLinkListComponent extends UntilDestroyedMixin implements OnInit
authenticationFailureHeader:string, authenticationFailureHeader:string,
authenticationFailureContent:string, authenticationFailureContent:string,
loginButton:string, loginButton:string,
},
actions:{
linkFile:string,
} }
}; };
constructor( constructor(
private readonly i18n:I18nService, private readonly i18n:I18nService,
private readonly fileLinkResourceService:FileLinksResourceService, private readonly fileLinkResourceService:FileLinksResourceService,
private readonly currentUserService:CurrentUserService,
) { ) {
super(); super();
} }
@ -100,6 +107,21 @@ export class FileLinkListComponent extends UntilDestroyedMixin implements OnInit
this.deriveStorageInformation(fileLinks.length); this.deriveStorageInformation(fileLinks.length);
}); });
this.currentUserService
.hasCapabilities$('file_links/manage', (this.resource.project as unknown&{ id:string }).id)
.pipe(this.untilDestroyed())
.subscribe((value) => {
this.allowEditing = value;
});
}
public removeFileLink(fileLink:IFileLink):void {
this.fileLinkResourceService.remove(this.collectionKey, fileLink);
}
public openStorageLocation():void {
window.open(this.storage._links.origin.href);
} }
private get collectionKey():string { private get collectionKey():string {
@ -112,9 +134,9 @@ export class FileLinkListComponent extends UntilDestroyedMixin implements OnInit
} }
private deriveStorageInformation(fileLinkCount:number):void { private deriveStorageInformation(fileLinkCount:number):void {
switch (this.storage._links?.connectionState.href) { switch (this.storage._links.connectionState.href) {
case 'urn:openproject-org:api:v3:storages:connection:FailedAuthentication': case 'urn:openproject-org:api:v3:storages:connection:FailedAuthentication':
this.setAuthenticationFailureState(); this.setAuthenticationFailureState(fileLinkCount);
break; break;
case 'urn:openproject-org:api:v3:storages:connection:Error': case 'urn:openproject-org:api:v3:storages:connection:Error':
this.setConnectionErrorState(); this.setConnectionErrorState();
@ -123,49 +145,49 @@ export class FileLinkListComponent extends UntilDestroyedMixin implements OnInit
if (fileLinkCount === 0) { if (fileLinkCount === 0) {
this.setEmptyFileLinkListState(); this.setEmptyFileLinkListState();
} else { } else {
this.showInformationBox = false; this.showInformationBox$.next(false);
this.showFileLinks = true; this.showFileLinks$.next(true);
} }
break; break;
default: default:
this.showInformationBox = false; this.showInformationBox$.next(false);
this.showFileLinks = false; this.showFileLinks$.next(false);
} }
} }
private setAuthenticationFailureState():void { private setAuthenticationFailureState(fileLinkCount:number):void {
this.informationBoxHeader = this.text.infoBox.authenticationFailureHeader; this.informationBoxHeader = this.text.infoBox.authenticationFailureHeader;
this.informationBoxContent = this.text.infoBox.authenticationFailureContent; this.informationBoxContent = this.text.infoBox.authenticationFailureContent;
this.informationBoxButton = this.text.infoBox.loginButton; this.informationBoxButton = this.text.infoBox.loginButton;
this.informationBoxIcon = 'info1'; this.informationBoxIcon = 'import';
this.showInformationBox = true; this.showInformationBox$.next(true);
this.showFileLinks = true; this.showFileLinks$.next(fileLinkCount > 0);
} }
private setConnectionErrorState():void { private setConnectionErrorState():void {
this.informationBoxHeader = this.text.infoBox.connectionErrorHeader; this.informationBoxHeader = this.text.infoBox.connectionErrorHeader;
this.informationBoxContent = this.text.infoBox.connectionErrorContent; this.informationBoxContent = this.text.infoBox.connectionErrorContent;
this.informationBoxButton = this.text.infoBox.loginButton; this.informationBoxButton = this.text.infoBox.loginButton;
this.informationBoxIcon = 'info1'; this.informationBoxIcon = 'remove-link';
this.showInformationBox = true; this.showInformationBox$.next(true);
this.showFileLinks = false; this.showFileLinks$.next(false);
} }
private setEmptyFileLinkListState():void { private setEmptyFileLinkListState():void {
this.informationBoxHeader = this.text.infoBox.emptyStorageHeader; this.informationBoxHeader = this.text.infoBox.emptyStorageHeader;
this.informationBoxContent = this.text.infoBox.emptyStorageContent; this.informationBoxContent = this.text.infoBox.emptyStorageContent;
this.informationBoxButton = this.text.infoBox.emptyStorageButton; this.informationBoxButton = this.text.infoBox.emptyStorageButton;
this.informationBoxIcon = 'info1'; this.informationBoxIcon = 'add-link';
this.showInformationBox = true; this.showInformationBox$.next(true);
this.showFileLinks = true; this.showFileLinks$.next(false);
} }
private initializeLocales():void { private initializeLocales():void {
const storageType = this.storageTypeMap[this.storage._links.type.href]; const storageType = this.storageTypeMap[this.storage._links.type.href];
this.text = { this.text = {
infoBox: { infoBox: {
emptyStorageHeader: this.i18n.t('js.label_no_file_links_header', { storageType }), emptyStorageHeader: this.i18n.t('js.label_link_files_in_storage', { storageType }),
emptyStorageContent: this.i18n.t('js.label_no_file_links_content', { storageType }), emptyStorageContent: this.i18n.t('js.label_no_file_links', { storageType }),
emptyStorageButton: this.i18n.t('js.label_open_storage', { storageType }), emptyStorageButton: this.i18n.t('js.label_open_storage', { storageType }),
connectionErrorHeader: this.i18n.t('js.label_no_storage_connection', { storageType }), connectionErrorHeader: this.i18n.t('js.label_no_storage_connection', { storageType }),
connectionErrorContent: this.i18n.t('js.label_storage_connection_error', { storageType }), connectionErrorContent: this.i18n.t('js.label_storage_connection_error', { storageType }),
@ -173,6 +195,9 @@ export class FileLinkListComponent extends UntilDestroyedMixin implements OnInit
authenticationFailureContent: this.i18n.t('js.label_storage_not_connected', { storageType }), authenticationFailureContent: this.i18n.t('js.label_storage_not_connected', { storageType }),
loginButton: this.i18n.t('js.label_storage_login', { storageType }), loginButton: this.i18n.t('js.label_storage_login', { storageType }),
}, },
actions: {
linkFile: this.i18n.t('js.label_link_files_in_storage', { storageType }),
},
}; };
} }
} }

@ -1,23 +1,44 @@
<div class="work-package--attachments--files" *ngIf="resource" data-qa-selector="op-files-tab--file-link-list"> <ng-container
*ngIf="resource"
>
<op-storage-information <op-storage-information
*ngIf="showInformationBox" *ngIf="showInformationBox$ | async"
[infoIcon]="'info1'" class="op-files-tab--storage-info-box"
[infoIcon]="informationBoxIcon"
[infoTextHeader]="informationBoxHeader" [infoTextHeader]="informationBoxHeader"
[infoTextContent]="informationBoxContent" [infoTextContent]="informationBoxContent"
[buttonText]="informationBoxButton" [buttonText]="informationBoxButton"
(buttonClickEventEmitter)="openStorageLocation()"
></op-storage-information> ></op-storage-information>
<div <ul
*ngIf="showFileLinks" *ngIf="showFileLinks$ | async"
class="spot-list spot-list_compact op-files-tab--file-list"
data-qa-selector="op-files-tab--file-list"
> >
<op-file-link-list-item <li
*ngFor="let fileLink of fileLinks$ | async; let i = index;" *ngFor="let fileLink of fileLinks$ | async; let i = index;"
class="op-files-tab--file-list-item" class="spot-list--item op-files-tab--file-list-item"
[ngClass]="{ 'inactive': showInformationBox || fileLink._links.permission.href === 'urn:openproject-org:api:v3:file-links:permission:NotAllowed' }"
data-qa-selector="op-files-tab--file-list-item" data-qa-selector="op-files-tab--file-list-item"
op-file-link-list-item
[fileLink]="fileLink" [fileLink]="fileLink"
[resource]="resource" [resource]="resource"
[index]="i"> [index]="i"
</op-file-link-list-item> [allowEditing]="allowEditing"
</div> (removeFileLink)="removeFileLink(fileLink)"
</div> ></li>
</ul>
<p
*ngIf="(showInformationBox$ | async) === false"
class="spot-body-small"
>
<a
class="spot-link op-files-tab--file-list-action-button"
(click)="openStorageLocation()"
>
<span class="spot-icon spot-icon_external-link"></span>
<span [textContent]="text.actions.linkFile"></span>
</a>
</p>
</ng-container>

@ -1,36 +1,27 @@
<span class="spot-icon big spot-icon_{{infoIcon}}"></span>
<div <div
class="op-files-tab--storage-info-box" class="text-box"
> >
<div <div
class="icon-box" class="text-box-header"
> >
<i <span [textContent]="infoTextHeader"></span>
class="icon icon-{{infoIcon}}"
></i>
</div> </div>
<div <div
class="text-box" class="text-box-content"
> >
<div <span [textContent]="infoTextContent"></span>
class="text-box-header"
>
<span [textContent]="infoTextHeader"></span>
</div>
<div
class="text-box-content"
>
<span [textContent]="infoTextContent"></span>
</div>
</div> </div>
</div>
<div <div
class="button-box" class="button-box"
> >
<button <button class="button button_no-margin">
class="action-button" <op-icon icon-classes="button--icon spot-icon spot-icon_external-link" class="op-icon--wrapper">
[textContent]="buttonText" <i class="button--icon spot-icon spot-icon_external-link"></i>
(click)="buttonClickEventEmitter.emit()" </op-icon>
></button> <span class="button--text">Open Nextcloud</span>
</div> </button>
</div> </div>

@ -0,0 +1,3 @@
<svg width="16" height="10" viewBox="0 0 16 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 3.07692H11.2V4.61538H4.8V3.07692ZM14.48 3.84615H16C16 1.72308 14.208 0 12 0H8.8V1.46154H12C13.368 1.46154 14.48 2.53077 14.48 3.84615ZM1.52 3.84615C1.52 2.53077 2.632 1.46154 4 1.46154H7.2V0H4C1.792 0 0 1.72308 0 3.84615C0 5.96923 1.792 7.69231 4 7.69231H7.2V6.23077H4C2.632 6.23077 1.52 5.16154 1.52 3.84615ZM13.6 3.84615H12V6.15385H9.6V7.69231H12V10H13.6V7.69231H16V6.15385H13.6V3.84615Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

@ -0,0 +1,3 @@
<svg width="18" height="10" viewBox="0 0 18 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.01372 0.899994C7.14796 0.899994 5.56658 2.16485 5.07646 3.87859C4.65049 2.9696 3.7274 2.33277 2.66417 2.33277C1.20197 2.33277 0 3.53474 0 4.99694C0 6.45914 1.20197 7.66166 2.66417 7.66166C3.7274 7.66166 4.65049 7.02444 5.07646 6.11529C5.56658 7.82916 7.14796 9.09444 9.01372 9.09444C10.8657 9.09444 12.439 7.84824 12.9417 6.1537C13.3755 7.04221 14.2872 7.66166 15.3353 7.66166C16.7975 7.66166 18 6.45914 18 4.99694C18 3.53474 16.7975 2.33277 15.3353 2.33277C14.2872 2.33277 13.3755 2.95183 12.9417 3.84018C12.439 2.14577 10.8657 0.899994 9.01372 0.899994ZM9.01372 2.46392C10.4222 2.46392 11.5473 3.58851 11.5473 4.99694C11.5473 6.40537 10.4222 7.53051 9.01372 7.53051C7.60529 7.53051 6.48071 6.40537 6.48071 4.99694C6.48071 3.58851 7.60529 2.46392 9.01372 2.46392ZM2.66417 3.8967C3.28123 3.8967 3.76495 4.37987 3.76495 4.99694C3.76495 5.61401 3.28123 6.09773 2.66417 6.09773C2.0471 6.09773 1.56393 5.61401 1.56393 4.99694C1.56393 4.37987 2.0471 3.8967 2.66417 3.8967ZM15.3353 3.8967C15.9524 3.8967 16.4361 4.37987 16.4361 4.99694C16.4361 5.61401 15.9524 6.09773 15.3353 6.09773C14.7182 6.09773 14.235 5.61401 14.235 4.99694C14.235 4.37987 14.7182 3.8967 15.3353 3.8967Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6343 16L7.67075 8.82415H4.0026V7.17585H6.07103L4.47132 5.52756H4.0026C3.36619 5.52756 2.75585 5.78805 2.30584 6.25172C1.85583 6.71539 1.60302 7.34427 1.60302 8C1.60302 8.65573 1.85583 9.28461 2.30584 9.74828C2.75585 10.212 3.36619 10.4724 4.0026 10.4724H6.40218V12.1207H4.0026C3.02898 12.1216 2.0885 11.7564 1.35753 11.0937C0.626559 10.4311 0.155306 9.51642 0.0321393 8.52129C-0.0910271 7.52616 0.142354 6.51891 0.688518 5.68843C1.23468 4.85795 2.05611 4.26128 2.99878 4.0103L0.236864 1.16534L1.36866 0L15.7661 14.8347L14.6351 15.9992V16H14.6343ZM14.5095 11.2084L13.3673 10.0323C13.7876 9.73232 14.1039 9.30181 14.2701 8.80357C14.4363 8.30534 14.4437 7.76544 14.2912 7.26258C14.1387 6.75972 13.8343 6.3202 13.4223 6.0081C13.0104 5.696 12.5125 5.52764 12.0012 5.52756H9.60161V3.87926H12.0012C12.8286 3.87936 13.6357 4.14391 14.3111 4.63643C14.9865 5.12894 15.497 5.82519 15.7722 6.6292C16.0475 7.43321 16.0739 8.30539 15.8479 9.12553C15.6219 9.94568 15.1546 10.6734 14.5103 11.2084H14.5095Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 179 KiB

@ -27,96 +27,104 @@
//++ //++
.op-files-tab .op-files-tab
&--file-list-item .op-files-tab--file-list
display: grid .op-files-tab--file-list-item
grid-template-columns: 1fr 1fr .op-files-tab--file-list-item-action
height: 40px padding-left: 0
padding-right: 0
&:hover .op-files-tab--file-list-item-title
text-decoration: underline
.op-files-tab--file-list-item-title
@include spot-body-small(bold)
&.inactive line-height: $spot-spacing-1-5
opacity: 0.5 word-break: normal
overflow: hidden
text-overflow: ellipsis
&--content .op-files-tab--file-list-item-text
align-self: center @include spot-caption()
width: 100%
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
&.file-name color: #9A9A9A
font-size: 14px line-height: $spot-spacing-1-5
flex-shrink: 0
&.file-info &:not(:last-child)
text-align: right margin-right: $spot-spacing-0-5
font-size: 12px
&--file-info-timestamp .spot-icon:first-child:not(:last-child)
margin-right: 8px width: $spot-spacing-1-5
height: $spot-spacing-1-5
.op-files-tab--file-list-item-avatar
flex-shrink: 0
display: inline-flex
justify-content: center
align-items: center
width: $spot-spacing-1-5
height: $spot-spacing-1-5
.op-files-tab--file-list-item-floating-actions
padding-right: 0
&--storage-info-box &--storage-info-box
margin-top: 14px margin-top: 0.875rem
display: grid display: grid
grid-template: "icon text" "button button" / auto 1fr grid-template: "icon text" "button button" / auto 1fr
.icon-box .spot-icon.big
height: 54px color: #9A9A9A
width: 54px
grid-area: icon grid-area: icon
display: flex width: 3.375rem
justify-content: center height: 3.375rem
align-items: center font-size: 1.25rem
.icon
color: #878787
font-size: 36px
.text-box .text-box
grid-area: text grid-area: text
.text-box-header .text-box-header
font-size: 16px
font-weight: 700 font-weight: 700
line-height: 24px line-height: $spot-spacing-1-5
.text-box-content .text-box-content
font-size: 14px @include spot-body-small()
font-weight: 400
line-height: 20px color: #9A9A9A
color: #878787
.button-box .button-box
grid-area: button grid-area: button
display: flex display: flex
justify-content: end justify-content: end
.action-button &--icon
padding: 4px 12px &_pdf
font-size: 14px
line-height: 20px
&--color
&-red
color: #B93A33 color: #B93A33
&-blue &_img
color: #00BDF3 color: #0081C7
&-blue-deep &_mov
color: #006E8F color: #7C006E
&-blue-dark &_txt
color: #0081C7 color: #9A9A9A
&-turquoise &_doc
color: #87E2C1 color: #006E8F
&-green &_sheet
color: #007528 color: #007528
&-grey-dark &_presentation
color: #9A9A9A color: #EF9E56
&-grey &_form
color: #878787 color: #87E2C1
&-orange &_dir
color: #EF9E56 color: #00BDF3
&_default
color: #9A9A9A

@ -28,17 +28,30 @@
.op-tab-content .op-tab-content
&--container &--container
margin-right: 16px margin-right: $spot-spacing-1
.spot-list:not(:last-child)
margin-bottom: $spot-spacing-0-75
.op-files-tab--storage-info-box:not(:last-child)
margin-bottom: $spot-spacing-1
&--tab-section
margin-bottom: $spot-spacing-2
&--header &--header
border-bottom: 1px solid #ddd border-bottom: 1px solid #ddd
padding-bottom: $spot-spacing-0-75
display: flex display: flex
align-items: center align-items: center
&-text &-text
font-size: 16px line-height: $spot-spacing-1_5
line-height: 1.1875em font-weight: 700
text-transform: uppercase text-transform: uppercase
margin: 16px 0
padding: 0 .spot-icon
border-bottom: none width: $spot-spacing-1_5
height: $spot-spacing-1_5
margin-right: $spot-spacing-0-5
color: #1A67A3

@ -5,6 +5,7 @@
<ul class="icon-list"> <ul class="icon-list">
<li><span class="icon icon-accessibility"></span>accessibility</li> <li><span class="icon icon-accessibility"></span>accessibility</li>
<li><span class="icon icon-accountable"></span>accountable</li> <li><span class="icon icon-accountable"></span>accountable</li>
<li><span class="icon icon-add-link"></span>add-link</li>
<li><span class="icon icon-add"></span>add</li> <li><span class="icon icon-add"></span>add</li>
<li><span class="icon icon-align-center"></span>align-center</li> <li><span class="icon icon-align-center"></span>align-center</li>
<li><span class="icon icon-align-justify"></span>align-justify</li> <li><span class="icon icon-align-justify"></span>align-justify</li>
@ -167,6 +168,7 @@
<li><span class="icon icon-new-planning-element"></span>new-planning-element</li> <li><span class="icon icon-new-planning-element"></span>new-planning-element</li>
<li><span class="icon icon-news"></span>news</li> <li><span class="icon icon-news"></span>news</li>
<li><span class="icon icon-nextcloud-circle"></span>nextcloud-circle</li> <li><span class="icon icon-nextcloud-circle"></span>nextcloud-circle</li>
<li><span class="icon icon-nextcloud"></span>nextcloud</li>
<li><span class="icon icon-no-hierarchy"></span>no-hierarchy</li> <li><span class="icon icon-no-hierarchy"></span>no-hierarchy</li>
<li><span class="icon icon-no-zen-mode"></span>no-zen-mode</li> <li><span class="icon icon-no-zen-mode"></span>no-zen-mode</li>
<li><span class="icon icon-not-supported"></span>not-supported</li> <li><span class="icon icon-not-supported"></span>not-supported</li>
@ -203,6 +205,7 @@
<li><span class="icon icon-relations"></span>relations</li> <li><span class="icon icon-relations"></span>relations</li>
<li><span class="icon icon-reload"></span>reload</li> <li><span class="icon icon-reload"></span>reload</li>
<li><span class="icon icon-reminder"></span>reminder</li> <li><span class="icon icon-reminder"></span>reminder</li>
<li><span class="icon icon-remove-link"></span>remove-link</li>
<li><span class="icon icon-remove"></span>remove</li> <li><span class="icon icon-remove"></span>remove</li>
<li><span class="icon icon-rename"></span>rename</li> <li><span class="icon icon-rename"></span>rename</li>
<li><span class="icon icon-reported-by-me"></span>reported-by-me</li> <li><span class="icon icon-reported-by-me"></span>reported-by-me</li>

@ -51,7 +51,7 @@ describe 'Showing of file links in work package', with_flag: { storages_module_a
context 'if work package has associated file links' do context 'if work package has associated file links' do
it "must show associated file links" do it "must show associated file links" do
expect(page).to have_selector('[data-qa-selector="op-tab-content--tab-section"]', count: 2) expect(page).to have_selector('[data-qa-selector="op-tab-content--tab-section"]', count: 2)
expect(page.find('[data-qa-selector="op-files-tab--file-link-list"]')) expect(page.find('[data-qa-selector="op-files-tab--file-list"]'))
.to have_selector('[data-qa-selector="op-files-tab--file-list-item"]', text: file_link.origin_name, count: 1) .to have_selector('[data-qa-selector="op-files-tab--file-list-item"]', text: file_link.origin_name, count: 1)
end end
end end

Loading…
Cancel
Save