diff --git a/projects/aca-content/assets/app.extensions.json b/projects/aca-content/assets/app.extensions.json index 288a453032..670e8f3df9 100644 --- a/projects/aca-content/assets/app.extensions.json +++ b/projects/aca-content/assets/app.extensions.json @@ -632,6 +632,21 @@ "visible": "app.selection.canDelete" } }, + { + "id": "app.context.menu.folder-info", + "title": "APP.ACTIONS.FOLDER_INFO", + "order": 800, + "icon": "info", + "actions": { + "click": "FOLDER_INFORMATION" + }, + "rules": { + "visible": [ + "app.selection.folder", + "!app.navigation.isTrashcan" + ] + } + }, { "id": "app.create.separator.3", "type": "separator", @@ -877,6 +892,21 @@ "visible": "app.selection.canDelete" } }, + { + "id": "app.context.menu.folder-info", + "title": "APP.ACTIONS.FOLDER_INFO", + "order": 1200, + "icon": "info", + "actions": { + "click": "FOLDER_INFORMATION" + }, + "rules": { + "visible": [ + "app.selection.folder", + "!app.navigation.isTrashcan" + ] + } + }, { "id": "app.create.separator.3", "type": "separator", diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index 51d5e22cc4..9d71c194b7 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -299,7 +299,8 @@ "EDIT_OFFLINE": "Edit Offline", "EDIT_OFFLINE_CANCEL": "Cancel Editing", "CHANGE_ASPECT": "Edit Aspects", - "ADD_ASPECTS": "Add Aspects" + "ADD_ASPECTS": "Add Aspects", + "FOLDER_INFO": "Folder Information" }, "DIALOGS": { "CONFIRM_PURGE": { @@ -451,7 +452,19 @@ "OPTIONS_SETTINGS": "Options and settings", "MY_PROFILE": "My profile", "EXPAND_NAVIGATION": "Expand navigation menu" - } + }, + "FOLDER_INFO": { + "ICON": "Folder Icon", + "TITLE": "Folder Information", + "SIZE" : "Size", + "CALCULATING": "Calculating...", + "CALCULATED_SIZE_LARGE": "{{sizeInBytes}} bytes ({{sizeInLargeUnit}} {{unit}} on disk) for {{count}} files", + "CALCULATED_SIZE_NORMAL": "{{sizeInBytes}} bytes for {{count}} files", + "LOCATION": "Location", + "CREATED": "Created", + "MODIFIED": "Modified", + "DONE": "Done" + } }, "NODE_SELECTOR": { "COPY_ITEM": "Copy '{{ name }}' to...", diff --git a/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.html b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.html new file mode 100644 index 0000000000..08fdbe364c --- /dev/null +++ b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.html @@ -0,0 +1,32 @@ +
+ {{ 'APP.FOLDER_INFO.ICON' | translate }} +
{{ folderDetails.name }}
+
+ +
+
+
{{ 'APP.FOLDER_INFO.SIZE' | translate }}
+
{{ folderDetails.size }}
+
+ +
+
{{ 'APP.FOLDER_INFO.LOCATION' | translate }}
+
{{ folderDetails.location }}
+
+ +
+
{{ 'APP.FOLDER_INFO.CREATED' | translate }}
+
{{ folderDetails.created | adfTimeAgo }}
+
+ +
+
{{ 'APP.FOLDER_INFO.MODIFIED' | translate }}
+
{{ folderDetails.modified | adfTimeAgo }}
+
+
diff --git a/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.scss b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.scss new file mode 100644 index 0000000000..b2173e9dcd --- /dev/null +++ b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.scss @@ -0,0 +1,43 @@ +.app-folder-info { + display: flex; + flex-direction: column; + border: 1px solid var(--theme-border-color); + border-radius: 12px; + + .aca-folder-info { + &-header { + display: flex; + flex-direction: row; + align-items: center; + column-gap: 10px; + padding: 20px; + + .aca-folder-title { + flex: 1; + font-weight: bold; + } + } + + &-body { + display: flex; + flex-direction: column; + flex: 1; + padding: 10px 20px; + + .aca-folder-info-item { + display: flex; + flex-direction: row; + padding: 20px 0; + + &-label { + width: 30%; + color: var(--theme-text-color); + } + + &-value { + width: 70%; + } + } + } + } +} diff --git a/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.spec.ts b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.spec.ts new file mode 100644 index 0000000000..00a5a3c60b --- /dev/null +++ b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.spec.ts @@ -0,0 +1,117 @@ +/*! + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Alfresco Example Content Application + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * from Hyland Software. If not, see . + */ + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FolderInformationComponent } from './folder-information.component'; +import { DIALOG_COMPONENT_DATA, RedirectAuthService } from '@alfresco/adf-core'; +import { ContentService, NodesApiService } from '@alfresco/adf-content-services'; +import { By } from '@angular/platform-browser'; +import { EMPTY, Observable, of, Subject } from 'rxjs'; +import { LibTestingModule } from '@alfresco/aca-shared'; +import { JobIdBodyEntry, SizeDetails, SizeDetailsEntry, Node } from '@alfresco/js-api'; + +describe('FolderInformationComponent', () => { + let fixture: ComponentFixture; + let nodeService: NodesApiService; + let initiateFolderSizeCalculationSpy: jasmine.Spy<(nodeId: string) => Observable>; + let getFolderSizeInfoSpy: jasmine.Spy<(nodeId: string, jobId: string) => Observable>; + + const mockSub = new Subject(); + const dialogData = { + name: 'mock-folder', + id: 'mock-folder-id', + path: { + name: 'mock-folder-path' + }, + createdAt: new Date(2024, 1, 1, 11, 11), + modifiedAt: new Date(2024, 2, 2, 22, 22) + } as Node; + const mockSizeDetailsEntry: SizeDetailsEntry = { + entry: { + id: 'mock-id', + sizeInBytes: '1', + calculatedAt: 'mock-date', + numberOfFiles: 1, + status: SizeDetails.StatusEnum.COMPLETE, + jobId: 'mock-job-id' + } + }; + + const getValueFromElement = (id: string): string => fixture.debugElement.query(By.css(`[data-automation-id="${id}"]`)).nativeElement.textContent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FolderInformationComponent, LibTestingModule], + providers: [ + { provide: DIALOG_COMPONENT_DATA, useValue: dialogData }, + { provide: RedirectAuthService, useValue: { onLogin: EMPTY, onTokenReceived: EMPTY } } + ] + }); + fixture = TestBed.createComponent(FolderInformationComponent); + nodeService = TestBed.inject(NodesApiService); + spyOn(TestBed.inject(ContentService), 'getNodeIcon').and.returnValue('./assets/images/ft_ic_folder.svg'); + initiateFolderSizeCalculationSpy = spyOn(nodeService, 'initiateFolderSizeCalculation').and.returnValue(mockSub.asObservable()); + getFolderSizeInfoSpy = spyOn(nodeService, 'getFolderSizeInfo').and.returnValue(EMPTY); + }); + + it('should render all information in init', () => { + fixture.detectChanges(); + expect(getValueFromElement('folder-info-name')).toBe('mock-folder'); + expect(getValueFromElement('folder-info-size')).toBe('APP.FOLDER_INFO.CALCULATING'); + expect(getValueFromElement('folder-info-location')).toBe('mock-folder-path'); + expect(getValueFromElement('folder-info-creation-date')).toBe('01/02/2024 11:11'); + expect(getValueFromElement('folder-info-modify-date')).toBe('02/03/2024 22:22'); + }); + + it('should make API call on init to start folder size calculation', () => { + fixture.detectChanges(); + expect(initiateFolderSizeCalculationSpy).toHaveBeenCalledWith('mock-folder-id'); + }); + + it('should fetch folder size only when the initial folder size calculation request is completed', () => { + fixture.detectChanges(); + expect(initiateFolderSizeCalculationSpy).toHaveBeenCalledWith('mock-folder-id'); + expect(getFolderSizeInfoSpy).not.toHaveBeenCalled(); + mockSub.next({ entry: { jobId: 'mock-job-id' } }); + expect(getFolderSizeInfoSpy).toHaveBeenCalled(); + }); + + it('should make repeated calls to get folder size info, if the response returned from the API is IN_PROGRESS', fakeAsync(() => { + mockSizeDetailsEntry.entry.status = SizeDetails.StatusEnum.IN_PROGRESS; + getFolderSizeInfoSpy.and.returnValue(of(mockSizeDetailsEntry)); + fixture.detectChanges(); + expect(getFolderSizeInfoSpy).not.toHaveBeenCalled(); + mockSub.next({ entry: { jobId: 'mock-job-id' } }); + expect(getFolderSizeInfoSpy).toHaveBeenCalledTimes(1); + tick(5000); + expect(getFolderSizeInfoSpy).toHaveBeenCalledTimes(2); + tick(5000); + expect(getFolderSizeInfoSpy).toHaveBeenCalledTimes(3); + mockSizeDetailsEntry.entry.status = SizeDetails.StatusEnum.COMPLETE; + tick(5000); + expect(getFolderSizeInfoSpy).toHaveBeenCalledTimes(4); + tick(5000); + expect(getFolderSizeInfoSpy).not.toHaveBeenCalledTimes(5); + })); +}); diff --git a/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.ts b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.ts new file mode 100644 index 0000000000..65dc223065 --- /dev/null +++ b/projects/aca-content/src/lib/dialogs/folder-details/folder-information.component.ts @@ -0,0 +1,110 @@ +/*! + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Alfresco Example Content Application + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * from Hyland Software. If not, see . + */ + +import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; +import { DIALOG_COMPONENT_DATA, LocalizedDatePipe, TimeAgoPipe } from '@alfresco/adf-core'; +import { JobIdBodyEntry, Node, SizeDetails } from '@alfresco/js-api'; +import { MatDividerModule } from '@angular/material/divider'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ContentService, NodesApiService } from '@alfresco/adf-content-services'; +import { concatMap, expand, first, switchMap } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EMPTY, timer } from 'rxjs'; + +const MEMORY_UNIT_LIST = ['bytes', 'KB', 'MB', 'GB', 'TB']; + +class FolderDetails { + name: string; + size: string; + location: string; + created: Date; + modified: Date; + icon: string; +} + +@Component({ + selector: 'app-folder-info', + standalone: true, + imports: [CommonModule, MatDividerModule, TimeAgoPipe, TranslateModule, LocalizedDatePipe, NgOptimizedImage], + templateUrl: './folder-information.component.html', + styleUrls: ['./folder-information.component.scss'], + encapsulation: ViewEncapsulation.None, + host: { class: 'app-folder-info' } +}) +export class FolderInformationComponent implements OnInit { + readonly contentService = inject(ContentService); + readonly nodesService = inject(NodesApiService); + readonly translateService = inject(TranslateService); + + private readonly destroyRef = inject(DestroyRef); + + data: Node = inject(DIALOG_COMPONENT_DATA); + folderDetails: FolderDetails = new FolderDetails(); + + ngOnInit() { + this.folderDetails.name = this.data.name; + this.folderDetails.location = this.data.path.name; + this.folderDetails.created = this.data.createdAt; + this.folderDetails.modified = this.data.modifiedAt; + this.folderDetails.icon = this.contentService.getNodeIcon(this.data); + this.folderDetails.size = this.translateService.instant('APP.FOLDER_INFO.CALCULATING'); + + this.nodesService + .initiateFolderSizeCalculation(this.data.id) + .pipe( + first(), + switchMap((jobIdEntry: JobIdBodyEntry) => { + return this.nodesService.getFolderSizeInfo(this.data.id, jobIdEntry.entry.jobId).pipe( + expand((result) => + result.entry.status === SizeDetails.StatusEnum.IN_PROGRESS + ? timer(5000).pipe(concatMap(() => this.nodesService.getFolderSizeInfo(this.data.id, jobIdEntry.entry.jobId))) + : EMPTY + ), + takeUntilDestroyed(this.destroyRef) + ); + }) + ) + .subscribe((folderInfo) => { + let size = parseFloat(folderInfo.entry.sizeInBytes); + let unitIndex = 0; + let isMoreThanBytes = false; + while (size > 1000) { + isMoreThanBytes = true; + size = size / 1000; + unitIndex++; + } + const params = { + sizeInBytes: parseFloat(folderInfo.entry.sizeInBytes).toLocaleString('en'), + sizeInLargeUnit: size.toFixed(2), + unit: MEMORY_UNIT_LIST[unitIndex], + count: folderInfo.entry.numberOfFiles + }; + this.folderDetails.size = this.translateService.instant( + isMoreThanBytes ? 'APP.FOLDER_INFO.CALCULATED_SIZE_LARGE' : 'APP.FOLDER_INFO.CALCULATED_SIZE_NORMAL', + params + ); + }); + } +} diff --git a/projects/aca-content/src/lib/services/content-management.service.spec.ts b/projects/aca-content/src/lib/services/content-management.service.spec.ts index c0e6f0a88d..6f29e7127a 100644 --- a/projects/aca-content/src/lib/services/content-management.service.spec.ts +++ b/projects/aca-content/src/lib/services/content-management.service.spec.ts @@ -53,10 +53,10 @@ import { AppHookService, AppSettingsService, ContentApiService } from '@alfresco import { Store } from '@ngrx/store'; import { ContentManagementService } from './content-management.service'; import { NodeActionsService } from './node-actions.service'; -import { NotificationService, TranslationService } from '@alfresco/adf-core'; +import { DialogComponent, DialogSize, NotificationService, TranslationService } from '@alfresco/adf-core'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule, MatSnackBarRef, SimpleSnackBar } from '@angular/material/snack-bar'; -import { Node, NodeEntry, VersionPaging } from '@alfresco/js-api'; +import { Node, NodeEntry, UserInfo, VersionPaging } from '@alfresco/js-api'; import { DocumentListService, FileModel, @@ -67,6 +67,7 @@ import { NodesApiService, ViewVersion } from '@alfresco/adf-content-services'; +import { FolderInformationComponent } from '../dialogs/folder-details/folder-information.component'; describe('ContentManagementService', () => { let dialog: MatDialog; @@ -1847,4 +1848,38 @@ describe('ContentManagementService', () => { expect(store.dispatch).toHaveBeenCalledWith(new NavigateRouteAction(['/libraries'])); })); }); + + describe('folderInformationDialog', () => { + it('should open folder information dialog', () => { + spyOn(dialog, 'open'); + + const fakeNode: NodeEntry = { + entry: { + id: 'folder-node-id', + name: 'mock-folder-name', + nodeType: 'fake-node-type', + isFolder: true, + isFile: false, + modifiedAt: new Date(), + modifiedByUser: new UserInfo(), + createdAt: new Date(), + createdByUser: new UserInfo() + } + }; + + contentManagementService.showFolderInformation(fakeNode); + expect(dialog.open).toHaveBeenCalledWith(DialogComponent, { + data: { + title: 'APP.FOLDER_INFO.TITLE', + confirmButtonTitle: 'APP.FOLDER_INFO.DONE', + isCancelButtonHidden: true, + isCloseButtonHidden: false, + dialogSize: DialogSize.Large, + contentComponent: FolderInformationComponent, + componentData: fakeNode.entry + }, + width: '700px' + }); + }); + }); }); diff --git a/projects/aca-content/src/lib/services/content-management.service.ts b/projects/aca-content/src/lib/services/content-management.service.ts index 1d4142d50d..10b6cd8f89 100644 --- a/projects/aca-content/src/lib/services/content-management.service.ts +++ b/projects/aca-content/src/lib/services/content-management.service.ts @@ -54,7 +54,7 @@ import { NodesApiService, ShareDialogComponent } from '@alfresco/adf-content-services'; -import { NotificationService, TranslationService, ConfirmDialogComponent } from '@alfresco/adf-core'; +import { NotificationService, TranslationService, ConfirmDialogComponent, DialogComponent, DialogSize } from '@alfresco/adf-core'; import { DeletedNodesPaging, Node, NodeEntry, PathInfo, SiteBodyCreate, SiteEntry } from '@alfresco/js-api'; import { inject, Injectable } from '@angular/core'; import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; @@ -63,6 +63,7 @@ import { forkJoin, Observable, of, zip } from 'rxjs'; import { catchError, map, mergeMap, take, tap } from 'rxjs/operators'; import { NodeActionsService } from './node-actions.service'; import { Router } from '@angular/router'; +import { FolderInformationComponent } from '../dialogs/folder-details/folder-information.component'; interface RestoredNode { status: number; @@ -1089,4 +1090,19 @@ export class ContentManagementService { document.querySelector(focusedElementSelector)?.focus(); } } + + showFolderInformation(node: NodeEntry) { + this.dialogRef.open(DialogComponent, { + data: { + title: 'APP.FOLDER_INFO.TITLE', + confirmButtonTitle: 'APP.FOLDER_INFO.DONE', + isCancelButtonHidden: true, + isCloseButtonHidden: false, + dialogSize: DialogSize.Large, + contentComponent: FolderInformationComponent, + componentData: node.entry + }, + width: '700px' + }); + } } diff --git a/projects/aca-content/src/lib/store/effects/node.effects.spec.ts b/projects/aca-content/src/lib/store/effects/node.effects.spec.ts index e2b8d38f75..e6d5696b37 100644 --- a/projects/aca-content/src/lib/store/effects/node.effects.spec.ts +++ b/projects/aca-content/src/lib/store/effects/node.effects.spec.ts @@ -34,6 +34,7 @@ import { DeleteNodesAction, EditFolderAction, ExpandInfoDrawerAction, + FolderInformationAction, FullscreenViewerAction, ManageAspectsAction, ManagePermissionsAction, @@ -59,6 +60,7 @@ import { NavigationEnd, Router, ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; import { MatDialogModule } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { NodeEntry, UserInfo } from '@alfresco/js-api'; describe('NodeEffects', () => { let store: Store; @@ -564,4 +566,40 @@ describe('NodeEffects', () => { expect(store.dispatch).toHaveBeenCalledWith(new NavigateUrlAction('personal-files/details/node-id?location=test-page')); }); }); + + describe('folderInformation$', () => { + it('should call folder information dialog', () => { + const node: any = { entry: { isFile: true } }; + spyOn(contentService, 'showFolderInformation').and.stub(); + + store.dispatch(new FolderInformationAction(node)); + + expect(contentService.showFolderInformation).toHaveBeenCalled(); + }); + + it('should call folder information dialog from the active folder selection', fakeAsync(() => { + spyOn(contentService, 'showFolderInformation').and.stub(); + + const node: NodeEntry = { + entry: { + id: 'folder-node-id', + name: 'mock-folder-name', + nodeType: 'fake-node-type', + isFolder: true, + isFile: false, + modifiedAt: new Date(), + modifiedByUser: new UserInfo(), + createdAt: new Date(), + createdByUser: new UserInfo() + } + }; + store.dispatch(new SetSelectedNodesAction([node])); + + tick(100); + + store.dispatch(new FolderInformationAction(null)); + + expect(contentService.showFolderInformation).toHaveBeenCalledWith(node); + })); + }); }); diff --git a/projects/aca-content/src/lib/store/effects/node.effects.ts b/projects/aca-content/src/lib/store/effects/node.effects.ts index a2378beae9..c699cddaec 100644 --- a/projects/aca-content/src/lib/store/effects/node.effects.ts +++ b/projects/aca-content/src/lib/store/effects/node.effects.ts @@ -51,7 +51,8 @@ import { ShowLoaderAction, UndoDeleteNodesAction, UnlockWriteAction, - UnshareNodesAction + UnshareNodesAction, + FolderInformationAction } from '@alfresco/aca-shared/store'; import { ContentManagementService } from '../../services/content-management.service'; import { RenditionService } from '@alfresco/adf-content-services'; @@ -460,4 +461,26 @@ export class NodeEffects { ), { dispatch: false } ); + + folderInformation$ = createEffect( + () => + this.actions$.pipe( + ofType(NodeActionTypes.FolderInformation), + map((action) => { + if (action?.payload) { + this.contentService.showFolderInformation(action.payload); + } else { + this.store + .select(getAppSelection) + .pipe(take(1)) + .subscribe((selection) => { + if (selection && !selection.isEmpty && selection.folder.entry) { + this.contentService.showFolderInformation(selection.folder); + } + }); + } + }) + ), + { dispatch: false } + ); } diff --git a/projects/aca-shared/store/src/actions/node.actions.ts b/projects/aca-shared/store/src/actions/node.actions.ts index ca39694761..da1174103d 100644 --- a/projects/aca-shared/store/src/actions/node.actions.ts +++ b/projects/aca-shared/store/src/actions/node.actions.ts @@ -39,6 +39,7 @@ export enum NodeActionTypes { Unshare = 'UNSHARE_NODES', Copy = 'COPY_NODES', Move = 'MOVE_NODES', + FolderInformation = 'FOLDER_INFORMATION', ManagePermissions = 'MANAGE_PERMISSIONS', PrintFile = 'PRINT_FILE', ManageVersions = 'MANAGE_VERSIONS', @@ -180,3 +181,9 @@ export class ManageRulesAction implements Action { constructor(public payload: NodeEntry) {} } + +export class FolderInformationAction implements Action { + readonly type = NodeActionTypes.FolderInformation; + + constructor(public payload: NodeEntry) {} +}