[ACS-5483] group details view general info and list of assigned users (#9329)

* ACS-5483 Added possibility to load and update group

* ACS-5483 Implemented unsaved changes dialog

* ACS-5483 Removed console log

* ACS-5483 Made dynamic chip list reusable

* ACS-5483 Fix for more than one row chips

* ACS-5483 Fix for pagination

* ACS-5483 Added some fixes

* ACS-5483 Fixed displaying tags for node

* ACS-5483 Renamed css classes

* ACS-5483 Fixed resizing when chips have pagination

* ACS-5483 Clearing code

* ACS-5483 Documentation for dynamic chip list component

* ACS-5483 Documentation for unsaved changes dialog and guard

* ACS-5483 Documentation for group service

* ACS-5483 Unit tests for GroupService

* ACS-5483 Unit tests for dynamic chip list component

* ACS-5483 Changed fdescribe to describe

* ACS-5483 Unit tests for tag node list component

* ACS-5483 Unit tests for unsaved changes dialog component

* ACS-5483 Unit tests for unsaved changes guard

* ACS-5483 Added description field to group models

* ACS-5483 Correction for updating with description

* ACS-5483 Fixed lint issues

* ACS-5483 Addressed PR comments

* ACS-5483 Reduced complexity

* ACS-5483 Reduced complexity

* ACS-5483 Addressed PR comments
This commit is contained in:
AleksanderSklorz
2024-02-15 13:02:24 +01:00
committed by GitHub
parent 8363d09e79
commit 213a73fc36
40 changed files with 1587 additions and 426 deletions

View File

@@ -67,6 +67,8 @@ import { AdfDateFnsAdapter } from './common/utils/date-fns-adapter';
import { MomentDateAdapter } from './common/utils/moment-date-adapter';
import { AdfDateTimeFnsAdapter } from './common/utils/datetime-fns-adapter';
import { StoragePrefixFactory } from './app-config';
import { UnsavedChangesDialogModule } from './dialogs';
import { DynamicChipListModule } from './dynamic-chip-list';
@NgModule({
imports: [
@@ -101,6 +103,8 @@ import { StoragePrefixFactory } from './app-config';
NotificationHistoryModule,
SearchTextModule,
BlankPageModule,
UnsavedChangesDialogModule,
DynamicChipListModule,
HttpClientModule,
HttpClientXsrfModule.withOptions({
cookieName: 'CSRF-TOKEN',
@@ -138,7 +142,9 @@ import { StoragePrefixFactory } from './app-config';
IconModule,
NotificationHistoryModule,
SearchTextModule,
BlankPageModule
BlankPageModule,
UnsavedChangesDialogModule,
DynamicChipListModule
]
})
export class CoreModule {

View File

@@ -17,3 +17,6 @@
export * from './edit-json/edit-json.dialog';
export * from './edit-json/edit-json.dialog.module';
export * from './unsaved-changes-dialog/unsaved-changes-dialog.component';
export * from './unsaved-changes-dialog/unsaved-changes-dialog.module';
export * from './unsaved-changes-dialog/unsaved-changes.guard';

View File

@@ -0,0 +1,29 @@
<h1 mat-dialog-title class="adf-unsaved-changes-dialog-title">
{{ 'CORE.DIALOG.UNSAVED_CHANGES.TITLE' | translate }}
<button
data-automation-id="adf-unsaved-changes-dialog-close-button"
mat-icon-button
[title]="'CLOSE' | translate"
[mat-dialog-close]="false">
<mat-icon>close</mat-icon>
</button>
</h1>
<mat-dialog-content>
{{ 'CORE.DIALOG.UNSAVED_CHANGES.DESCRIPTION' | translate }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
data-automation-id="adf-unsaved-changes-dialog-cancel-button"
mat-button
[mat-dialog-close]="false"
class="adf-unsaved-changes-dialog-cancel-button">
{{ 'CANCEL' | translate | titlecase }}
</button>
<button
data-automation-id="adf-unsaved-changes-dialog-discard-changes-button"
mat-button
[mat-dialog-close]="true"
class="adf-unsaved-changes-dialog-discard-changes-button">
{{ 'CORE.DIALOG.UNSAVED_CHANGES.DISCARD_CHANGES_BUTTON' | translate }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,32 @@
adf-unsaved-changes-dialog {
margin-top: -4px;
display: block;
.adf-unsaved-changes-dialog {
&-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
font-weight: bold;
}
&-cancel-button {
background-color: var(--adf-secondary-button-background);
margin-right: 4px;
}
&-discard-changes-button {
color: var(--theme-warn-color-default-contrast);
background-color: var(--adf-danger-button-background);
min-width: 143px;
}
&-cancel-button, &-discard-changes-button {
padding: 4px 14px;
height: 32px;
display: flex;
align-items: center;
}
}
}

View File

@@ -0,0 +1,68 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CoreTestingModule, UnsavedChangesDialogComponent } from '@alfresco/adf-core';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { MatDialogClose } from '@angular/material/dialog';
describe('UnsavedChangesDialog', () => {
let fixture: ComponentFixture<UnsavedChangesDialogComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
fixture = TestBed.createComponent(UnsavedChangesDialogComponent);
fixture.detectChanges();
});
describe('Close icon button', () => {
let closeIconButton: DebugElement;
beforeEach(() => {
closeIconButton = fixture.debugElement.query(By.css(
'[data-automation-id="adf-unsaved-changes-dialog-close-button"]'
));
});
it('should have assigned mat-dialog-close with false as result', () => {
expect(closeIconButton.injector.get(MatDialogClose).dialogResult).toBeFalse();
});
it('should have displayed correct icon', () => {
expect(closeIconButton.nativeElement.textContent).toBe('close');
});
});
describe('Cancel button', () => {
it('should have assigned mat-dialog-close with false as result', () => {
expect(fixture.debugElement.query(By.css(
'[data-automation-id="adf-unsaved-changes-dialog-cancel-button"]'
)).injector.get(MatDialogClose).dialogResult).toBeFalse();
});
});
describe('Discard changes button', () => {
it('should have assigned mat-dialog-close with true as result', () => {
expect(fixture.debugElement.query(By.css(
'[data-automation-id="adf-unsaved-changes-dialog-discard-changes-button"]'
)).injector.get(MatDialogClose).dialogResult).toBeTrue();
});
});
});

View File

@@ -0,0 +1,29 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ViewEncapsulation } from '@angular/core';
/**
* Dialog which informs about unsaved changes. Allows discard them and proceed or close dialog and stop proceeding.
*/
@Component({
selector: 'adf-unsaved-changes-dialog',
encapsulation: ViewEncapsulation.None,
templateUrl: './unsaved-changes-dialog.component.html',
styleUrls: ['./unsaved-changes-dialog.component.scss']
})
export class UnsavedChangesDialogComponent {}

View File

@@ -0,0 +1,37 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { UnsavedChangesDialogComponent } from './unsaved-changes-dialog.component';
import { MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [UnsavedChangesDialogComponent],
imports: [
MatDialogModule,
TranslateModule,
MatButtonModule,
MatIconModule,
CommonModule
],
exports: [UnsavedChangesDialogComponent]
})
export class UnsavedChangesDialogModule {}

