Merge branch 'implementation/43696-add-modal-navigation-on-chevrons' into implementation/43698-add-select-all

pull/11539/head
Eric Schubert 2 years ago
commit 28b3e51f98
No known key found for this signature in database
GPG Key ID: 1D346C019BD4BAA2
  1. 14
      docs/api/apiv3/openapi-spec.yml
  2. 73
      frontend/src/app/core/state/file-links/file-links.service.ts
  3. 86
      frontend/src/app/shared/components/file-links/file-picker-modal/file-picker-modal.component.ts
  4. 4
      frontend/src/app/shared/components/file-links/file-picker-modal/file-picker-modal.html
  5. 43
      frontend/src/app/shared/components/file-links/storage-file-list-item/storage-file-list-item.component.ts
  6. 32
      frontend/src/app/shared/components/file-links/storage-file-list-item/storage-file-list-item.html
  7. 46
      frontend/src/app/shared/components/file-links/storage-file-list-item/storage-file-list-item.ts
  8. 8
      modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud_storage_query.rb
  9. 3
      modules/storages/config/locales/js-en.yml
  10. 2
      modules/storages/lib/api/v3/storage_files/storage_files_api.rb
  11. 32
      modules/storages/spec/common/peripherals/storage_requests_spec.rb
  12. 2
      modules/storages/spec/requests/api/v3/storages/storages_spec.rb

@ -355,11 +355,8 @@ paths:
"$ref": "./paths/status.yml"
"/api/v3/storages/{id}":
"$ref": "./paths/storage.yml"
# Temporarily removed for release 12.3 as the specification is not finished.
# Also the implementation is not yet ready.
# Discussion is tracked here: https://community.openproject.org/work_packages/43694
# "/api/v3/storages/{id}/files":
# "$ref": "./paths/storage_files.yml"
"/api/v3/storages/{id}/files":
"$ref": "./paths/storage_files.yml"
"/api/v3/time_entries":
"$ref": "./paths/time_entries.yml"
"/api/v3/time_entries/{id}/form":
@ -571,11 +568,8 @@ components:
"$ref": "./components/schemas/example_schema_model.yml"
Execute_custom_action:
"$ref": "./components/schemas/execute_custom_action.yml"
# Temporarily removed for the 12.3 release as it is not yet
# implemented.
# Discussion is tracked here: https://community.openproject.org/work_packages/43694
# FileCollectionModel:
# $ref: './components/schemas/file_collection_model.yml'
FileCollectionModel:
$ref: './components/schemas/file_collection_model.yml'
FileLinkCollectionReadModel:
$ref: './components/schemas/file_link_collection_read_model.yml'
FileLinkCollectionWriteModel:

