Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MNT-24575] Added dialog to display folder information #4282

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
Open
30 changes: 30 additions & 0 deletions projects/aca-content/assets/app.extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions projects/aca-content/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -451,7 +452,20 @@
"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",
"ERROR": "Something went wrong, please close this dialog and try again"
}
},
"NODE_SELECTOR": {
"COPY_ITEM": "Copy '{{ name }}' to...",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<div class="aca-folder-info-header">
<img alt="{{ 'APP.FOLDER_INFO.ICON' | translate }}" ngSrc="{{ folderDetails.icon }}" width="24" height="24">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we use adf-icon for this?

<div class="aca-folder-title" data-automation-id="folder-info-name">{{ folderDetails.name }}</div>
</div>
<mat-divider/>
<div class="aca-folder-info-body">
<div class="aca-folder-info-item">
<div class="aca-folder-info-item-label">{{ 'APP.FOLDER_INFO.SIZE' | translate }}</div>
<div class="aca-folder-info-item-value"
data-automation-id="folder-info-size">{{ folderDetails.size }}</div>
</div>
<mat-divider/>
<div class="aca-folder-info-item">
<div class="aca-folder-info-item-label">{{ 'APP.FOLDER_INFO.LOCATION' | translate }}</div>
<div class="aca-folder-info-item-value"
data-automation-id="folder-info-location">{{ folderDetails.location }}</div>
</div>
<mat-divider/>
<div class="aca-folder-info-item">
<div class="aca-folder-info-item-label">{{ 'APP.FOLDER_INFO.CREATED' | translate }}</div>
<div class="aca-folder-info-item-value"
data-automation-id="folder-info-creation-date"
[title]="folderDetails.created | adfLocalizedDate">{{ folderDetails.created | adfTimeAgo }}</div>
</div>
<mat-divider/>
<div class="aca-folder-info-item">
<div class="aca-folder-info-item-label">{{ 'APP.FOLDER_INFO.MODIFIED' | translate }}</div>
<div class="aca-folder-info-item-value"
data-automation-id="folder-info-modify-date"
[title]="folderDetails.modified | adfLocalizedDate">{{ folderDetails.modified | adfTimeAgo }}</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -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%;
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<FolderInformationComponent>;
let nodeService: NodesApiService;
let initiateFolderSizeCalculationSpy: jasmine.Spy<(nodeId: string) => Observable<JobIdBodyEntry>>;
let getFolderSizeInfoSpy: jasmine.Spy<(nodeId: string, jobId: string) => Observable<SizeDetailsEntry>>;

const mockSub = new Subject<JobIdBodyEntry>();
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'
}
};
AleksanderSklorz marked this conversation as resolved.
Show resolved Hide resolved

const getValueFromElement = (id: string): string => fixture.debugElement.query(By.css(`[data-automation-id="${id}"]`)).nativeElement.textContent;
swapnil-verma-gl marked this conversation as resolved.
Show resolved Hide resolved

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);
}));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*!
* 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 <http://www.gnu.org/licenses/>.
*/

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 { 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 { catchError, concatMap, expand, first, switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EMPTY, of, 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 = 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
AleksanderSklorz marked this conversation as resolved.
Show resolved Hide resolved
AleksanderSklorz marked this conversation as resolved.
Show resolved Hide resolved
.initiateFolderSizeCalculation(this.data.id)
.pipe(
first(),
switchMap((jobIdEntry) => {
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
),
catchError(() => {
this.folderDetails.size = this.translateService.instant('APP.FOLDER_INFO.ERROR');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make the error message a bit more specific depending on the error that BE returns us?

return of(null);
})
);
}),
takeUntilDestroyed(this.destroyRef),
catchError(() => {
this.folderDetails.size = this.translateService.instant('APP.FOLDER_INFO.ERROR');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

return of(null);
})
)
.subscribe((folderInfo) => {
if (folderInfo?.entry?.status === SizeDetails.StatusEnum.COMPLETE) {
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
);
} else {
this.folderDetails.size = this.translateService.instant('APP.FOLDER_INFO.ERROR');
}
});
}
}
Loading
Loading