View File

@@ -0,0 +1,94 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { CoreTestingModule, UnsavedChangesDialogComponent, UnsavedChangesGuard } from '@alfresco/adf-core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Observable, Subject } from 'rxjs';
describe('UnsavedChangesGuard', () => {
let guard: UnsavedChangesGuard;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
guard = TestBed.inject(UnsavedChangesGuard);
});
describe('canDeactivate', () => {
let dialog: MatDialog;
let afterClosed$: Subject<boolean>;
beforeEach(() => {
afterClosed$ = new Subject<boolean>();
dialog = TestBed.inject(MatDialog);
spyOn(dialog, 'open').and.returnValue({
afterClosed: () => afterClosed$ as Observable<boolean>
} as MatDialogRef<UnsavedChangesDialogComponent>);
});
it('should return true if unsaved is set to false', () => {
guard.unsaved = false;
expect(guard.canDeactivate()).toBeTrue();
});
it('should return true if unsaved was not set', () => {
expect(guard.canDeactivate()).toBeTrue();
});
it('should return true when unsaved is set to true and result of dialog is true', (done) => {
guard.unsaved = true;
(guard.canDeactivate() as Observable<boolean>).subscribe((allowed) => {
expect(allowed).toBeTrue();
done();
});
afterClosed$.next(true);
});
it('should return false when unsaved is set to true and result of dialog is false', (done) => {
guard.unsaved = true;
(guard.canDeactivate() as Observable<boolean>).subscribe((allowed) => {
expect(allowed).toBeFalse();
done();
});
afterClosed$.next(false);
});
it('should keep unsaved set to true when unsaved was to true and result of dialog is false', (done) => {
guard.unsaved = true;
(guard.canDeactivate() as Observable<boolean>).subscribe(() => {
expect(guard.unsaved).toBeTrue();
done();
});
afterClosed$.next(false);
});
it('should set unsaved to false when unsaved is set to true and result of dialog is true', (done) => {
guard.unsaved = true;
(guard.canDeactivate() as Observable<boolean>).subscribe(() => {
expect(guard.unsaved).toBeFalse();
done();
});
afterClosed$.next(true);
});
});
});