@ -31,7 +31,6 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http';
import { from } from 'rxjs';
import {
catchError,
groupBy,
mergeMap,
reduce,
@ -71,17 +70,16 @@ export class FileLinksResourceService extends ResourceCollectionService<IFileLin
return acc;
}, seed));
}),
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
)
.subscribe((fileLinkCollections) => {
const storageId = idFromLink(fileLinkCollections.storage);
const collectionKey = `${fileLinksSelfLink}?filters=[{"storage":{"operator":"=","values":["${storageId}"]}}]`;
const collection = { _embedded: { elements: fileLinkCollections.fileLinks } } as IHALCollection<IFileLink>;
insertCollectionIntoState(this.store, collection, collectionKey);
});
.subscribe(
(fileLinkCollections) => {
const storageId = idFromLink(fileLinkCollections.storage);
const collectionKey = `${fileLinksSelfLink}?filters=[{"storage":{"operator":"=","values":["${storageId}"]}}]`;
const collection = { _embedded: { elements: fileLinkCollections.fileLinks } } as IHALCollection<IFileLink>;
insertCollectionIntoState(this.store, collection, collectionKey);
},
this.toastAndThrow.bind(this),
);
}
protected createStore():CollectionStore<IFileLink> {
@ -96,13 +94,10 @@ export class FileLinksResourceService extends ResourceCollectionService<IFileLin
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));
.subscribe(
() => removeEntityFromCollectionAndState(this.store, fileLink.id, collectionKey),
this.toastAndThrow.bind(this),
);
}
addFileLinks(collectionKey:string, addFileLinksHref:string, storage:IHalResourceLink, filesToLink:IStorageFile[]):void {
@ -122,33 +117,37 @@ export class FileLinksResourceService extends ResourceCollectionService<IFileLin
this.http
.post<IHALCollection<IFileLink>>(addFileLinksHref, { _type: 'Collection', _embedded: { elements } })
.pipe(
tap((collection) => {
.subscribe(
(collection) => {
applyTransaction(() => {
const newFileLinks = collection._embedded.elements;
this.store.add(newFileLinks);
this.store.update(({ collections }) => (
{
collections: {
...collections,
[collectionKey]: {
...collections[collectionKey],
ids: (collections[collectionKey]?.ids || []).concat(newFileLinks.map((link) => link.id)),
this.store.update(
({ collections }) => (
{
collections: {
...collections,
[collectionKey]: {
...collections[collectionKey],
ids: (collections[collectionKey]?.ids || []).concat(newFileLinks.map((link) => link.id)),
},
},
},
}
));
}
),
);
});
}),
catchError((error) => {
this.toastService.addError(error);
throw error;
}),
)
.subscribe();
},
this.toastAndThrow.bind(this),
);
}
protected basePath():string {
return this.apiV3Service.file_links.path;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private toastAndThrow(error:any):void {
this.toastService.addError(error);
throw error;
}
}

@ -36,20 +36,23 @@ import {
OnInit,
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { take } from 'rxjs/operators';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { IHalResourceLink } from 'core-app/core/state/hal-resource';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { IFileLink } from 'core-app/core/state/file-links/file-link.model';
import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model';
import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
import { StorageFilesResourceService } from 'core-app/core/state/storage-files/storage-files.service';
import { BreadcrumbsContent } from 'core-app/spot/components/breadcrumbs/breadcrumbs-content';
import { Breadcrumb, BreadcrumbsContent } from 'core-app/spot/components/breadcrumbs/breadcrumbs-content';
import {
IStorageFileListItem,
StorageFileListItem,
} from 'core-app/shared/components/file-links/storage-file-list-item/storage-file-list-item';
import { FileLinksResourceService } from 'core-app/core/state/file-links/file-links.service';
import { isDirectory } from 'core-app/shared/components/file-links/file-link-icons/file-icons.helper';
import getIconForStorageType from 'core-app/shared/components/file-links/storage-icons/get-icon-for-storage-type';
@Component({
@ -59,7 +62,7 @@ import getIconForStorageType from 'core-app/shared/components/file-links/storage
export class FilePickerModalComponent extends OpModalComponent implements OnInit, OnDestroy {
public loading$ = new BehaviorSubject<boolean>(true);
public storageFiles$ = new BehaviorSubject<IStorageFileListItem[]>([]);
public listItems$ = new BehaviorSubject<StorageFileListItem[]>([]);
public breadcrumbs:BreadcrumbsContent;
@ -77,17 +80,22 @@ export class FilePickerModalComponent extends OpModalComponent implements OnInit
return this.selection.size;
}
private get storageLink():IHalResourceLink {
return this.locals.storageLink as IHalResourceLink;
}
private readonly selection = new Set<string>();
private readonly fileMap:Record<string, IStorageFile> = {};
private storageLink:IHalResourceLink;
private storageFiles$ = new BehaviorSubject<IStorageFile[]>([]);
constructor(
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly elementRef:ElementRef,
readonly cdRef:ChangeDetectorRef,
private readonly i18n:I18nService,
private readonly timezoneService:TimezoneService,
private readonly fileLinksResourceService:FileLinksResourceService,
private readonly storageFilesResourceService:StorageFilesResourceService,
) {
@ -100,25 +108,22 @@ export class FilePickerModalComponent extends OpModalComponent implements OnInit
this.breadcrumbs = new BreadcrumbsContent([{
text: this.locals.storageName as string,
icon: getIconForStorageType(this.locals.storageType as string),
navigate: () => this.changeLevel(null, this.breadcrumbs.crumbs.slice(0, 1)),
}]);
this.storageLink = (this.locals.storageLink as IHalResourceLink);
const filesLink:IHalResourceLink = {
href: `${this.storageLink.href}/files`,
title: 'Storage files',
};
this.storageFilesResourceService.files(filesLink)
this.storageFiles$
.pipe(this.untilDestroyed())
.subscribe((files) => {
const fileListItems = files.map((file, index) => ({
disabled: this.isAlreadyLinked(file),
isFirst: index === 0,
changeSelection: () => { this.changeSelection(file); },
...file,
}));
this.storageFiles$.next(fileListItems);
const fileListItems = files.map((file, index) => this.storageFileToListItem(file, index));
this.listItems$.next(fileListItems);
this.loading$.next(false);
});
this.storageFilesResourceService.files(this.makeFilesCollectionLink(null))
.pipe(take(1))
.subscribe((files) => {
this.storageFiles$.next(files);
});
}
ngOnDestroy():void {
@ -153,6 +158,51 @@ export class FilePickerModalComponent extends OpModalComponent implements OnInit
}
}
private changeLevel(parent:string|null, crumbs:Breadcrumb[]):void {
this.storageFilesResourceService.files(this.makeFilesCollectionLink(parent))
.pipe(take(1))
.subscribe((files) => {
this.storageFiles$.next(files);
this.breadcrumbs = new BreadcrumbsContent(crumbs);
});
}
private makeFilesCollectionLink(parent:string|null):IHalResourceLink {
let query = '';
if (parent !== null) {
query = `?parent=${parent}`;
}
return {
href: `${this.storageLink.href}/files${query}`,
title: 'Storage files',
};
}
private storageFileToListItem(file:IStorageFile, index:number):StorageFileListItem {
const enterDirectoryCallback = isDirectory(file.mimeType)
? () => {
const crumbs = this.breadcrumbs.crumbs;
const end = crumbs.length + 1;
const newCrumb:Breadcrumb = {
text: file.name,
navigate: () => this.changeLevel(file.location, this.breadcrumbs.crumbs.slice(0, end)),
};
this.changeLevel(file.location, crumbs.concat(newCrumb));
}
: undefined;
return new StorageFileListItem(
this.timezoneService,
file,
this.isAlreadyLinked(file),
index === 0,
this.selection.has(file.id as string),
() => { this.changeSelection(file); },
enterDirectoryCallback,
);
}
private isAlreadyLinked(file:IStorageFile):boolean {
const currentFileLinks = this.locals.fileLinks as IFileLink[];
const found = currentFileLinks.find((a) => a.originData.id === file.id);

@ -24,11 +24,11 @@
data-qa-selector="op-files-picker-modal--file-list"
>
<li
*ngFor="let file of storageFiles$ | async | sortFiles; let i = index;"
*ngFor="let file of listItems$ | async | sortFiles; let i = index;"
class="spot-list--item op-file-list--item"
data-qa-selector="op-file-list--item"
op-storage-file-list-item
[fileListItemContent]="file"
[content]="file"
></li>
</ul>
</ng-container>

@ -30,18 +30,11 @@ import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
} from '@angular/core';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
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';
import { isDirectory } from 'core-app/shared/components/file-links/file-link-icons/file-icons.helper';
import { PrincipalLike } from 'core-app/shared/components/principal/principal-types';
import {
IStorageFileListItem,
StorageFileListItem,
} from 'core-app/shared/components/file-links/storage-file-list-item/storage-file-list-item';
import SpotDropAlignmentOption from 'core-app/spot/drop-alignment-options';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@ -52,23 +45,18 @@ import { I18nService } from 'core-app/core/i18n/i18n.service';
templateUrl: './storage-file-list-item.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StorageFileListItemComponent implements OnInit {
@Input() public fileListItemContent:IStorageFileListItem;
infoTimestampText:string;
fileLinkIcon:IFileIcon;
showDetails:boolean;
export class StorageFileListItemComponent {
@Input() public content:StorageFileListItem;
text = {
tooltip: this.i18n.t('js.storages.file_links.already_linked'),
alreadyLinkedFile: this.i18n.t('js.storages.file_links.already_linked_file'),
alreadyLinkedDirectory: this.i18n.t('js.storages.file_links.already_linked_directory'),
};
get principal():PrincipalLike {
return this.fileListItemContent.createdByName
return this.content.createdByName
? {
name: this.fileListItemContent.createdByName,
name: this.content.createdByName,
href: '/external_users/1',
}
: {
@ -77,8 +65,12 @@ export class StorageFileListItemComponent implements OnInit {
};
}
get tooltip():string {
return this.content.isDirectory ? this.text.alreadyLinkedDirectory : this.text.alreadyLinkedFile;
}
get getTooltipAlignment():SpotDropAlignmentOption {
if (this.fileListItemContent.isFirst) {
if (this.content.isFirst) {
return SpotDropAlignmentOption.BottomLeft;
}
@ -87,16 +79,5 @@ export class StorageFileListItemComponent implements OnInit {
constructor(
private readonly i18n:I18nService,
private readonly timezoneService:TimezoneService,
) {}
ngOnInit():void {
if (this.fileListItemContent.lastModifiedAt) {
this.infoTimestampText = this.timezoneService.parseDatetime(this.fileListItemContent.lastModifiedAt).fromNow();
}
this.fileLinkIcon = getIconForMimeType(this.fileListItemContent.mimeType);
this.showDetails = !isDirectory(this.fileListItemContent.mimeType);
}
}

@ -1,45 +1,53 @@
<spot-tooltip
[alignment]="getTooltipAlignment"
[disabled]="!fileListItemContent.disabled"
[disabled]="!content.disabled"
>
<p
slot="body"
class="spot-body-small"
>{{text.tooltip}}</p>
>{{tooltip}}</p>
<label
slot="trigger"
class="spot-list--item-action"
[ngClass]="{ 'spot-list--item-action_disabled': fileListItemContent.disabled }"
[ngClass]="{ 'spot-list--item-action_disabled': content.disabled }"
>
<spot-checkbox
[checked]="fileListItemContent.disabled"
(checkedChange)="fileListItemContent.changeSelection()"
[disabled]="fileListItemContent.disabled"
[checked]="content.disabled || content.selected"
(checkedChange)="content.changeSelection()"
[disabled]="content.disabled"
></spot-checkbox>
<span
class="spot-icon spot-icon_{{fileLinkIcon.icon}} op-files-tab--icon op-files-tab--icon_{{fileLinkIcon.clazz}}"
class="spot-icon spot-icon_{{content.icon.icon}} op-files-tab--icon op-files-tab--icon_{{content.icon.clazz}}"
></span>
<span
[textContent]="fileListItemContent.name"
[title]="fileListItemContent.name"
[textContent]="content.name"
[title]="content.name"
class="spot-list--item-title op-file-list--item-title"
></span>
<span
*ngIf="showDetails"
*ngIf="!content.isDirectory"
class="op-file-list--item-text"
[textContent]="infoTimestampText"
[textContent]="content.timestamp"
></span>
<op-principal
*ngIf="showDetails"
*ngIf="!content.isDirectory"
class="op-file-list--item-avatar"
[principal]="principal"
[hideName]="true"
[size]="'mini'"
[link]="false"
></op-principal>
<button
*ngIf="content.isDirectory"
class="spot-link"
(click)="content.enterDirectory()"
>
<span class="spot-icon spot-icon_arrow-right2"></span>
</button>
</label>
</spot-tooltip>

@ -27,9 +27,47 @@
//++
import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import {
getIconForMimeType,
} from 'core-app/shared/components/file-links/file-link-icons/file-link-list-item-icon.factory';
import { IFileIcon } from 'core-app/shared/components/file-links/file-link-icons/icon-mappings';
import { isDirectory } from 'core-app/shared/components/file-links/file-link-icons/file-icons.helper';
export interface IStorageFileListItem extends IStorageFile {
disabled:boolean;
isFirst:boolean;
changeSelection:() => void;
export class StorageFileListItem {
get name():string {
return this.storageFile.name;
}
get mimeType():string|undefined {
return this.storageFile.mimeType;
}
get createdByName():string|undefined {
return this.storageFile.createdByName;
}
get timestamp():string|undefined {
return this.storageFile.lastModifiedAt
? this.timezoneService.parseDatetime(this.storageFile.lastModifiedAt).fromNow()
: undefined;
}
get icon():IFileIcon {
return getIconForMimeType(this.storageFile.mimeType);
}
get isDirectory():boolean {
return isDirectory(this.storageFile.mimeType);
}
constructor(
private readonly timezoneService:TimezoneService,
private readonly storageFile:IStorageFile,
public readonly disabled:boolean,
public readonly isFirst:boolean,
public readonly selected:boolean,
public readonly changeSelection:() => void,
public readonly enterDirectory?:() => void,
) {}
}

@ -33,16 +33,16 @@ module Storages::Peripherals::StorageInteraction
@origin_user_id = origin_user_id
@token = token
@with_refreshed_token = with_refreshed_token
@base_path = "/remote.php/dav/files/#{@origin_user_id}/"
@base_path = "/remote.php/dav/files/#{@origin_user_id}"
end
def files
def files(parent)
http = Net::HTTP.new(@uri.host, @uri.port)
http.use_ssl = @uri.scheme == 'https'
result = @with_refreshed_token.call do
response = http.propfind(
@base_path,
"#{@base_path}#{parent}",
requested_properties,
{
'Depth' => '1',
@ -100,7 +100,7 @@ module Storages::Peripherals::StorageInteraction
::Storages::StorageFile.new(
id(file_element),
CGI.unescape(name),
CGI.unescape(name.split('/').last),
size(file_element),
mime_type(file_element),
nil,

@ -37,4 +37,5 @@ en:
selection_none: "Select files to link"
selection_any: "Link %{number} files"
not_allowed_tooltip: "Please log in to Nextcloud to access this file"
already_linked: "This file is already linked to this work package."
already_linked_file: "This file is already linked to this work package."
already_linked_directory: "This directory is already linked to this work package."

@ -51,7 +51,7 @@ module API::V3::StorageFiles
.match(
on_success: ->(files_query) {
files_query
.call
.call(params[:parent])
.map do |files|
API::V3::StorageFiles::StorageFileCollectionRepresenter.new(
files,

@ -119,7 +119,7 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
before do
allow(::OAuthClients::ConnectionManager).to receive(:new).and_return(connection_manager)
stub_request(:propfind, "#{url}/remote.php/dav/files/#{origin_user_id}/")
stub_request(:propfind, "#{url}/remote.php/dav/files/#{origin_user_id}")
.to_return(status: 207, body: xml, headers: {})
end
@ -130,7 +130,7 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
.files_query(user:)
.match(
on_success: ->(query) do
result = query.call
result = query.call(nil)
expect(result).to be_success
expect(result.result.size).to eq(2)
end,
@ -145,7 +145,7 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
.files_query(user:)
.match(
on_success: ->(query) do
result = query.call
result = query.call(nil)
expect(result).to be_success
expect(result.result[1].name).to eq('Documents')
expect(result.result[1].mime_type).to eq('application/x-op-directory')
@ -162,7 +162,7 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
.files_query(user:)
.match(
on_success: ->(query) do
result = query.call
result = query.call(nil)
expect(result).to be_success
expect(result.result[0].name).to eq('Nextcloud Manual.pdf')
expect(result.result[0].mime_type).to eq('application/pdf')
@ -173,6 +173,26 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
end
)
end
describe 'with parent query parameter' do
let(:parent) { '/Photos/Birds' }
let(:request_url) { "#{url}/remote.php/dav/files/#{origin_user_id}#{parent}" }
before do
stub_request(:propfind, request_url).to_return(status: 207, body: xml, headers: {})
end
it do
subject
.files_query(user:)
.match(
on_success: ->(query) { query.call(parent) },
on_failure: ->(error) { raise "Files query could not be created: #{error}" }
)
assert_requested(:propfind, request_url)
end
end
end
describe 'with not supported storage type selected' do
@ -198,7 +218,7 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
shared_examples_for 'outbound is failing' do |code = 500, symbol = :error|
describe "with outbound request returning #{code}" do
before do
stub_request(:propfind, "#{url}/remote.php/dav/files/#{origin_user_id}/").to_return(status: code)
stub_request(:propfind, "#{url}/remote.php/dav/files/#{origin_user_id}").to_return(status: code)
end
it "must return :#{symbol} ServiceResult" do
@ -206,7 +226,7 @@ describe Storages::Peripherals::StorageRequests, webmock: true do
.files_query(user:)
.match(
on_success: ->(query) do
result = query.call
result = query.call(nil)
expect(result).to be_failure
expect(result.result).to be(symbol)
end,

@ -219,7 +219,7 @@ describe 'API v3 storages resource', type: :request, content_type: :json do
describe 'with query failed' do
let(:files_query) do
Struct.new('FilesQuery', :error) do
def files
def files(_)
ServiceResult.failure(result: error)
end
end.new(error)

Loading…
Cancel
Save