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 9 commits into
base: develop
Choose a base branch
from
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
17 changes: 15 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,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...",
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">
<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,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 <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 { 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();
Copy link
Contributor

Choose a reason for hiding this comment

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

no need to set type at left side when you do inline initialization because ts knows then that type is 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
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please add some error handling (for example you can display some error content inside dialog when loading is failed)?

Copy link
Contributor

Choose a reason for hiding this comment

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

@swapnil-verma-gl I guess this one is not addressed because I can't see changes for that one?

.initiateFolderSizeCalculation(this.data.id)
.pipe(
first(),
switchMap((jobIdEntry: JobIdBodyEntry) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to set type in this place because it's inferred by ts:
image

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

takeUntilDestroyed(this.destroyRef) is added for pipe for result of getFolderSizeInfo function. I guess we should add takeUntilDestroyed for pipe for result of initiateFolderSizeCalculation instead as we call subscribe for result of that function?

);
})
)
.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
);
});
}
}
Loading
Loading