View File

@@ -0,0 +1,47 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { UnsavedChangesDialogComponent } from './unsaved-changes-dialog.component';
import { tap } from 'rxjs/operators';
/**
* Guard responsible for protecting leaving page with unsaved changes.
*/
@Injectable({
providedIn: 'root'
})
export class UnsavedChangesGuard implements CanDeactivate<any> {
unsaved = false;
constructor(private dialog: MatDialog) {}
/**
* Allows to deactivate route when there is no unsaved changes, otherwise displays dialog to confirm discarding changes.
*
* @returns boolean | Observable<boolean> true when there is no unsaved changes or changes can be discarded, false otherwise.
*/
canDeactivate(): boolean | Observable<boolean> {
return this.unsaved ?
this.dialog.open<UnsavedChangesDialogComponent, undefined, boolean>(UnsavedChangesDialogComponent, {
maxWidth: 346
}).afterClosed().pipe(tap((confirmed) => this.unsaved = !confirmed)) : true;
}
}

View File

@@ -0,0 +1,21 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface Chip {
name: string;
id: string;
}

View File

@@ -0,0 +1,39 @@
<div
class="adf-dynamic-chip-list-container"
[class.adf-dynamic-chip-list-flex-column]="limitChipsDisplayed && (!calculationsDone || columnFlexDirection)"
[class.adf-dynamic-chip-list-button-in-next-line]="moveLoadMoreButtonToNextRow"
[class.adf-dynamic-chip-list-paginated]="paginationData"
#nodeListContainer>
<mat-chip-list
[class.adf-dynamic-chip-list-full-width]="limitChipsDisplayed && !calculationsDone"
role="listbox"
[attr.aria-label]="'METADATA.BASIC.TAGS' | translate">
<mat-chip
class="adf-dynamic-chip-list-chip"
*ngFor="let chip of chipsToDisplay; let idx = index"
(removed)="removedChip.emit(chip.id)">
<span id="adf-dynamic-chip-list-chip-name-{{ idx }}">{{ chip.name }}</span>
<mat-icon
*ngIf="showDelete"
id="adf-dynamic-chip-list-delete-{{ chip.name }}"
class="adf-dynamic-chip-list-delete-icon"
matChipRemove>
cancel
</mat-icon>
</mat-chip>
</mat-chip-list>
<button
data-automation-id="adf-dynamic-chip-list-view-more-button"
mat-button
[hidden]="!limitChipsDisplayed"
[style.left.px]="viewMoreButtonLeftOffset"
[style.top.px]="viewMoreButtonTop"
class="adf-dynamic-chip-list-view-more-button"
[class.adf-dynamic-chip-list-hidden-btn]="!calculationsDone"
(click)="displayNextChips($event)">
{{
paginationData ? ('DYNAMIC_CHIP_LIST.LOAD_MORE' | translate) :
('TAG_NODE_LIST.VIEW_MORE' | translate: { count: undisplayedChipsCount})
}}
</button>
</div>

View File

@@ -0,0 +1,82 @@
.adf-dynamic-chip-list-container {
display: flex;
flex-direction: row;
width: inherit;
padding-top: 12px;
padding-bottom: 12px;
.adf-dynamic-chip-list-view-more-button {
color: var(--adf-theme-foreground-text-color-054);
position: absolute;
&[hidden] {
visibility: hidden;
}
}
&.adf-dynamic-chip-list-flex-column {
flex-direction: column;
.adf-dynamic-chip-list-view-more-button {
position: relative;
}
}
&.adf-dynamic-chip-list-paginated {
mat-chip-list {
width: 100%;
& > div {
width: 100%;
}
}
.adf-dynamic-chip-list-view-more-button {
margin: -2px 4px 4px 24px;
}
}
&.adf-dynamic-chip-list-button-in-next-line {
align-items: unset;
padding-bottom: 54px;
.adf-dynamic-chip-list-view-more-button {
margin-top: 4px;
}
}
&:not(.adf-dynamic-chip-list-paginated) {
align-items: center;
&:not(.adf-dynamic-chip-list-flex-column) {
.adf-dynamic-chip-list-view-more-button {
margin-top: 10px;
}
}
}
.adf-dynamic-chip-list-full-width {
width: 100%;
}
.adf-dynamic-chip-list-hidden-btn {
visibility: hidden;
}
.adf-dynamic-chip-list-chip {
color: var(--theme-primary-color-default-contrast);
background-color: var(--theme-primary-color);
height: auto;
word-break: break-word;
}
.adf-dynamic-chip-list-delete-icon {
font-size: var(--theme-title-font-size);
background-repeat: no-repeat;
display: inline-block;
fill: currentcolor;
height: 20px;
width: 20px;
color: var(--theme-primary-color-default-contrast);
}
}

View File

@@ -0,0 +1,297 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { Chip, CoreTestingModule, DynamicChipListComponent } from '@alfresco/adf-core';
import { SimpleChange } from '@angular/core';
describe('DynamicChipListComponent', () => {
let chips: Chip[] = [{
name: 'test1',
id: '0ee933fa-57fc-4587-8a77-b787e814f1d2'
}, {
name: 'test2',
id: 'fcb92659-1f10-41b4-9b17-851b72a3b597'
}, {
name: 'test3',
id: 'fb4213c0-729d-466c-9a6c-ee2e937273bf'
}, {
name: 'test4',
id: 'as4213c0-729d-466c-9a6c-ee2e937273as'
}];
let component: DynamicChipListComponent;
let fixture: ComponentFixture<DynamicChipListComponent>;
let element: HTMLElement;
let resizeCallback: ResizeObserverCallback;
/**
* Find 'More' button
*
* @returns native element
*/
function findViewMoreButton(): HTMLButtonElement {
return element.querySelector('[data-automation-id="adf-dynamic-chip-list-view-more-button"]');
}
/**
* Get the chips
*
* @returns native element list
*/
function findChips(): NodeListOf<Element> {
return element.querySelectorAll('.adf-dynamic-chip-list-chip');
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CoreTestingModule
]
});
const resizeObserverSpy = spyOn(window, 'ResizeObserver').and.callThrough();
fixture = TestBed.createComponent(DynamicChipListComponent);
element = fixture.nativeElement;
component = fixture.componentInstance;
component.chips = chips;
fixture.detectChanges();
resizeCallback = resizeObserverSpy.calls.mostRecent().args[0];
});
describe('Rendering tests', () => {
it('should render every chip', async () => {
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('#adf-dynamic-chip-list-chip-name-0').innerHTML).toBe('test1');
expect(element.querySelector('#adf-dynamic-chip-list-chip-name-1').innerHTML).toBe('test2');
expect(element.querySelector('#adf-dynamic-chip-list-chip-name-2').innerHTML).toBe('test3');
expect(element.querySelector('#adf-dynamic-chip-list-delete-test1')).not.toBe(null);
expect(element.querySelector('#adf-dynamic-chip-list-delete-test2')).not.toBe(null);
expect(element.querySelector('#adf-dynamic-chip-list-delete-test3')).not.toBe(null);
});
it('should emit removedChip event when clicked on delete icon', async () => {
spyOn(component.removedChip, 'emit');
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
fixture.detectChanges();
await fixture.whenStable();
const deleteButton: any = element.querySelector('#adf-dynamic-chip-list-delete-test1');
deleteButton.click();
expect(component.removedChip.emit).toHaveBeenCalledWith('0ee933fa-57fc-4587-8a77-b787e814f1d2');
});
it('should not show the delete button if showDelete is false', async () => {
component.showDelete = false;
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
fixture.detectChanges();
await fixture.whenStable();
const deleteButton: any = element.querySelector('#adf-dynamic-chip-list-delete-test1');
expect(deleteButton).toBeNull();
});
it('should show the delete button if showDelete is true', async () => {
component.showDelete = true;
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
fixture.detectChanges();
await fixture.whenStable();
const deleteButton: any = element.querySelector('#adf-dynamic-chip-list-delete-test1');
expect(deleteButton).not.toBeNull();
});
it('should not render view more button by default', async () => {
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
fixture.detectChanges();
await fixture.whenStable();
expect(findViewMoreButton().hidden).toBeTrue();
expect(findChips()).toHaveSize(4);
});
});
describe('Limit chips display', () => {
let initialChips: Chip[];
/**
* Render chips
*
* @param chipsToRender chips to render
*/
async function renderChips(chipsToRender?: Chip[]) {
chips = chipsToRender || initialChips;
component.chips = chips;
fixture.detectChanges();
}
beforeAll(() => {
initialChips = chips;
});
beforeEach(() => {
component.limitChipsDisplayed = true;
component.ngOnInit();
element.style.maxWidth = '309px';
});
it('should render view more button when limiting is enabled', fakeAsync(() => {
renderChips();
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
expect(findViewMoreButton().hidden).toBeFalse();
expect(findChips()).toHaveSize(1);
}));
it('should not render view more button when limiting is enabled and all chips fits into container', fakeAsync(() => {
renderChips();
element.style.maxWidth = '800px';
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
expect(findViewMoreButton().hidden).toBeTrue();
expect(findChips()).toHaveSize(4);
}));
it('should emit displayNext event when view more button is clicked', fakeAsync(() => {
renderChips();
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
spyOn(component.displayNext, 'emit');
const viewMoreButton = findViewMoreButton();
viewMoreButton.click();
fixture.detectChanges();
expect(viewMoreButton.hidden).toBeTrue();
expect(findChips()).toHaveSize(1);
expect(component.displayNext.emit).toHaveBeenCalled();
}));
it('should not render view more button when chip takes more than one line and there are no more chips', fakeAsync(() => {
renderChips([{
name: 'VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag',
id: '0ee933fa-57fc-4587-8a77-b787e814f1d2'
}]);
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
expect(findViewMoreButton().hidden).toBeTrue();
expect(findChips()).toHaveSize(component.chips.length);
}));
it('should render view more button when chip takes more than one line and there are more chips', fakeAsync(() => {
renderChips([{
name: 'VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag VeryLongTag',
id: '0ee933fa-57fc-4587-8a77-b787e814f1d2'
}, {
name: 'Some other tag',
id: '0ee933fa-57fc-4587-8a77-b787e814f1d3'
}]);
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
const viewMoreButton = findViewMoreButton();
expect(viewMoreButton.hidden).toBeFalse();
expect(viewMoreButton.style.left).toBe('0px');
expect(findChips()).toHaveSize(1);
}));
it('should not render view more button when there is enough space after resizing', fakeAsync(() => {
renderChips();
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
element.style.maxWidth = '800px';
resizeCallback([], null);
fixture.detectChanges();
const viewMoreButton = findViewMoreButton();
expect(viewMoreButton.hidden).toBeTrue();
expect(findChips()).toHaveSize(4);
}));
it('should render view more button when there is not enough space after resizing', fakeAsync(() => {
renderChips();
element.style.maxWidth = '800px';
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
element.style.maxWidth = '309px';
resizeCallback([], null);
fixture.detectChanges();
expect(findViewMoreButton().hidden).toBeFalse();
expect(findChips()).toHaveSize(1);
}));
it('should not render view more button again after resizing when there is not enough space if user requested to see all chips', fakeAsync(() => {
renderChips();
component.ngOnChanges({
chips: new SimpleChange(undefined, component.chips, true)
});
tick();
fixture.detectChanges();
const viewMoreButton = findViewMoreButton();
viewMoreButton.click();
fixture.detectChanges();
element.style.maxWidth = '309px';
resizeCallback([], null);
fixture.detectChanges();
expect(viewMoreButton.hidden).toBeTrue();
}));
});
});

View File

@@ -0,0 +1,218 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
QueryList,
SimpleChanges,
ViewChild,
ViewChildren,
ViewEncapsulation
} from '@angular/core';
import { Subject } from 'rxjs';
import { MatChip } from '@angular/material/chips';
import { Chip } from './chip';
import { Pagination } from '@alfresco/js-api';
/**
* This component shows dynamic list of chips which render depending on free space.
*/
@Component({
selector: 'adf-dynamic-chip-list',
templateUrl: './dynamic-chip-list.component.html',
styleUrls: ['./dynamic-chip-list.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class DynamicChipListComponent implements OnChanges, OnInit, AfterViewInit, OnDestroy {
/* eslint no-underscore-dangle: ["error", { "allow": ["_elementRef"] }]*/
/** Provide if you want to use paginated chips. */
@Input()
pagination: Pagination;
/** List of chips to display. */
@Input()
chips: Chip[];
/** Show delete button. */
@Input()
showDelete = true;
/** Should limit number of chips displayed. */
@Input()
limitChipsDisplayed = false;
/** Emitted when button for view more is clicked. */
@Output()
displayNext = new EventEmitter<void>();
/** Emitted when any chip is removed. */
@Output()
removedChip = new EventEmitter<string>();
@ViewChild('nodeListContainer')
containerView: ElementRef;
@ViewChildren(MatChip)
matChips: QueryList<MatChip>;
chipsToDisplay: Chip[] = [];
calculationsDone = false;
columnFlexDirection = false;
moveLoadMoreButtonToNextRow = false;
undisplayedChipsCount = 0;
viewMoreButtonLeftOffset: number;
viewMoreButtonTop = 0;
paginationData: Pagination;
private initialChips: Chip[] = [];
private onDestroy$ = new Subject<boolean>();
private initialLimitChipsDisplayed: boolean;
private viewMoreButtonLeftOffsetBeforeFlexDirection: number;
private requestedDisplayingAllChips = false;
private resizeObserver = new ResizeObserver(() => {
this.calculateChipsToDisplay();
this.changeDetectorRef.detectChanges();
});
constructor(private changeDetectorRef: ChangeDetectorRef) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.pagination) {
this.limitChipsDisplayed = this.pagination?.hasMoreItems;
this.paginationData = this.pagination;
this.initialLimitChipsDisplayed = this.limitChipsDisplayed;
}
if (changes.chips) {
this.initialChips = this.chips;
this.chipsToDisplay = this.initialChips;
if (this.limitChipsDisplayed && this.chipsToDisplay.length) {
setTimeout(() => {
this.calculateChipsToDisplay();
this.changeDetectorRef.detectChanges();
});
}
}
}
ngOnInit(): void {
if (this.paginationData) {
this.limitChipsDisplayed = this.paginationData.hasMoreItems;
}
this.initialLimitChipsDisplayed = this.limitChipsDisplayed;
}
ngAfterViewInit(): void {
this.resizeObserver.observe(this.containerView.nativeElement);
}
ngOnDestroy(): void {
this.onDestroy$.next(true);
this.onDestroy$.complete();
this.resizeObserver.unobserve(this.containerView.nativeElement);
}
displayNextChips(event: Event): void {
event.preventDefault();
event.stopPropagation();
if (this.paginationData) {
this.requestedDisplayingAllChips = !this.paginationData.hasMoreItems;
} else {
this.limitChipsDisplayed = false;
this.requestedDisplayingAllChips = true;
}
if (this.requestedDisplayingAllChips) {
this.resizeObserver.unobserve(this.containerView.nativeElement);
}
this.displayNext.emit();
}
private calculateChipsToDisplay(): void {
if (this.requestedDisplayingAllChips || !this.chips.length) {
return;
}
this.chipsToDisplay = this.initialChips;
this.changeDetectorRef.detectChanges();
this.undisplayedChipsCount = 0;
let chipsToDisplay = 1;
const containerWidth: number = this.containerView.nativeElement.clientWidth;
const viewMoreButton: HTMLButtonElement = this.containerView.nativeElement.children[1];
const viewMoreBtnWidth: number = viewMoreButton.getBoundingClientRect().width;
const firstChip = this.matChips.get(0);
const chipMargin = firstChip ? this.getChipMargin(firstChip) : 0;
let chipsWidth = 0;
const chips = this.matChips.toArray();
let lastIndex = 0;
do {
chipsWidth = Math.max(chips.reduce((width, val, index) => {
width += val._elementRef.nativeElement.getBoundingClientRect().width + chipMargin;
const availableSpace = index && index === chips.length - 1 || !this.paginationData ? containerWidth - viewMoreBtnWidth : containerWidth;
if (availableSpace >= width) {
chipsToDisplay = (this.paginationData ? chipsToDisplay : index) + 1;
lastIndex++;
this.viewMoreButtonLeftOffset = width;
this.viewMoreButtonLeftOffsetBeforeFlexDirection = width;
}
return width;
}, 0), chipsWidth);
chips.splice(0, lastIndex);
lastIndex = 0;
} while ((chips.length || chipsToDisplay < this.matChips.length && this.matChips.length) && this.paginationData);
this.arrangeElements(containerWidth, chipsWidth, viewMoreBtnWidth, chipsToDisplay, viewMoreButton);
this.calculationsDone = true;
}
private getChipMargin(chip: MatChip): number {
const chipStyles = window.getComputedStyle(chip._elementRef.nativeElement);
return parseInt(chipStyles.marginLeft, 10) + parseInt(chipStyles.marginRight, 10);
}
private arrangeElements(containerWidth: number, chipsWidth: number, viewMoreBtnWidth: number, chipsToDisplay: number,
viewMoreButton: HTMLButtonElement): void {
if ((containerWidth - chipsWidth - viewMoreBtnWidth) <= 0) {
const chip = this.paginationData ? this.matChips.last : this.matChips.first;
const hasNotEnoughSpaceForMoreButton = (containerWidth < (chip?._elementRef.nativeElement.offsetWidth + chip?._elementRef.nativeElement.offsetLeft + viewMoreBtnWidth));
this.columnFlexDirection = chipsToDisplay === 1 && !this.paginationData && hasNotEnoughSpaceForMoreButton;
this.moveLoadMoreButtonToNextRow = this.paginationData && hasNotEnoughSpaceForMoreButton;
this.undisplayedChipsCount = this.chipsToDisplay.length - chipsToDisplay;
this.chipsToDisplay = this.chipsToDisplay.slice(0, chipsToDisplay);
} else {
this.moveLoadMoreButtonToNextRow = false;
}
this.limitChipsDisplayed = this.undisplayedChipsCount ? this.initialLimitChipsDisplayed : this.paginationData?.hasMoreItems;
if (this.paginationData?.hasMoreItems) {
const lastChipTop = this.matChips.last._elementRef.nativeElement.offsetTop;
if (this.moveLoadMoreButtonToNextRow) {
this.viewMoreButtonLeftOffset = 0;
this.viewMoreButtonTop = lastChipTop + viewMoreButton.offsetHeight;
} else {
this.viewMoreButtonLeftOffset = this.viewMoreButtonLeftOffsetBeforeFlexDirection;
this.viewMoreButtonTop = lastChipTop;
}
} else {
this.viewMoreButtonLeftOffset = this.columnFlexDirection ? 0 : this.viewMoreButtonLeftOffsetBeforeFlexDirection;
}
}
}

View File

@@ -0,0 +1,37 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { DynamicChipListComponent } from './dynamic-chip-list.component';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [DynamicChipListComponent],
imports: [
MatChipsModule,
MatIconModule,
MatButtonModule,
TranslateModule,
CommonModule
],
exports: [DynamicChipListComponent]
})
export class DynamicChipListModule {}

View File

@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './public-api';

View File

@@ -0,0 +1,20 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './dynamic-chip-list.component';
export * from './dynamic-chip-list.module';
export * from './chip';

View File

@@ -106,6 +106,11 @@
"EDIT_JSON": {
"CLOSE": "Close",
"UPDATE": "Update"
},
"UNSAVED_CHANGES": {
"TITLE": "Unsaved changes",
"DESCRIPTION": "You have unsaved changes on this screen. Are you sure want to leave without saving changes?",
"DISCARD_CHANGES_BUTTON": "Discard Changes"
}
},
"BREADCRUMBS": {
@@ -573,5 +578,8 @@
},
"INFO_DRAWER": {
"ICON": "Node Icon"
},
"DYNAMIC_CHIP_LIST": {
"LOAD_MORE": "Load more"
}
}

View File

@@ -96,6 +96,8 @@
--adf-header-icon-button-hover-color: $adf-ref-header-icon-color,
--adf-header-icon-button-pressed-color: $adf-ref-header-icon-color,
--adf-header-icon-button-disabled-color: $adf-ref-header-icon-color,
--adf-danger-button-background: $adf-danger-button-background,
--adf-secondary-button-background: $adf-secondary-button-background
);
// propagates SCSS variables into the CSS variables scope

View File

@@ -25,3 +25,5 @@ $adf-ref-metadata-property-panel-label-color: rgba(33, 33, 33, 0.24);
$adf-ref-metadata-property-panel-title-color: rgb(33, 33, 33);
$adf-ref-header-icon-color: inherit;
$adf-ref-header-icon-border-radius: 50%;
$adf-danger-button-background: #ba1b1b;
$adf-secondary-button-background: #2121210d;

View File

@@ -37,6 +37,7 @@ export * from './lib/templates/index';
export * from './lib/pipes/index';
export * from './lib/services/index';
export * from './lib/directives/index';
export * from './lib/dynamic-chip-list/index';
export * from './lib/clipboard/index';
export * from './lib/dialogs/index';
export * from './lib/icon/index';